Files
webapp/app/pages/catalog/offers/[uuid].vue
2026-01-07 09:10:35 +07:00

220 lines
7.4 KiB
Vue

<template>
<Stack gap="0">
<!-- Loading -->
<Section v-if="isLoading" variant="plain" paddingY="lg">
<Stack align="center" justify="center" gap="4">
<Spinner />
<Text tone="muted">{{ t('catalogOffer.states.loading') }}</Text>
</Stack>
</Section>
<!-- Error / Not Found -->
<Section v-else-if="!offer" variant="plain" paddingY="lg">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:package" size="24" />
</IconCircle>
<Heading :level="2">{{ t('catalogOffer.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogOffer.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
{{ t('catalogOffer.actions.back_to_catalog') }}
</Button>
</Stack>
</Card>
</Section>
<template v-else>
<!-- Map Hero -->
<MapHero
:title="offer.productName"
:location="mapLocation"
:badges="offerBadges"
/>
<!-- Product and price -->
<Section variant="plain" paddingY="md">
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" align="start" justify="between" gap="4">
<div>
<NuxtLink
v-if="offer.productUuid"
:to="localePath(`/catalog/products/${offer.productUuid}`)"
class="hover:text-primary transition-colors"
>
<Heading :level="2">{{ offer.productName }}</Heading>
</NuxtLink>
<Heading v-else :level="2">{{ offer.productName }}</Heading>
<Pill v-if="offer.categoryName" variant="outline" size="sm" class="mt-2">
{{ offer.categoryName }}
</Pill>
</div>
<div v-if="offer.pricePerUnit" class="text-right">
<div class="text-2xl font-bold text-primary">
{{ formatPrice(offer.pricePerUnit, offer.currency) }}
</div>
<Text tone="muted" size="sm">{{ t('catalogOffer.labels.per_unit', { unit: displayUnit(offer.unit) }) }}</Text>
</div>
</Stack>
<div class="flex items-center gap-4">
<Badge variant="primary" size="md">{{ t('catalogOffer.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit(offer.unit) }) }}</Badge>
<span v-if="offer.validUntil" class="text-base-content/60">
{{ t('catalogOffer.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
</span>
</div>
</Stack>
</Card>
</Section>
<!-- Description -->
<Section v-if="offer.description" variant="plain" paddingY="md">
<Card padding="lg">
<Stack gap="3">
<Heading :level="3">{{ t('catalogOffer.sections.description.title') }}</Heading>
<Text>{{ offer.description }}</Text>
</Stack>
</Card>
</Section>
<!-- Supplier -->
<Section v-if="supplier" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="3">{{ t('catalogOffer.sections.supplier.title') }}</Heading>
<SupplierCard :supplier="supplier" />
</Stack>
</Section>
<!-- Location -->
<Section v-if="offer.locationUuid" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="3">{{ t('catalogOffer.sections.location.title') }}</Heading>
<NuxtLink :to="localePath(`/catalog/hubs/${offer.locationUuid}`)">
<Card padding="lg" interactive>
<Stack direction="row" align="center" gap="3">
<span class="text-2xl">{{ countryFlag }}</span>
<Stack gap="1">
<Text weight="semibold">{{ offer.locationName }}</Text>
<Text v-if="offer.locationCountry" tone="muted" size="sm">{{ offer.locationCountry }}</Text>
</Stack>
<Icon name="lucide:arrow-right" size="20" class="ml-auto text-base-content/50" />
</Stack>
</Card>
</NuxtLink>
</Stack>
</Section>
</template>
</Stack>
</template>
<script setup lang="ts">
import {
GetOfferDocument,
GetSupplierProfilesDocument,
} from '~/composables/graphql/public/exchange-generated'
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const { data: offerData, pending: offerPending } = await useServerQuery('catalog-offer-detail', GetOfferDocument, { uuid: route.params.uuid as string }, 'public', 'exchange')
const { data: suppliersData, pending: suppliersPending } = await useServerQuery('catalog-offer-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
const isLoading = computed(() => offerPending.value || suppliersPending.value)
const offer = computed(() => offerData.value?.getOffer || null)
const supplier = computed(() => {
const suppliers = suppliersData.value?.getSupplierProfiles || []
if (offer.value?.teamUuid) {
return suppliers.find((s: any) => s?.teamUuid === offer.value.teamUuid)
}
return null
})
const offerUuid = computed(() => route.params.uuid as string)
// Map location
const mapLocation = computed(() => ({
uuid: offer.value?.locationUuid,
name: offer.value?.locationName,
latitude: offer.value?.locationLatitude,
longitude: offer.value?.locationLongitude,
country: offer.value?.locationCountry,
countryCode: offer.value?.locationCountryCode
}))
// Badges for MapHero
const offerBadges = computed(() => {
const badges: Array<{ icon?: string; text: string }> = []
if (offer.value?.locationName) {
badges.push({ icon: 'lucide:map-pin', text: offer.value.locationName })
}
if (offer.value?.locationCountry) {
badges.push({ icon: 'lucide:globe', text: offer.value.locationCountry })
}
if (offer.value?.validUntil) {
badges.push({ icon: 'lucide:calendar', text: t('catalogOffer.badges.valid_until', { date: formatDate(offer.value.validUntil) }) })
}
return badges
})
const formatDate = (date: string) => {
try {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(date))
} catch {
return date
}
}
const formatPrice = (price: number | string, currency?: string) => {
const num = typeof price === 'string' ? parseFloat(price) : price
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: curr,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
const displayUnit = (unit?: string | null) => unit || t('catalogOffer.units.ton_short')
// Convert 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 (offer.value?.locationCountryCode) {
return isoToEmoji(offer.value.locationCountryCode)
}
return '📍'
})
// SEO
useHead(() => ({
title: offer.value?.title
? t('catalogOffer.meta.title_with_name', { title: offer.value.title })
: t('catalogOffer.meta.title'),
meta: [
{
name: 'description',
content: offer.value?.description || t('catalogOffer.meta.description', { title: offer.value?.title || '' })
}
]
}))
</script>