Initial commit from monorepo
This commit is contained in:
52
app/components/catalog/AddressCard.vue
Normal file
52
app/components/catalog/AddressCard.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<component
|
||||
:is="linkable ? NuxtLink : 'div'"
|
||||
:to="linkable ? localePath('/clientarea/addresses') : undefined"
|
||||
class="block"
|
||||
:class="{ 'cursor-pointer': selectable }"
|
||||
@click="selectable && $emit('select')"
|
||||
>
|
||||
<Card
|
||||
padding="sm"
|
||||
:interactive="linkable || selectable"
|
||||
:class="[
|
||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||
]"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" align="center" gap="2">
|
||||
<Icon name="lucide:map-pin" size="18" class="text-primary" />
|
||||
<Text size="base" weight="semibold">{{ address.name }}</Text>
|
||||
<Pill v-if="address.isDefault" variant="outline" size="sm">{{ t('catalogAddress.badges.default') }}</Pill>
|
||||
</Stack>
|
||||
<Text tone="muted" size="sm">{{ address.address }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from '#components'
|
||||
|
||||
interface Address {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
address?: string | null
|
||||
isDefault?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
address: Address
|
||||
selectable?: boolean
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const linkable = computed(() => !props.selectable && props.address.uuid)
|
||||
</script>
|
||||
29
app/components/catalog/CatalogFilters.vue
Normal file
29
app/components/catalog/CatalogFilters.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Pill
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
:variant="filter.id === modelValue ? 'primary' : 'outline'"
|
||||
class="cursor-pointer"
|
||||
@click="$emit('update:modelValue', filter.id)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Filter {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
filters: Filter[]
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
80
app/components/catalog/CatalogHubsSection.vue
Normal file
80
app/components/catalog/CatalogHubsSection.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">{{ t('catalogHubsSection.header.title') }}</Heading>
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/hubs')"
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
<span>{{ t('catalogHubsSection.actions.view_all') }}</span>
|
||||
<Icon name="lucide:arrow-right" size="16" />
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="6">
|
||||
<div v-for="country in hubsByCountry" :key="country.name">
|
||||
<Text weight="semibold" class="mb-3">{{ 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>
|
||||
|
||||
<Stack v-if="totalHubs > 0" direction="row" align="center" justify="between">
|
||||
<Text tone="muted">
|
||||
{{ t('common.pagination.showing', { shown: hubs.length, total: totalHubs }) }}
|
||||
</Text>
|
||||
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||
{{ t('common.actions.load_more') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Hub {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
distance?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hubs: Hub[]
|
||||
total?: number
|
||||
canLoadMore?: boolean
|
||||
onLoadMore?: () => void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
const totalHubs = computed(() => props.total ?? props.hubs.length)
|
||||
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||
const loadMore = () => {
|
||||
props.onLoadMore?.()
|
||||
}
|
||||
|
||||
const hubsByCountry = computed(() => {
|
||||
const grouped = new Map<string, Hub[]>()
|
||||
|
||||
props.hubs.forEach(hub => {
|
||||
const country = hub.country || 'Other'
|
||||
if (!grouped.has(country)) {
|
||||
grouped.set(country, [])
|
||||
}
|
||||
grouped.get(country)!.push(hub)
|
||||
})
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([name, hubs]) => ({ name, hubs }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
</script>
|
||||
192
app/components/catalog/CatalogMap.vue
Normal file
192
app/components/catalog/CatalogMap.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 min-h-0 w-full h-full">
|
||||
<ClientOnly>
|
||||
<MapboxMap
|
||||
:map-id="mapId"
|
||||
class="flex-1 min-h-0"
|
||||
style="width: 100%; height: 100%"
|
||||
:options="mapOptions"
|
||||
@load="onMapCreated"
|
||||
>
|
||||
<MapboxNavigationControl position="top-right" />
|
||||
</MapboxMap>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||
|
||||
interface MapItem {
|
||||
uuid: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
country?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mapId: string
|
||||
items: MapItem[]
|
||||
pointColor?: string
|
||||
initialCenter?: [number, number]
|
||||
initialZoom?: number
|
||||
}>(), {
|
||||
pointColor: '#10b981',
|
||||
initialCenter: () => [37.64, 55.76],
|
||||
initialZoom: 2
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-item': [uuid: string]
|
||||
}>()
|
||||
|
||||
const mapRef = useMapboxRef(props.mapId)
|
||||
|
||||
const mapOptions = computed(() => ({
|
||||
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||
center: props.initialCenter,
|
||||
zoom: props.initialZoom,
|
||||
projection: 'globe',
|
||||
pitch: 20
|
||||
}))
|
||||
|
||||
const geoJsonData = computed(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: props.items.map(item => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { uuid: item.uuid, name: item.name, country: item.country },
|
||||
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] }
|
||||
}))
|
||||
}))
|
||||
|
||||
const sourceId = computed(() => `${props.mapId}-points`)
|
||||
|
||||
const onMapCreated = (map: MapboxMapType) => {
|
||||
const initMap = () => {
|
||||
map.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.02,
|
||||
'space-color': 'rgb(11, 11, 25)',
|
||||
'star-intensity': 0.6
|
||||
})
|
||||
|
||||
map.addSource(sourceId.value, {
|
||||
type: 'geojson',
|
||||
data: geoJsonData.value,
|
||||
cluster: true,
|
||||
clusterMaxZoom: 14,
|
||||
clusterRadius: 50
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: sourceId.value,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': props.pointColor,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
|
||||
'circle-opacity': 0.8,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: sourceId.value,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 14
|
||||
},
|
||||
paint: { 'text-color': '#ffffff' }
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'unclustered-point',
|
||||
type: 'circle',
|
||||
source: sourceId.value,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 12,
|
||||
'circle-color': props.pointColor,
|
||||
'circle-stroke-width': 3,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'point-labels',
|
||||
type: 'symbol',
|
||||
source: sourceId.value,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 1.5],
|
||||
'text-size': 12,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1.5
|
||||
}
|
||||
})
|
||||
|
||||
map.on('click', 'clusters', (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
|
||||
if (!features.length) return
|
||||
const clusterId = features[0].properties?.cluster_id
|
||||
const source = map.getSource(sourceId.value) as mapboxgl.GeoJSONSource
|
||||
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||
if (err) return
|
||||
const geometry = features[0].geometry as GeoJSON.Point
|
||||
map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 })
|
||||
})
|
||||
})
|
||||
|
||||
map.on('click', 'unclustered-point', (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
|
||||
if (!features.length) return
|
||||
emit('select-item', features[0].properties?.uuid)
|
||||
})
|
||||
|
||||
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
|
||||
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
|
||||
}
|
||||
|
||||
if (map.loaded()) {
|
||||
initMap()
|
||||
} else {
|
||||
map.on('load', initMap)
|
||||
}
|
||||
}
|
||||
|
||||
// Update map data when items change
|
||||
watch(geoJsonData, (newData) => {
|
||||
if (!mapRef.value) return
|
||||
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||
if (source) {
|
||||
source.setData(newData)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Expose flyTo method for external use
|
||||
const flyTo = (lat: number, lng: number, zoom = 8) => {
|
||||
if (!mapRef.value) return
|
||||
mapRef.value.easeTo({
|
||||
center: [lng, lat],
|
||||
zoom,
|
||||
duration: 2000,
|
||||
easing: (t) => t * (2 - t)
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ flyTo })
|
||||
</script>
|
||||
155
app/components/catalog/CatalogMapPanel.vue
Normal file
155
app/components/catalog/CatalogMapPanel.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<Card class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-base-300 flex-shrink-0 bg-base-200/70 rounded-t-xl">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex-1 px-4 py-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring focus-visible:ring-primary/30"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'text-primary border-b-2 border-primary bg-primary/10'
|
||||
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
|
||||
]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ t(tab.label) }}
|
||||
<span class="ml-1 text-xs text-base-content/60">({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-2 bg-base-100">
|
||||
<!-- Hubs Tab -->
|
||||
<template v-if="activeTab === 'hubs'">
|
||||
<HubCard
|
||||
v-for="hub in hubs"
|
||||
:key="hub.uuid"
|
||||
:hub="hub"
|
||||
selectable
|
||||
:is-selected="selectedId === hub.uuid"
|
||||
@select="selectHub(hub)"
|
||||
/>
|
||||
<Text v-if="hubs.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
{{ t('catalogMap.empty.hubs') }}
|
||||
</Text>
|
||||
</template>
|
||||
|
||||
<!-- Offers Tab -->
|
||||
<template v-if="activeTab === 'offers'">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
selectable
|
||||
compact
|
||||
:is-selected="selectedId === offer.uuid"
|
||||
@select="selectOffer(offer)"
|
||||
/>
|
||||
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
{{ t('catalogMap.empty.offers') }}
|
||||
</Text>
|
||||
</template>
|
||||
|
||||
<!-- Suppliers Tab -->
|
||||
<template v-if="activeTab === 'suppliers'">
|
||||
<SupplierCard
|
||||
v-for="supplier in suppliers"
|
||||
:key="supplier.uuid"
|
||||
:supplier="supplier"
|
||||
selectable
|
||||
:is-selected="selectedId === supplier.uuid"
|
||||
@select="selectSupplier(supplier)"
|
||||
/>
|
||||
<Text v-if="suppliers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
{{ t('catalogMap.empty.suppliers') }}
|
||||
</Text>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Hub {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
distance?: string
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
uuid?: string | null
|
||||
title?: string | null
|
||||
locationName?: string | null
|
||||
status?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
lines?: any[] | null
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
offersCount?: number | null
|
||||
isVerified?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hubs: Hub[]
|
||||
offers: Offer[]
|
||||
suppliers: Supplier[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'fly-to', location: { uuid: string; name: string; latitude: number; longitude: number; country?: string }): void
|
||||
}>()
|
||||
|
||||
type TabId = 'hubs' | 'offers' | 'suppliers'
|
||||
|
||||
const activeTab = ref<TabId>('hubs')
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
|
||||
])
|
||||
|
||||
const selectHub = (hub: Hub) => {
|
||||
selectedId.value = hub.uuid || null
|
||||
|
||||
if (hub.latitude && hub.longitude) {
|
||||
emit('fly-to', {
|
||||
uuid: hub.uuid!,
|
||||
name: hub.name || '',
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
country: hub.country || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectOffer = (offer: Offer) => {
|
||||
selectedId.value = offer.uuid || null
|
||||
|
||||
if (offer.latitude && offer.longitude) {
|
||||
emit('fly-to', {
|
||||
uuid: offer.uuid!,
|
||||
name: offer.title || '',
|
||||
latitude: offer.latitude,
|
||||
longitude: offer.longitude,
|
||||
country: offer.locationName || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectSupplier = (supplier: Supplier) => {
|
||||
selectedId.value = supplier.uuid || null
|
||||
// Suppliers don't have coordinates currently
|
||||
}
|
||||
</script>
|
||||
59
app/components/catalog/CatalogMapSidebar.vue
Normal file
59
app/components/catalog/CatalogMapSidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<aside class="w-64 bg-base-100 h-screen flex flex-col border-r border-base-300">
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<NuxtLink :to="backLink" class="btn btn-sm btn-ghost gap-2">
|
||||
<Icon name="lucide:arrow-left" size="18" />
|
||||
{{ backLabel }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<Text weight="semibold">{{ title }}</Text>
|
||||
<Text tone="muted" size="sm">{{ itemsCount }} {{ t('catalogMap.labels.items') }}</Text>
|
||||
</div>
|
||||
|
||||
<div v-if="filters && filters.length > 0" class="p-4 border-b border-base-300">
|
||||
<CatalogFilters
|
||||
:filters="filters"
|
||||
:model-value="selectedFilter"
|
||||
@update:model-value="$emit('update:selectedFilter', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 bg-base-200">
|
||||
<div v-if="loading" class="flex items-center justify-center h-32">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<slot name="cards" />
|
||||
<div v-if="itemsCount === 0" class="text-center text-base-content/50 py-8">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Filter {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
backLink: string
|
||||
backLabel: string
|
||||
itemsCount: number
|
||||
loading?: boolean
|
||||
filters?: Filter[]
|
||||
selectedFilter?: string
|
||||
emptyText?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:selectedFilter': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
69
app/components/catalog/CatalogOffersSection.vue
Normal file
69
app/components/catalog/CatalogOffersSection.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">{{ t('catalogOffersSection.header.title') }}</Heading>
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/offers')"
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
<span>{{ t('catalogOffersSection.actions.view_all') }}</span>
|
||||
<Icon name="lucide:arrow-right" size="16" />
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<OfferCard
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:offer="offer"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
||||
<Text tone="muted">
|
||||
{{ t('common.pagination.showing', { shown: offers.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">
|
||||
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface OfferLine {
|
||||
uuid?: string | null
|
||||
productName?: string | null
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
uuid?: string | null
|
||||
title?: string | null
|
||||
locationName?: string | null
|
||||
status?: string | null
|
||||
validUntil?: string | null
|
||||
lines?: (OfferLine | null)[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
offers: Offer[]
|
||||
total?: number
|
||||
canLoadMore?: boolean
|
||||
onLoadMore?: () => void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const totalOffers = computed(() => props.total ?? props.offers.length)
|
||||
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||
const loadMore = () => {
|
||||
props.onLoadMore?.()
|
||||
}
|
||||
</script>
|
||||
63
app/components/catalog/CatalogSuppliersSection.vue
Normal file
63
app/components/catalog/CatalogSuppliersSection.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">{{ t('catalogSuppliersSection.header.title') }}</Heading>
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/suppliers')"
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
<span>{{ t('catalogSuppliersSection.actions.view_all') }}</span>
|
||||
<Icon name="lucide:arrow-right" size="16" />
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<SupplierCard
|
||||
v-for="supplier in suppliers"
|
||||
:key="supplier.uuid"
|
||||
:supplier="supplier"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack v-if="totalSuppliers > 0" direction="row" align="center" justify="between">
|
||||
<Text tone="muted">
|
||||
{{ t('common.pagination.showing', { shown: suppliers.length, total: totalSuppliers }) }}
|
||||
</Text>
|
||||
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||
{{ t('common.actions.load_more') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack v-if="suppliers.length === 0" align="center" gap="2">
|
||||
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Supplier {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
offersCount?: number | null
|
||||
isVerified?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
suppliers: Supplier[]
|
||||
total?: number
|
||||
canLoadMore?: boolean
|
||||
onLoadMore?: () => void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const totalSuppliers = computed(() => props.total ?? props.suppliers.length)
|
||||
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||
const loadMore = () => {
|
||||
props.onLoadMore?.()
|
||||
}
|
||||
</script>
|
||||
70
app/components/catalog/HubCard.vue
Normal file
70
app/components/catalog/HubCard.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<component
|
||||
:is="linkable ? NuxtLink : 'div'"
|
||||
:to="linkable ? localePath(`/catalog/hubs/${hub.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">
|
||||
<!-- Title -->
|
||||
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
|
||||
<!-- Country left, distance right -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Text tone="muted" size="sm">
|
||||
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
|
||||
</Text>
|
||||
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
|
||||
{{ hub.distance }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from '#components'
|
||||
|
||||
interface Hub {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
countryCode?: string | null
|
||||
distance?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hub: Hub
|
||||
selectable?: boolean
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const linkable = computed(() => !props.selectable && props.hub.uuid)
|
||||
|
||||
// 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.hub.countryCode) {
|
||||
return isoToEmoji(props.hub.countryCode)
|
||||
}
|
||||
return '🌍'
|
||||
})
|
||||
</script>
|
||||
114
app/components/catalog/MapHero.vue
Normal file
114
app/components/catalog/MapHero.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="relative h-80 sm:h-96 overflow-hidden rounded-2xl border border-base-300 bg-base-200">
|
||||
<!-- Map -->
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
v-if="hasCoordinates"
|
||||
:key="mapKey"
|
||||
:map-id="`hero-${mapKey}`"
|
||||
:locations="[mapLocation]"
|
||||
height="100%"
|
||||
:initial-center="mapCenter"
|
||||
:initial-zoom="initialZoom"
|
||||
/>
|
||||
<div v-else class="w-full h-full bg-gradient-to-br from-primary/20 via-primary/10 to-base-200" />
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 sm:p-8">
|
||||
<Stack gap="3">
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-white">{{ title }}</h1>
|
||||
<p v-if="location?.country" class="text-white/80 flex items-center gap-2">
|
||||
<span class="text-xl leading-none">{{ countryFlag }}</span>
|
||||
<span>{{ location.country }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div v-if="badges.length > 0" class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-for="(badge, index) in badges"
|
||||
:key="index"
|
||||
class="badge badge-dash"
|
||||
:class="badgeTone(badge, index)"
|
||||
>
|
||||
<Icon v-if="badge.icon" :name="badge.icon" size="14" />
|
||||
{{ badge.text }}
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Badge {
|
||||
icon?: string
|
||||
text: string
|
||||
tone?: string
|
||||
}
|
||||
|
||||
interface Location {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
country?: string | null
|
||||
countryCode?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
badges?: Badge[]
|
||||
location?: Location | null
|
||||
initialZoom?: number
|
||||
}>(), {
|
||||
badges: () => [],
|
||||
location: null,
|
||||
initialZoom: 6
|
||||
})
|
||||
|
||||
const hasCoordinates = computed(() =>
|
||||
props.location?.latitude != null && props.location?.longitude != null
|
||||
)
|
||||
|
||||
// Key to force map recreation when location changes
|
||||
const mapKey = computed(() =>
|
||||
`${props.location?.uuid}-${props.location?.latitude}-${props.location?.longitude}`
|
||||
)
|
||||
|
||||
const mapCenter = computed<[number, number]>(() => {
|
||||
if (hasCoordinates.value) {
|
||||
return [props.location!.longitude!, props.location!.latitude!]
|
||||
}
|
||||
return [37.64, 55.76]
|
||||
})
|
||||
|
||||
const mapLocation = computed(() => ({
|
||||
uuid: props.location?.uuid,
|
||||
name: props.location?.name,
|
||||
latitude: props.location?.latitude,
|
||||
longitude: props.location?.longitude,
|
||||
country: props.location?.country
|
||||
}))
|
||||
|
||||
const badgeTone = (badge: Badge, index: number) => {
|
||||
if (badge.tone) return `badge-${badge.tone}`
|
||||
const palette = ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success']
|
||||
return palette[index % palette.length]
|
||||
}
|
||||
|
||||
const isoToEmoji = (code: string): string => {
|
||||
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
||||
}
|
||||
|
||||
const countryFlag = computed(() => {
|
||||
if (props.location?.countryCode) {
|
||||
return isoToEmoji(props.location.countryCode)
|
||||
}
|
||||
if (props.location?.country) {
|
||||
return '🌍'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
118
app/components/catalog/OfferCard.vue
Normal file
118
app/components/catalog/OfferCard.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<component
|
||||
:is="linkable ? NuxtLink : 'div'"
|
||||
:to="linkable ? localePath(`/catalog/offers/${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>
|
||||
52
app/components/catalog/ProductCard.vue
Normal file
52
app/components/catalog/ProductCard.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<component
|
||||
:is="linkable ? NuxtLink : 'div'"
|
||||
:to="linkable ? localePath(`/catalog/products/${product.uuid}`) : undefined"
|
||||
class="block"
|
||||
:class="{ 'cursor-pointer': selectable }"
|
||||
@click="selectable && $emit('select')"
|
||||
>
|
||||
<Card
|
||||
padding="sm"
|
||||
:interactive="linkable || selectable"
|
||||
:class="[
|
||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||
]"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ product.name }}</Text>
|
||||
<Text tone="muted">{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}</Text>
|
||||
</Stack>
|
||||
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from '#components'
|
||||
|
||||
interface Product {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
categoryName?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product
|
||||
selectable?: boolean
|
||||
isSelected?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const linkable = computed(() => !props.selectable && props.product.uuid)
|
||||
</script>
|
||||
95
app/components/catalog/SupplierCard.vue
Normal file
95
app/components/catalog/SupplierCard.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<component
|
||||
:is="linkable ? NuxtLink : 'div'"
|
||||
:to="linkable ? localePath(`/catalog/suppliers/${supplier.teamUuid || supplier.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">
|
||||
<!-- Logo -->
|
||||
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
|
||||
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
|
||||
</div>
|
||||
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
|
||||
{{ supplier.name?.charAt(0) }}
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-if="supplier.isVerified" class="badge badge-neutral badge-dash text-xs">
|
||||
{{ t('catalogSupplier.badges.verified') }}
|
||||
</span>
|
||||
<span class="badge badge-neutral badge-dash text-xs">
|
||||
{{ reliabilityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Country below -->
|
||||
<Text tone="muted" size="sm">
|
||||
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from '#components'
|
||||
|
||||
interface Supplier {
|
||||
uuid?: string | null
|
||||
teamUuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
countryCode?: string | null
|
||||
logo?: string | null
|
||||
onTimeRate?: number | null
|
||||
offersCount?: number | null
|
||||
isVerified?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
supplier: Supplier
|
||||
selectable?: boolean
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const linkable = computed(() => !props.selectable && (props.supplier.teamUuid || props.supplier.uuid))
|
||||
|
||||
const reliabilityLabel = computed(() => {
|
||||
if (props.supplier.onTimeRate !== undefined && props.supplier.onTimeRate !== null) {
|
||||
return t('catalogSupplier.labels.on_time', { percent: Math.round(props.supplier.onTimeRate * 100) })
|
||||
}
|
||||
if (props.supplier.isVerified) {
|
||||
return t('catalogSupplier.labels.trusted_partner')
|
||||
}
|
||||
return t('catalogSupplier.labels.on_time_default')
|
||||
})
|
||||
|
||||
// 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.supplier.countryCode) {
|
||||
return isoToEmoji(props.supplier.countryCode)
|
||||
}
|
||||
return '🌍'
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user