Update OfferResultCard: add location, mini map, fix price format
All checks were successful
Build Docker Image / build (push) Successful in 4m30s
All checks were successful
Build Docker Image / build (push) Successful in 4m30s
- Remove distance from right side (shown in stepper) - Change price format to symbol + formatted number (e.g. $1,200/тонна) - Add locationName prop for displaying offer location - Add LocationMiniMap component for showing point on map - Update hub page and CalcResultContent to pass coordinates
This commit is contained in:
@@ -21,11 +21,13 @@
|
|||||||
v-for="option in productRouteOptions"
|
v-for="option in productRouteOptions"
|
||||||
:key="option.sourceUuid"
|
:key="option.sourceUuid"
|
||||||
:source-name="option.sourceName || 'Склад'"
|
:source-name="option.sourceName || 'Склад'"
|
||||||
|
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
||||||
:product-name="productName"
|
:product-name="productName"
|
||||||
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
|
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
|
||||||
:currency="getOfferData(option.sourceUuid)?.currency"
|
:currency="getOfferData(option.sourceUuid)?.currency"
|
||||||
:unit="getOfferData(option.sourceUuid)?.unit"
|
:unit="getOfferData(option.sourceUuid)?.unit"
|
||||||
:total-distance="option.distanceKm || 0"
|
:latitude="option.sourceLat"
|
||||||
|
:longitude="option.sourceLon"
|
||||||
:stages="getRouteStages(option)"
|
:stages="getRouteStages(option)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
app/components/catalog/LocationMiniMap.vue
Normal file
103
app/components/catalog/LocationMiniMap.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-lg overflow-hidden border border-base-300">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
:style="`height: ${height}px; width: 100%;`"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div :style="`height: ${height}px`" class="w-full bg-base-200 flex items-center justify-center">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
height?: number
|
||||||
|
}>(), {
|
||||||
|
height: 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
|
||||||
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
|
const mapId = computed(() => `location-mini-map-${instanceId}`)
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: [props.longitude, props.latitude] as [number, number],
|
||||||
|
zoom: 5,
|
||||||
|
interactive: false,
|
||||||
|
attributionControl: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('location-point', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [props.longitude, props.latitude]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'location-point-layer',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'location-point',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': '#10b981',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.latitude, props.longitude],
|
||||||
|
() => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
map.setCenter([props.longitude, props.latitude])
|
||||||
|
|
||||||
|
const source = map.getSource('location-point') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (source) {
|
||||||
|
source.setData({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [props.longitude, props.latitude]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card padding="md" interactive @click="$emit('select')">
|
<Card padding="md" interactive @click="$emit('select')">
|
||||||
<!-- Header: Source + Price -->
|
<!-- Header: Source name + Location + Price -->
|
||||||
<div class="flex items-start justify-between mb-2">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<Text weight="semibold">{{ sourceName }}</Text>
|
<Text weight="semibold">{{ sourceName }}</Text>
|
||||||
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
|
<Text v-if="locationName" tone="muted" size="sm">{{ locationName }}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Text>
|
</Text>
|
||||||
<Text tone="muted" size="sm">{{ formatDistance(totalDistance) }} км</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Route stepper -->
|
<!-- Product name + Route stepper -->
|
||||||
<RouteStepper v-if="stages.length > 0" :stages="stages" />
|
<div v-if="productName" class="mb-3">
|
||||||
|
<Text size="sm" class="mb-1">{{ productName }}</Text>
|
||||||
|
<RouteStepper v-if="stages.length > 0" :stages="stages" />
|
||||||
|
</div>
|
||||||
|
<RouteStepper v-else-if="stages.length > 0" :stages="stages" class="mb-3" />
|
||||||
|
|
||||||
|
<!-- Mini map -->
|
||||||
|
<LocationMiniMap
|
||||||
|
v-if="latitude && longitude"
|
||||||
|
:latitude="latitude"
|
||||||
|
:longitude="longitude"
|
||||||
|
:height="100"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -24,11 +35,13 @@ import type { RouteStage } from './RouteStepper.vue'
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
sourceName: string
|
sourceName: string
|
||||||
|
locationName?: string
|
||||||
productName?: string
|
productName?: string
|
||||||
pricePerUnit?: number | null
|
pricePerUnit?: number | null
|
||||||
currency?: string | null
|
currency?: string | null
|
||||||
unit?: string | null
|
unit?: string | null
|
||||||
totalDistance: number
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
stages?: RouteStage[]
|
stages?: RouteStage[]
|
||||||
}>(), {
|
}>(), {
|
||||||
stages: () => []
|
stages: () => []
|
||||||
@@ -40,13 +53,33 @@ defineEmits<{
|
|||||||
|
|
||||||
const priceDisplay = computed(() => {
|
const priceDisplay = computed(() => {
|
||||||
if (!props.pricePerUnit) return null
|
if (!props.pricePerUnit) return null
|
||||||
const curr = props.currency || 'USD'
|
const currSymbol = getCurrencySymbol(props.currency)
|
||||||
const u = props.unit || 'т'
|
const unitName = getUnitName(props.unit)
|
||||||
return `${props.pricePerUnit} ${curr}/${u}`
|
const formattedPrice = props.pricePerUnit.toLocaleString()
|
||||||
|
return `${currSymbol}${formattedPrice}/${unitName}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDistance = (km?: number | null) => {
|
const getCurrencySymbol = (currency?: string | null) => {
|
||||||
if (!km) return '0'
|
switch (currency?.toUpperCase()) {
|
||||||
return Math.round(km).toLocaleString()
|
case 'USD': return '$'
|
||||||
|
case 'EUR': return '€'
|
||||||
|
case 'RUB': return '₽'
|
||||||
|
case 'CNY': return '¥'
|
||||||
|
default: return '$'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUnitName = (unit?: string | null) => {
|
||||||
|
switch (unit?.toLowerCase()) {
|
||||||
|
case 'т':
|
||||||
|
case 'ton':
|
||||||
|
case 'tonne':
|
||||||
|
return 'тонна'
|
||||||
|
case 'кг':
|
||||||
|
case 'kg':
|
||||||
|
return 'кг'
|
||||||
|
default:
|
||||||
|
return 'тонна'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -61,11 +61,13 @@
|
|||||||
<template #card="{ item }">
|
<template #card="{ item }">
|
||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
:source-name="item.name"
|
:source-name="item.name"
|
||||||
|
:location-name="getOfferData(item.uuid)?.locationName"
|
||||||
:product-name="selectedProductName"
|
:product-name="selectedProductName"
|
||||||
:price-per-unit="getOfferData(item.uuid)?.pricePerUnit"
|
:price-per-unit="getOfferData(item.uuid)?.pricePerUnit"
|
||||||
:currency="getOfferData(item.uuid)?.currency"
|
:currency="getOfferData(item.uuid)?.currency"
|
||||||
:unit="getOfferData(item.uuid)?.unit"
|
:unit="getOfferData(item.uuid)?.unit"
|
||||||
:total-distance="item.distanceKm"
|
:latitude="item.latitude"
|
||||||
|
:longitude="item.longitude"
|
||||||
:stages="item.stages"
|
:stages="item.stages"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user