Unify offer cards: RouteStepper + OfferResultCard components
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:
Ruslan Bakiev
2026-01-14 23:47:42 +07:00
parent 1c19e5cb78
commit de95dbd059
4 changed files with 202 additions and 91 deletions

View File

@@ -1,86 +1,61 @@
<template>
<div class="space-y-10">
<div class="space-y-6">
<!-- Header -->
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
</Card>
<!-- Loading -->
<div v-if="pending" class="text-sm text-base-content/60">
Загрузка маршрутов...
</div>
<!-- Error -->
<div v-else-if="error" class="text-sm text-error">
Ошибка загрузки маршрутов: {{ error.message }}
</div>
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
<div v-if="productRouteOptions.length" class="space-y-10">
<div
v-for="(option, optionIndex) in productRouteOptions"
:key="option.sourceUuid || optionIndex"
class="space-y-6"
>
<div class="space-y-1">
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
</div>
<div v-if="option.routes?.length" class="space-y-6">
<Card
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
v-for="(route, routeIndex) in legacyRoutes"
: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>
</template>
<!-- Results -->
<div v-else-if="productRouteOptions.length > 0" class="space-y-4">
<OfferResultCard
v-for="option in productRouteOptions"
:key="option.sourceUuid"
:source-name="option.sourceName || 'Склад'"
:product-name="productName"
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
:currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit"
:total-distance="option.distanceKm || 0"
:stages="getRouteStages(option)"
/>
</div>
<!-- Legacy routes (fallback) -->
<div v-else-if="legacyRoutes.length > 0" class="space-y-6">
<Card
v-for="(route, routeIndex) in legacyRoutes"
: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>
<!-- Empty -->
<div v-else class="text-sm text-base-content/60">
Маршруты не найдены. Возможно, нет связи между точками в графе.
</div>
@@ -91,14 +66,19 @@
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
import { GetOfferDocument } from '~/composables/graphql/public/exchange-generated'
const route = useRoute()
const searchStore = useSearchStore()
const { execute } = useGraphQL()
const productName = computed(() => searchStore.searchForm.product || (route.query.product 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)
// Offer data for prices
const offersData = ref<Map<string, any>>(new Map())
const summaryTitle = computed(() => `${productName.value}${locationName.value}`)
const summaryMeta = computed(() => {
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
const formatDistance = (km: number | null | undefined) => {
if (!km) return '0'