Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:10:35 +07:00
commit 3db50d9637
371 changed files with 43223 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>