Unify offers UI to OfferResultCard and require price
All checks were successful
Build Docker Image / build (push) Successful in 5m59s
All checks were successful
Build Docker Image / build (push) Successful in 5m59s
This commit is contained in:
@@ -81,15 +81,18 @@
|
|||||||
|
|
||||||
<!-- Offers Tab -->
|
<!-- Offers Tab -->
|
||||||
<div v-else-if="activeTab === 'offers'" class="space-y-2">
|
<div v-else-if="activeTab === 'offers'" class="space-y-2">
|
||||||
<OfferCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:offer="offer"
|
:location-name="offer.locationName || offer.locationCountry"
|
||||||
selectable
|
:product-name="offer.productName"
|
||||||
:is-selected="selectedItemId === offer.uuid"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
|
:stages="[]"
|
||||||
@select="$emit('select', offer, 'offer')"
|
@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') }}
|
{{ t('catalogMap.empty.offers') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +139,7 @@ interface Offer {
|
|||||||
validUntil?: string | null
|
validUntil?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
activeTab: 'hubs' | 'suppliers' | 'offers'
|
activeTab: 'hubs' | 'suppliers' | 'offers'
|
||||||
hubs: Hub[]
|
hubs: Hub[]
|
||||||
suppliers: Supplier[]
|
suppliers: Supplier[]
|
||||||
@@ -152,4 +155,8 @@ defineEmits<{
|
|||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const offersWithPrice = computed(() =>
|
||||||
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -38,16 +38,17 @@
|
|||||||
<!-- Offers Tab -->
|
<!-- Offers Tab -->
|
||||||
<template v-if="activeTab === 'offers'">
|
<template v-if="activeTab === 'offers'">
|
||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:location-name="offer.locationName"
|
: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="[]"
|
:stages="[]"
|
||||||
:start-name="offer.locationName || undefined"
|
|
||||||
:end-name="undefined"
|
|
||||||
@select="selectOffer(offer)"
|
@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') }}
|
{{ t('catalogMap.empty.offers') }}
|
||||||
</Text>
|
</Text>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,10 +84,14 @@ interface Hub {
|
|||||||
interface Offer {
|
interface Offer {
|
||||||
uuid?: string | null
|
uuid?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
|
productName?: string | null
|
||||||
locationName?: string | null
|
locationName?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
latitude?: number | null
|
latitude?: number | null
|
||||||
longitude?: number | null
|
longitude?: number | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
@@ -114,9 +119,13 @@ const selectedId = ref<string | null>(null)
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const offersWithPrice = computed(() =>
|
||||||
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
|
{ 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 }
|
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,27 @@
|
|||||||
|
|
||||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:location-name="offer.locationName"
|
:location-name="offer.locationName"
|
||||||
:product-name="offer.title || undefined"
|
:product-name="offer.title || undefined"
|
||||||
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
:stages="[]"
|
:stages="[]"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
||||||
<Text tone="muted">
|
<Text tone="muted">
|
||||||
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
|
{{ t('common.pagination.showing', { shown: offersWithPrice.length, total: totalOffers }) }}
|
||||||
</Text>
|
</Text>
|
||||||
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||||
{{ t('common.actions.load_more') }}
|
{{ t('common.actions.load_more') }}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</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>
|
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -51,6 +54,9 @@ interface Offer {
|
|||||||
status?: string | null
|
status?: string | null
|
||||||
validUntil?: string | null
|
validUntil?: string | null
|
||||||
lines?: (OfferLine | null)[] | null
|
lines?: (OfferLine | null)[] | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -63,7 +69,10 @@ const props = defineProps<{
|
|||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const { t } = useI18n()
|
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 canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
props.onLoadMore?.()
|
props.onLoadMore?.()
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
<Icon name="lucide:shopping-bag" size="16" />
|
<Icon name="lucide:shopping-bag" size="16" />
|
||||||
{{ $t('catalog.headers.offers') }}
|
{{ $t('catalog.headers.offers') }}
|
||||||
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
|
<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>
|
</h3>
|
||||||
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
|
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
|
||||||
<Icon name="lucide:x" size="14" />
|
<Icon name="lucide:x" size="14" />
|
||||||
@@ -137,12 +137,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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') }}
|
{{ $t('catalog.empty.noOffers') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in relatedOffers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
|
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
|
||||||
:product-name="offer.productName"
|
:product-name="offer.productName"
|
||||||
@@ -257,6 +257,9 @@ const relatedProducts = computed(() => props.relatedProducts ?? [])
|
|||||||
const relatedHubs = computed(() => props.relatedHubs ?? [])
|
const relatedHubs = computed(() => props.relatedHubs ?? [])
|
||||||
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
|
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
|
||||||
const relatedOffers = computed(() => props.relatedOffers ?? [])
|
const relatedOffers = computed(() => props.relatedOffers ?? [])
|
||||||
|
const offersWithPrice = computed(() =>
|
||||||
|
relatedOffers.value.filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
|
|
||||||
// Entity name
|
// Entity name
|
||||||
const entityName = computed(() => {
|
const entityName = computed(() => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -14,14 +14,14 @@
|
|||||||
<span class="loading loading-spinner loading-md text-white" />
|
<span class="loading loading-spinner loading-md text-white" />
|
||||||
</div>
|
</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" />
|
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="offer in offers"
|
v-for="offer in offersWithPrice"
|
||||||
:key="offer.uuid"
|
:key="offer.uuid"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@click="emit('select-offer', offer)"
|
@click="emit('select-offer', offer)"
|
||||||
@@ -54,12 +54,16 @@ interface Offer {
|
|||||||
locationCountryCode?: string | null
|
locationCountryCode?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const emit = defineEmits<{
|
||||||
|
'select-offer': [offer: Offer]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
loading: boolean
|
loading: boolean
|
||||||
offers: Offer[]
|
offers: Offer[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const offersWithPrice = computed(() =>
|
||||||
'select-offer': [offer: Offer]
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
}>()
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user