Initial commit from monorepo
This commit is contained in:
219
app/pages/catalog/offers/[uuid].vue
Normal file
219
app/pages/catalog/offers/[uuid].vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<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>
|
||||
76
app/pages/catalog/offers/index.vue
Normal file
76
app/pages/catalog/offers/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogOffersSection.header.title')" />
|
||||
</Section>
|
||||
|
||||
<Section v-if="isLoading" variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<NuxtLink :to="localePath('/catalog/offers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="offers-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in items"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
|
||||
<Stack v-if="total === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogOffers()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogOffersSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
72
app/pages/catalog/offers/map.vue
Normal file
72
app/pages/catalog/offers/map.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogOffersSection.header.title')"
|
||||
:back-link="localePath('/catalog/offers')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.offers')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<OfferCard
|
||||
v-for="offer in items"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
selectable
|
||||
:is-selected="selectedItemId === offer.uuid"
|
||||
@select="selectItem(offer)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="offers-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#f59e0b"
|
||||
@select-item="onMapSelectItem"
|
||||
/>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
itemsWithCoords,
|
||||
init
|
||||
} = useCatalogOffers()
|
||||
|
||||
await init()
|
||||
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
const selectedItemId = ref<string | null>(null)
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
selectedItemId.value = item.uuid
|
||||
const lat = item.locationLatitude
|
||||
const lng = item.locationLongitude
|
||||
if (lat && lng) {
|
||||
mapRef.value?.flyTo(lat, lng, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user