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

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

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