Unify offer cards: RouteStepper + OfferResultCard components
All checks were successful
Build Docker Image / build (push) Successful in 4m36s
All checks were successful
Build Docker Image / build (push) Successful in 4m36s
- Add RouteStepper component with transport icons (🚛 🚂 🚢) - Add OfferResultCard with price, distance, route stages - Update hub page to use OfferResultCard - Update CalcResultContent to use OfferResultCard
This commit is contained in:
@@ -1,61 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-10">
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
<Card padding="lg" class="border border-base-300">
|
<Card padding="lg" class="border border-base-300">
|
||||||
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
|
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
<div v-if="pending" class="text-sm text-base-content/60">
|
<div v-if="pending" class="text-sm text-base-content/60">
|
||||||
Загрузка маршрутов...
|
Загрузка маршрутов...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
<div v-else-if="error" class="text-sm text-error">
|
<div v-else-if="error" class="text-sm text-error">
|
||||||
Ошибка загрузки маршрутов: {{ error.message }}
|
Ошибка загрузки маршрутов: {{ error.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
|
<!-- Results -->
|
||||||
<div v-if="productRouteOptions.length" class="space-y-10">
|
<div v-else-if="productRouteOptions.length > 0" class="space-y-4">
|
||||||
<div
|
<OfferResultCard
|
||||||
v-for="(option, optionIndex) in productRouteOptions"
|
v-for="option in productRouteOptions"
|
||||||
:key="option.sourceUuid || optionIndex"
|
:key="option.sourceUuid"
|
||||||
class="space-y-6"
|
:source-name="option.sourceName || 'Склад'"
|
||||||
>
|
:product-name="productName"
|
||||||
<div class="space-y-1">
|
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
|
||||||
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
|
:currency="getOfferData(option.sourceUuid)?.currency"
|
||||||
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
|
:unit="getOfferData(option.sourceUuid)?.unit"
|
||||||
|
:total-distance="option.distanceKm || 0"
|
||||||
|
:stages="getRouteStages(option)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="option.routes?.length" class="space-y-6">
|
<!-- Legacy routes (fallback) -->
|
||||||
<Card
|
<div v-else-if="legacyRoutes.length > 0" class="space-y-6">
|
||||||
v-for="(route, routeIndex) in option.routes"
|
|
||||||
:key="routeIndex"
|
|
||||||
padding="lg"
|
|
||||||
class="border border-base-300"
|
|
||||||
>
|
|
||||||
<Stack gap="4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
|
|
||||||
<Text tone="muted" size="sm">
|
|
||||||
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RouteStagesList :stages="mapRouteStages(route)" />
|
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
|
||||||
|
|
||||||
<RequestRoutesMap :routes="[route]" :height="240" />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text v-else tone="muted" size="sm">
|
|
||||||
Маршруты от источника не найдены.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="!productRouteOptions.length && legacyRoutes.length">
|
|
||||||
<div class="space-y-6">
|
|
||||||
<Card
|
<Card
|
||||||
v-for="(route, routeIndex) in legacyRoutes"
|
v-for="(route, routeIndex) in legacyRoutes"
|
||||||
:key="routeIndex"
|
:key="routeIndex"
|
||||||
@@ -78,9 +54,8 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
<div v-else class="text-sm text-base-content/60">
|
<div v-else class="text-sm text-base-content/60">
|
||||||
Маршруты не найдены. Возможно, нет связи между точками в графе.
|
Маршруты не найдены. Возможно, нет связи между точками в графе.
|
||||||
</div>
|
</div>
|
||||||
@@ -91,14 +66,19 @@
|
|||||||
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
|
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
|
||||||
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
|
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
|
||||||
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
||||||
|
import { GetOfferDocument } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const searchStore = useSearchStore()
|
const searchStore = useSearchStore()
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
||||||
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
||||||
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
|
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
|
||||||
|
|
||||||
|
// Offer data for prices
|
||||||
|
const offersData = ref<Map<string, any>>(new Map())
|
||||||
|
|
||||||
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
||||||
const summaryMeta = computed(() => {
|
const summaryMeta = computed(() => {
|
||||||
const meta: string[] = []
|
const meta: string[] = []
|
||||||
@@ -226,6 +206,51 @@ const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get route stages for OfferResultCard stepper
|
||||||
|
const getRouteStages = (option: ProductRouteOption) => {
|
||||||
|
const route = option.routes?.[0]
|
||||||
|
if (!route?.stages) return []
|
||||||
|
return route.stages.filter(Boolean).map((stage: any) => ({
|
||||||
|
transportType: stage?.transportType,
|
||||||
|
distanceKm: stage?.distanceKm
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get offer data for card
|
||||||
|
const getOfferData = (uuid?: string | null) => {
|
||||||
|
if (!uuid) return null
|
||||||
|
return offersData.value.get(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load offer details for prices
|
||||||
|
const loadOfferDetails = async (options: ProductRouteOption[]) => {
|
||||||
|
if (options.length === 0) {
|
||||||
|
offersData.value.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOffersData = new Map<string, any>()
|
||||||
|
await Promise.all(options.map(async (option) => {
|
||||||
|
if (!option.sourceUuid) return
|
||||||
|
try {
|
||||||
|
const data = await execute(GetOfferDocument, { uuid: option.sourceUuid }, 'public', 'exchange')
|
||||||
|
if (data?.getOffer) {
|
||||||
|
newOffersData.set(option.sourceUuid, data.getOffer)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading offer:', option.sourceUuid, error)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
offersData.value = newOffersData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for route options and load offers
|
||||||
|
watch(productRouteOptions, (options) => {
|
||||||
|
if (options.length > 0) {
|
||||||
|
loadOfferDetails(options)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// Formatting helpers
|
// Formatting helpers
|
||||||
const formatDistance = (km: number | null | undefined) => {
|
const formatDistance = (km: number | null | undefined) => {
|
||||||
if (!km) return '0'
|
if (!km) return '0'
|
||||||
|
|||||||
52
app/components/catalog/OfferResultCard.vue
Normal file
52
app/components/catalog/OfferResultCard.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<Card padding="md" interactive @click="$emit('select')">
|
||||||
|
<!-- Header: Source + Price -->
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<Text weight="semibold">{{ sourceName }}</Text>
|
||||||
|
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
||||||
|
{{ priceDisplay }}
|
||||||
|
</Text>
|
||||||
|
<Text tone="muted" size="sm">{{ formatDistance(totalDistance) }} км</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Route stepper -->
|
||||||
|
<RouteStepper v-if="stages.length > 0" :stages="stages" />
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteStage } from './RouteStepper.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
sourceName: string
|
||||||
|
productName?: string
|
||||||
|
pricePerUnit?: number | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
|
totalDistance: number
|
||||||
|
stages?: RouteStage[]
|
||||||
|
}>(), {
|
||||||
|
stages: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const priceDisplay = computed(() => {
|
||||||
|
if (!props.pricePerUnit) return null
|
||||||
|
const curr = props.currency || 'USD'
|
||||||
|
const u = props.unit || 'т'
|
||||||
|
return `${props.pricePerUnit} ${curr}/${u}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDistance = (km?: number | null) => {
|
||||||
|
if (!km) return '0'
|
||||||
|
return Math.round(km).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
39
app/components/catalog/RouteStepper.vue
Normal file
39
app/components/catalog/RouteStepper.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1 flex-wrap text-xs">
|
||||||
|
<template v-for="(stage, index) in stages" :key="index">
|
||||||
|
<div v-if="index > 0" class="w-3 h-px bg-base-300" />
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
<span>{{ getTransportIcon(stage.transportType) }}</span>
|
||||||
|
<span class="text-base-content/70">{{ formatDistance(stage.distanceKm) }}км</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface RouteStage {
|
||||||
|
transportType?: string | null
|
||||||
|
distanceKm?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
stages: RouteStage[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getTransportIcon = (type?: string | null) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'rail':
|
||||||
|
return '🚂'
|
||||||
|
case 'sea':
|
||||||
|
return '🚢'
|
||||||
|
case 'road':
|
||||||
|
default:
|
||||||
|
return '🚛'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDistance = (km?: number | null) => {
|
||||||
|
if (!km) return '0'
|
||||||
|
return Math.round(km).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -59,22 +59,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #card="{ item }">
|
<template #card="{ item }">
|
||||||
<Card padding="sm" interactive>
|
<OfferResultCard
|
||||||
<div class="flex items-center justify-between">
|
:source-name="item.name"
|
||||||
<div>
|
:product-name="selectedProductName"
|
||||||
<Text weight="semibold">{{ item.name }}</Text>
|
:price-per-unit="getOfferData(item.uuid)?.pricePerUnit"
|
||||||
<Text tone="muted" size="sm">{{ selectedProductName }}</Text>
|
:currency="getOfferData(item.uuid)?.currency"
|
||||||
</div>
|
:unit="getOfferData(item.uuid)?.unit"
|
||||||
<div class="text-right">
|
:total-distance="item.distanceKm"
|
||||||
<Text weight="semibold" class="text-primary">
|
:stages="item.stages"
|
||||||
{{ getOfferPrice(item.uuid) }}
|
/>
|
||||||
</Text>
|
|
||||||
<Text tone="muted" size="sm">
|
|
||||||
{{ formatDistance(item.distanceKm) }} км
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
@@ -120,7 +113,7 @@ const selectedProductName = computed(() => {
|
|||||||
return product?.name || ''
|
return product?.name || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform sources for CatalogPage (needs uuid, latitude, longitude, name)
|
// Transform sources for CatalogPage (needs uuid, latitude, longitude, name, stages)
|
||||||
const sources = computed(() => {
|
const sources = computed(() => {
|
||||||
return rawSources.value.map(source => ({
|
return rawSources.value.map(source => ({
|
||||||
uuid: source.sourceUuid || '',
|
uuid: source.sourceUuid || '',
|
||||||
@@ -128,15 +121,17 @@ const sources = computed(() => {
|
|||||||
latitude: source.sourceLat,
|
latitude: source.sourceLat,
|
||||||
longitude: source.sourceLon,
|
longitude: source.sourceLon,
|
||||||
distanceKm: source.distanceKm,
|
distanceKm: source.distanceKm,
|
||||||
durationSeconds: source.routes?.[0]?.totalTimeSeconds
|
durationSeconds: source.routes?.[0]?.totalTimeSeconds,
|
||||||
|
stages: (source.routes?.[0]?.stages || []).map((stage: any) => ({
|
||||||
|
transportType: stage?.transportType,
|
||||||
|
distanceKm: stage?.distanceKm
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get offer price for display
|
// Get offer data for card
|
||||||
const getOfferPrice = (uuid: string) => {
|
const getOfferData = (uuid: string) => {
|
||||||
const offer = offersData.value.get(uuid)
|
return offersData.value.get(uuid)
|
||||||
if (!offer) return '-'
|
|
||||||
return `${offer.pricePerUnit} ${offer.currency}/${offer.unit}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load offer details for prices
|
// Load offer details for prices
|
||||||
|
|||||||
Reference in New Issue
Block a user