Initial commit from monorepo
This commit is contained in:
310
app/pages/catalog/hubs/[id].vue
Normal file
310
app/pages/catalog/hubs/[id].vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<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('catalogHub.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!hub" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:map-pin" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogHub.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogHub.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogHub.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- Map Hero -->
|
||||
<MapHero
|
||||
:title="hub.name"
|
||||
:location="mapLocation"
|
||||
:badges="hubBadges"
|
||||
/>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Empty offers state -->
|
||||
<Section v-else variant="plain" paddingY="md">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:package-x" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="3">{{ t('catalogHub.empty.offers.title') }}</Heading>
|
||||
<Text tone="muted" align="center">
|
||||
{{ t('catalogHub.empty.offers.subtitle') }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Suppliers Section -->
|
||||
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.suppliers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<SupplierCard
|
||||
v-for="supplier in uniqueSuppliers"
|
||||
:key="supplier.teamUuid"
|
||||
:supplier="supplier"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Products Section -->
|
||||
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogHub.sections.products.title') }}</Heading>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="product in uniqueProducts"
|
||||
:key="product.uuid"
|
||||
:to="localePath(`/catalog/products/${product.uuid}`)"
|
||||
>
|
||||
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
|
||||
{{ product.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Nearby Connections -->
|
||||
<NearbyConnectionsSection
|
||||
:auto-edges="autoEdges"
|
||||
:rail-edges="railEdges"
|
||||
:hub="currentHubForMap"
|
||||
:rail-hub="railHubForMap"
|
||||
:auto-route-geometries="autoRouteGeometries"
|
||||
:rail-route-geometries="railRouteGeometries"
|
||||
/>
|
||||
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetLocationOffersDocument, GetSupplierProfilesDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
import { GetNodeConnectionsDocument, GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
|
||||
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
interface RouteGeometry {
|
||||
toUuid: string
|
||||
coordinates: [number, number][]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const hub = ref<any>(null)
|
||||
const railHub = ref<any>(null)
|
||||
const offers = ref<any[]>([])
|
||||
const allSuppliers = ref<any[]>([])
|
||||
const autoEdges = ref<EdgeType[]>([])
|
||||
const railEdges = ref<EdgeType[]>([])
|
||||
const autoRouteGeometries = ref<RouteGeometry[]>([])
|
||||
const railRouteGeometries = ref<RouteGeometry[]>([])
|
||||
|
||||
const hubId = computed(() => route.params.id as string)
|
||||
|
||||
// Map location
|
||||
const mapLocation = computed(() => ({
|
||||
uuid: hub.value?.uuid,
|
||||
name: hub.value?.name,
|
||||
latitude: hub.value?.latitude,
|
||||
longitude: hub.value?.longitude,
|
||||
country: hub.value?.country,
|
||||
countryCode: hub.value?.countryCode
|
||||
}))
|
||||
|
||||
// Badges for MapHero
|
||||
const hubBadges = computed(() => {
|
||||
const badges: Array<{ icon?: string; text: string }> = []
|
||||
if (hub.value?.country) {
|
||||
badges.push({ icon: 'lucide:globe', text: hub.value.country })
|
||||
}
|
||||
if (offers.value.length > 0) {
|
||||
badges.push({ icon: 'lucide:package', text: t('catalogHub.badges.offers', { count: offers.value.length }) })
|
||||
}
|
||||
if (hub.value?.latitude && hub.value?.longitude) {
|
||||
badges.push({ icon: 'lucide:map-pin', text: `${hub.value.latitude.toFixed(2)}°, ${hub.value.longitude.toFixed(2)}°` })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
// Unique suppliers
|
||||
const uniqueSuppliers = computed(() => {
|
||||
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.teamUuid) {
|
||||
const existing = suppliers.get(offer.teamUuid)
|
||||
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
|
||||
if (existing) {
|
||||
existing.offersCount++
|
||||
} else {
|
||||
suppliers.set(offer.teamUuid, {
|
||||
teamUuid: offer.teamUuid,
|
||||
name: supplierInfo?.name || t('catalogHub.labels.default_supplier'),
|
||||
offersCount: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(suppliers.values())
|
||||
})
|
||||
|
||||
// Unique products
|
||||
const uniqueProducts = computed(() => {
|
||||
const products = new Map<string, { uuid: string; name: string }>()
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid && line?.productName) {
|
||||
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
|
||||
}
|
||||
})
|
||||
})
|
||||
return Array.from(products.values())
|
||||
})
|
||||
|
||||
// Current hub for NearbyConnectionsSection
|
||||
const currentHubForMap = computed(() => ({
|
||||
uuid: hub.value?.uuid || '',
|
||||
name: hub.value?.name || '',
|
||||
latitude: hub.value?.latitude || 0,
|
||||
longitude: hub.value?.longitude || 0
|
||||
}))
|
||||
|
||||
const railHubForMap = computed(() => ({
|
||||
uuid: railHub.value?.uuid || hub.value?.uuid || '',
|
||||
name: railHub.value?.name || hub.value?.name || '',
|
||||
latitude: railHub.value?.latitude || hub.value?.latitude || 0,
|
||||
longitude: railHub.value?.longitude || hub.value?.longitude || 0
|
||||
}))
|
||||
|
||||
// Load route geometries for edges
|
||||
const loadRouteGeometries = async (
|
||||
edges: EdgeType[],
|
||||
hubLat: number,
|
||||
hubLon: number,
|
||||
transportType: 'auto' | 'rail'
|
||||
): Promise<RouteGeometry[]> => {
|
||||
const RouteDocument = transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
|
||||
const routeField = transportType === 'auto' ? 'autoRoute' : 'railRoute'
|
||||
|
||||
const filteredEdges = edges
|
||||
.filter(e => e?.transportType === transportType && e?.toLatitude && e?.toLongitude)
|
||||
.sort((a, b) => (a.distanceKm || 0) - (b.distanceKm || 0))
|
||||
.slice(0, 12)
|
||||
|
||||
const routePromises = filteredEdges.map(async (edge) => {
|
||||
try {
|
||||
const { data: routeDataResponse } = await useServerQuery(
|
||||
`hub-route-${transportType}-${edge.toUuid}`,
|
||||
RouteDocument,
|
||||
{
|
||||
fromLat: hubLat,
|
||||
fromLon: hubLon,
|
||||
toLat: edge.toLatitude!,
|
||||
toLon: edge.toLongitude!
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
const routeData = routeDataResponse.value?.[routeField]
|
||||
if (routeData?.geometry) {
|
||||
const geometryArray = typeof routeData.geometry === 'string'
|
||||
? JSON.parse(routeData.geometry)
|
||||
: routeData.geometry
|
||||
|
||||
if (Array.isArray(geometryArray) && geometryArray.length > 0) {
|
||||
return {
|
||||
toUuid: edge.toUuid!,
|
||||
coordinates: geometryArray as [number, number][]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${transportType} route to ${edge.toName}:`, error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const results = await Promise.all(routePromises)
|
||||
return results.filter(Boolean) as RouteGeometry[]
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ data: connectionsData }, { data: offersData }, { data: suppliersData }] = await Promise.all([
|
||||
useServerQuery('hub-connections', GetNodeConnectionsDocument, { uuid: hubId.value }, 'public', 'geo'),
|
||||
useServerQuery('hub-offers', GetLocationOffersDocument, { locationUuid: hubId.value }, 'public', 'exchange'),
|
||||
useServerQuery('hub-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
])
|
||||
|
||||
const connectionsResult = connectionsData.value
|
||||
hub.value = connectionsResult?.nodeConnections?.hub || null
|
||||
railHub.value = connectionsResult?.nodeConnections?.railNode || null
|
||||
offers.value = offersData.value?.getOffers || []
|
||||
allSuppliers.value = suppliersData.value?.getSupplierProfiles || []
|
||||
autoEdges.value = (connectionsResult?.nodeConnections?.autoEdges || []).filter((e): e is EdgeType => e !== null)
|
||||
railEdges.value = (connectionsResult?.nodeConnections?.railEdges || []).filter((e): e is EdgeType => e !== null)
|
||||
|
||||
if (hub.value?.latitude && hub.value?.longitude) {
|
||||
const railOrigin = railHub.value || hub.value
|
||||
const [autoGeometries, railGeometries] = await Promise.all([
|
||||
loadRouteGeometries(autoEdges.value, hub.value.latitude, hub.value.longitude, 'auto'),
|
||||
loadRouteGeometries(railEdges.value, railOrigin.latitude, railOrigin.longitude, 'rail')
|
||||
])
|
||||
autoRouteGeometries.value = autoGeometries
|
||||
railRouteGeometries.value = railGeometries
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading hub:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: hub.value?.name
|
||||
? t('catalogHub.meta.title_with_name', { name: hub.value.name })
|
||||
: t('catalogHub.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: t('catalogHub.meta.description', {
|
||||
name: hub.value?.name || '',
|
||||
country: hub.value?.country || '',
|
||||
offers: offers.value.length,
|
||||
suppliers: uniqueSuppliers.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
80
app/pages/catalog/hubs/index.vue
Normal file
80
app/pages/catalog/hubs/index.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogHubsSection.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="6">
|
||||
<NuxtLink :to="localePath('/catalog/hubs/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="hubs-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<div v-for="country in itemsByCountry" :key="country.name" class="space-y-3">
|
||||
<Text weight="semibold">{{ country.name }}</Text>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<HubCard
|
||||
v-for="hub in country.hubs"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
/>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="items.length"
|
||||
:total="total"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
|
||||
<Stack v-if="items.length === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogHubsSection.empty.no_hubs') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
selectedFilter,
|
||||
filters,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
itemsWithCoords,
|
||||
itemsByCountry,
|
||||
canLoadMore,
|
||||
loadMore,
|
||||
init
|
||||
} = useCatalogHubs()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogHubsSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
70
app/pages/catalog/hubs/map.vue
Normal file
70
app/pages/catalog/hubs/map.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogHubsSection.header.title')"
|
||||
:back-link="localePath('/catalog/hubs')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.hubs')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<HubCard
|
||||
v-for="hub in items"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
selectable
|
||||
:is-selected="selectedItemId === hub.uuid"
|
||||
@select="selectItem(hub)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="hubs-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#10b981"
|
||||
@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
|
||||
} = useCatalogHubs()
|
||||
|
||||
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
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
384
app/pages/catalog/products/[id].vue
Normal file
384
app/pages/catalog/products/[id].vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<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('catalogProduct.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!product" 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('catalogProduct.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogProduct.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- HERO Section -->
|
||||
<Section variant="hero" paddingY="lg">
|
||||
<Stack gap="6">
|
||||
<Stack direction="row" align="start" gap="6">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<IconCircle tone="primary" class="w-20 h-20 text-3xl bg-white/20">
|
||||
<Icon :name="getCategoryIcon(product.categoryName)" size="32" />
|
||||
</IconCircle>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<Stack gap="3" class="flex-1">
|
||||
<Heading :level="1" tone="inverse">{{ product.name }}</Heading>
|
||||
<Stack direction="row" align="center" gap="4">
|
||||
<Pill variant="inverse">
|
||||
<Icon name="lucide:folder" size="14" />
|
||||
{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}
|
||||
</Pill>
|
||||
<Pill v-if="offers.length > 0" variant="inverse">
|
||||
<Icon name="lucide:tag" size="14" />
|
||||
{{ t('catalogProduct.labels.offers_count', { count: offers.length }) }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Grid :cols="2" :md="4" :gap="4">
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ offers.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.offers') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueSuppliers.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.suppliers') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueLocations.length }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.locations') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Stack gap="1" align="center">
|
||||
<Text size="2xl" weight="bold" class="text-primary">{{ priceRange }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.price_range') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
padding="md"
|
||||
interactive
|
||||
@click="navigateTo(localePath(`/catalog/offers/${offer.uuid}`))"
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="lg" weight="semibold">{{ offer.title }}</Text>
|
||||
<Stack direction="row" align="center" gap="2">
|
||||
<Icon name="lucide:map-pin" size="14" class="text-base-content/60" />
|
||||
<Text tone="muted">{{ offer.locationName }}, {{ offer.locationCountry }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<!-- Line with this product -->
|
||||
<template v-for="line in getProductLines(offer)" :key="line?.uuid">
|
||||
<Card padding="sm" class="bg-base-200">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack gap="0">
|
||||
<Text weight="semibold">{{ line?.quantity }} {{ line?.unit }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.in_stock') }}</Text>
|
||||
</Stack>
|
||||
<Stack gap="0" align="end">
|
||||
<Text weight="bold" class="text-primary">
|
||||
{{ formatPrice(line?.pricePerUnit, line?.currency) }}
|
||||
</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.per_unit', { unit: line?.unit }) }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusLabel(offer.status) }}
|
||||
</Badge>
|
||||
<Text v-if="offer.validUntil" tone="muted" size="sm">
|
||||
{{ t('catalogProduct.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Suppliers Section -->
|
||||
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.suppliers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="supplier in uniqueSuppliers"
|
||||
:key="supplier.teamUuid"
|
||||
padding="md"
|
||||
interactive
|
||||
@click="navigateTo(localePath(`/catalog/suppliers/${supplier.teamUuid}`))"
|
||||
>
|
||||
<Stack direction="row" align="center" gap="3">
|
||||
<IconCircle tone="primary">
|
||||
{{ supplier.name?.charAt(0) || '?' }}
|
||||
</IconCircle>
|
||||
<Stack gap="1" class="flex-1">
|
||||
<Text weight="semibold">{{ supplier.name || t('catalogProduct.labels.default_supplier') }}</Text>
|
||||
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.supplier_offers', { count: supplier.offersCount }) }}</Text>
|
||||
</Stack>
|
||||
<Icon name="lucide:chevron-right" size="20" class="text-base-content/60" />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Map Section -->
|
||||
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogProduct.sections.locations.title') }}</Heading>
|
||||
<div class="h-80 rounded-lg overflow-hidden">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
:locations="mapLocations"
|
||||
:height="320"
|
||||
:initial-zoom="2"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<Section variant="plain" paddingY="lg">
|
||||
<Card padding="lg" class="bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10 border border-base-300">
|
||||
<Stack gap="4" align="center">
|
||||
<Heading :level="3">{{ t('catalogProduct.cta.title') }}</Heading>
|
||||
<Text tone="muted" align="center">
|
||||
{{ t('catalogProduct.cta.subtitle') }}
|
||||
</Text>
|
||||
<Button size="large">
|
||||
<Icon name="lucide:plus" size="18" class="mr-2" />
|
||||
{{ t('catalogProduct.cta.action') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetProductsDocument,
|
||||
GetProductOffersDocument,
|
||||
GetSupplierProfilesDocument,
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
data: productsData,
|
||||
pending: productsPending
|
||||
} = await useServerQuery('catalog-product-list', GetProductsDocument, {}, 'public', 'exchange')
|
||||
|
||||
const {
|
||||
data: productOffersData,
|
||||
pending: productOffersPending
|
||||
} = await useServerQuery('catalog-product-offers', GetProductOffersDocument, { productUuid: route.params.id as string }, 'public', 'exchange')
|
||||
|
||||
const {
|
||||
data: suppliersData,
|
||||
pending: suppliersPending
|
||||
} = await useServerQuery('catalog-product-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
|
||||
const isLoading = computed(() => productsPending.value || productOffersPending.value || suppliersPending.value)
|
||||
const product = computed(() => findProduct(productsData.value?.getProducts || []))
|
||||
const offers = computed(() => productOffersData.value?.getOffers || [])
|
||||
const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles || [])
|
||||
|
||||
const productId = computed(() => route.params.id as string)
|
||||
|
||||
// Find product by uuid from list
|
||||
const findProduct = (products: any[]) => {
|
||||
return products.find(p => p?.uuid === productId.value)
|
||||
}
|
||||
|
||||
// Unique suppliers from offers
|
||||
const uniqueSuppliers = computed(() => {
|
||||
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.teamUuid) {
|
||||
const existing = suppliers.get(offer.teamUuid)
|
||||
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
|
||||
if (existing) {
|
||||
existing.offersCount++
|
||||
} else {
|
||||
suppliers.set(offer.teamUuid, {
|
||||
teamUuid: offer.teamUuid,
|
||||
name: supplierInfo?.name || t('catalogProduct.labels.default_supplier'),
|
||||
offersCount: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(suppliers.values())
|
||||
})
|
||||
|
||||
// Unique locations
|
||||
const uniqueLocations = computed(() => {
|
||||
const locations = new Map<string, { uuid: string; name: string; country: string; latitude: number; longitude: number }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.locationUuid && offer.locationLatitude && offer.locationLongitude) {
|
||||
locations.set(offer.locationUuid, {
|
||||
uuid: offer.locationUuid,
|
||||
name: offer.locationName,
|
||||
country: offer.locationCountry || '',
|
||||
latitude: offer.locationLatitude,
|
||||
longitude: offer.locationLongitude
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(locations.values())
|
||||
})
|
||||
|
||||
// Locations for map
|
||||
const mapLocations = computed(() => {
|
||||
return uniqueLocations.value.map(loc => ({
|
||||
uuid: loc.uuid,
|
||||
name: loc.name,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
country: loc.country
|
||||
}))
|
||||
})
|
||||
|
||||
// Price range
|
||||
const priceRange = computed(() => {
|
||||
const prices: number[] = []
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid === productId.value && line?.pricePerUnit) {
|
||||
prices.push(Number(line.pricePerUnit))
|
||||
}
|
||||
})
|
||||
})
|
||||
if (prices.length === 0) return t('common.values.not_available')
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
if (min === max) return t('catalogProduct.labels.price_single', { price: min.toLocaleString() })
|
||||
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
|
||||
})
|
||||
|
||||
// Get lines with this product
|
||||
const getProductLines = (offer: any) => {
|
||||
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
|
||||
}
|
||||
|
||||
const getCategoryIcon = (categoryName: string | null | undefined) => {
|
||||
const name = (categoryName || '').toLowerCase()
|
||||
if (name.includes('metal')) return 'lucide:boxes'
|
||||
if (name.includes('food')) return 'lucide:wheat'
|
||||
if (name.includes('chem')) return 'lucide:flask-conical'
|
||||
if (name.includes('build')) return 'lucide:building'
|
||||
return 'lucide:package'
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string | null | undefined) => {
|
||||
const variants: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
DRAFT: 'warning',
|
||||
CANCELLED: 'error',
|
||||
CLOSED: 'muted'
|
||||
}
|
||||
return variants[status || ''] || 'muted'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string | null | undefined) => {
|
||||
const labels: Record<string, string> = {
|
||||
ACTIVE: t('catalogProduct.status.active'),
|
||||
DRAFT: t('catalogProduct.status.draft'),
|
||||
CANCELLED: t('catalogProduct.status.cancelled'),
|
||||
CLOSED: t('catalogProduct.status.closed')
|
||||
}
|
||||
return labels[status || ''] || status || t('catalogProduct.status.unknown')
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat('ru', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr))
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: any, currency: string | null | undefined) => {
|
||||
if (!price) return '—'
|
||||
const num = Number(price)
|
||||
const curr = currency || 'USD'
|
||||
try {
|
||||
return new Intl.NumberFormat('ru', {
|
||||
style: 'currency',
|
||||
currency: curr,
|
||||
maximumFractionDigits: 0
|
||||
}).format(num)
|
||||
} catch {
|
||||
return `${num} ${curr}`
|
||||
}
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: product.value?.name
|
||||
? t('catalogProduct.meta.title_with_name', { name: product.value.name })
|
||||
: t('catalogProduct.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: t('catalogProduct.meta.description', {
|
||||
name: product.value?.name || '',
|
||||
offers: offers.value.length,
|
||||
suppliers: uniqueSuppliers.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
251
app/pages/catalog/suppliers/[id].vue
Normal file
251
app/pages/catalog/suppliers/[id].vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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('catalogSupplier.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!supplier" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:building-2" size="24" />
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogSupplier.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogSupplier.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
{{ t('catalogSupplier.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<template v-else>
|
||||
<!-- Map Hero -->
|
||||
<MapHero
|
||||
:title="supplier.name"
|
||||
:location="supplierLocation"
|
||||
:badges="supplierBadges"
|
||||
:initial-zoom="3"
|
||||
/>
|
||||
|
||||
<!-- Offers Section -->
|
||||
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.offers.title') }}</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Products Section -->
|
||||
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.products.title') }}</Heading>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="product in uniqueProducts"
|
||||
:key="product.uuid"
|
||||
:to="localePath(`/catalog/products/${product.uuid}`)"
|
||||
>
|
||||
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
|
||||
{{ product.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<!-- Locations Map Section -->
|
||||
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">{{ t('catalogSupplier.sections.locations.title') }}</Heading>
|
||||
<div class="h-64 rounded-lg overflow-hidden border border-base-300 bg-base-100">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
:key="`supplier-locations-${supplierId}`"
|
||||
:map-id="`supplier-locations-${supplierId}`"
|
||||
:locations="mapLocations"
|
||||
:height="256"
|
||||
:initial-zoom="3"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<NuxtLink
|
||||
v-for="location in uniqueLocations"
|
||||
:key="location.uuid"
|
||||
:to="localePath(`/catalog/hubs/${location.uuid}`)"
|
||||
>
|
||||
<Pill variant="outline" class="hover:bg-base-200 transition-colors cursor-pointer">
|
||||
<Icon name="lucide:map-pin" size="14" />
|
||||
{{ location.name }}
|
||||
</Pill>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GetSupplierProfileDocument,
|
||||
GetSupplierOffersDocument,
|
||||
GetSupplierProfilesDocument,
|
||||
GetOffersDocument,
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const supplier = ref<any>(null)
|
||||
const offers = ref<any[]>([])
|
||||
|
||||
const supplierId = computed(() => route.params.id as string)
|
||||
|
||||
// Supplier location for map - use supplier's own coordinates
|
||||
const supplierLocation = computed(() => {
|
||||
if (supplier.value?.latitude && supplier.value?.longitude) {
|
||||
return {
|
||||
uuid: supplier.value.uuid,
|
||||
name: supplier.value.name,
|
||||
latitude: supplier.value.latitude,
|
||||
longitude: supplier.value.longitude,
|
||||
country: supplier.value.country,
|
||||
countryCode: supplier.value.countryCode
|
||||
}
|
||||
}
|
||||
// Fallback to first offer location if supplier has no coordinates
|
||||
const firstOffer = offers.value.find(o => o.locationLatitude && o.locationLongitude)
|
||||
if (firstOffer) {
|
||||
return {
|
||||
uuid: firstOffer.locationUuid,
|
||||
name: firstOffer.locationName,
|
||||
latitude: firstOffer.locationLatitude,
|
||||
longitude: firstOffer.locationLongitude,
|
||||
country: firstOffer.locationCountry,
|
||||
countryCode: firstOffer.locationCountryCode
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Badges for MapHero
|
||||
const supplierBadges = computed(() => {
|
||||
const badges: Array<{ icon?: string; text: string }> = []
|
||||
if (supplier.value?.country) {
|
||||
badges.push({ icon: 'lucide:globe', text: supplier.value.country })
|
||||
}
|
||||
if (supplier.value?.isVerified) {
|
||||
badges.push({ icon: 'lucide:check-circle', text: t('catalogSupplier.badges.verified') })
|
||||
}
|
||||
if (offers.value.length > 0) {
|
||||
badges.push({ icon: 'lucide:package', text: t('catalogSupplier.badges.offers', { count: offers.value.length }) })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
// Unique products from offers
|
||||
const uniqueProducts = computed(() => {
|
||||
const products = new Map<string, { uuid: string; name: string }>()
|
||||
offers.value.forEach(offer => {
|
||||
offer.lines?.forEach((line: any) => {
|
||||
if (line?.productUuid && line?.productName) {
|
||||
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
|
||||
}
|
||||
})
|
||||
})
|
||||
return Array.from(products.values())
|
||||
})
|
||||
|
||||
// Unique locations from offers
|
||||
const uniqueLocations = computed(() => {
|
||||
const locations = new Map<string, { uuid: string; name: string; latitude: number; longitude: number; country?: string | null; countryCode?: string | null }>()
|
||||
offers.value.forEach(offer => {
|
||||
if (offer.locationUuid && offer.locationName && offer.locationLatitude && offer.locationLongitude) {
|
||||
locations.set(offer.locationUuid, {
|
||||
uuid: offer.locationUuid,
|
||||
name: offer.locationName,
|
||||
latitude: offer.locationLatitude,
|
||||
longitude: offer.locationLongitude,
|
||||
country: offer.locationCountry,
|
||||
countryCode: offer.locationCountryCode
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(locations.values())
|
||||
})
|
||||
|
||||
// Locations for the map
|
||||
const mapLocations = computed(() => {
|
||||
return uniqueLocations.value.map(loc => ({
|
||||
uuid: loc.uuid,
|
||||
name: loc.name,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
country: ''
|
||||
}))
|
||||
})
|
||||
|
||||
try {
|
||||
const { data: supplierData } = await useServerQuery('catalog-supplier-detail', GetSupplierProfileDocument, { uuid: supplierId.value }, 'public', 'exchange')
|
||||
supplier.value = supplierData.value?.getSupplierProfile || null
|
||||
|
||||
if (!supplier.value) {
|
||||
const { data: suppliersList } = await useServerQuery('catalog-suppliers-fallback', GetSupplierProfilesDocument, {}, 'public', 'exchange')
|
||||
supplier.value = (suppliersList.value?.getSupplierProfiles || []).find((s: any) => s?.teamUuid === supplierId.value || s?.uuid === supplierId.value) || null
|
||||
}
|
||||
|
||||
const teamIds = [
|
||||
supplier.value?.teamUuid,
|
||||
supplier.value?.uuid,
|
||||
supplierId.value
|
||||
].filter(Boolean)
|
||||
|
||||
if (teamIds.length) {
|
||||
const primaryId = teamIds[0] as string
|
||||
const { data: offersData } = await useServerQuery('catalog-supplier-offers', GetSupplierOffersDocument, { teamUuid: primaryId }, 'public', 'exchange')
|
||||
offers.value = offersData.value?.getOffers || []
|
||||
|
||||
if (!offers.value.length) {
|
||||
const { data: allOffersData } = await useServerQuery('catalog-supplier-offers-fallback', GetOffersDocument, {}, 'public', 'exchange')
|
||||
const ids = new Set(teamIds)
|
||||
offers.value = (allOffersData.value?.getOffers || []).filter((o: any) => o?.teamUuid && ids.has(o.teamUuid))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading supplier:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: supplier.value?.name
|
||||
? t('catalogSupplier.meta.title_with_name', { name: supplier.value.name })
|
||||
: t('catalogSupplier.meta.title'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: supplier.value?.description
|
||||
|| (supplier.value?.name
|
||||
? t('catalogSupplier.meta.description_with_name', { name: supplier.value.name })
|
||||
: t('catalogSupplier.meta.description'))
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
76
app/pages/catalog/suppliers/index.vue
Normal file
76
app/pages/catalog/suppliers/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<Section variant="plain" paddingY="md">
|
||||
<PageHeader :title="t('catalogSuppliersSection.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/suppliers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
map-id="suppliers-map"
|
||||
:locations="itemsWithCoords"
|
||||
:height="192"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</NuxtLink>
|
||||
|
||||
<CatalogFilters :filters="filters" v-model="selectedFilter" />
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<SupplierCard
|
||||
v-for="supplier in items"
|
||||
:key="supplier.uuid || supplier.teamUuid"
|
||||
:supplier="supplier"
|
||||
/>
|
||||
</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('catalogSuppliersSection.empty.no_suppliers') }}</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
|
||||
} = useCatalogSuppliers()
|
||||
|
||||
await init()
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalogSuppliersSection.header.title')
|
||||
}))
|
||||
</script>
|
||||
70
app/pages/catalog/suppliers/map.vue
Normal file
70
app/pages/catalog/suppliers/map.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<NuxtLayout name="map">
|
||||
<template #sidebar>
|
||||
<CatalogMapSidebar
|
||||
:title="t('catalogSuppliersSection.header.title')"
|
||||
:back-link="localePath('/catalog/suppliers')"
|
||||
:back-label="t('catalogMap.actions.list_view')"
|
||||
:items-count="items.length"
|
||||
:filters="filters"
|
||||
:selected-filter="selectedFilter"
|
||||
:loading="isLoading"
|
||||
:empty-text="t('catalogMap.empty.suppliers')"
|
||||
@update:selected-filter="selectedFilter = $event"
|
||||
>
|
||||
<template #cards>
|
||||
<SupplierCard
|
||||
v-for="supplier in items"
|
||||
:key="supplier.uuid"
|
||||
:supplier="supplier"
|
||||
selectable
|
||||
:is-selected="selectedItemId === supplier.uuid"
|
||||
@select="selectItem(supplier)"
|
||||
/>
|
||||
</template>
|
||||
</CatalogMapSidebar>
|
||||
</template>
|
||||
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="suppliers-fullscreen-map"
|
||||
:items="itemsWithCoords"
|
||||
point-color="#3b82f6"
|
||||
@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
|
||||
} = useCatalogSuppliers()
|
||||
|
||||
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
|
||||
if (item.latitude && item.longitude) {
|
||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||
}
|
||||
}
|
||||
|
||||
const onMapSelectItem = (uuid: string) => {
|
||||
selectedItemId.value = uuid
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user