Unify offers UI to OfferResultCard and require price
All checks were successful
Build Docker Image / build (push) Successful in 5m59s

This commit is contained in:
Ruslan Bakiev
2026-02-05 19:12:39 +07:00
parent beb02bd3fc
commit 0f0b1db394
6 changed files with 58 additions and 144 deletions

View File

@@ -81,15 +81,18 @@
<!-- Offers Tab -->
<div v-else-if="activeTab === 'offers'" class="space-y-2">
<OfferCard
v-for="(offer, index) in offers"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:offer="offer"
selectable
:is-selected="selectedItemId === offer.uuid"
:location-name="offer.locationName || offer.locationCountry"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
@select="$emit('select', offer, 'offer')"
/>
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
<div v-if="offersWithPrice.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.offers') }}
</div>
</div>
@@ -136,7 +139,7 @@ interface Offer {
validUntil?: string | null
}
defineProps<{
const props = defineProps<{
activeTab: 'hubs' | 'suppliers' | 'offers'
hubs: Hub[]
suppliers: Supplier[]
@@ -152,4 +155,8 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
</script>

View File

@@ -38,16 +38,17 @@
<!-- Offers Tab -->
<template v-if="activeTab === 'offers'">
<OfferResultCard
v-for="(offer, index) in offers"
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:location-name="offer.locationName"
:product-name="offer.title || undefined"
:product-name="offer.productName || offer.title || undefined"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
:start-name="offer.locationName || undefined"
:end-name="undefined"
@select="selectOffer(offer)"
/>
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
<Text v-if="offersWithPrice.length === 0" tone="muted" size="sm" class="text-center py-4">
{{ t('catalogMap.empty.offers') }}
</Text>
</template>
@@ -83,10 +84,14 @@ interface Hub {
interface Offer {
uuid?: string | null
title?: string | null
productName?: string | null
locationName?: string | null
status?: string | null
latitude?: number | null
longitude?: number | null
pricePerUnit?: number | string | null
currency?: string | null
unit?: string | null
}
interface Supplier {
@@ -114,9 +119,13 @@ const selectedId = ref<string | null>(null)
const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
const tabs = computed(() => [
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: props.offers.length },
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: offersWithPrice.value.length },
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
])

View File

@@ -14,24 +14,27 @@
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferResultCard
v-for="(offer, index) in offers"
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:location-name="offer.locationName"
:product-name="offer.title || undefined"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
/>
</Grid>
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
<Text tone="muted">
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
{{ t('common.pagination.showing', { shown: offersWithPrice.length, total: totalOffers }) }}
</Text>
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
{{ t('common.actions.load_more') }}
</Button>
</Stack>
<Stack v-if="offers.length === 0" align="center" gap="2">
<Stack v-if="offersWithPrice.length === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</Stack>
</Stack>
@@ -51,6 +54,9 @@ interface Offer {
status?: string | null
validUntil?: string | null
lines?: (OfferLine | null)[] | null
pricePerUnit?: number | string | null
currency?: string | null
unit?: string | null
}
const props = defineProps<{
@@ -63,7 +69,10 @@ const props = defineProps<{
const localePath = useLocalePath()
const { t } = useI18n()
const totalOffers = computed(() => props.total ?? props.offers.length)
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
const totalOffers = computed(() => props.total ?? offersWithPrice.value.length)
const canLoadMore = computed(() => props.canLoadMore ?? false)
const loadMore = () => {
props.onLoadMore?.()

View File

@@ -129,7 +129,7 @@
<Icon name="lucide:shopping-bag" size="16" />
{{ $t('catalog.headers.offers') }}
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedOffers.length > 0" class="text-white/50">({{ relatedOffers.length }})</span>
<span v-else-if="offersWithPrice.length > 0" class="text-white/50">({{ offersWithPrice.length }})</span>
</h3>
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
<Icon name="lucide:x" size="14" />
@@ -137,12 +137,12 @@
</button>
</div>
<div v-if="!loadingOffers && relatedOffers.length === 0" class="text-white/50 text-sm py-2">
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.empty.noOffers') }}
</div>
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
<OfferResultCard
v-for="(offer, index) in relatedOffers"
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
:product-name="offer.productName"
@@ -257,6 +257,9 @@ const relatedProducts = computed(() => props.relatedProducts ?? [])
const relatedHubs = computed(() => props.relatedHubs ?? [])
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
const relatedOffers = computed(() => props.relatedOffers ?? [])
const offersWithPrice = computed(() =>
relatedOffers.value.filter(o => o?.pricePerUnit != null)
)
// Entity name
const entityName = computed(() => {

View File

@@ -1,118 +0,0 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/offers/detail/${offer.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="small"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<div class="flex flex-col gap-1">
<!-- Product title -->
<Text size="base" weight="semibold" class="truncate">{{ offer.productName }}</Text>
<!-- Quantity -->
<div v-if="offer.quantity" class="flex">
<span class="badge badge-neutral badge-dash text-xs">
{{ t('catalogOfferCard.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit }) }}
</span>
</div>
<!-- Price -->
<div v-if="offer.pricePerUnit" class="font-semibold text-primary text-sm">
{{ formatPrice(offer.pricePerUnit, offer.currency) }}/{{ displayUnit }}
</div>
<!-- Country below -->
<Text v-if="!compact" tone="muted" size="sm">
{{ countryFlag }} {{ offer.locationCountry || offer.locationName || t('catalogOfferCard.labels.country_unknown') }}
</Text>
</div>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Offer {
uuid?: string | null
// Product
productUuid?: string | null
productName?: string | null
categoryName?: string | null
// Location
locationUuid?: string | null
locationName?: string | null
locationCountry?: string | null
locationCountryCode?: string | null
// Price
quantity?: number | string | null
unit?: string | null
pricePerUnit?: number | string | null
currency?: string | null
// Misc
status?: string | null
validUntil?: string | null
}
const props = defineProps<{
offer: Offer
selectable?: boolean
isSelected?: boolean
compact?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && !!props.offer.uuid)
const formattedDate = computed(() => {
if (!props.offer.validUntil) return ''
try {
return new Intl.DateTimeFormat('ru', {
day: 'numeric',
month: 'short'
}).format(new Date(props.offer.validUntil))
} catch {
return props.offer.validUntil
}
})
const formatPrice = (price: number | string | null | undefined, currency: string | null | undefined) => {
if (!price) return ''
const num = typeof price === 'string' ? parseFloat(price) : price
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru', {
style: 'currency',
currency: curr,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
// ISO code to emoji flag
const isoToEmoji = (code: string): string => {
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
}
const countryFlag = computed(() => {
if (props.offer.locationCountryCode) {
return isoToEmoji(props.offer.locationCountryCode)
}
return '🌍'
})
const displayUnit = computed(() => props.offer.unit || t('catalogOfferCard.labels.default_unit'))
</script>

View File

@@ -14,14 +14,14 @@
<span class="loading loading-spinner loading-md text-white" />
</div>
<div v-else-if="offers.length === 0" class="text-center py-8 text-white/60">
<div v-else-if="offersWithPrice.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noOffers') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="offer in offers"
v-for="offer in offersWithPrice"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
@@ -54,12 +54,16 @@ interface Offer {
locationCountryCode?: string | null
}
defineProps<{
const emit = defineEmits<{
'select-offer': [offer: Offer]
}>()
const props = defineProps<{
loading: boolean
offers: Offer[]
}>()
const emit = defineEmits<{
'select-offer': [offer: Offer]
}>()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
</script>