Compare commits
56 Commits
4585d30d53
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29c34a048a | ||
|
|
4467d20160 | ||
|
|
2e9ce856f2 | ||
|
|
1c8c81a54e | ||
|
|
61a37040d6 | ||
|
|
055d682167 | ||
|
|
24398ad918 | ||
|
|
37c9419155 | ||
|
|
fea81b43b8 | ||
|
|
25f946b293 | ||
|
|
15563991df | ||
|
|
5982838ebd | ||
|
|
84e857ffc1 | ||
|
|
e4d6c9ce81 | ||
|
|
4001756c3c | ||
|
|
85913a760d | ||
|
|
bef34eeaa5 | ||
|
|
8ff44c42bc | ||
|
|
3f92b3876d | ||
|
|
a73a801a1d | ||
|
|
2d54dc3283 | ||
|
|
d36409df57 | ||
|
|
87d3d5b1a7 | ||
|
|
1c033a55b4 | ||
|
|
49f2c237b7 | ||
|
|
6b9935e8e8 | ||
|
|
38081a5cb0 | ||
|
|
481a38b3a1 | ||
|
|
1f60062d15 | ||
|
|
74dd220104 | ||
|
|
c0466c7234 | ||
|
|
2fb34f664f | ||
|
|
28eff7c323 | ||
|
|
589a74d75e | ||
|
|
1fa4a707ad | ||
|
|
f85b1504e2 | ||
|
|
34fc1bfab6 | ||
|
|
755a92d194 | ||
|
|
aa7790f45e | ||
|
|
2d85e7187e | ||
|
|
795aa0381e | ||
|
|
c5d1dc87ae | ||
|
|
2939482fc3 | ||
|
|
1287ae9db7 | ||
|
|
87133ed37a | ||
|
|
0453aeae07 | ||
|
|
d877eff212 | ||
|
|
269d801493 | ||
|
|
85457a34d5 | ||
|
|
675f46a75e | ||
|
|
e4f81dba7c | ||
|
|
b971391fd7 | ||
|
|
8c1827fab6 | ||
|
|
eb31b8299b | ||
|
|
981500ec5d | ||
|
|
ca7c6fa8a5 |
@@ -26,12 +26,6 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: gitea.dsrptlab.com/optovia/webapp/webapp:latest
|
||||
build-args: |
|
||||
INFISICAL_API_URL=${{ secrets.INFISICAL_API_URL }}
|
||||
INFISICAL_CLIENT_ID=${{ secrets.INFISICAL_CLIENT_ID }}
|
||||
INFISICAL_CLIENT_SECRET=${{ secrets.INFISICAL_CLIENT_SECRET }}
|
||||
INFISICAL_PROJECT_ID=${{ secrets.INFISICAL_PROJECT_ID }}
|
||||
INFISICAL_ENV=prod
|
||||
|
||||
- name: Deploy to Dokploy
|
||||
run: curl -X POST "https://dokploy.optovia.ru/api/deploy/0_iNAXPDx28BLZIddGTzB"
|
||||
run: curl -k -X POST "https://dokploy.dsrptlab.com/api/deploy/3zjbiuDvfDQ435HvMUAG8"
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -12,23 +12,11 @@ WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
ARG INFISICAL_API_URL
|
||||
ARG INFISICAL_CLIENT_ID
|
||||
ARG INFISICAL_CLIENT_SECRET
|
||||
ARG INFISICAL_PROJECT_ID
|
||||
ARG INFISICAL_ENV
|
||||
|
||||
ENV INFISICAL_API_URL=$INFISICAL_API_URL \
|
||||
INFISICAL_CLIENT_ID=$INFISICAL_CLIENT_ID \
|
||||
INFISICAL_CLIENT_SECRET=$INFISICAL_CLIENT_SECRET \
|
||||
INFISICAL_PROJECT_ID=$INFISICAL_PROJECT_ID \
|
||||
INFISICAL_ENV=$INFISICAL_ENV
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
|
||||
@@ -36,6 +36,104 @@
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Three-tier glass system (Apple-style glassmorphism) ── */
|
||||
|
||||
/* Tier 1 — lightest underlay, large panels / sidebars */
|
||||
.glass-underlay {
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
box-shadow:
|
||||
0 16px 44px rgba(24, 20, 12, 0.11),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
/* Tier 2 — medium capsule, nav pills / search bar */
|
||||
.glass-capsule {
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(24, 20, 12, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.56);
|
||||
backdrop-filter: blur(22px);
|
||||
-webkit-backdrop-filter: blur(22px);
|
||||
}
|
||||
|
||||
/* Tier 3 — densest chip, small tags / badges */
|
||||
.glass-chip {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Legacy aliases — keep backward compat during transition */
|
||||
.glass-soft {
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
box-shadow:
|
||||
0 16px 44px rgba(24, 20, 12, 0.11),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.glass-bright {
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(24, 20, 12, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.56);
|
||||
backdrop-filter: blur(22px);
|
||||
-webkit-backdrop-filter: blur(22px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Header glass: two-layer Apple-style glassmorphism ── */
|
||||
|
||||
.header-glass {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Layer 1: frosted bar backdrop — fades to transparent at bottom */
|
||||
.header-glass-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 350%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Layer 2: capsule pills — denser frosted glass with inner shine */
|
||||
.pill-glass {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(31, 38, 135, 0.2),
|
||||
inset 0 4px 20px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Inner shine highlight — liquid glass refraction */
|
||||
.pill-glass::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
inset -10px -8px 0 -11px rgba(255, 255, 255, 1),
|
||||
inset 0 -9px 0 -8px rgba(255, 255, 255, 1);
|
||||
opacity: 0.6;
|
||||
filter: blur(1px) brightness(115%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "silk";
|
||||
default: false;
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
||||
:product-name="productName"
|
||||
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
|
||||
:quantity="getOfferData(option.sourceUuid)?.quantity"
|
||||
:currency="getOfferData(option.sourceUuid)?.currency"
|
||||
:unit="getOfferData(option.sourceUuid)?.unit"
|
||||
:stages="getRouteStages(option)"
|
||||
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
|
||||
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
|
||||
@select="navigateToOffer(option.sourceUuid)"
|
||||
/>
|
||||
@@ -83,7 +85,7 @@ interface RoutePathType {
|
||||
stages?: (RouteStage | null)[]
|
||||
}
|
||||
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||
import type { OfferWithRouteType, RouteStageType } from '~/composables/graphql/public/geo-generated'
|
||||
import type { OfferWithRoute, RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
@@ -154,7 +156,7 @@ const fetchOffersByHub = async () => {
|
||||
|
||||
// Offers already include routes from backend
|
||||
const offersWithRoutes = offers
|
||||
.filter((offer): offer is NonNullable<OfferWithRouteType> => offer !== null)
|
||||
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
|
||||
.map((offer) => ({
|
||||
sourceUuid: offer.uuid,
|
||||
sourceName: offer.productName,
|
||||
@@ -205,10 +207,12 @@ const getRouteStages = (option: ProductRouteOption) => {
|
||||
const route = option.routes?.[0]
|
||||
if (!route?.stages) return []
|
||||
return route.stages
|
||||
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
|
||||
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||
.map((stage) => ({
|
||||
transportType: stage.transportType,
|
||||
distanceKm: stage.distanceKm
|
||||
distanceKm: stage.distanceKm,
|
||||
travelTimeSeconds: stage.travelTimeSeconds,
|
||||
fromName: stage.fromName
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Header with back button -->
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog')"
|
||||
:to="localePath('/catalog?select=product')"
|
||||
class="btn btn-sm btn-ghost gap-2"
|
||||
>
|
||||
<Icon name="lucide:arrow-left" size="18" />
|
||||
@@ -88,6 +88,7 @@
|
||||
:location-name="offer.locationName || offer.locationCountry"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="[]"
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||
import type { Edge } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
interface CurrentHub {
|
||||
uuid: string
|
||||
@@ -119,8 +119,8 @@ interface RouteGeometry {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
autoEdges: EdgeType[]
|
||||
railEdges: EdgeType[]
|
||||
autoEdges: Edge[]
|
||||
railEdges: Edge[]
|
||||
hub: CurrentHub
|
||||
railHub: CurrentHub
|
||||
autoRouteGeometries: RouteGeometry[]
|
||||
@@ -190,7 +190,7 @@ const buildRouteFeatureCollection = (routes: RouteGeometry[], transportType: 'au
|
||||
}))
|
||||
})
|
||||
|
||||
const buildNeighborsFeatureCollection = (edges: EdgeType[], transportType: 'auto' | 'rail') => ({
|
||||
const buildNeighborsFeatureCollection = (edges: Edge[], transportType: 'auto' | 'rail') => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: edges
|
||||
.filter(e => e.toLatitude && e.toLongitude)
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||
import type { Edge } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
interface CurrentHub {
|
||||
uuid: string
|
||||
@@ -81,7 +81,7 @@ interface RouteGeometry {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
edges: EdgeType[]
|
||||
edges: Edge[]
|
||||
currentHub: CurrentHub
|
||||
routeGeometries: RouteGeometry[]
|
||||
transportType: 'auto' | 'rail'
|
||||
|
||||
159
app/components/ai/AiChatSidebar.vue
Normal file
159
app/components/ai/AiChatSidebar.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<aside
|
||||
class="fixed top-0 left-0 bottom-0 z-50 overflow-hidden transition-[width] duration-300"
|
||||
:style="{ width: open ? width : '0px' }"
|
||||
aria-label="AI assistant"
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col bg-base-100/80 backdrop-blur-xl border-r border-white/10 shadow-xl transition-opacity duration-200"
|
||||
:class="open ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Icon name="lucide:bot" size="16" class="text-primary" />
|
||||
</div>
|
||||
<div class="font-semibold text-base-content">{{ $t('aiAssistants.view.agentName') }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content"
|
||||
aria-label="Close"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<Icon name="lucide:x" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div
|
||||
v-for="(message, idx) in chat"
|
||||
:key="idx"
|
||||
class="flex"
|
||||
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[90%] rounded-2xl px-3 py-2 shadow-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary text-primary-content' : 'bg-base-100 text-base-content border border-base-300'"
|
||||
>
|
||||
<Text weight="semibold" class="mb-1">
|
||||
{{ message.role === 'user' ? $t('aiAssistants.view.you') : $t('aiAssistants.view.agentName') }}
|
||||
</Text>
|
||||
<Text :tone="message.role === 'user' ? undefined : 'muted'">
|
||||
{{ message.content }}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isStreaming" class="text-sm text-base-content/60">
|
||||
{{ $t('aiAssistants.view.typing') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-base-300 bg-base-100/70 p-3">
|
||||
<form class="flex items-end gap-2" @submit.prevent="handleSend">
|
||||
<div class="flex-1">
|
||||
<Textarea
|
||||
v-model="input"
|
||||
:placeholder="$t('aiAssistants.view.placeholder')"
|
||||
rows="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button type="submit" size="sm" :loading="isSending" :disabled="!input.trim()">
|
||||
{{ $t('aiAssistants.view.send') }}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" @click="resetChat" :disabled="isSending">
|
||||
{{ $t('aiAssistants.view.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-xs text-error text-center mt-2" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
width: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
const agentUrl = computed(() => runtimeConfig.public.langAgentUrl || '')
|
||||
const chatContainer = ref<HTMLElement | null>(null)
|
||||
const chat = ref<{ role: 'user' | 'assistant', content: string }[]>([
|
||||
{ role: 'assistant', content: t('aiAssistants.view.welcome') }
|
||||
])
|
||||
const input = ref('')
|
||||
const isSending = ref(false)
|
||||
const isStreaming = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.value.trim()) return
|
||||
error.value = ''
|
||||
const userMessage = input.value.trim()
|
||||
chat.value.push({ role: 'user', content: userMessage })
|
||||
input.value = ''
|
||||
isSending.value = true
|
||||
isStreaming.value = true
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const body = {
|
||||
input: {
|
||||
messages: chat.value.map((m) => ({
|
||||
type: m.role === 'assistant' ? 'ai' : 'human',
|
||||
content: m.content
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const response = await $fetch(`${agentUrl.value}/invoke`, {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
const outputMessages = (response as any)?.output?.messages || []
|
||||
const last = outputMessages[outputMessages.length - 1]
|
||||
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
|
||||
chat.value.push({ role: 'assistant', content })
|
||||
scrollToBottom()
|
||||
} catch (e: unknown) {
|
||||
console.error('Agent error', e)
|
||||
error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
|
||||
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
|
||||
scrollToBottom()
|
||||
} finally {
|
||||
isSending.value = false
|
||||
isStreaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetChat = () => {
|
||||
chat.value = [{ role: 'assistant', content: t('aiAssistants.view.welcome') }]
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
@@ -2,84 +2,84 @@
|
||||
<Transition name="address-slide">
|
||||
<div
|
||||
v-if="isOpen && addressUuid"
|
||||
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
||||
style="height: 70vh"
|
||||
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
|
||||
style="height: 72vh"
|
||||
>
|
||||
<!-- Backdrop (clickable to close) -->
|
||||
<div
|
||||
class="absolute inset-0 -top-[30vh] bg-black/30"
|
||||
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Sheet content -->
|
||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
|
||||
<!-- Header with drag handle and close -->
|
||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
||||
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100/90">
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-6 pb-4">
|
||||
<template v-if="address">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center flex-shrink-0 text-2xl">
|
||||
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success/20 text-2xl">
|
||||
{{ isoToEmoji(address.countryCode) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-white truncate">{{ address.name }}</div>
|
||||
<div class="text-sm text-white/60 truncate">{{ address.address }}</div>
|
||||
<div class="truncate text-xl font-black text-base-content">{{ address.name }}</div>
|
||||
<div class="truncate text-sm text-base-content/60">{{ address.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
||||
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
||||
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
||||
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
|
||||
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @click="emit('close')">
|
||||
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||
<Icon name="lucide:x" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="address" class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4 space-y-4">
|
||||
<div v-if="address" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||
<!-- Location info -->
|
||||
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:map-pin" size="18" />
|
||||
{{ t('profileAddresses.detail.location') }}
|
||||
<span class="text-lg font-black">{{ t('profileAddresses.detail.location') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start gap-2 text-white/80">
|
||||
<Icon name="lucide:navigation" size="14" class="text-white/50 mt-0.5 flex-shrink-0" />
|
||||
<div class="flex items-start gap-2 text-base-content/80">
|
||||
<Icon name="lucide:navigation" size="14" class="mt-0.5 flex-shrink-0 text-base-content/50" />
|
||||
<span>{{ address.address }}</span>
|
||||
</div>
|
||||
<div v-if="address.latitude && address.longitude" class="flex items-center gap-2 text-white/60">
|
||||
<Icon name="lucide:crosshair" size="14" class="text-white/50" />
|
||||
<div v-if="address.latitude && address.longitude" class="flex items-center gap-2 text-base-content/60">
|
||||
<Icon name="lucide:crosshair" size="14" class="text-base-content/50" />
|
||||
<span class="font-mono text-xs">{{ address.latitude.toFixed(6) }}, {{ address.longitude.toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map preview -->
|
||||
<div v-if="address.latitude && address.longitude" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="address.latitude && address.longitude" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:map" size="18" />
|
||||
{{ t('profileAddresses.detail.map') }}
|
||||
<span class="text-lg font-black">{{ t('profileAddresses.detail.map') }}</span>
|
||||
</div>
|
||||
<div class="h-48 rounded-lg overflow-hidden">
|
||||
<div class="h-48 overflow-hidden rounded-xl">
|
||||
<ClientOnly>
|
||||
<MapboxMap
|
||||
:map-id="'address-preview-' + addressUuid"
|
||||
style="width: 100%; height: 100%"
|
||||
:options="{
|
||||
style: 'mapbox://styles/mapbox/dark-v11',
|
||||
style: 'mapbox://styles/mapbox/light-v11',
|
||||
center: [address.longitude, address.latitude],
|
||||
zoom: 14,
|
||||
interactive: false
|
||||
@@ -98,7 +98,7 @@
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
|
||||
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||
<button class="btn btn-sm w-full btn-outline">
|
||||
<Icon name="lucide:pencil" size="14" class="mr-2" />
|
||||
{{ t('profileAddresses.actions.edit') }}
|
||||
</button>
|
||||
@@ -115,8 +115,8 @@
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="px-6 py-4 space-y-4">
|
||||
<div class="h-24 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-24 animate-pulse rounded-xl bg-base-300/70" />
|
||||
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||
import { LngLatBounds } from 'mapbox-gl'
|
||||
import type { ClusterPointType } from '~/composables/graphql/public/geo-generated'
|
||||
import type { ClusterPoint } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
interface MapItem {
|
||||
uuid?: string | null
|
||||
@@ -43,8 +43,8 @@ interface HoveredItem {
|
||||
const props = withDefaults(defineProps<{
|
||||
mapId: string
|
||||
items?: MapItem[]
|
||||
clusteredPoints?: ClusterPointType[]
|
||||
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPointType[]>>
|
||||
clusteredPoints?: ClusterPoint[]
|
||||
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPoint[]>>
|
||||
useServerClustering?: boolean
|
||||
hoveredItemId?: string | null
|
||||
hoveredItem?: HoveredItem | null
|
||||
@@ -53,6 +53,7 @@ const props = withDefaults(defineProps<{
|
||||
initialCenter?: [number, number]
|
||||
initialZoom?: number
|
||||
infoLoading?: boolean
|
||||
fitPaddingLeft?: number
|
||||
relatedPoints?: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
@@ -67,6 +68,7 @@ const props = withDefaults(defineProps<{
|
||||
initialZoom: 2,
|
||||
useServerClustering: false,
|
||||
infoLoading: false,
|
||||
fitPaddingLeft: 0,
|
||||
items: () => [],
|
||||
clusteredPoints: () => [],
|
||||
clusteredPointsByType: undefined,
|
||||
@@ -88,6 +90,16 @@ const usesTypedClusters = computed(() => {
|
||||
return !!typed && Object.keys(typed).length > 0
|
||||
})
|
||||
|
||||
const buildFitPadding = (base: number) => {
|
||||
const extraLeft = Math.max(0, props.fitPaddingLeft || 0)
|
||||
return {
|
||||
top: base,
|
||||
bottom: base,
|
||||
left: base + extraLeft,
|
||||
right: base
|
||||
}
|
||||
}
|
||||
|
||||
// Entity type icons - SVG data URLs with specific colors
|
||||
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
|
||||
const icons = {
|
||||
@@ -226,7 +238,7 @@ const serverClusteredGeoJson = computed(() => ({
|
||||
}))
|
||||
|
||||
const serverClusteredGeoJsonByType = computed(() => {
|
||||
const build = (points: ClusterPointType[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
|
||||
const build = (points: ClusterPoint[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: (points || []).filter(Boolean).map(point => ({
|
||||
type: 'Feature' as const,
|
||||
@@ -536,7 +548,7 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
||||
bounds.extend([item.longitude, item.latitude])
|
||||
}
|
||||
})
|
||||
map.fitBounds(bounds, { padding: 50, maxZoom: 10 })
|
||||
map.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 10 })
|
||||
didFitBounds.value = true
|
||||
}
|
||||
}
|
||||
@@ -962,7 +974,7 @@ watch(() => props.infoLoading, (loading, wasLoading) => {
|
||||
bounds.extend([p.longitude, p.latitude])
|
||||
})
|
||||
if (!bounds.isEmpty()) {
|
||||
mapRef.value.fitBounds(bounds, { padding: 80, maxZoom: 12 })
|
||||
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1013,7 +1025,7 @@ watch(() => props.clusteredPoints, (points) => {
|
||||
}
|
||||
})
|
||||
if (!bounds.isEmpty()) {
|
||||
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 })
|
||||
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
|
||||
didFitBounds.value = true
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1045,7 @@ watch(() => props.clusteredPointsByType, () => {
|
||||
})
|
||||
})
|
||||
if (!bounds.isEmpty()) {
|
||||
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 })
|
||||
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
|
||||
didFitBounds.value = true
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
:location-name="offer.locationName"
|
||||
:product-name="offer.productName || offer.title || undefined"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="[]"
|
||||
@@ -91,6 +92,7 @@ interface Offer {
|
||||
status?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
quantity?: number | string | null
|
||||
pricePerUnit?: number | string | null
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
:location-name="offer.locationName"
|
||||
:product-name="offer.title || undefined"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="[]"
|
||||
@@ -56,6 +57,7 @@ interface Offer {
|
||||
status?: string | null
|
||||
validUntil?: string | null
|
||||
lines?: (OfferLine | null)[] | null
|
||||
quantity?: number | string | null
|
||||
pricePerUnit?: number | string | null
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
<!-- Title + distance/compass -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
|
||||
<div class="flex items-center gap-2 text-xs text-white/50 whitespace-nowrap">
|
||||
<Text v-if="distanceLabel" size="xs" class="text-white/50">{{ distanceLabel }}</Text>
|
||||
<div class="flex items-center gap-2 text-xs text-base-content/60 whitespace-nowrap">
|
||||
<Text v-if="distanceLabel" size="xs" class="text-base-content/60">{{ distanceLabel }}</Text>
|
||||
<div v-if="bearing !== null" class="flex items-center gap-1">
|
||||
<div class="w-6 h-6 rounded-full border border-white/10 bg-white/5 flex items-center justify-center">
|
||||
<div class="w-6 h-6 rounded-full border border-base-content/10 bg-base-200/40 flex items-center justify-center">
|
||||
<Icon
|
||||
name="lucide:arrow-up"
|
||||
size="12"
|
||||
class="text-white/50"
|
||||
class="text-base-content/60"
|
||||
:style="{ transform: `rotate(${bearing}deg)` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,20 @@
|
||||
</div>
|
||||
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
||||
<Icon name="lucide:x" size="16" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="(entityType === 'hub' || entityType === 'supplier') && entity?.uuid"
|
||||
class="rounded-full glass-bright border border-white/30 shadow-lg p-1.5 transition-transform hover:scale-105"
|
||||
@click="emit('pin', entityType, { uuid: entity?.uuid, name: entity?.name })"
|
||||
aria-label="Pin"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
||||
<Icon name="lucide:x" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,14 +122,26 @@
|
||||
{{ $t('catalog.empty.noProducts') }}
|
||||
</div>
|
||||
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
|
||||
<ProductCard
|
||||
<div
|
||||
v-for="(product, index) in relatedProducts"
|
||||
:key="product.uuid ?? index"
|
||||
:product="product"
|
||||
compact
|
||||
selectable
|
||||
@select="onProductSelect(product)"
|
||||
/>
|
||||
class="relative group"
|
||||
>
|
||||
<ProductCard
|
||||
:product="product"
|
||||
compact
|
||||
selectable
|
||||
@select="onProductSelect(product)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'product', product)"
|
||||
aria-label="Pin product"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -131,9 +154,13 @@
|
||||
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
|
||||
<span v-else-if="offersWithPrice.length > 0" class="text-white/50">({{ offersWithPrice.length }})</span>
|
||||
</h3>
|
||||
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
|
||||
<Icon name="lucide:x" size="14" />
|
||||
{{ $t('common.cancel') }}
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1 rounded-full border border-white/15 bg-white/10 text-xs text-white/80 hover:bg-white/20 transition-colors"
|
||||
@click="emit('select-product', null)"
|
||||
>
|
||||
<Icon name="lucide:package" size="12" />
|
||||
<span class="max-w-32 truncate">{{ selectedProductName }}</span>
|
||||
<Icon name="lucide:x" size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -144,20 +171,22 @@
|
||||
<OfferResultCard
|
||||
v-for="(offer, index) in offersWithPrice"
|
||||
:key="offer.uuid ?? index"
|
||||
:supplier-name="offer.supplierName"
|
||||
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
|
||||
:supplier-name="getOfferSupplierName(offer)"
|
||||
:location-name="offer.country || ''"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="getOfferStages(offer)"
|
||||
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||
@select="onOfferSelect(offer)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Suppliers Section (for hub only) -->
|
||||
<section v-if="entityType === 'hub'">
|
||||
<section v-if="entityType === 'hub' && !selectedProduct">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||||
<Icon name="lucide:factory" size="16" />
|
||||
{{ $t('catalog.info.suppliersNearby') }}
|
||||
@@ -169,13 +198,25 @@
|
||||
{{ $t('catalog.info.noSuppliers') }}
|
||||
</div>
|
||||
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
|
||||
<SupplierCard
|
||||
<div
|
||||
v-for="(supplier, index) in relatedSuppliers"
|
||||
:key="supplier.uuid ?? index"
|
||||
:supplier="supplier"
|
||||
selectable
|
||||
@select="onSupplierSelect(supplier)"
|
||||
/>
|
||||
class="relative group"
|
||||
>
|
||||
<SupplierCard
|
||||
:supplier="supplier"
|
||||
selectable
|
||||
@select="onSupplierSelect(supplier)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'supplier', supplier)"
|
||||
aria-label="Pin supplier"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -202,14 +243,26 @@
|
||||
<div class="text-sm text-white/80">{{ $t('catalog.info.railHubs') }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<HubCard
|
||||
<div
|
||||
v-for="(hub, index) in railHubs"
|
||||
:key="hub.uuid ?? index"
|
||||
:hub="hub"
|
||||
:origin="originCoords"
|
||||
selectable
|
||||
@select="onHubSelect(hub)"
|
||||
/>
|
||||
class="relative group"
|
||||
>
|
||||
<HubCard
|
||||
:hub="hub"
|
||||
:origin="originCoords"
|
||||
selectable
|
||||
@select="onHubSelect(hub)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'hub', hub)"
|
||||
aria-label="Pin hub"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -223,24 +276,31 @@
|
||||
<div class="text-sm text-white/80">{{ $t('catalog.info.seaHubs') }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<HubCard
|
||||
<div
|
||||
v-for="(hub, index) in seaHubs"
|
||||
:key="hub.uuid ?? index"
|
||||
:hub="hub"
|
||||
:origin="originCoords"
|
||||
selectable
|
||||
@select="onHubSelect(hub)"
|
||||
/>
|
||||
class="relative group"
|
||||
>
|
||||
<HubCard
|
||||
:hub="hub"
|
||||
:origin="originCoords"
|
||||
selectable
|
||||
@select="onHubSelect(hub)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'hub', hub)"
|
||||
aria-label="Pin hub"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Add to filter button -->
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="emit('add-to-filter')">
|
||||
<Icon name="lucide:filter-plus" size="16" />
|
||||
{{ $t('catalog.info.addToFilter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +315,7 @@ import type {
|
||||
InfoSupplierItem,
|
||||
InfoOfferItem
|
||||
} from '~/composables/useCatalogInfo'
|
||||
import type { RouteStageType } from '~/composables/graphql/public/geo-generated'
|
||||
import type { RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: InfoEntityType
|
||||
@@ -276,12 +336,12 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'add-to-filter': []
|
||||
'open-info': [type: InfoEntityType, uuid: string]
|
||||
'select-product': [uuid: string | null]
|
||||
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
|
||||
'update:current-tab': [tab: string]
|
||||
'open-kyc': [uuid: string | undefined]
|
||||
'pin': [type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -296,6 +356,33 @@ const offersWithPrice = computed(() =>
|
||||
relatedOffers.value.filter(o => o?.pricePerUnit != null)
|
||||
)
|
||||
|
||||
const suppliersByUuid = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
relatedSuppliers.value.forEach(supplier => {
|
||||
if (supplier?.uuid && supplier?.name) {
|
||||
map.set(supplier.uuid, supplier.name)
|
||||
}
|
||||
if (supplier?.teamUuid && supplier?.name) {
|
||||
map.set(supplier.teamUuid, supplier.name)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const getOfferSupplierName = (offer: InfoOfferItem) => {
|
||||
if (offer.supplierName) return offer.supplierName
|
||||
if (offer.supplierUuid && suppliersByUuid.value.has(offer.supplierUuid)) {
|
||||
return suppliersByUuid.value.get(offer.supplierUuid)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedProductName = computed(() => {
|
||||
if (!props.selectedProduct) return ''
|
||||
const match = relatedProducts.value.find(p => p.uuid === props.selectedProduct)
|
||||
return match?.name || props.selectedProduct.slice(0, 8) + '...'
|
||||
})
|
||||
|
||||
// Entity name
|
||||
const entityName = computed(() => {
|
||||
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
|
||||
@@ -406,10 +493,12 @@ const getOfferStages = (offer: InfoOfferItem) => {
|
||||
const route = offer.routes?.[0]
|
||||
if (!route?.stages) return []
|
||||
return route.stages
|
||||
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
|
||||
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||
.map(stage => ({
|
||||
transportType: stage.transportType,
|
||||
distanceKm: stage.distanceKm
|
||||
distanceKm: stage.distanceKm,
|
||||
travelTimeSeconds: stage.travelTimeSeconds,
|
||||
fromName: stage.fromName
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
<template>
|
||||
<Card padding="md" interactive @click="$emit('select')">
|
||||
<!-- Header: Location + Price -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<Text weight="semibold">{{ supplierDisplay }}</Text>
|
||||
<Text tone="muted" size="sm">
|
||||
{{ t('catalogOfferCard.labels.origin_label') }}: {{ originDisplay }}
|
||||
</Text>
|
||||
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
|
||||
<Card padding="md" interactive :class="groupClass" @click="$emit('select')">
|
||||
<!-- Header: Supplier + Price -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
|
||||
<Icon name="lucide:factory" size="14" class="text-white" />
|
||||
</div>
|
||||
<Text weight="semibold">{{ supplierDisplay }}</Text>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<Icon name="lucide:map-pin" size="14" class="text-base-content/60" />
|
||||
<span>{{ originDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="productName" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<Icon name="lucide:package" size="14" class="text-base-content/60" />
|
||||
<span>{{ productName }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="quantityDisplay" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<Icon name="lucide:scale" size="14" class="text-base-content/60" />
|
||||
<span>{{ quantityDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
||||
{{ priceDisplay }}
|
||||
</Text>
|
||||
<Text v-if="durationDisplay" size="xs" class="text-base-content/60">
|
||||
{{ t('catalogOfferCard.labels.duration_label') }} {{ durationDisplay }}
|
||||
</Text>
|
||||
</div>
|
||||
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
||||
{{ priceDisplay }}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<!-- Supplier info -->
|
||||
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
|
||||
|
||||
<!-- Route stepper -->
|
||||
<RouteStepper
|
||||
v-if="stages.length > 0"
|
||||
:stages="stages"
|
||||
:start-name="startName"
|
||||
:end-name="endName"
|
||||
/>
|
||||
<!-- Route lines -->
|
||||
<div v-if="routeRows.length" class="mt-3 pt-2 border-t border-base-200/60">
|
||||
<div v-for="(row, index) in routeRows" :key="index" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<Icon :name="row.icon" size="14" class="text-base-content/60" />
|
||||
<span>{{ row.distanceLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteStage } from './RouteStepper.vue'
|
||||
interface RouteStage {
|
||||
transportType?: string | null
|
||||
distanceKm?: number | null
|
||||
travelTimeSeconds?: number | null
|
||||
fromName?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
locationName?: string
|
||||
supplierName?: string
|
||||
productName?: string
|
||||
pricePerUnit?: number | null
|
||||
quantity?: number | string | null
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
stages?: RouteStage[]
|
||||
startName?: string
|
||||
endName?: string
|
||||
totalTimeSeconds?: number | null
|
||||
kycProfileUuid?: string | null
|
||||
grouped?: boolean
|
||||
}>(), {
|
||||
stages: () => []
|
||||
stages: () => [],
|
||||
grouped: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
@@ -56,17 +85,32 @@ const supplierDisplay = computed(() => {
|
||||
})
|
||||
|
||||
const originDisplay = computed(() => {
|
||||
return props.locationName || t('catalogOfferCard.labels.origin_unknown')
|
||||
const fromStage = props.stages?.find(stage => stage?.fromName)?.fromName
|
||||
return props.locationName || fromStage || t('catalogOfferCard.labels.origin_unknown')
|
||||
})
|
||||
|
||||
const priceDisplay = computed(() => {
|
||||
if (!props.pricePerUnit) return null
|
||||
if (props.pricePerUnit == null) return null
|
||||
const currSymbol = getCurrencySymbol(props.currency)
|
||||
const unitName = getUnitName(props.unit)
|
||||
const formattedPrice = props.pricePerUnit.toLocaleString()
|
||||
const basePrice = Number(props.pricePerUnit)
|
||||
const totalPrice = basePrice + (logisticsCost.value ?? 0)
|
||||
const formattedPrice = totalPrice.toLocaleString()
|
||||
return `${currSymbol}${formattedPrice}/${unitName}`
|
||||
})
|
||||
|
||||
const quantityDisplay = computed(() => {
|
||||
if (props.quantity == null || props.quantity === '') return null
|
||||
const quantityValue = Number(props.quantity)
|
||||
if (Number.isNaN(quantityValue)) return null
|
||||
const formattedQuantity = quantityValue.toLocaleString()
|
||||
const unitName = getUnitName(props.unit)
|
||||
return t('catalogOfferCard.labels.quantity_with_unit', {
|
||||
quantity: formattedQuantity,
|
||||
unit: unitName
|
||||
})
|
||||
})
|
||||
|
||||
const getCurrencySymbol = (currency?: string | null) => {
|
||||
switch (currency?.toUpperCase()) {
|
||||
case 'USD': return '$'
|
||||
@@ -80,14 +124,104 @@ const getCurrencySymbol = (currency?: string | null) => {
|
||||
const getUnitName = (unit?: string | null) => {
|
||||
switch (unit?.toLowerCase()) {
|
||||
case 'т':
|
||||
case 't':
|
||||
case 'ton':
|
||||
case 'tonne':
|
||||
return 'тонна'
|
||||
return t('catalogOfferCard.labels.default_unit')
|
||||
case 'кг':
|
||||
case 'kg':
|
||||
return 'кг'
|
||||
return t('catalogOfferCard.labels.unit_kg')
|
||||
default:
|
||||
return 'тонна'
|
||||
return t('catalogOfferCard.labels.default_unit')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDistance = (km?: number | null) => {
|
||||
if (km == null) return null
|
||||
const formatted = Math.round(km).toLocaleString()
|
||||
return t('catalogOfferCard.labels.distance_km', { km: formatted })
|
||||
}
|
||||
|
||||
const formatDurationDays = (days?: number | null) => {
|
||||
if (!days) return null
|
||||
const rounded = Math.max(1, Math.ceil(days))
|
||||
return t('catalogOfferCard.labels.duration_days', { days: rounded })
|
||||
}
|
||||
|
||||
const getTransportIcon = (type?: string | null) => {
|
||||
switch (type) {
|
||||
case 'rail':
|
||||
return 'lucide:train-front'
|
||||
case 'sea':
|
||||
return 'lucide:ship'
|
||||
case 'road':
|
||||
case 'auto':
|
||||
default:
|
||||
return 'lucide:truck'
|
||||
}
|
||||
}
|
||||
|
||||
const getTransportRate = (type?: string | null) => {
|
||||
switch (type) {
|
||||
case 'rail':
|
||||
return 0.12
|
||||
case 'sea':
|
||||
return 0.06
|
||||
case 'road':
|
||||
case 'auto':
|
||||
default:
|
||||
return 0.22
|
||||
}
|
||||
}
|
||||
|
||||
const getTransportSpeedPerDay = (type?: string | null) => {
|
||||
switch (type) {
|
||||
case 'rail':
|
||||
return 900
|
||||
case 'sea':
|
||||
return 800
|
||||
case 'road':
|
||||
case 'auto':
|
||||
default:
|
||||
return 600
|
||||
}
|
||||
}
|
||||
|
||||
const logisticsCost = computed(() => {
|
||||
if (!props.stages?.length) return null
|
||||
return props.stages.reduce((sum, stage) => {
|
||||
const km = stage?.distanceKm
|
||||
if (km == null) return sum
|
||||
return sum + km * getTransportRate(stage?.transportType) + 40
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalDurationDays = computed(() => {
|
||||
if (!props.stages?.length) return null
|
||||
const stageDays = props.stages.reduce((sum, stage) => {
|
||||
const km = stage?.distanceKm
|
||||
if (km == null) return sum
|
||||
return sum + km / getTransportSpeedPerDay(stage?.transportType)
|
||||
}, 0)
|
||||
const transfers = Math.max(0, props.stages.length - 1) * 0.5
|
||||
const buffer = 1
|
||||
return stageDays + transfers + buffer
|
||||
})
|
||||
|
||||
const durationDisplay = computed(() => formatDurationDays(totalDurationDays.value))
|
||||
|
||||
const groupClass = computed(() => {
|
||||
if (!props.grouped) return ''
|
||||
return 'rounded-none shadow-none hover:shadow-none'
|
||||
})
|
||||
|
||||
const routeRows = computed(() =>
|
||||
(props.stages || [])
|
||||
.filter(stage => stage?.distanceKm != null)
|
||||
.map(stage => ({
|
||||
icon: getTransportIcon(stage?.transportType),
|
||||
distanceLabel: formatDistance(stage?.distanceKm)
|
||||
}))
|
||||
.filter(row => !!row.distanceLabel)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -2,40 +2,40 @@
|
||||
<Transition name="order-slide">
|
||||
<div
|
||||
v-if="isOpen && orderUuid"
|
||||
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
||||
style="height: 70vh"
|
||||
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
|
||||
style="height: 72vh"
|
||||
>
|
||||
<!-- Backdrop (clickable to close) -->
|
||||
<div
|
||||
class="absolute inset-0 -top-[30vh] bg-black/30"
|
||||
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Sheet content -->
|
||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
|
||||
<!-- Header with drag handle and close -->
|
||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
||||
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100/90">
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-6 pb-4">
|
||||
<template v-if="hasOrderError">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-white">{{ t('common.error') }}</div>
|
||||
<div class="text-sm text-white/50">{{ orderError }}</div>
|
||||
<div class="font-black text-base-content">{{ t('common.error') }}</div>
|
||||
<div class="text-sm text-base-content/60">{{ orderError }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!isLoadingOrder && order">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Icon name="lucide:package" size="24" class="text-indigo-400" />
|
||||
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Icon name="lucide:package" size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-white truncate">{{ orderTitle }}</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
|
||||
<span v-if="order.status" class="badge badge-outline badge-sm text-white/60">{{ order.status }}</span>
|
||||
<span v-if="order.status" class="badge badge-outline badge-sm">{{ order.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,15 +43,15 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
||||
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
||||
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
||||
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
|
||||
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @click="emit('close')">
|
||||
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||
<Icon name="lucide:x" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -59,26 +59,26 @@
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="hasOrderError" class="px-6 py-8 text-center">
|
||||
<div class="text-white/70 mb-4">{{ orderError }}</div>
|
||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
||||
<div class="mb-4 text-base-content/70">{{ orderError }}</div>
|
||||
<button class="btn btn-sm btn-outline" @click="loadOrder">
|
||||
{{ t('ordersDetail.errors.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div v-else-if="order" class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4 space-y-4">
|
||||
<div v-else-if="order" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||
<!-- Order meta -->
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="px-3 py-1 bg-white/10 rounded-full text-white/70">
|
||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="rounded-full border border-base-300 bg-base-200 px-3 py-1 text-base-content/70">
|
||||
{{ meta }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Route stages -->
|
||||
<div v-if="orderStageItems.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:route" size="18" />
|
||||
{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
@@ -87,15 +87,15 @@
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-indigo-500" />
|
||||
<div v-if="idx < orderStageItems.length - 1" class="w-0.5 flex-1 bg-white/20 my-1" />
|
||||
<div class="h-3 w-3 rounded-full bg-primary" />
|
||||
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
|
||||
</div>
|
||||
<div class="flex-1 pb-3">
|
||||
<div class="text-sm text-white font-medium">{{ stage.from }}</div>
|
||||
<div v-if="stage.to && stage.to !== stage.from" class="text-xs text-white/50 mt-0.5">
|
||||
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
|
||||
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
|
||||
→ {{ stage.to }}
|
||||
</div>
|
||||
<div v-if="stage.meta?.length" class="text-xs text-white/40 mt-1">
|
||||
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
|
||||
{{ stage.meta.join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,10 +104,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div v-if="order.stages?.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:calendar" size="18" />
|
||||
{{ t('ordersDetail.sections.timeline.title') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||
</div>
|
||||
<GanttTimeline
|
||||
:stages="order.stages"
|
||||
@@ -117,10 +117,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Map preview (small) -->
|
||||
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:map" size="18" />
|
||||
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||
</div>
|
||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||
</div>
|
||||
@@ -128,9 +128,9 @@
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="px-6 py-4 space-y-4">
|
||||
<div class="h-20 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-32 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-20 animate-pulse rounded-xl bg-base-300/70" />
|
||||
<div class="h-32 animate-pulse rounded-xl bg-base-300/70" />
|
||||
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold text-base text-white">{{ $t('catalog.headers.offers') }}</h3>
|
||||
<span class="badge badge-neutral">{{ offers.length }}</span>
|
||||
<span class="badge badge-neutral">{{ totalOffers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,25 +19,59 @@
|
||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="offer in offersWithPrice"
|
||||
:key="offer.uuid"
|
||||
class="cursor-pointer"
|
||||
@click="emit('select-offer', offer)"
|
||||
>
|
||||
<OfferResultCard
|
||||
:supplier-name="offer.supplierName"
|
||||
:location-name="offer.locationName || offer.locationCountry"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="[]"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="group in offerGroups"
|
||||
:key="group.id"
|
||||
class="flex flex-col gap-0"
|
||||
>
|
||||
<div
|
||||
v-if="group.offers.length > 1"
|
||||
class="rounded-2xl overflow-hidden border border-base-200/60 divide-y divide-base-200/60"
|
||||
>
|
||||
<div
|
||||
v-for="offer in group.offers"
|
||||
:key="offer.uuid"
|
||||
class="cursor-pointer"
|
||||
@click="emit('select-offer', offer)"
|
||||
>
|
||||
<OfferResultCard
|
||||
grouped
|
||||
:supplier-name="offer.supplierName"
|
||||
:location-name="offer.country || ''"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="getOfferStages(offer)"
|
||||
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="offer in group.offers"
|
||||
:key="offer.uuid"
|
||||
class="cursor-pointer"
|
||||
@click="emit('select-offer', offer)"
|
||||
>
|
||||
<OfferResultCard
|
||||
:supplier-name="offer.supplierName"
|
||||
:location-name="offer.country || ''"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="getOfferStages(offer)"
|
||||
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,13 +81,27 @@ interface Offer {
|
||||
productName?: string | null
|
||||
productUuid?: string | null
|
||||
supplierName?: string | null
|
||||
supplierUuid?: string | null
|
||||
quantity?: number | string | null
|
||||
unit?: string | null
|
||||
pricePerUnit?: number | string | null
|
||||
currency?: string | null
|
||||
locationName?: string | null
|
||||
locationCountry?: string | null
|
||||
locationCountryCode?: string | null
|
||||
country?: string | null
|
||||
countryCode?: string | null
|
||||
routes?: Array<{
|
||||
totalTimeSeconds?: number | null
|
||||
stages?: Array<{
|
||||
transportType?: string | null
|
||||
distanceKm?: number | null
|
||||
travelTimeSeconds?: number | null
|
||||
fromName?: string | null
|
||||
} | null> | null
|
||||
} | null> | null
|
||||
}
|
||||
|
||||
interface OfferGroup {
|
||||
id: string
|
||||
offers: Offer[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -63,9 +111,38 @@ const emit = defineEmits<{
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
offers: Offer[]
|
||||
calculations?: OfferGroup[]
|
||||
}>()
|
||||
|
||||
const offersWithPrice = computed(() =>
|
||||
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||
)
|
||||
|
||||
const totalOffers = computed(() => {
|
||||
if (props.calculations?.length) {
|
||||
return props.calculations.reduce((sum, calc) => sum + (calc.offers?.length || 0), 0)
|
||||
}
|
||||
return props.offers.length
|
||||
})
|
||||
|
||||
const offerGroups = computed<OfferGroup[]>(() => {
|
||||
if (props.calculations?.length) return props.calculations
|
||||
return offersWithPrice.value.map(offer => ({
|
||||
id: offer.uuid,
|
||||
offers: [offer]
|
||||
}))
|
||||
})
|
||||
|
||||
const getOfferStages = (offer: Offer) => {
|
||||
const route = offer.routes?.[0]
|
||||
if (!route?.stages) return []
|
||||
return route.stages
|
||||
.filter((stage): stage is NonNullable<typeof stage> => stage !== null)
|
||||
.map((stage) => ({
|
||||
transportType: stage.transportType,
|
||||
distanceKm: stage.distanceKm,
|
||||
travelTimeSeconds: stage.travelTimeSeconds,
|
||||
fromName: stage.fromName
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,19 +31,20 @@
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<button
|
||||
v-if="item.uuid"
|
||||
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||
@click.stop="onPin(item)"
|
||||
>
|
||||
<Icon name="lucide:pin" size="12" />
|
||||
</button>
|
||||
<ProductCard
|
||||
:product="item"
|
||||
selectable
|
||||
compact
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'product', item)"
|
||||
aria-label="Pin product"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,18 +57,19 @@
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<button
|
||||
v-if="item.uuid"
|
||||
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||
@click.stop="onPin(item)"
|
||||
>
|
||||
<Icon name="lucide:pin" size="12" />
|
||||
</button>
|
||||
<HubCard
|
||||
:hub="item"
|
||||
selectable
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'hub', item)"
|
||||
aria-label="Pin hub"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,18 +82,19 @@
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<button
|
||||
v-if="item.uuid"
|
||||
class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 hover:bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||
@click.stop="onPin(item)"
|
||||
>
|
||||
<Icon name="lucide:pin" size="12" />
|
||||
</button>
|
||||
<SupplierCard
|
||||
:supplier="item"
|
||||
selectable
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
|
||||
@click.stop="emit('pin', 'supplier', item)"
|
||||
aria-label="Pin supplier"
|
||||
title="Pin"
|
||||
>
|
||||
<Icon name="lucide:pin" size="16" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -129,10 +132,10 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select': [type: string, item: Item]
|
||||
'pin': [type: string, item: Item]
|
||||
'close': []
|
||||
'load-more': []
|
||||
'hover': [uuid: string | null]
|
||||
'pin': [type: 'product' | 'hub' | 'supplier', item: Item]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -191,10 +194,4 @@ const onSelect = (item: Item) => {
|
||||
emit('select', props.selectMode, item)
|
||||
}
|
||||
}
|
||||
|
||||
const onPin = (item: Item) => {
|
||||
if (props.selectMode && item.uuid) {
|
||||
emit('pin', props.selectMode, item)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,89 +1,113 @@
|
||||
<template>
|
||||
<header
|
||||
class="shadow-lg"
|
||||
:class="headerClasses"
|
||||
class="relative"
|
||||
:style="{ height: `${height}px` }"
|
||||
>
|
||||
<!-- Single row: Logo + Search + Icons -->
|
||||
<div class="flex items-stretch h-full px-4 lg:px-6 gap-4">
|
||||
<!-- Left: Logo + Nav links (top aligned) -->
|
||||
<div class="flex items-start gap-6 flex-shrink-0 pt-4">
|
||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
||||
<span class="font-bold text-xl" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
|
||||
</NuxtLink>
|
||||
<div class="relative mx-auto max-w-[2200px] px-3 py-2 md:px-4">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
:class="isHeroLayout ? 'items-start' : ''"
|
||||
:style="rowStyle"
|
||||
>
|
||||
<!-- Left: Logo + AI button + Nav links (top aligned) -->
|
||||
<div class="flex items-center flex-shrink-0 rounded-full pill-glass">
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
||||
<span class="font-black text-xl tracking-tight" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="[
|
||||
useWhiteText
|
||||
? (chatOpen ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10')
|
||||
: (chatOpen ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')
|
||||
]"
|
||||
aria-label="Toggle AI assistant"
|
||||
@click="$emit('toggle-chat')"
|
||||
>
|
||||
<Icon name="lucide:bot" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Service nav links -->
|
||||
<nav v-if="showModeToggle" class="flex items-center gap-1">
|
||||
<button
|
||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="showActiveMode && catalogMode === 'explore' && !isClientArea
|
||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||
@click="$emit('set-catalog-mode', 'explore')"
|
||||
>
|
||||
{{ $t('catalog.modes.explore') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="showActiveMode && catalogMode === 'quote' && !isClientArea
|
||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||
@click="$emit('set-catalog-mode', 'quote')"
|
||||
>
|
||||
{{ $t('catalog.modes.quote') }}
|
||||
</button>
|
||||
<!-- Role switcher: Я клиент + dropdown -->
|
||||
<div v-if="loggedIn" class="flex items-center">
|
||||
<div v-if="showModeToggle" class="w-px h-6 bg-white/20 self-center" />
|
||||
<div v-if="showModeToggle" class="flex items-center px-3 py-2">
|
||||
<nav class="flex items-center gap-1">
|
||||
<button
|
||||
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||
:class="showActiveMode && catalogMode === 'explore' && !isClientArea
|
||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||
@click="$emit('set-catalog-mode', 'explore')"
|
||||
>
|
||||
{{ $t('catalog.modes.explore') }}
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
|
||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="isClientArea
|
||||
:to="localePath('/catalog/product')"
|
||||
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||
:class="isQuoteStepPage
|
||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||
>
|
||||
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
|
||||
{{ $t('catalog.modes.quote') }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Dropdown для переключения роли (если есть обе роли) -->
|
||||
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="p-1 ml-0.5 transition-colors"
|
||||
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
|
||||
<!-- Role switcher: Я клиент + dropdown -->
|
||||
<div v-if="loggedIn" class="flex items-center">
|
||||
<NuxtLink
|
||||
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
|
||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="isClientArea
|
||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||
>
|
||||
<Icon name="lucide:chevron-down" size="14" />
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
|
||||
<li>
|
||||
<a
|
||||
:class="{ active: currentRole === 'BUYER' }"
|
||||
@click="$emit('switch-role', 'BUYER')"
|
||||
>
|
||||
{{ $t('cabinetNav.roles.client') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
:class="{ active: currentRole === 'SELLER' }"
|
||||
@click="$emit('switch-role', 'SELLER')"
|
||||
>
|
||||
{{ $t('cabinetNav.roles.seller') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Dropdown для переключения роли (если есть обе роли) -->
|
||||
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="p-1 ml-0.5 transition-colors"
|
||||
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
|
||||
>
|
||||
<Icon name="lucide:chevron-down" size="14" />
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
|
||||
<li>
|
||||
<a
|
||||
:class="{ active: currentRole === 'BUYER' }"
|
||||
@click="$emit('switch-role', 'BUYER')"
|
||||
>
|
||||
{{ $t('cabinetNav.roles.client') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
:class="{ active: currentRole === 'SELLER' }"
|
||||
@click="$emit('switch-role', 'SELLER')"
|
||||
>
|
||||
{{ $t('cabinetNav.roles.seller') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search input OR Client Area tabs (vertically centered) -->
|
||||
<div class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 justify-center">
|
||||
<div
|
||||
class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 transition-all"
|
||||
:class="isHeroLayout ? 'justify-start' : 'justify-center'"
|
||||
:style="centerStyle"
|
||||
>
|
||||
<!-- Hero slot for home page title -->
|
||||
<slot name="hero" />
|
||||
|
||||
<!-- Client Area tabs -->
|
||||
<template v-if="isClientArea">
|
||||
<div class="flex items-center gap-1 rounded-full border border-white/20 bg-white/80 backdrop-blur-md shadow-lg p-1">
|
||||
<div class="flex items-center gap-1 rounded-full pill-glass p-1">
|
||||
<!-- BUYER tabs -->
|
||||
<template v-if="currentRole !== 'SELLER'">
|
||||
<NuxtLink
|
||||
@@ -115,47 +139,39 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
|
||||
<!-- Quote mode: Step-based capsule navigation (like logistics) -->
|
||||
<template v-else-if="catalogMode === 'quote'">
|
||||
<div class="flex items-center w-full rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg divide-x divide-base-300/30">
|
||||
<div class="flex items-center w-full rounded-full pill-glass overflow-hidden">
|
||||
<!-- Product segment -->
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 rounded-l-full transition-colors min-w-0"
|
||||
@click="$emit('edit-token', 'product')"
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/product')"
|
||||
class="flex-1 px-4 py-2 text-left hover:bg-white/10 rounded-l-full transition-colors min-w-0"
|
||||
>
|
||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.product') }}</div>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.product') }}</span>
|
||||
<div class="font-medium truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</div>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<div class="w-px h-8 bg-base-300/40 self-center" />
|
||||
<!-- Hub segment -->
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 transition-colors min-w-0"
|
||||
@click="$emit('edit-token', 'hub')"
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/destination')"
|
||||
class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
|
||||
>
|
||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.hub') }}</div>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.hub') }}</span>
|
||||
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
|
||||
</button>
|
||||
<!-- Quantity segment (inline input) -->
|
||||
<div class="flex-1 px-4 py-2 min-w-0">
|
||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
v-model="localQuantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="—"
|
||||
class="w-16 font-medium bg-transparent outline-none text-base-content [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@blur="$emit('update-quantity', localQuantity)"
|
||||
@keyup.enter="$emit('update-quantity', localQuantity)"
|
||||
/>
|
||||
<span v-if="localQuantity" class="text-base-content/60 text-sm">{{ $t('units.t') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search button inside -->
|
||||
</NuxtLink>
|
||||
<div class="w-px h-8 bg-base-300/40 self-center" />
|
||||
<!-- Quantity segment -->
|
||||
<NuxtLink
|
||||
:to="localePath('/catalog/quantity')"
|
||||
class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
|
||||
>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.quantity') }}</span>
|
||||
<div class="font-medium truncate text-base-content">{{ quantity || '—' }} {{ quantity ? $t('units.t') : '' }}</div>
|
||||
</NuxtLink>
|
||||
<!-- Search button -->
|
||||
<button
|
||||
class="btn btn-primary btn-circle m-1"
|
||||
:disabled="!canSearch"
|
||||
@click="$emit('search')"
|
||||
@click="navigateToSearch"
|
||||
>
|
||||
<Icon name="lucide:search" size="18" />
|
||||
</button>
|
||||
@@ -166,7 +182,7 @@
|
||||
<template v-else>
|
||||
<!-- Big pill input -->
|
||||
<div
|
||||
class="flex items-center gap-3 w-full px-5 py-3 rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
|
||||
class="flex items-center gap-3 w-full px-5 py-3 rounded-full pill-glass focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
|
||||
@click="focusInput"
|
||||
>
|
||||
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
|
||||
@@ -210,52 +226,47 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Right: AI + Globe + Team + User (top aligned like logo) -->
|
||||
<div class="flex items-start gap-1 flex-shrink-0 pt-4">
|
||||
<!-- AI Assistant button -->
|
||||
<NuxtLink
|
||||
:to="localePath('/clientarea/ai')"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
||||
>
|
||||
<Icon name="lucide:bot" size="18" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Globe (language/currency) dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
||||
>
|
||||
<Icon name="lucide:globe" size="18" />
|
||||
</button>
|
||||
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
|
||||
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="btn btn-sm"
|
||||
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
{{ loc.code.toUpperCase() }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
|
||||
<!-- Right: Globe + Team + User (top aligned like logo) -->
|
||||
<div class="flex items-center flex-shrink-0 rounded-full pill-glass">
|
||||
<div class="w-px h-6 bg-white/20 self-center" />
|
||||
<div class="flex items-center px-2 py-2">
|
||||
<!-- Globe (language/currency) dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost w-full justify-start"
|
||||
@click="$emit('toggle-theme')"
|
||||
tabindex="0"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
||||
>
|
||||
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
|
||||
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
|
||||
<Icon name="lucide:globe" size="18" />
|
||||
</button>
|
||||
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
|
||||
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<NuxtLink
|
||||
v-for="loc in locales"
|
||||
:key="loc.code"
|
||||
:to="switchLocalePath(loc.code)"
|
||||
class="btn btn-sm"
|
||||
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
|
||||
>
|
||||
{{ loc.code.toUpperCase() }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost w-full justify-start"
|
||||
@click="$emit('toggle-theme')"
|
||||
>
|
||||
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
|
||||
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team dropdown -->
|
||||
<template v-if="loggedIn && userData?.teams?.length">
|
||||
<div v-if="loggedIn && userData?.teams?.length" class="w-px h-6 bg-white/20 self-center" />
|
||||
<div v-if="loggedIn && userData?.teams?.length" class="flex items-center px-2 py-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
@@ -287,10 +298,11 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<template v-if="sessionChecked">
|
||||
<div v-if="sessionChecked" class="w-px h-6 bg-white/20 self-center" />
|
||||
<div v-if="sessionChecked" class="flex items-center px-2 py-2">
|
||||
<template v-if="loggedIn">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
@@ -340,9 +352,10 @@
|
||||
{{ $t('auth.login') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
</template>
|
||||
@@ -390,13 +403,19 @@ const props = withDefaults(defineProps<{
|
||||
isHomePage?: boolean
|
||||
// Client area flag - shows cabinet tabs instead of search
|
||||
isClientArea?: boolean
|
||||
// AI chat sidebar state
|
||||
chatOpen?: boolean
|
||||
// Dynamic height for hero effect
|
||||
height?: number
|
||||
// Collapse progress for hero layout
|
||||
collapseProgress?: number
|
||||
}>(), {
|
||||
height: 100
|
||||
height: 100,
|
||||
collapseProgress: 1
|
||||
})
|
||||
|
||||
defineEmits([
|
||||
'toggle-chat',
|
||||
'toggle-theme',
|
||||
'sign-out',
|
||||
'sign-in',
|
||||
@@ -419,6 +438,23 @@ const route = useRoute()
|
||||
const { locale, locales } = useI18n()
|
||||
const switchLocalePath = useSwitchLocalePath()
|
||||
const { t } = useI18n()
|
||||
const { chatOpen } = toRefs(props)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Check if we're on a quote step page
|
||||
const isQuoteStepPage = computed(() => {
|
||||
const path = route.path
|
||||
return path.includes('/catalog/product') ||
|
||||
path.includes('/catalog/destination') ||
|
||||
path.includes('/catalog/quantity') ||
|
||||
path.includes('/catalog/results')
|
||||
})
|
||||
|
||||
// Navigate to search results (quote mode step flow)
|
||||
const navigateToSearch = () => {
|
||||
router.push(localePath('/catalog/product'))
|
||||
}
|
||||
|
||||
// Check if client area tab is active
|
||||
const isClientAreaTabActive = (path: string) => {
|
||||
@@ -478,21 +514,26 @@ const getTokenIcon = (type: string) => {
|
||||
return icons[type] || 'lucide:tag'
|
||||
}
|
||||
|
||||
// Header background classes
|
||||
const headerClasses = computed(() => {
|
||||
if (props.isCollapsed) {
|
||||
// Glass style when collapsed
|
||||
return 'bg-black/30 backdrop-blur-md border-b border-white/10'
|
||||
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
|
||||
const topRowHeight = 100
|
||||
|
||||
const rowStyle = computed(() => {
|
||||
if (isHeroLayout.value) {
|
||||
return { height: `${topRowHeight}px` }
|
||||
}
|
||||
if (props.isHomePage) {
|
||||
// Transparent on home page (animation visible behind)
|
||||
return 'bg-transparent'
|
||||
}
|
||||
// White on other pages
|
||||
return 'bg-base-100 border-b border-base-300'
|
||||
return { height: `${props.height}px` }
|
||||
})
|
||||
|
||||
const centerStyle = computed(() => {
|
||||
if (!isHeroLayout.value) return {}
|
||||
const heroHeight = props.height || topRowHeight
|
||||
const minTop = 0
|
||||
const maxTop = Math.max(120, Math.round(heroHeight * 0.42))
|
||||
const progress = Math.min(1, Math.max(0, props.collapseProgress || 0))
|
||||
const top = Math.round(maxTop - (maxTop - minTop) * progress)
|
||||
return { marginTop: `${top}px` }
|
||||
})
|
||||
|
||||
// Use white text on dark backgrounds (collapsed or home page with animation)
|
||||
const useWhiteText = computed(() => props.isCollapsed || props.isHomePage)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-base-100/80">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ $t('catalogLanding.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Map -->
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
@@ -18,7 +8,7 @@
|
||||
:map-id="mapId"
|
||||
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
|
||||
:clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])"
|
||||
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
|
||||
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
|
||||
:use-server-clustering="useServerClustering && !isInfoMode"
|
||||
:point-color="activePointColor"
|
||||
:entity-type="activeEntityType"
|
||||
@@ -26,6 +16,7 @@
|
||||
:hovered-item="hoveredItem"
|
||||
:related-points="relatedPoints"
|
||||
:info-loading="infoLoading"
|
||||
:fit-padding-left="fitPaddingLeft"
|
||||
@select-item="onMapSelect"
|
||||
@bounds-change="onBoundsChange"
|
||||
/>
|
||||
@@ -34,17 +25,17 @@
|
||||
|
||||
<!-- View mode loading indicator -->
|
||||
<div
|
||||
v-if="clusterLoading"
|
||||
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-black/50 backdrop-blur-md rounded-full px-4 py-2 border border-white/20"
|
||||
v-if="clusterLoading || loading"
|
||||
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 pill-glass rounded-full px-4 py-2"
|
||||
>
|
||||
<span class="loading loading-spinner loading-sm text-white" />
|
||||
<span class="text-white text-sm">{{ $t('common.loading') }}</span>
|
||||
<span class="loading loading-spinner loading-sm text-base-content" />
|
||||
<span class="text-base-content text-sm font-medium">{{ $t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- List button (LEFT, opens panel) - hide when panel is open -->
|
||||
<button
|
||||
v-if="!isPanelOpen"
|
||||
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 text-white text-sm hover:bg-black/40 transition-colors"
|
||||
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 pill-glass rounded-full px-3 py-1.5 text-base-content text-sm hover:bg-white/20 transition-colors"
|
||||
@click="openPanel"
|
||||
>
|
||||
<Icon name="lucide:menu" size="16" />
|
||||
@@ -54,7 +45,7 @@
|
||||
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
|
||||
<label
|
||||
v-if="selectMode !== null"
|
||||
class="absolute top-[116px] left-[420px] z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 cursor-pointer text-white text-sm hover:bg-black/40 transition-colors"
|
||||
class="absolute top-[116px] left-[calc(1rem+32rem+1rem)] z-20 hidden lg:flex items-center gap-2 pill-glass rounded-full px-3 py-1.5 cursor-pointer text-base-content text-sm hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -69,10 +60,11 @@
|
||||
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
|
||||
<div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
|
||||
<!-- View mode toggle -->
|
||||
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||
<div class="flex gap-1 pill-glass rounded-full p-1">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
||||
:class="mapViewMode === 'offers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
||||
v-if="showOffersToggle"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||
:class="mapViewMode === 'offers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
|
||||
@click="setMapViewMode('offers')"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
|
||||
@@ -81,8 +73,9 @@
|
||||
{{ $t('catalog.views.offers') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
||||
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
||||
v-if="showHubsToggle"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
|
||||
@click="setMapViewMode('hubs')"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
|
||||
@@ -91,8 +84,9 @@
|
||||
{{ $t('catalog.views.hubs') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
||||
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
||||
v-if="showSuppliersToggle"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
|
||||
@click="setMapViewMode('suppliers')"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
|
||||
@@ -110,7 +104,7 @@
|
||||
class="absolute top-[116px] left-4 bottom-4 z-30 max-w-[calc(100vw-2rem)] hidden lg:block"
|
||||
:class="panelWidth"
|
||||
>
|
||||
<div class="bg-black/50 backdrop-blur-md rounded-xl shadow-lg border border-white/10 h-full flex flex-col text-white">
|
||||
<div class="bg-white/90 backdrop-blur-[14px] border border-white/50 rounded-[1.1rem] shadow-2xl h-full flex flex-col text-base-content">
|
||||
<slot name="panel" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +116,7 @@
|
||||
<div class="flex justify-between px-4 mb-2">
|
||||
<!-- List button (mobile) -->
|
||||
<button
|
||||
class="flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-2 border border-white/10 text-white text-sm"
|
||||
class="flex items-center gap-2 pill-glass rounded-full px-3 py-2 text-base-content text-sm font-medium"
|
||||
@click="openPanel"
|
||||
>
|
||||
<Icon name="lucide:menu" size="16" />
|
||||
@@ -130,9 +124,10 @@
|
||||
</button>
|
||||
|
||||
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
|
||||
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 pill-glass rounded-full p-1">
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
||||
v-if="showOffersToggle"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||
:class="mapViewMode === 'offers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||
@click="setMapViewMode('offers')"
|
||||
>
|
||||
@@ -141,7 +136,8 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
||||
v-if="showHubsToggle"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||
:class="mapViewMode === 'hubs' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||
@click="setMapViewMode('hubs')"
|
||||
>
|
||||
@@ -150,7 +146,8 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
||||
v-if="showSuppliersToggle"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||
:class="mapViewMode === 'suppliers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||
@click="setMapViewMode('suppliers')"
|
||||
>
|
||||
@@ -165,14 +162,14 @@
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="isPanelOpen"
|
||||
class="bg-black/50 backdrop-blur-md rounded-t-xl shadow-lg border border-white/10 transition-all duration-300 text-white h-[60vh]"
|
||||
class="bg-white rounded-t-3xl shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 text-base-content h-[60vh]"
|
||||
>
|
||||
<!-- Drag handle / close -->
|
||||
<div
|
||||
class="flex justify-center py-2 cursor-pointer"
|
||||
@click="closePanel"
|
||||
>
|
||||
<div class="w-10 h-1 bg-white/30 rounded-full" />
|
||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
|
||||
@@ -192,6 +189,34 @@ const { mapViewMode, setMapViewMode, selectMode, startSelect, cancelSelect } = u
|
||||
// Panel is open when selectMode is set OR when showPanel prop is true (info/quote)
|
||||
const isPanelOpen = computed(() => props.showPanel || selectMode.value !== null)
|
||||
|
||||
const isDesktop = ref(false)
|
||||
onMounted(() => {
|
||||
const media = window.matchMedia('(min-width: 1024px)')
|
||||
const update = () => {
|
||||
isDesktop.value = media.matches
|
||||
}
|
||||
update()
|
||||
media.addEventListener('change', update)
|
||||
onUnmounted(() => {
|
||||
media.removeEventListener('change', update)
|
||||
})
|
||||
})
|
||||
|
||||
const panelWidthPx = computed(() => {
|
||||
const match = props.panelWidth.match(/w-\[(\d+(?:\.\d+)?)rem\]/)
|
||||
if (match) return Number(match[1]) * 16
|
||||
if (props.panelWidth === 'w-96') return 24 * 16
|
||||
if (props.panelWidth === 'w-80') return 20 * 16
|
||||
return 0
|
||||
})
|
||||
|
||||
const fitPaddingLeft = computed(() => {
|
||||
if (!isPanelOpen.value || !isDesktop.value || panelWidthPx.value === 0) return 0
|
||||
const leftInset = 16
|
||||
const rightInset = 16
|
||||
return leftInset + panelWidthPx.value + rightInset
|
||||
})
|
||||
|
||||
// Open panel based on current mapViewMode
|
||||
const openPanel = () => {
|
||||
const newSelectMode = mapViewMode.value === 'hubs' ? 'hub'
|
||||
@@ -255,8 +280,15 @@ const props = withDefaults(defineProps<{
|
||||
showPanel?: boolean
|
||||
filterByBounds?: boolean
|
||||
infoLoading?: boolean
|
||||
forceInfoMode?: boolean
|
||||
panelWidth?: string
|
||||
hideViewToggle?: boolean
|
||||
showOffersToggle?: boolean
|
||||
showHubsToggle?: boolean
|
||||
showSuppliersToggle?: boolean
|
||||
clusterProductUuid?: string
|
||||
clusterHubUuid?: string
|
||||
clusterSupplierUuid?: string
|
||||
relatedPoints?: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
@@ -275,8 +307,15 @@ const props = withDefaults(defineProps<{
|
||||
showPanel: false,
|
||||
filterByBounds: false,
|
||||
infoLoading: false,
|
||||
forceInfoMode: false,
|
||||
panelWidth: 'w-96',
|
||||
hideViewToggle: false,
|
||||
showOffersToggle: true,
|
||||
showHubsToggle: true,
|
||||
showSuppliersToggle: true,
|
||||
clusterProductUuid: undefined,
|
||||
clusterHubUuid: undefined,
|
||||
clusterSupplierUuid: undefined,
|
||||
relatedPoints: () => []
|
||||
})
|
||||
|
||||
@@ -289,8 +328,15 @@ const emit = defineEmits<{
|
||||
|
||||
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
|
||||
|
||||
const clusterProductUuid = computed(() => props.clusterProductUuid ?? undefined)
|
||||
const clusterHubUuid = computed(() => props.clusterHubUuid ?? undefined)
|
||||
const clusterSupplierUuid = computed(() => props.clusterSupplierUuid ?? undefined)
|
||||
|
||||
// Server-side clustering (single-type mode)
|
||||
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
|
||||
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(
|
||||
undefined,
|
||||
activeClusterNodeType,
|
||||
)
|
||||
|
||||
// Server-side clustering (typed mode)
|
||||
const offerClusters = useClusteredNodes(undefined, ref('offer'))
|
||||
@@ -339,6 +385,7 @@ const fetchActiveClusters = async () => {
|
||||
// Refetch clusters when view mode changes
|
||||
watch(mapViewMode, async () => {
|
||||
if (!props.useServerClustering) return
|
||||
if (isInfoMode.value) return
|
||||
if (useTypedClusters.value) {
|
||||
clearInactiveClusters(activeClusterType.value)
|
||||
if (currentBounds.value) {
|
||||
@@ -354,6 +401,17 @@ watch(mapViewMode, async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([clusterProductUuid, clusterHubUuid, clusterSupplierUuid], async () => {
|
||||
if (!props.useServerClustering) return
|
||||
if (isInfoMode.value) return
|
||||
if (!currentBounds.value) return
|
||||
if (useTypedClusters.value) {
|
||||
await fetchActiveClusters()
|
||||
return
|
||||
}
|
||||
await fetchClusters(currentBounds.value)
|
||||
})
|
||||
|
||||
// Map refs
|
||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||
|
||||
@@ -364,7 +422,7 @@ const selectedMapItem = ref<MapItem | null>(null)
|
||||
const mobilePanelExpanded = ref(false)
|
||||
|
||||
// Info mode - when relatedPoints are present, hide clusters and show only related points
|
||||
const isInfoMode = computed(() => props.relatedPoints && props.relatedPoints.length > 0)
|
||||
const isInfoMode = computed(() => props.forceInfoMode || (props.relatedPoints && props.relatedPoints.length > 0))
|
||||
|
||||
// Hovered item with coordinates for map highlight
|
||||
const hoveredItem = computed(() => {
|
||||
|
||||
@@ -13,96 +13,61 @@ export type Scalars = {
|
||||
Boolean: { input: boolean; output: boolean; }
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
Date: { input: string; output: string; }
|
||||
DateTime: { input: string; output: string; }
|
||||
Decimal: { input: string; output: string; }
|
||||
};
|
||||
|
||||
export type OfferType = {
|
||||
__typename?: 'OfferType';
|
||||
categoryName: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
export type Offer = {
|
||||
__typename?: 'Offer';
|
||||
categoryName?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['String']['output'];
|
||||
currency: Scalars['String']['output'];
|
||||
description: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
locationCountry: Scalars['String']['output'];
|
||||
locationCountryCode: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
locationCountry?: Maybe<Scalars['String']['output']>;
|
||||
locationCountryCode?: Maybe<Scalars['String']['output']>;
|
||||
locationLatitude?: Maybe<Scalars['Float']['output']>;
|
||||
locationLongitude?: Maybe<Scalars['Float']['output']>;
|
||||
locationName: Scalars['String']['output'];
|
||||
locationUuid: Scalars['String']['output'];
|
||||
pricePerUnit?: Maybe<Scalars['Decimal']['output']>;
|
||||
locationName?: Maybe<Scalars['String']['output']>;
|
||||
locationUuid?: Maybe<Scalars['String']['output']>;
|
||||
pricePerUnit: Scalars['Float']['output'];
|
||||
productName: Scalars['String']['output'];
|
||||
productUuid: Scalars['String']['output'];
|
||||
quantity: Scalars['Decimal']['output'];
|
||||
status: OffersOfferStatusChoices;
|
||||
quantity: Scalars['Float']['output'];
|
||||
status: Scalars['String']['output'];
|
||||
teamUuid: Scalars['String']['output'];
|
||||
terminusDocumentId: Scalars['String']['output'];
|
||||
terminusSchemaId: Scalars['String']['output'];
|
||||
unit: Scalars['String']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
updatedAt: Scalars['String']['output'];
|
||||
uuid: Scalars['String']['output'];
|
||||
validUntil?: Maybe<Scalars['Date']['output']>;
|
||||
workflowError: Scalars['String']['output'];
|
||||
workflowStatus: OffersOfferWorkflowStatusChoices;
|
||||
validUntil?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** An enumeration. */
|
||||
export enum OffersOfferStatusChoices {
|
||||
/** Активно */
|
||||
Active = 'ACTIVE',
|
||||
/** Отменено */
|
||||
Cancelled = 'CANCELLED',
|
||||
/** Закрыто */
|
||||
Closed = 'CLOSED',
|
||||
/** Черновик */
|
||||
Draft = 'DRAFT'
|
||||
}
|
||||
|
||||
/** An enumeration. */
|
||||
export enum OffersOfferWorkflowStatusChoices {
|
||||
/** Активен */
|
||||
Active = 'ACTIVE',
|
||||
/** Ошибка */
|
||||
Error = 'ERROR',
|
||||
/** Ожидает обработки */
|
||||
Pending = 'PENDING'
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
__typename?: 'Product';
|
||||
categoryId?: Maybe<Scalars['Int']['output']>;
|
||||
categoryId?: Maybe<Scalars['String']['output']>;
|
||||
categoryName?: Maybe<Scalars['String']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
terminusSchemaId?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQuery = {
|
||||
__typename?: 'PublicQuery';
|
||||
/** Get products that have active offers */
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
getAvailableProducts?: Maybe<Array<Maybe<Product>>>;
|
||||
getOffer?: Maybe<OfferType>;
|
||||
getOffers?: Maybe<Array<Maybe<OfferType>>>;
|
||||
getOffer?: Maybe<Offer>;
|
||||
getOffers?: Maybe<Array<Maybe<Offer>>>;
|
||||
getOffersCount?: Maybe<Scalars['Int']['output']>;
|
||||
getProducts?: Maybe<Array<Maybe<Product>>>;
|
||||
getSupplierProfile?: Maybe<SupplierProfileType>;
|
||||
/** Get supplier profile by team UUID */
|
||||
getSupplierProfileByTeam?: Maybe<SupplierProfileType>;
|
||||
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfileType>>>;
|
||||
getSupplierProfile?: Maybe<SupplierProfile>;
|
||||
getSupplierProfileByTeam?: Maybe<SupplierProfile>;
|
||||
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfile>>>;
|
||||
getSupplierProfilesCount?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetOfferArgs = {
|
||||
export type QueryGetOfferArgs = {
|
||||
uuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetOffersArgs = {
|
||||
export type QueryGetOffersArgs = {
|
||||
categoryName?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -113,8 +78,7 @@ export type PublicQueryGetOffersArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetOffersCountArgs = {
|
||||
export type QueryGetOffersCountArgs = {
|
||||
categoryName?: InputMaybe<Scalars['String']['input']>;
|
||||
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -123,20 +87,17 @@ export type PublicQueryGetOffersCountArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetSupplierProfileArgs = {
|
||||
export type QueryGetSupplierProfileArgs = {
|
||||
uuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetSupplierProfileByTeamArgs = {
|
||||
export type QueryGetSupplierProfileByTeamArgs = {
|
||||
teamUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetSupplierProfilesArgs = {
|
||||
export type QueryGetSupplierProfilesArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -144,51 +105,46 @@ export type PublicQueryGetSupplierProfilesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Public schema - no authentication required */
|
||||
export type PublicQueryGetSupplierProfilesCountArgs = {
|
||||
export type QueryGetSupplierProfilesCountArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
/** Профиль поставщика на бирже */
|
||||
export type SupplierProfileType = {
|
||||
__typename?: 'SupplierProfileType';
|
||||
country: Scalars['String']['output'];
|
||||
export type SupplierProfile = {
|
||||
__typename?: 'SupplierProfile';
|
||||
country?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
description: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
isActive: Scalars['Boolean']['output'];
|
||||
isVerified: Scalars['Boolean']['output'];
|
||||
kycProfileUuid: Scalars['String']['output'];
|
||||
kycProfileUuid?: Maybe<Scalars['String']['output']>;
|
||||
latitude?: Maybe<Scalars['Float']['output']>;
|
||||
logoUrl: Scalars['String']['output'];
|
||||
logoUrl?: Maybe<Scalars['String']['output']>;
|
||||
longitude?: Maybe<Scalars['Float']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
offersCount?: Maybe<Scalars['Int']['output']>;
|
||||
teamUuid: Scalars['String']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
uuid: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type GetAvailableProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetAvailableProductsQueryResult = { __typename?: 'PublicQuery', getAvailableProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
export type GetAvailableProductsQueryResult = { __typename?: 'Query', getAvailableProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
|
||||
export type GetLocationOffersQueryVariables = Exact<{
|
||||
locationUuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetLocationOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
export type GetLocationOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
|
||||
export type GetOfferQueryVariables = Exact<{
|
||||
uuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOfferQueryResult = { __typename?: 'PublicQuery', getOffer?: { __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
|
||||
export type GetOfferQueryResult = { __typename?: 'Query', getOffer?: { __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
|
||||
|
||||
export type GetOffersQueryVariables = Exact<{
|
||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -200,47 +156,47 @@ export type GetOffersQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOffersQueryResult = { __typename?: 'PublicQuery', getOffersCount?: number | null, getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
export type GetOffersQueryResult = { __typename?: 'Query', getOffersCount?: number | null, getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
|
||||
export type GetProductQueryVariables = Exact<{
|
||||
uuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetProductQueryResult = { __typename?: 'PublicQuery', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
export type GetProductQueryResult = { __typename?: 'Query', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
|
||||
export type GetProductOffersQueryVariables = Exact<{
|
||||
productUuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetProductOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
export type GetProductOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
|
||||
export type GetProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetProductsQueryResult = { __typename?: 'PublicQuery', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
export type GetProductsQueryResult = { __typename?: 'Query', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
|
||||
|
||||
export type GetSupplierOffersQueryVariables = Exact<{
|
||||
teamUuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetSupplierOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
export type GetSupplierOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||
|
||||
export type GetSupplierProfileQueryVariables = Exact<{
|
||||
uuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetSupplierProfileQueryResult = { __typename?: 'PublicQuery', getSupplierProfile?: { __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, kycProfileUuid: string, name: string, description: string, country: string, logoUrl: string, isVerified: boolean, isActive: boolean, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null };
|
||||
export type GetSupplierProfileQueryResult = { __typename?: 'Query', getSupplierProfile?: { __typename?: 'SupplierProfile', uuid: string, teamUuid: string, kycProfileUuid?: string | null, name: string, description?: string | null, country?: string | null, logoUrl?: string | null, isVerified: boolean, isActive: boolean, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null };
|
||||
|
||||
export type GetSupplierProfileByTeamQueryVariables = Exact<{
|
||||
teamUuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetSupplierProfileByTeamQueryResult = { __typename?: 'PublicQuery', getSupplierProfileByTeam?: { __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, kycProfileUuid: string, name: string, description: string, country: string, logoUrl: string, isVerified: boolean, isActive: boolean, offersCount?: number | null } | null };
|
||||
export type GetSupplierProfileByTeamQueryResult = { __typename?: 'Query', getSupplierProfileByTeam?: { __typename?: 'SupplierProfile', uuid: string, teamUuid: string, kycProfileUuid?: string | null, name: string, description?: string | null, country?: string | null, logoUrl?: string | null, isVerified: boolean, isActive: boolean, offersCount?: number | null } | null };
|
||||
|
||||
export type GetSupplierProfilesQueryVariables = Exact<{
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -249,7 +205,7 @@ export type GetSupplierProfilesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetSupplierProfilesQueryResult = { __typename?: 'PublicQuery', getSupplierProfilesCount?: number | null, getSupplierProfiles?: Array<{ __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, name: string, description: string, country: string, countryCode?: string | null, logoUrl: string, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null> | null };
|
||||
export type GetSupplierProfilesQueryResult = { __typename?: 'Query', getSupplierProfilesCount?: number | null, getSupplierProfiles?: Array<{ __typename?: 'SupplierProfile', uuid: string, teamUuid: string, name: string, description?: string | null, country?: string | null, countryCode?: string | null, logoUrl?: string | null, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null> | null };
|
||||
|
||||
|
||||
export const GetAvailableProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAvailableProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAvailableProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"categoryId"}},{"kind":"Field","name":{"kind":"Name","value":"categoryName"}},{"kind":"Field","name":{"kind":"Name","value":"terminusSchemaId"}}]}}]}}]} as unknown as DocumentNode<GetAvailableProductsQueryResult, GetAvailableProductsQueryVariables>;
|
||||
|
||||
@@ -13,27 +13,21 @@ export type Scalars = {
|
||||
Boolean: { input: boolean; output: boolean; }
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||
JSON: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||
};
|
||||
|
||||
/** Cluster or individual point for map display. */
|
||||
export type ClusterPointType = {
|
||||
__typename?: 'ClusterPointType';
|
||||
/** 1 for single point, >1 for cluster */
|
||||
export type ClusterPoint = {
|
||||
__typename?: 'ClusterPoint';
|
||||
count?: Maybe<Scalars['Int']['output']>;
|
||||
/** Zoom level to expand cluster */
|
||||
expansionZoom?: Maybe<Scalars['Int']['output']>;
|
||||
/** UUID for points, 'cluster-N' for clusters */
|
||||
id?: Maybe<Scalars['String']['output']>;
|
||||
latitude?: Maybe<Scalars['Float']['output']>;
|
||||
longitude?: Maybe<Scalars['Float']['output']>;
|
||||
/** Node name (only for single points) */
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Edge between two nodes (route). */
|
||||
export type EdgeType = {
|
||||
__typename?: 'EdgeType';
|
||||
export type Edge = {
|
||||
__typename?: 'Edge';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
toLatitude?: Maybe<Scalars['Float']['output']>;
|
||||
toLongitude?: Maybe<Scalars['Float']['output']>;
|
||||
@@ -43,22 +37,12 @@ export type EdgeType = {
|
||||
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
/** Auto + rail edges for a node, rail uses nearest rail node. */
|
||||
export type NodeConnectionsType = {
|
||||
__typename?: 'NodeConnectionsType';
|
||||
autoEdges?: Maybe<Array<Maybe<EdgeType>>>;
|
||||
hub?: Maybe<NodeType>;
|
||||
railEdges?: Maybe<Array<Maybe<EdgeType>>>;
|
||||
railNode?: Maybe<NodeType>;
|
||||
};
|
||||
|
||||
/** Logistics node with edges to neighbors. */
|
||||
export type NodeType = {
|
||||
__typename?: 'NodeType';
|
||||
export type Node = {
|
||||
__typename?: 'Node';
|
||||
country?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
edges?: Maybe<Array<Maybe<EdgeType>>>;
|
||||
edges?: Maybe<Array<Maybe<Edge>>>;
|
||||
latitude?: Maybe<Scalars['Float']['output']>;
|
||||
longitude?: Maybe<Scalars['Float']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
@@ -67,9 +51,16 @@ export type NodeType = {
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Offer node with location and product info. */
|
||||
export type OfferNodeType = {
|
||||
__typename?: 'OfferNodeType';
|
||||
export type NodeConnections = {
|
||||
__typename?: 'NodeConnections';
|
||||
autoEdges?: Maybe<Array<Maybe<Edge>>>;
|
||||
hub?: Maybe<Node>;
|
||||
railEdges?: Maybe<Array<Maybe<Edge>>>;
|
||||
railNode?: Maybe<Node>;
|
||||
};
|
||||
|
||||
export type OfferNode = {
|
||||
__typename?: 'OfferNode';
|
||||
country?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
currency?: Maybe<Scalars['String']['output']>;
|
||||
@@ -86,9 +77,8 @@ export type OfferNodeType = {
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Offer with route information to destination. */
|
||||
export type OfferWithRouteType = {
|
||||
__typename?: 'OfferWithRouteType';
|
||||
export type OfferWithRoute = {
|
||||
__typename?: 'OfferWithRoute';
|
||||
country?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
currency?: Maybe<Scalars['String']['output']>;
|
||||
@@ -99,94 +89,62 @@ export type OfferWithRouteType = {
|
||||
productName?: Maybe<Scalars['String']['output']>;
|
||||
productUuid?: Maybe<Scalars['String']['output']>;
|
||||
quantity?: Maybe<Scalars['String']['output']>;
|
||||
routes?: Maybe<Array<Maybe<RoutePathType>>>;
|
||||
routes?: Maybe<Array<Maybe<RoutePath>>>;
|
||||
supplierName?: Maybe<Scalars['String']['output']>;
|
||||
supplierUuid?: Maybe<Scalars['String']['output']>;
|
||||
unit?: Maybe<Scalars['String']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Route options for a product source to the destination. */
|
||||
export type ProductRouteOptionType = {
|
||||
__typename?: 'ProductRouteOptionType';
|
||||
export type Product = {
|
||||
__typename?: 'Product';
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
offersCount?: Maybe<Scalars['Int']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ProductRouteOption = {
|
||||
__typename?: 'ProductRouteOption';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
routes?: Maybe<Array<Maybe<RoutePathType>>>;
|
||||
routes?: Maybe<Array<Maybe<RoutePath>>>;
|
||||
sourceLat?: Maybe<Scalars['Float']['output']>;
|
||||
sourceLon?: Maybe<Scalars['Float']['output']>;
|
||||
sourceName?: Maybe<Scalars['String']['output']>;
|
||||
sourceUuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Unique product from offers. */
|
||||
export type ProductType = {
|
||||
__typename?: 'ProductType';
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of offers for this product */
|
||||
offersCount?: Maybe<Scalars['Int']['output']>;
|
||||
uuid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Root query. */
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
/** Get auto route between two points via GraphHopper */
|
||||
autoRoute?: Maybe<RouteType>;
|
||||
/** Get clustered nodes for map display (server-side clustering) */
|
||||
clusteredNodes?: Maybe<Array<Maybe<ClusterPointType>>>;
|
||||
/** List of countries that have logistics hubs */
|
||||
hubCountries?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
/** Get hubs where a product is available nearby */
|
||||
hubsForProduct?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Get paginated list of logistics hubs */
|
||||
hubsList?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Get nearest hubs to an offer location */
|
||||
hubsNearOffer?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Find nearest hubs to coordinates (optionally filtered by product) */
|
||||
nearestHubs?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Find nearest logistics nodes to given coordinates */
|
||||
nearestNodes?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Find nearest offers to coordinates with optional routes to hub */
|
||||
nearestOffers?: Maybe<Array<Maybe<OfferWithRouteType>>>;
|
||||
/** Find nearest suppliers to coordinates (optionally filtered by product) */
|
||||
nearestSuppliers?: Maybe<Array<Maybe<SupplierType>>>;
|
||||
/** Get node by UUID with all edges to neighbors */
|
||||
node?: Maybe<NodeType>;
|
||||
/** Get auto + rail edges for a node (rail uses nearest rail node) */
|
||||
nodeConnections?: Maybe<NodeConnectionsType>;
|
||||
/** Get all nodes (without edges for performance) */
|
||||
nodes?: Maybe<Array<Maybe<NodeType>>>;
|
||||
/** Get total count of nodes (with optional transport/country/bounds filter) */
|
||||
nodesCount?: Maybe<Scalars['Int']['output']>;
|
||||
/** Get route from a specific offer to hub */
|
||||
offerToHub?: Maybe<ProductRouteOptionType>;
|
||||
/** Get offers for a product with routes to hub (auto → rail* → auto) */
|
||||
offersByHub?: Maybe<Array<Maybe<ProductRouteOptionType>>>;
|
||||
/** Get all offers for a product */
|
||||
offersByProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
|
||||
/** Get offers from a supplier for a specific product */
|
||||
offersBySupplierProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
|
||||
/** Get unique products from all offers */
|
||||
products?: Maybe<Array<Maybe<ProductType>>>;
|
||||
/** Get products offered by a supplier */
|
||||
productsBySupplier?: Maybe<Array<Maybe<ProductType>>>;
|
||||
/** Get paginated list of products from graph */
|
||||
productsList?: Maybe<Array<Maybe<ProductType>>>;
|
||||
/** Get products available near a hub */
|
||||
productsNearHub?: Maybe<Array<Maybe<ProductType>>>;
|
||||
/** Get rail route between two points via OpenRailRouting */
|
||||
railRoute?: Maybe<RouteType>;
|
||||
/** Get route from offer to target coordinates (finds nearest hub to coordinate) */
|
||||
routeToCoordinate?: Maybe<ProductRouteOptionType>;
|
||||
/** Get unique suppliers from all offers */
|
||||
suppliers?: Maybe<Array<Maybe<SupplierType>>>;
|
||||
/** Get suppliers that offer a specific product */
|
||||
suppliersForProduct?: Maybe<Array<Maybe<SupplierType>>>;
|
||||
/** Get paginated list of suppliers from graph */
|
||||
suppliersList?: Maybe<Array<Maybe<SupplierType>>>;
|
||||
autoRoute?: Maybe<Route>;
|
||||
clusteredNodes: Array<ClusterPoint>;
|
||||
hubCountries: Array<Scalars['String']['output']>;
|
||||
hubsForProduct: Array<Node>;
|
||||
hubsList: Array<Node>;
|
||||
hubsNearOffer: Array<Node>;
|
||||
nearestHubs: Array<Node>;
|
||||
nearestNodes: Array<Node>;
|
||||
nearestOffers: Array<OfferWithRoute>;
|
||||
nearestSuppliers: Array<Supplier>;
|
||||
node?: Maybe<Node>;
|
||||
nodeConnections?: Maybe<NodeConnections>;
|
||||
nodes: Array<Node>;
|
||||
nodesCount: Scalars['Int']['output'];
|
||||
offerToHub?: Maybe<ProductRouteOption>;
|
||||
offersByHub: Array<ProductRouteOption>;
|
||||
offersByProduct: Array<OfferNode>;
|
||||
offersBySupplierProduct: Array<OfferNode>;
|
||||
products: Array<Product>;
|
||||
productsBySupplier: Array<Product>;
|
||||
productsList: Array<Product>;
|
||||
productsNearHub: Array<Product>;
|
||||
railRoute?: Maybe<Route>;
|
||||
routeToCoordinate?: Maybe<ProductRouteOption>;
|
||||
suppliers: Array<Supplier>;
|
||||
suppliersForProduct: Array<Supplier>;
|
||||
suppliersList: Array<Supplier>;
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryAutoRouteArgs = {
|
||||
fromLat: Scalars['Float']['input'];
|
||||
fromLon: Scalars['Float']['input'];
|
||||
@@ -195,7 +153,6 @@ export type QueryAutoRouteArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryClusteredNodesArgs = {
|
||||
east: Scalars['Float']['input'];
|
||||
nodeType?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -207,14 +164,12 @@ export type QueryClusteredNodesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryHubsForProductArgs = {
|
||||
productUuid: Scalars['String']['input'];
|
||||
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryHubsListArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
east?: InputMaybe<Scalars['Float']['input']>;
|
||||
@@ -227,25 +182,21 @@ export type QueryHubsListArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryHubsNearOfferArgs = {
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offerUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNearestHubsArgs = {
|
||||
lat: Scalars['Float']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
lon: Scalars['Float']['input'];
|
||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
radius?: InputMaybe<Scalars['Float']['input']>;
|
||||
sourceUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNearestNodesArgs = {
|
||||
lat: Scalars['Float']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -253,7 +204,6 @@ export type QueryNearestNodesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNearestOffersArgs = {
|
||||
hubUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
lat: Scalars['Float']['input'];
|
||||
@@ -264,7 +214,6 @@ export type QueryNearestOffersArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNearestSuppliersArgs = {
|
||||
lat: Scalars['Float']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -274,13 +223,11 @@ export type QueryNearestSuppliersArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNodeArgs = {
|
||||
uuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNodeConnectionsArgs = {
|
||||
limitAuto?: InputMaybe<Scalars['Int']['input']>;
|
||||
limitRail?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -288,7 +235,6 @@ export type QueryNodeConnectionsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNodesArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
east?: InputMaybe<Scalars['Float']['input']>;
|
||||
@@ -302,7 +248,6 @@ export type QueryNodesArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryNodesCountArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
east?: InputMaybe<Scalars['Float']['input']>;
|
||||
@@ -313,14 +258,12 @@ export type QueryNodesCountArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryOfferToHubArgs = {
|
||||
hubUuid: Scalars['String']['input'];
|
||||
offerUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryOffersByHubArgs = {
|
||||
hubUuid: Scalars['String']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -328,26 +271,22 @@ export type QueryOffersByHubArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryOffersByProductArgs = {
|
||||
productUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryOffersBySupplierProductArgs = {
|
||||
productUuid: Scalars['String']['input'];
|
||||
supplierUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryProductsBySupplierArgs = {
|
||||
supplierUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryProductsListArgs = {
|
||||
east?: InputMaybe<Scalars['Float']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -358,14 +297,12 @@ export type QueryProductsListArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryProductsNearHubArgs = {
|
||||
hubUuid: Scalars['String']['input'];
|
||||
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryRailRouteArgs = {
|
||||
fromLat: Scalars['Float']['input'];
|
||||
fromLon: Scalars['Float']['input'];
|
||||
@@ -374,7 +311,6 @@ export type QueryRailRouteArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QueryRouteToCoordinateArgs = {
|
||||
lat: Scalars['Float']['input'];
|
||||
lon: Scalars['Float']['input'];
|
||||
@@ -382,13 +318,11 @@ export type QueryRouteToCoordinateArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QuerySuppliersForProductArgs = {
|
||||
productUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Root query. */
|
||||
export type QuerySuppliersListArgs = {
|
||||
country?: InputMaybe<Scalars['String']['input']>;
|
||||
east?: InputMaybe<Scalars['Float']['input']>;
|
||||
@@ -399,17 +333,21 @@ export type QuerySuppliersListArgs = {
|
||||
west?: InputMaybe<Scalars['Float']['input']>;
|
||||
};
|
||||
|
||||
/** Complete route through graph with multiple stages. */
|
||||
export type RoutePathType = {
|
||||
__typename?: 'RoutePathType';
|
||||
stages?: Maybe<Array<Maybe<RouteStageType>>>;
|
||||
export type Route = {
|
||||
__typename?: 'Route';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
geometry?: Maybe<Scalars['JSON']['output']>;
|
||||
};
|
||||
|
||||
export type RoutePath = {
|
||||
__typename?: 'RoutePath';
|
||||
stages?: Maybe<Array<Maybe<RouteStage>>>;
|
||||
totalDistanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
totalTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
/** Single stage in a multi-hop route. */
|
||||
export type RouteStageType = {
|
||||
__typename?: 'RouteStageType';
|
||||
export type RouteStage = {
|
||||
__typename?: 'RouteStage';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
fromLat?: Maybe<Scalars['Float']['output']>;
|
||||
fromLon?: Maybe<Scalars['Float']['output']>;
|
||||
@@ -423,17 +361,8 @@ export type RouteStageType = {
|
||||
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
/** Route between two points with geometry. */
|
||||
export type RouteType = {
|
||||
__typename?: 'RouteType';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
/** GeoJSON LineString coordinates */
|
||||
geometry?: Maybe<Scalars['JSONString']['output']>;
|
||||
};
|
||||
|
||||
/** Unique supplier from offers. */
|
||||
export type SupplierType = {
|
||||
__typename?: 'SupplierType';
|
||||
export type Supplier = {
|
||||
__typename?: 'Supplier';
|
||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||
latitude?: Maybe<Scalars['Float']['output']>;
|
||||
longitude?: Maybe<Scalars['Float']['output']>;
|
||||
@@ -449,7 +378,7 @@ export type GetAutoRouteQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||
|
||||
export type GetClusteredNodesQueryVariables = Exact<{
|
||||
west: Scalars['Float']['input'];
|
||||
@@ -462,19 +391,19 @@ export type GetClusteredNodesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes?: Array<{ __typename?: 'ClusterPointType', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null } | null> | null };
|
||||
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes: Array<{ __typename?: 'ClusterPoint', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null }> };
|
||||
|
||||
export type GetHubCountriesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries?: Array<string | null> | null };
|
||||
export type GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries: Array<string> };
|
||||
|
||||
export type GetNodeQueryVariables = Exact<{
|
||||
uuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
|
||||
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
|
||||
|
||||
export type GetRailRouteQueryVariables = Exact<{
|
||||
fromLat: Scalars['Float']['input'];
|
||||
@@ -484,7 +413,7 @@ export type GetRailRouteQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||
|
||||
export type HubsListQueryVariables = Exact<{
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -498,19 +427,18 @@ export type HubsListQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type HubsListQueryResult = { __typename?: 'Query', hubsList?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null> | null };
|
||||
export type HubsListQueryResult = { __typename?: 'Query', hubsList: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
|
||||
|
||||
export type NearestHubsQueryVariables = Exact<{
|
||||
lat: Scalars['Float']['input'];
|
||||
lon: Scalars['Float']['input'];
|
||||
radius?: InputMaybe<Scalars['Float']['input']>;
|
||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
sourceUuid?: InputMaybe<Scalars['String']['input']>;
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null, distanceKm?: number | null } | null> | null };
|
||||
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
|
||||
|
||||
export type NearestOffersQueryVariables = Exact<{
|
||||
lat: Scalars['Float']['input'];
|
||||
@@ -522,7 +450,7 @@ export type NearestOffersQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers?: Array<{ __typename?: 'OfferWithRouteType', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePathType', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStageType', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null } | null> | null };
|
||||
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers: Array<{ __typename?: 'OfferWithRoute', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePath', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStage', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null }> };
|
||||
|
||||
export type NearestSuppliersQueryVariables = Exact<{
|
||||
lat: Scalars['Float']['input'];
|
||||
@@ -533,7 +461,7 @@ export type NearestSuppliersQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers?: Array<{ __typename?: 'SupplierType', uuid?: string | null } | null> | null };
|
||||
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers: Array<{ __typename?: 'Supplier', uuid?: string | null }> };
|
||||
|
||||
export type ProductsListQueryVariables = Exact<{
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -545,7 +473,7 @@ export type ProductsListQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type ProductsListQueryResult = { __typename?: 'Query', productsList?: Array<{ __typename?: 'ProductType', uuid?: string | null, name?: string | null, offersCount?: number | null } | null> | null };
|
||||
export type ProductsListQueryResult = { __typename?: 'Query', productsList: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, offersCount?: number | null }> };
|
||||
|
||||
export type SuppliersListQueryVariables = Exact<{
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -558,7 +486,7 @@ export type SuppliersListQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList?: Array<{ __typename?: 'SupplierType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null } | null> | null };
|
||||
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList: Array<{ __typename?: 'Supplier', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null }> };
|
||||
|
||||
|
||||
export const GetAutoRouteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAutoRoute"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoRoute"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"geometry"}}]}}]}}]} as unknown as DocumentNode<GetAutoRouteQueryResult, GetAutoRouteQueryVariables>;
|
||||
@@ -567,8 +495,8 @@ export const GetHubCountriesDocument = {"kind":"Document","definitions":[{"kind"
|
||||
export const GetNodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetNode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"uuid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"uuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"uuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<GetNodeQueryResult, GetNodeQueryVariables>;
|
||||
export const GetRailRouteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRailRoute"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"railRoute"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"geometry"}}]}}]}}]} as unknown as DocumentNode<GetRailRouteQueryResult, GetRailRouteQueryVariables>;
|
||||
export const HubsListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"HubsList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"country"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"transportType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"west"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"south"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"east"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"north"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hubsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"country"},"value":{"kind":"Variable","name":{"kind":"Name","value":"country"}}},{"kind":"Argument","name":{"kind":"Name","value":"transportType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"transportType"}}},{"kind":"Argument","name":{"kind":"Name","value":"west"},"value":{"kind":"Variable","name":{"kind":"Name","value":"west"}}},{"kind":"Argument","name":{"kind":"Name","value":"south"},"value":{"kind":"Variable","name":{"kind":"Name","value":"south"}}},{"kind":"Argument","name":{"kind":"Name","value":"east"},"value":{"kind":"Variable","name":{"kind":"Name","value":"east"}}},{"kind":"Argument","name":{"kind":"Name","value":"north"},"value":{"kind":"Variable","name":{"kind":"Name","value":"north"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<HubsListQueryResult, HubsListQueryVariables>;
|
||||
export const NearestHubsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestHubs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestHubs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}}]}}]}}]} as unknown as DocumentNode<NearestHubsQueryResult, NearestHubsQueryVariables>;
|
||||
export const NearestHubsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestHubs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestHubs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<NearestHubsQueryResult, NearestHubsQueryVariables>;
|
||||
export const NearestOffersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestOffers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"hubUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestOffers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"hubUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"hubUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"supplierUuid"}},{"kind":"Field","name":{"kind":"Name","value":"supplierName"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"pricePerUnit"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"routes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalDistanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"totalTimeSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"stages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromUuid"}},{"kind":"Field","name":{"kind":"Name","value":"fromName"}},{"kind":"Field","name":{"kind":"Name","value":"fromLat"}},{"kind":"Field","name":{"kind":"Name","value":"fromLon"}},{"kind":"Field","name":{"kind":"Name","value":"toUuid"}},{"kind":"Field","name":{"kind":"Name","value":"toName"}},{"kind":"Field","name":{"kind":"Name","value":"toLat"}},{"kind":"Field","name":{"kind":"Name","value":"toLon"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"travelTimeSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"transportType"}}]}}]}}]}}]}}]} as unknown as DocumentNode<NearestOffersQueryResult, NearestOffersQueryVariables>;
|
||||
export const NearestSuppliersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestSuppliers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestSuppliers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}}]}}]} as unknown as DocumentNode<NearestSuppliersQueryResult, NearestSuppliersQueryVariables>;
|
||||
export const ProductsListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ProductsList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"west"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"south"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"east"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"north"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"west"},"value":{"kind":"Variable","name":{"kind":"Name","value":"west"}}},{"kind":"Argument","name":{"kind":"Name","value":"south"},"value":{"kind":"Variable","name":{"kind":"Name","value":"south"}}},{"kind":"Argument","name":{"kind":"Name","value":"east"},"value":{"kind":"Variable","name":{"kind":"Name","value":"east"}}},{"kind":"Argument","name":{"kind":"Name","value":"north"},"value":{"kind":"Variable","name":{"kind":"Name","value":"north"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"offersCount"}}]}}]}}]} as unknown as DocumentNode<ProductsListQueryResult, ProductsListQueryVariables>;
|
||||
export const SuppliersListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SuppliersList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"country"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"west"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"south"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"east"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"north"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"suppliersList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"country"},"value":{"kind":"Variable","name":{"kind":"Name","value":"country"}}},{"kind":"Argument","name":{"kind":"Name","value":"west"},"value":{"kind":"Variable","name":{"kind":"Name","value":"west"}}},{"kind":"Argument","name":{"kind":"Name","value":"south"},"value":{"kind":"Variable","name":{"kind":"Name","value":"south"}}},{"kind":"Argument","name":{"kind":"Name","value":"east"},"value":{"kind":"Variable","name":{"kind":"Name","value":"east"}}},{"kind":"Argument","name":{"kind":"Name","value":"north"},"value":{"kind":"Variable","name":{"kind":"Name","value":"north"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}}]}}]}}]} as unknown as DocumentNode<SuppliersListQueryResult, SuppliersListQueryVariables>;
|
||||
export const SuppliersListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SuppliersList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"country"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"west"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"south"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"east"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"north"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"suppliersList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"country"},"value":{"kind":"Variable","name":{"kind":"Name","value":"country"}}},{"kind":"Argument","name":{"kind":"Name","value":"west"},"value":{"kind":"Variable","name":{"kind":"Name","value":"west"}}},{"kind":"Argument","name":{"kind":"Name","value":"south"},"value":{"kind":"Variable","name":{"kind":"Name","value":"south"}}},{"kind":"Argument","name":{"kind":"Name","value":"east"},"value":{"kind":"Variable","name":{"kind":"Name","value":"east"}}},{"kind":"Argument","name":{"kind":"Name","value":"north"},"value":{"kind":"Variable","name":{"kind":"Name","value":"north"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}}]}}]}}]} as unknown as DocumentNode<SuppliersListQueryResult, SuppliersListQueryVariables>;
|
||||
@@ -13,12 +13,10 @@ export type Scalars = {
|
||||
Boolean: { input: boolean; output: boolean; }
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
DateTime: { input: string; output: string; }
|
||||
};
|
||||
|
||||
/** Full company data (requires auth). */
|
||||
export type CompanyFullType = {
|
||||
__typename?: 'CompanyFullType';
|
||||
export type CompanyFull = {
|
||||
__typename?: 'CompanyFull';
|
||||
activities?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
address?: Maybe<Scalars['String']['output']>;
|
||||
capital?: Maybe<Scalars['String']['output']>;
|
||||
@@ -26,45 +24,35 @@ export type CompanyFullType = {
|
||||
director?: Maybe<Scalars['String']['output']>;
|
||||
inn?: Maybe<Scalars['String']['output']>;
|
||||
isActive?: Maybe<Scalars['Boolean']['output']>;
|
||||
lastUpdated?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastUpdated?: Maybe<Scalars['String']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
ogrn?: Maybe<Scalars['String']['output']>;
|
||||
registrationYear?: Maybe<Scalars['Int']['output']>;
|
||||
sources?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||
};
|
||||
|
||||
/** Public company data (teaser). */
|
||||
export type CompanyTeaserType = {
|
||||
__typename?: 'CompanyTeaserType';
|
||||
/** Company type: ООО, АО, ИП, etc. */
|
||||
export type CompanyTeaser = {
|
||||
__typename?: 'CompanyTeaser';
|
||||
companyType?: Maybe<Scalars['String']['output']>;
|
||||
/** Is company active */
|
||||
isActive?: Maybe<Scalars['Boolean']['output']>;
|
||||
/** Year of registration */
|
||||
registrationYear?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of data sources */
|
||||
sourcesCount?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
/** Public queries - no authentication required. */
|
||||
export type PublicQuery = {
|
||||
__typename?: 'PublicQuery';
|
||||
health?: Maybe<Scalars['String']['output']>;
|
||||
/** Get full KYC profile data by UUID (requires auth) */
|
||||
kycProfileFull?: Maybe<CompanyFullType>;
|
||||
/** Get public KYC profile teaser data by UUID */
|
||||
kycProfileTeaser?: Maybe<CompanyTeaserType>;
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
health: Scalars['String']['output'];
|
||||
kycProfileFull?: Maybe<CompanyFull>;
|
||||
kycProfileTeaser?: Maybe<CompanyTeaser>;
|
||||
};
|
||||
|
||||
|
||||
/** Public queries - no authentication required. */
|
||||
export type PublicQueryKycProfileFullArgs = {
|
||||
export type QueryKycProfileFullArgs = {
|
||||
profileUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
/** Public queries - no authentication required. */
|
||||
export type PublicQueryKycProfileTeaserArgs = {
|
||||
export type QueryKycProfileTeaserArgs = {
|
||||
profileUuid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
@@ -73,14 +61,14 @@ export type GetKycProfileFullQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetKycProfileFullQueryResult = { __typename?: 'PublicQuery', kycProfileFull?: { __typename?: 'CompanyFullType', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
|
||||
export type GetKycProfileFullQueryResult = { __typename?: 'Query', kycProfileFull?: { __typename?: 'CompanyFull', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
|
||||
|
||||
export type GetKycProfileTeaserQueryVariables = Exact<{
|
||||
profileUuid: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetKycProfileTeaserQueryResult = { __typename?: 'PublicQuery', kycProfileTeaser?: { __typename?: 'CompanyTeaserType', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
|
||||
export type GetKycProfileTeaserQueryResult = { __typename?: 'Query', kycProfileTeaser?: { __typename?: 'CompanyTeaser', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
|
||||
|
||||
|
||||
export const GetKycProfileFullDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetKycProfileFull"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kycProfileFull"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"profileUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inn"}},{"kind":"Field","name":{"kind":"Name","value":"ogrn"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"companyType"}},{"kind":"Field","name":{"kind":"Name","value":"registrationYear"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"address"}},{"kind":"Field","name":{"kind":"Name","value":"director"}},{"kind":"Field","name":{"kind":"Name","value":"capital"}},{"kind":"Field","name":{"kind":"Name","value":"activities"}},{"kind":"Field","name":{"kind":"Name","value":"sources"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}}]}}]}}]} as unknown as DocumentNode<GetKycProfileFullQueryResult, GetKycProfileFullQueryVariables>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const PAGE_SIZE = 500
|
||||
|
||||
// Type from codegen - exported for use in pages
|
||||
export type CatalogHubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
|
||||
@@ -61,16 +61,15 @@ export function useCatalogHubs() {
|
||||
const fetchPage = async (offset: number, replace = false) => {
|
||||
if (replace) isLoading.value = true
|
||||
try {
|
||||
// If filtering by product, use nearestHubs with global search
|
||||
// (center point 0,0 with very large radius to cover entire globe)
|
||||
// If filtering by product, use nearestHubs (graph-based)
|
||||
if (filterProductUuid.value) {
|
||||
const data = await execute(
|
||||
NearestHubsDocument,
|
||||
{
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
radius: 20000, // 20000 km radius covers entire Earth
|
||||
productUuid: filterProductUuid.value,
|
||||
useGraph: true,
|
||||
limit: 500 // Increased limit for global search
|
||||
},
|
||||
'public',
|
||||
@@ -147,9 +146,7 @@ export function useCatalogHubs() {
|
||||
const setProductFilter = (uuid: string | null) => {
|
||||
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
||||
filterProductUuid.value = uuid
|
||||
if (isInitialized.value) {
|
||||
fetchPage(0, true)
|
||||
}
|
||||
fetchPage(0, true)
|
||||
}
|
||||
|
||||
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import type {
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
import {
|
||||
GetOfferDocument,
|
||||
GetSupplierProfileDocument
|
||||
GetSupplierProfileDocument,
|
||||
GetSupplierOffersDocument
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
// Types from codegen
|
||||
@@ -125,7 +126,8 @@ export function useCatalogInfo() {
|
||||
{
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
radius: 500
|
||||
hubUuid: uuid,
|
||||
limit: 500
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
@@ -224,21 +226,16 @@ export function useCatalogInfo() {
|
||||
isLoadingProducts.value = true
|
||||
isLoadingHubs.value = true
|
||||
|
||||
// Load products (offers grouped by product)
|
||||
// Load products from supplier offers (no geo radius)
|
||||
execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: entity.value.latitude,
|
||||
lon: entity.value.longitude,
|
||||
radius: 500
|
||||
},
|
||||
GetSupplierOffersDocument,
|
||||
{ teamUuid: uuid },
|
||||
'public',
|
||||
'geo'
|
||||
'exchange'
|
||||
).then(offersData => {
|
||||
// Group offers by product
|
||||
const productsMap = new Map<string, InfoProductItem>()
|
||||
offersData?.nearestOffers?.forEach(offer => {
|
||||
if (!offer || !offer.productUuid || !offer.productName) return
|
||||
offersData?.getOffers?.forEach(offer => {
|
||||
if (!offer?.productUuid || !offer.productName) return
|
||||
const existing = productsMap.get(offer.productUuid)
|
||||
if (existing) {
|
||||
existing.offersCount = (existing.offersCount || 0) + 1
|
||||
@@ -261,7 +258,6 @@ export function useCatalogInfo() {
|
||||
{
|
||||
lat: entity.value.latitude,
|
||||
lon: entity.value.longitude,
|
||||
radius: 1000,
|
||||
sourceUuid: entity.value.uuid,
|
||||
limit: 12
|
||||
},
|
||||
@@ -312,7 +308,6 @@ export function useCatalogInfo() {
|
||||
{
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
radius: 1000,
|
||||
sourceUuid: entity.value?.uuid ?? null,
|
||||
limit: 12
|
||||
},
|
||||
@@ -372,7 +367,6 @@ export function useCatalogInfo() {
|
||||
lon: hub.longitude,
|
||||
productUuid,
|
||||
hubUuid, // Pass hubUuid to get routes calculated on backend
|
||||
radius: 500,
|
||||
limit: 12
|
||||
},
|
||||
'public',
|
||||
@@ -438,7 +432,6 @@ export function useCatalogInfo() {
|
||||
{
|
||||
lat: supplier.latitude,
|
||||
lon: supplier.longitude,
|
||||
radius: 1000,
|
||||
sourceUuid: supplier.uuid,
|
||||
limit: 1
|
||||
},
|
||||
@@ -462,14 +455,17 @@ export function useCatalogInfo() {
|
||||
lon: supplier.longitude,
|
||||
productUuid,
|
||||
...(hubUuid ? { hubUuid } : {}),
|
||||
radius: 500,
|
||||
limit: 12
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
|
||||
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => {
|
||||
if (!o) return false
|
||||
if (!supplier.uuid) return true
|
||||
return o.supplierUuid === supplier.uuid
|
||||
})
|
||||
isLoadingOffers.value = false
|
||||
} finally {
|
||||
isLoadingOffers.value = false
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NearestOffersDocument
|
||||
} from '~/composables/graphql/public/geo-generated'
|
||||
import {
|
||||
GetSupplierProfileDocument
|
||||
GetSupplierOffersDocument
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
// Type from codegen
|
||||
@@ -43,46 +43,26 @@ export function useCatalogProducts() {
|
||||
let data
|
||||
|
||||
if (filterSupplierUuid.value) {
|
||||
// Products from specific supplier - get supplier coordinates first
|
||||
const supplierData = await execute(
|
||||
GetSupplierProfileDocument,
|
||||
{ uuid: filterSupplierUuid.value },
|
||||
// Products from specific supplier - get offers directly (no geo radius)
|
||||
const offersData = await execute(
|
||||
GetSupplierOffersDocument,
|
||||
{ teamUuid: filterSupplierUuid.value },
|
||||
'public',
|
||||
'exchange'
|
||||
)
|
||||
const supplier = supplierData?.getSupplierProfile
|
||||
|
||||
if (!supplier?.latitude || !supplier?.longitude) {
|
||||
console.warn('Supplier has no coordinates')
|
||||
items.value = []
|
||||
} else {
|
||||
// Get offers near supplier and group by product
|
||||
const offersData = await execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: supplier.latitude,
|
||||
lon: supplier.longitude,
|
||||
radius: 500
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
// Group offers by product
|
||||
const productsMap = new Map<string, AggregatedProduct>()
|
||||
offersData?.nearestOffers?.forEach((offer) => {
|
||||
if (!offer?.productUuid) return
|
||||
if (!productsMap.has(offer.productUuid)) {
|
||||
productsMap.set(offer.productUuid, {
|
||||
uuid: offer.productUuid,
|
||||
name: offer.productName,
|
||||
offersCount: 0
|
||||
})
|
||||
}
|
||||
productsMap.get(offer.productUuid)!.offersCount++
|
||||
})
|
||||
items.value = Array.from(productsMap.values()) as ProductItem[]
|
||||
}
|
||||
const productsMap = new Map<string, AggregatedProduct>()
|
||||
offersData?.getOffers?.forEach((offer) => {
|
||||
if (!offer?.productUuid) return
|
||||
if (!productsMap.has(offer.productUuid)) {
|
||||
productsMap.set(offer.productUuid, {
|
||||
uuid: offer.productUuid,
|
||||
name: offer.productName,
|
||||
offersCount: 0
|
||||
})
|
||||
}
|
||||
productsMap.get(offer.productUuid)!.offersCount++
|
||||
})
|
||||
items.value = Array.from(productsMap.values()) as ProductItem[]
|
||||
} else if (filterHubUuid.value) {
|
||||
// Products near hub - get hub coordinates first
|
||||
const hubData = await execute(
|
||||
@@ -97,13 +77,14 @@ export function useCatalogProducts() {
|
||||
console.warn('Hub has no coordinates')
|
||||
items.value = []
|
||||
} else {
|
||||
// Get offers near hub and group by product
|
||||
// Get offers by graph from hub and group by product
|
||||
const offersData = await execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: hub.latitude,
|
||||
lon: hub.longitude,
|
||||
radius: 500
|
||||
hubUuid: filterHubUuid.value,
|
||||
limit: 500
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
|
||||
@@ -94,7 +94,8 @@ export function useCatalogSearch() {
|
||||
})
|
||||
|
||||
// Filter by bounds checkbox state from URL
|
||||
const filterByBounds = computed(() => route.query.bounds !== undefined)
|
||||
// Use explicit flag so bounds don't auto-enable filtering.
|
||||
const filterByBounds = computed(() => route.query.boundsFilter === '1')
|
||||
|
||||
// Get label for a filter (from cache or fallback to ID)
|
||||
const getLabel = (type: string, id: string | undefined): string | null => {
|
||||
@@ -228,18 +229,18 @@ export function useCatalogSearch() {
|
||||
|
||||
const cancelSelect = () => {
|
||||
updateQuery({
|
||||
select: null,
|
||||
view: lastViewMode.value === 'offers' ? null : lastViewMode.value
|
||||
select: null
|
||||
})
|
||||
}
|
||||
|
||||
const selectItem = (type: string, id: string, label: string) => {
|
||||
setLabel(type, id, label)
|
||||
const forcedView = (type === 'hub' || type === 'supplier') ? null : (lastViewMode.value === 'offers' ? null : lastViewMode.value)
|
||||
updateQuery({
|
||||
[type]: id,
|
||||
select: null, // Exit selection mode
|
||||
info: null, // Exit info mode
|
||||
view: lastViewMode.value === 'offers' ? null : lastViewMode.value
|
||||
view: forcedView
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +261,7 @@ export function useCatalogSearch() {
|
||||
const setBoundsInUrl = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||
if (bounds) {
|
||||
const boundsStr = `${bounds.west.toFixed(4)},${bounds.south.toFixed(4)},${bounds.east.toFixed(4)},${bounds.north.toFixed(4)}`
|
||||
updateQuery({ bounds: boundsStr })
|
||||
updateQuery({ bounds: boundsStr, boundsFilter: '1' })
|
||||
} else {
|
||||
updateQuery({ bounds: null })
|
||||
}
|
||||
@@ -268,7 +269,12 @@ export function useCatalogSearch() {
|
||||
|
||||
// Clear bounds from URL
|
||||
const clearBoundsFromUrl = () => {
|
||||
updateQuery({ bounds: null })
|
||||
updateQuery({ bounds: null, boundsFilter: null })
|
||||
}
|
||||
|
||||
// Explicitly enable/disable bounds filter flag in URL
|
||||
const setBoundsFilterEnabled = (enabled: boolean) => {
|
||||
updateQuery({ boundsFilter: enabled ? '1' : null })
|
||||
}
|
||||
|
||||
const openInfo = (type: InfoEntityType, uuid: string) => {
|
||||
@@ -308,17 +314,13 @@ export function useCatalogSearch() {
|
||||
})
|
||||
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
|
||||
const setMapViewMode = (mode: MapViewMode) => {
|
||||
if (selectMode.value) {
|
||||
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
|
||||
: mode === 'suppliers' ? 'supplier'
|
||||
: 'product'
|
||||
updateQuery({
|
||||
view: mode === 'offers' ? null : mode,
|
||||
select: newSelectMode
|
||||
})
|
||||
return
|
||||
}
|
||||
updateQuery({ view: mode === 'offers' ? null : mode })
|
||||
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
|
||||
: mode === 'suppliers' ? 'supplier'
|
||||
: 'product'
|
||||
updateQuery({
|
||||
view: mode === 'offers' ? null : mode,
|
||||
select: newSelectMode
|
||||
})
|
||||
}
|
||||
|
||||
// Drawer state for list view
|
||||
@@ -368,7 +370,15 @@ export function useCatalogSearch() {
|
||||
})
|
||||
|
||||
const setCatalogMode = (newMode: CatalogMode) => {
|
||||
updateQuery({ mode: newMode })
|
||||
const defaultSelect: SelectMode = selectMode.value
|
||||
|| (mapViewMode.value === 'hubs' ? 'hub'
|
||||
: mapViewMode.value === 'suppliers' ? 'supplier'
|
||||
: 'product')
|
||||
if (newMode === 'explore') {
|
||||
updateQuery({ mode: newMode, qty: null, select: defaultSelect })
|
||||
} else {
|
||||
updateQuery({ mode: newMode, select: defaultSelect })
|
||||
}
|
||||
}
|
||||
|
||||
// Can search for offers (product + hub or product + supplier required)
|
||||
@@ -422,6 +432,7 @@ export function useCatalogSearch() {
|
||||
setQuantity,
|
||||
setBoundsInUrl,
|
||||
clearBoundsFromUrl,
|
||||
setBoundsFilterEnabled,
|
||||
openInfo,
|
||||
closeInfo,
|
||||
setInfoTab,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
import { SuppliersListDocument, NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const PAGE_SIZE = 500
|
||||
|
||||
// Types from codegen
|
||||
type SupplierItem = NonNullable<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
|
||||
@@ -28,15 +28,13 @@ export function useCatalogSuppliers() {
|
||||
const fetchPage = async (offset: number, replace = false) => {
|
||||
if (replace) isLoading.value = true
|
||||
try {
|
||||
// If filtering by product, use nearestSuppliers with global search
|
||||
// (center point 0,0 with very large radius to cover entire globe)
|
||||
// If filtering by product, use nearestSuppliers (product-only list)
|
||||
if (filterProductUuid.value) {
|
||||
const data = await execute(
|
||||
NearestSuppliersDocument,
|
||||
{
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
radius: 20000, // 20000 km radius covers entire Earth
|
||||
productUuid: filterProductUuid.value,
|
||||
limit: 500 // Increased limit for global search
|
||||
},
|
||||
@@ -100,9 +98,7 @@ export function useCatalogSuppliers() {
|
||||
const setProductFilter = (uuid: string | null) => {
|
||||
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
||||
filterProductUuid.value = uuid
|
||||
if (isInitialized.value) {
|
||||
fetchPage(0, true)
|
||||
}
|
||||
fetchPage(0, true)
|
||||
}
|
||||
|
||||
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GetClusteredNodesDocument } from './graphql/public/geo-generated'
|
||||
import type { ClusterPointType } from './graphql/public/geo-generated'
|
||||
import type { ClusterPoint } from './graphql/public/geo-generated'
|
||||
|
||||
export interface MapBounds {
|
||||
west: number
|
||||
@@ -11,11 +11,11 @@ export interface MapBounds {
|
||||
|
||||
export function useClusteredNodes(
|
||||
transportType?: Ref<string | undefined>,
|
||||
nodeType?: Ref<string | undefined>
|
||||
nodeType?: Ref<string | undefined>,
|
||||
) {
|
||||
const { client } = useApolloClient('publicGeo')
|
||||
|
||||
const clusteredNodes = ref<ClusterPointType[]>([])
|
||||
const clusteredNodes = ref<ClusterPoint[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchClusters = async (bounds: MapBounds) => {
|
||||
@@ -30,12 +30,12 @@ export function useClusteredNodes(
|
||||
north: bounds.north,
|
||||
zoom: Math.floor(bounds.zoom),
|
||||
transportType: transportType?.value,
|
||||
nodeType: nodeType?.value
|
||||
nodeType: nodeType?.value,
|
||||
},
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
|
||||
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPointType[]
|
||||
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPoint[]
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch clustered nodes:', error)
|
||||
clusteredNodes.value = []
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-base-300">
|
||||
<!-- Fixed Header Container -->
|
||||
<div class="fixed top-0 left-0 right-0 z-40" :style="headerContainerStyle">
|
||||
<AiChatSidebar
|
||||
:open="isChatOpen"
|
||||
:width="chatWidth"
|
||||
@close="isChatOpen = false"
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex flex-col" :style="contentStyle">
|
||||
<!-- Fixed Header Container -->
|
||||
<div class="header-glass fixed inset-x-0 top-0 z-50 border-0" :style="headerContainerStyle">
|
||||
<div class="header-glass-backdrop" aria-hidden="true" />
|
||||
<!-- Animated background for home page -->
|
||||
<HeroBackground v-if="isHomePage" :collapse-progress="collapseProgress" />
|
||||
|
||||
@@ -9,6 +17,7 @@
|
||||
<MainNavigation
|
||||
class="relative z-10"
|
||||
:height="isHomePage ? heroHeight : 100"
|
||||
:collapse-progress="isHomePage ? collapseProgress : 1"
|
||||
:session-checked="sessionChecked"
|
||||
:logged-in="isLoggedIn"
|
||||
:user-avatar-svg="userAvatarSvg"
|
||||
@@ -33,7 +42,9 @@
|
||||
:is-collapsed="isHomePage ? heroIsCollapsed : (isCatalogSection || isClientArea)"
|
||||
:is-home-page="isHomePage"
|
||||
:is-client-area="isClientArea"
|
||||
:chat-open="isChatOpen"
|
||||
@toggle-theme="toggleTheme"
|
||||
@toggle-chat="isChatOpen = !isChatOpen"
|
||||
@set-catalog-mode="setCatalogMode"
|
||||
@sign-out="onClickSignOut"
|
||||
@sign-in="signIn()"
|
||||
@@ -50,7 +61,7 @@
|
||||
<!-- Hero content for home page -->
|
||||
<template v-if="isHomePage && collapseProgress < 1" #hero>
|
||||
<h1
|
||||
class="text-3xl lg:text-4xl font-bold text-white mb-4"
|
||||
class="text-3xl lg:text-5xl font-black tracking-tight text-white mb-4"
|
||||
:style="{ opacity: 1 - collapseProgress }"
|
||||
>
|
||||
{{ $t('hero.tagline', 'Make trade easy') }}
|
||||
@@ -63,12 +74,13 @@
|
||||
v-if="!isHomePage && !isCatalogSection && !isClientArea"
|
||||
:section="currentSection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page content - padding-top compensates for fixed header -->
|
||||
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
||||
<slot />
|
||||
</main>
|
||||
<!-- Page content - padding-top compensates for fixed header -->
|
||||
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,6 +94,14 @@ const localePath = useLocalePath()
|
||||
const { locale, locales } = useI18n()
|
||||
const switchLocalePath = useSwitchLocalePath()
|
||||
|
||||
const isChatOpen = useState('ai-chat-open', () => false)
|
||||
const chatWidth = computed(() => (isChatOpen.value ? 'clamp(240px, 15vw, 360px)' : '0px'))
|
||||
const contentStyle = computed(() => ({
|
||||
transform: isChatOpen.value ? `translateX(${chatWidth.value})` : 'translateX(0)',
|
||||
width: isChatOpen.value ? `calc(100% - ${chatWidth.value})` : '100%',
|
||||
transition: 'transform 250ms ease, width 250ms ease'
|
||||
}))
|
||||
|
||||
// Catalog search state
|
||||
const {
|
||||
selectMode,
|
||||
@@ -332,9 +352,10 @@ const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
|
||||
const onSearch = () => {
|
||||
// Navigate to catalog page if not there
|
||||
if (!route.path.includes('/catalog')) {
|
||||
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote' } })
|
||||
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote', select: 'product' } })
|
||||
}
|
||||
// Trigger search by incrementing the counter (page watches this)
|
||||
searchTrigger.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
172
app/pages/catalog/destination.vue
Normal file
172
app/pages/catalog/destination.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Fullscreen Map -->
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="step-hub-map"
|
||||
:items="hubMapItems"
|
||||
:use-server-clustering="false"
|
||||
point-color="#22c55e"
|
||||
entity-type="hub"
|
||||
@select-item="onMapSelect"
|
||||
@bounds-change="onBoundsChange"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet card -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
||||
<article
|
||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
||||
style="max-height: 60vh"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 2 }) }}</p>
|
||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectDestination') }}</h2>
|
||||
</div>
|
||||
<span class="badge badge-neutral">{{ hubs.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Selected product chip -->
|
||||
<div v-if="productName" class="flex items-center gap-2 mt-2 mb-1">
|
||||
<span class="badge badge-warning gap-1">
|
||||
<Icon name="lucide:package" size="12" />
|
||||
{{ productName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
|
||||
<Icon name="lucide:search" size="16" class="text-base-content/40" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('catalog.search.searchHubs')"
|
||||
class="grow bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hub list -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredHubs.length === 0" class="text-center py-8 text-base-content/50">
|
||||
<Icon name="lucide:warehouse" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noHubs') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="hub in filteredHubs"
|
||||
:key="hub.uuid"
|
||||
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
|
||||
@click="selectHub(hub)"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-green-400 to-green-600 shadow-lg">
|
||||
<Icon name="lucide:warehouse" size="20" class="text-white" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-base font-bold text-base-content block truncate">{{ hub.name || hub.uuid }}</span>
|
||||
<span v-if="hub.country" class="text-sm text-base-content/50">{{ hub.country }}</span>
|
||||
</div>
|
||||
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||
|
||||
definePageMeta({ layout: 'topnav' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
|
||||
const mapRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Get product from query
|
||||
const productUuid = computed(() => route.query.product as string | undefined)
|
||||
const productName = computed(() => route.query.productName as string | undefined)
|
||||
|
||||
// Load hubs (filtered by product if available)
|
||||
const { items: hubs, isLoading, init: initHubs, setProductFilter } = useCatalogHubs()
|
||||
|
||||
const onBoundsChange = (_bounds: MapBounds) => {
|
||||
// No clustering needed — showing hub items directly
|
||||
}
|
||||
|
||||
// Hub items for map
|
||||
const hubMapItems = computed(() =>
|
||||
hubs.value
|
||||
.filter(h => h.latitude != null && h.longitude != null)
|
||||
.map(h => ({
|
||||
uuid: h.uuid || '',
|
||||
name: h.name || '',
|
||||
latitude: Number(h.latitude),
|
||||
longitude: Number(h.longitude),
|
||||
country: h.country || undefined,
|
||||
}))
|
||||
)
|
||||
|
||||
// Map click → select hub
|
||||
const onMapSelect = (uuid: string) => {
|
||||
const hub = hubs.value.find(h => h.uuid === uuid)
|
||||
if (hub) selectHub(hub)
|
||||
}
|
||||
|
||||
// Filter hubs by search
|
||||
const filteredHubs = computed(() => {
|
||||
if (!searchQuery.value.trim()) return hubs.value
|
||||
const q = searchQuery.value.toLowerCase().trim()
|
||||
return hubs.value.filter(h =>
|
||||
(h.name || '').toLowerCase().includes(q) ||
|
||||
(h.country || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// Select hub → navigate to quantity step
|
||||
const selectHub = (hub: { uuid?: string | null; name?: string | null }) => {
|
||||
if (!hub.uuid) return
|
||||
const query: Record<string, string> = {
|
||||
...route.query as Record<string, string>,
|
||||
hub: hub.uuid,
|
||||
}
|
||||
if (hub.name) query.hubName = hub.name
|
||||
|
||||
router.push({
|
||||
path: localePath('/catalog/quantity'),
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
// Init
|
||||
onMounted(() => {
|
||||
if (productUuid.value) {
|
||||
setProductFilter(productUuid.value)
|
||||
}
|
||||
initHubs()
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalog.steps.selectDestination')
|
||||
}))
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetNodeDocument, NearestOffersDocument, type OfferWithRouteType, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
import { GetNodeDocument, NearestOffersDocument, type OfferWithRoute, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
type Hub = NonNullable<GetNodeQueryResult['node']>
|
||||
|
||||
|
||||
@@ -3,21 +3,29 @@
|
||||
<CatalogPage
|
||||
ref="catalogPageRef"
|
||||
:loading="isLoading"
|
||||
:use-server-clustering="true"
|
||||
:use-typed-clusters="true"
|
||||
:use-server-clustering="useServerClustering"
|
||||
:use-typed-clusters="useServerClustering"
|
||||
:cluster-node-type="clusterNodeType"
|
||||
panel-width="w-[32rem]"
|
||||
map-id="unified-catalog-map"
|
||||
:point-color="mapPointColor"
|
||||
:items="currentSelectionItems"
|
||||
:items="mapItems"
|
||||
:hovered-id="hoveredItemId ?? undefined"
|
||||
:show-panel="showPanel && !kycSheetUuid"
|
||||
:filter-by-bounds="filterByBounds"
|
||||
:related-points="relatedPoints"
|
||||
:info-loading="isInfoLoading"
|
||||
:info-loading="mapInfoLoading"
|
||||
:force-info-mode="forceInfoMode"
|
||||
:hide-view-toggle="hideViewToggle"
|
||||
:show-offers-toggle="showOffersToggle"
|
||||
:show-hubs-toggle="showHubsToggle"
|
||||
:show-suppliers-toggle="showSuppliersToggle"
|
||||
:cluster-product-uuid="clusterProductUuid"
|
||||
:cluster-hub-uuid="clusterHubUuid"
|
||||
:cluster-supplier-uuid="clusterSupplierUuid"
|
||||
@select="onMapSelect"
|
||||
@bounds-change="onBoundsChange"
|
||||
@update:filter-by-bounds="$event ? setBoundsInUrl(currentMapBounds) : clearBoundsFromUrl()"
|
||||
@update:filter-by-bounds="onToggleBoundsFilter"
|
||||
>
|
||||
<!-- Panel slot - shows selection list OR info OR quote results -->
|
||||
<template #panel>
|
||||
@@ -55,11 +63,11 @@
|
||||
:loading-suppliers="isLoadingSuppliers"
|
||||
:loading-offers="isLoadingOffers"
|
||||
@close="onInfoClose"
|
||||
@add-to-filter="onInfoAddToFilter"
|
||||
@open-info="onInfoOpenRelated"
|
||||
@select-product="onInfoSelectProduct"
|
||||
@select-offer="onSelectOffer"
|
||||
@open-kyc="onOpenKyc"
|
||||
@pin="onPinItem"
|
||||
/>
|
||||
|
||||
<!-- Quote results: show offers after search -->
|
||||
@@ -67,6 +75,7 @@
|
||||
v-else-if="showQuoteResults"
|
||||
:loading="offersLoading"
|
||||
:offers="offers"
|
||||
:calculations="quoteCalculations"
|
||||
@select-offer="onSelectOffer"
|
||||
/>
|
||||
</template>
|
||||
@@ -82,11 +91,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetOffersDocument, GetOfferDocument, type GetOffersQueryVariables, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||
import { GetOffersDocument, type GetOffersQueryVariables } from '~/composables/graphql/public/exchange-generated'
|
||||
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||
|
||||
// Offer type from search results
|
||||
type OfferResult = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
|
||||
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
|
||||
|
||||
definePageMeta({
|
||||
layout: 'topnav'
|
||||
@@ -128,6 +137,7 @@ const toMapItems = <T extends { uuid?: string | null; name?: string | null; lati
|
||||
|
||||
// Current selection items for hover highlighting on map
|
||||
const currentSelectionItems = computed((): MapItemWithCoords[] => {
|
||||
if (showQuoteResults.value) return []
|
||||
if (selectMode.value === 'product') return [] // Products don't have coordinates
|
||||
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
|
||||
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
|
||||
@@ -164,7 +174,8 @@ const {
|
||||
urlBounds,
|
||||
filterByBounds,
|
||||
setBoundsInUrl,
|
||||
clearBoundsFromUrl
|
||||
clearBoundsFromUrl,
|
||||
setBoundsFilterEnabled
|
||||
} = useCatalogSearch()
|
||||
|
||||
// Info panel composable
|
||||
@@ -244,7 +255,20 @@ const getSelectionBounds = () => {
|
||||
return { west: bounds.west, south: bounds.south, east: bounds.east, north: bounds.north }
|
||||
}
|
||||
|
||||
const onToggleBoundsFilter = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setBoundsFilterEnabled(true)
|
||||
const bounds = getSelectionBounds()
|
||||
if (bounds) {
|
||||
setBoundsInUrl(bounds)
|
||||
}
|
||||
} else {
|
||||
clearBoundsFromUrl()
|
||||
}
|
||||
}
|
||||
|
||||
const applySelectionBounds = () => {
|
||||
if (!filterByBounds.value) return
|
||||
if (!selectionBoundsBackup.value) {
|
||||
selectionBoundsBackup.value = {
|
||||
hadBounds: !!urlBounds.value,
|
||||
@@ -306,6 +330,16 @@ watch(productId, (newProductId) => {
|
||||
setSupplierProductFilter(newProductId || null)
|
||||
}, { immediate: true })
|
||||
|
||||
// If a filter locks a view type, switch away from that view
|
||||
watch([hubId, supplierId], ([newHubId, newSupplierId]) => {
|
||||
if (newHubId && mapViewMode.value === 'hubs') {
|
||||
setMapViewMode('offers')
|
||||
}
|
||||
if (newSupplierId && mapViewMode.value === 'suppliers') {
|
||||
setMapViewMode('offers')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Apply bounds filter when "filter by map bounds" is enabled
|
||||
// Only watch URL bounds - currentMapBounds changes too often (every map move)
|
||||
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
|
||||
@@ -337,7 +371,7 @@ watch(infoProduct, async (productUuid) => {
|
||||
})
|
||||
|
||||
// Related points for Info mode (shown on map) - show current entity + all related entities
|
||||
const relatedPoints = computed(() => {
|
||||
const infoRelatedPoints = computed(() => {
|
||||
if (!infoId.value) return []
|
||||
|
||||
const points: Array<{
|
||||
@@ -391,8 +425,56 @@ const relatedPoints = computed(() => {
|
||||
return points
|
||||
})
|
||||
|
||||
// Related points for Quote mode (shown on map)
|
||||
const searchHubPoint = ref<MapItemWithCoords | null>(null)
|
||||
|
||||
const searchOfferPoints = computed(() =>
|
||||
offers.value
|
||||
.filter((offer) => offer.latitude != null && offer.longitude != null)
|
||||
.map((offer) => ({
|
||||
uuid: offer.uuid,
|
||||
name: offer.productName || '',
|
||||
latitude: Number(offer.latitude),
|
||||
longitude: Number(offer.longitude),
|
||||
type: 'offer' as const
|
||||
}))
|
||||
)
|
||||
|
||||
const searchRelatedPoints = computed(() => {
|
||||
const points: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
type: 'hub' | 'supplier' | 'offer'
|
||||
}> = []
|
||||
|
||||
if (searchHubPoint.value) {
|
||||
points.push({
|
||||
uuid: searchHubPoint.value.uuid,
|
||||
name: searchHubPoint.value.name,
|
||||
latitude: searchHubPoint.value.latitude,
|
||||
longitude: searchHubPoint.value.longitude,
|
||||
type: 'hub'
|
||||
})
|
||||
}
|
||||
|
||||
searchOfferPoints.value.forEach((point) => points.push(point))
|
||||
return points
|
||||
})
|
||||
|
||||
const relatedPoints = computed(() => {
|
||||
if (infoId.value) return infoRelatedPoints.value
|
||||
if (showQuoteResults.value) return searchRelatedPoints.value
|
||||
return []
|
||||
})
|
||||
|
||||
// Offers data for quote results
|
||||
const offers = ref<OfferResult[]>([])
|
||||
const offers = ref<NearestOffer[]>([])
|
||||
const quoteCalculations = ref<{ offers: NearestOffer[] }[]>([])
|
||||
|
||||
const buildCalculationsFromOffers = (list: NearestOffer[]) =>
|
||||
list.map((offer) => ({ offers: [offer] }))
|
||||
const offersLoading = ref(false)
|
||||
const showQuoteResults = ref(false)
|
||||
|
||||
@@ -406,13 +488,99 @@ watch(searchTrigger, () => {
|
||||
})
|
||||
|
||||
// Loading state
|
||||
const isLoading = computed(() => offersLoading.value || selectionLoading.value)
|
||||
const isLoading = computed(() => offersLoading.value || selectionLoading.value || exploreOffersLoading.value)
|
||||
|
||||
// Info loading state for map fitBounds (true while any info data is still loading)
|
||||
const isInfoLoading = computed(() =>
|
||||
infoLoading.value || isLoadingProducts.value || isLoadingHubs.value || isLoadingSuppliers.value || isLoadingOffers.value
|
||||
)
|
||||
|
||||
const mapInfoLoading = computed(() =>
|
||||
isInfoLoading.value || (showQuoteResults.value && offersLoading.value)
|
||||
)
|
||||
|
||||
const forceInfoMode = computed(() => showQuoteResults.value)
|
||||
const hideViewToggle = computed(() => showQuoteResults.value)
|
||||
|
||||
const showOffersToggle = computed(() => true)
|
||||
const showHubsToggle = computed(() => !hubId.value)
|
||||
const showSuppliersToggle = computed(() => !supplierId.value)
|
||||
|
||||
const clusterProductUuid = computed(() => productId.value || undefined)
|
||||
const clusterHubUuid = computed(() => hubId.value || undefined)
|
||||
const clusterSupplierUuid = computed(() => supplierId.value || undefined)
|
||||
|
||||
// When a product filter is active and we're viewing hubs, use the same list data on the map
|
||||
// to avoid mismatch between graph-filtered list and clustered map results.
|
||||
const useServerClustering = computed(() => {
|
||||
if (productId.value && (mapViewMode.value === 'hubs' || mapViewMode.value === 'suppliers')) return false
|
||||
if (hubId.value && mapViewMode.value === 'offers') return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Offers for Explore map when hub filter is active (graph-based)
|
||||
const exploreOffers = ref<NearestOffer[]>([])
|
||||
const exploreOffersLoading = ref(false)
|
||||
|
||||
const shouldLoadExploreOffers = computed(() =>
|
||||
catalogMode.value === 'explore' && mapViewMode.value === 'offers' && !!hubId.value
|
||||
)
|
||||
|
||||
const loadExploreOffers = async () => {
|
||||
if (!hubId.value) return
|
||||
exploreOffersLoading.value = true
|
||||
try {
|
||||
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
|
||||
const hub = hubData?.node
|
||||
if (!hub?.latitude || !hub?.longitude) {
|
||||
exploreOffers.value = []
|
||||
return
|
||||
}
|
||||
const geoData = await execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: hub.latitude,
|
||||
lon: hub.longitude,
|
||||
hubUuid: hubId.value,
|
||||
productUuid: productId.value || null,
|
||||
limit: 500
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
exploreOffers.value = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
|
||||
} finally {
|
||||
exploreOffersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([shouldLoadExploreOffers, hubId, productId], ([enabled]) => {
|
||||
if (!enabled) {
|
||||
exploreOffers.value = []
|
||||
exploreOffersLoading.value = false
|
||||
return
|
||||
}
|
||||
loadExploreOffers()
|
||||
}, { immediate: true })
|
||||
|
||||
const mapItems = computed((): MapItemWithCoords[] => {
|
||||
if (!useServerClustering.value) {
|
||||
if (mapViewMode.value === 'offers') {
|
||||
return exploreOffers.value
|
||||
.filter((offer) => offer.uuid && offer.latitude != null && offer.longitude != null)
|
||||
.map((offer) => ({
|
||||
uuid: offer.uuid,
|
||||
name: offer.productName || '',
|
||||
latitude: Number(offer.latitude),
|
||||
longitude: Number(offer.longitude)
|
||||
}))
|
||||
}
|
||||
if (mapViewMode.value === 'hubs') return toMapItems(filteredHubs.value)
|
||||
if (mapViewMode.value === 'suppliers') return toMapItems(filteredSuppliers.value)
|
||||
}
|
||||
return currentSelectionItems.value
|
||||
})
|
||||
|
||||
// Show panel when selecting OR when showing info OR when showing quote results
|
||||
const showPanel = computed(() => {
|
||||
return selectMode.value !== null || infoId.value !== null || showQuoteResults.value
|
||||
@@ -442,7 +610,7 @@ interface MapSelectItem {
|
||||
}
|
||||
|
||||
// Handle map item selection
|
||||
const onMapSelect = async (item: MapSelectItem) => {
|
||||
const onMapSelect = (item: MapSelectItem) => {
|
||||
// Get uuid from item - clusters use 'id', regular items use 'uuid'
|
||||
const itemId = item.uuid || item.id
|
||||
if (!itemId || itemId.startsWith('cluster-')) return
|
||||
@@ -451,39 +619,7 @@ const onMapSelect = async (item: MapSelectItem) => {
|
||||
|
||||
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
|
||||
|
||||
// If in selection mode, use map click to fill the selector
|
||||
if (selectMode.value) {
|
||||
// For hubs selection - click on hub fills hub selector
|
||||
if (selectMode.value === 'hub' && (itemType === 'hub' || mapViewMode.value === 'hubs')) {
|
||||
selectItem('hub', itemId, itemName)
|
||||
showQuoteResults.value = false
|
||||
offers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// For supplier selection - click on supplier fills supplier selector
|
||||
if (selectMode.value === 'supplier' && (itemType === 'supplier' || mapViewMode.value === 'suppliers')) {
|
||||
selectItem('supplier', itemId, itemName)
|
||||
showQuoteResults.value = false
|
||||
offers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// For product selection viewing offers - fetch offer to get productUuid
|
||||
if (selectMode.value === 'product' && (itemType === 'offer' || mapViewMode.value === 'offers')) {
|
||||
// Fetch offer details to get productUuid (not available in cluster data)
|
||||
const data = await execute(GetOfferDocument, { uuid: itemId }, 'public', 'exchange')
|
||||
const offer = data?.getOffer
|
||||
if (offer?.productUuid) {
|
||||
selectItem('product', offer.productUuid, offer.productName || itemName)
|
||||
showQuoteResults.value = false
|
||||
offers.value = []
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Default behavior - open Info directly
|
||||
// Default behavior - open Info directly
|
||||
let infoType: 'hub' | 'supplier' | 'offer'
|
||||
if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
|
||||
else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
|
||||
@@ -493,17 +629,25 @@ const onMapSelect = async (item: MapSelectItem) => {
|
||||
setLabel(infoType, itemId, itemName)
|
||||
}
|
||||
|
||||
// Handle selection from SelectionPanel - add to filter (show badge in search)
|
||||
// Handle selection from SelectionPanel - open info card (pin only via pin button)
|
||||
const onSelectItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
|
||||
if (item.uuid && item.name) {
|
||||
selectItem(type, item.uuid, item.name)
|
||||
if (!item.uuid) return
|
||||
if (type === 'hub' || type === 'supplier') {
|
||||
if (item.name) {
|
||||
setLabel(type, item.uuid, item.name)
|
||||
}
|
||||
openInfo(type, item.uuid)
|
||||
return
|
||||
}
|
||||
if (type === 'product') {
|
||||
router.push(localePath(`/catalog/products/${item.uuid}`))
|
||||
}
|
||||
}
|
||||
|
||||
const onPinItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
|
||||
if (item.uuid && item.name) {
|
||||
selectItem(type, item.uuid, item.name)
|
||||
}
|
||||
const onPinItem = (type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }) => {
|
||||
if (!item.uuid) return
|
||||
const label = item.name || item.uuid.slice(0, 8) + '...'
|
||||
selectItem(type, item.uuid, label)
|
||||
}
|
||||
|
||||
// Close panel (cancel select mode)
|
||||
@@ -517,30 +661,6 @@ const onInfoClose = () => {
|
||||
clearInfo()
|
||||
}
|
||||
|
||||
const onInfoAddToFilter = () => {
|
||||
if (!infoId.value || !entity.value) return
|
||||
const { type, uuid } = infoId.value
|
||||
|
||||
// For offers, add the product AND hub to filter
|
||||
if (type === 'offer') {
|
||||
if (entity.value.productUuid) {
|
||||
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
|
||||
selectItem('product', entity.value.productUuid, productName)
|
||||
}
|
||||
// Also add hub (location) to filter if available
|
||||
if (entity.value.locationUuid) {
|
||||
const hubName = entity.value.locationName || entity.value.locationUuid.slice(0, 8) + '...'
|
||||
selectItem('hub', entity.value.locationUuid, hubName)
|
||||
}
|
||||
} else {
|
||||
// For hubs and suppliers, add directly
|
||||
const name = entity.value.name || uuid.slice(0, 8) + '...'
|
||||
selectItem(type, uuid, name)
|
||||
}
|
||||
|
||||
closeInfo()
|
||||
clearInfo()
|
||||
}
|
||||
|
||||
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
|
||||
openInfo(type, uuid)
|
||||
@@ -571,26 +691,82 @@ const onSearch = async () => {
|
||||
|
||||
offersLoading.value = true
|
||||
showQuoteResults.value = true
|
||||
searchHubPoint.value = null
|
||||
|
||||
try {
|
||||
const vars: GetOffersQueryVariables = {}
|
||||
if (productId.value) vars.productUuid = productId.value
|
||||
if (supplierId.value) vars.teamUuid = supplierId.value
|
||||
if (hubId.value) vars.locationUuid = hubId.value
|
||||
// Prefer geo-based offers with routes when hub + product are selected
|
||||
if (hubId.value && productId.value) {
|
||||
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
|
||||
const hub = hubData?.node
|
||||
if (hub?.latitude != null && hub?.longitude != null) {
|
||||
searchHubPoint.value = {
|
||||
uuid: hub.uuid,
|
||||
name: hub.name || hub.uuid,
|
||||
latitude: Number(hub.latitude),
|
||||
longitude: Number(hub.longitude)
|
||||
}
|
||||
const geoData = await execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: hub.latitude,
|
||||
lon: hub.longitude,
|
||||
productUuid: productId.value,
|
||||
hubUuid: hubId.value,
|
||||
limit: 12
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
|
||||
offers.value = (data?.getOffers || []).filter((o): o is OfferResult => o !== null)
|
||||
let nearest = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
|
||||
if (supplierId.value) {
|
||||
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
|
||||
}
|
||||
|
||||
// Update labels from response
|
||||
const first = offers.value[0]
|
||||
if (first) {
|
||||
if (productId.value && first.productName) {
|
||||
setLabel('product', productId.value, first.productName)
|
||||
offers.value = nearest
|
||||
quoteCalculations.value = buildCalculationsFromOffers(nearest)
|
||||
|
||||
const first = offers.value[0]
|
||||
if (first?.productName) {
|
||||
setLabel('product', productId.value, first.productName)
|
||||
}
|
||||
} else {
|
||||
offers.value = []
|
||||
quoteCalculations.value = []
|
||||
}
|
||||
if (hubId.value && first.locationName) {
|
||||
setLabel('hub', hubId.value, first.locationName)
|
||||
} else {
|
||||
searchHubPoint.value = null
|
||||
const vars: GetOffersQueryVariables = {}
|
||||
if (productId.value) vars.productUuid = productId.value
|
||||
if (supplierId.value) vars.teamUuid = supplierId.value
|
||||
if (hubId.value) vars.locationUuid = hubId.value
|
||||
|
||||
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
|
||||
const exchangeOffers = (data?.getOffers || []).filter((o): o is NonNullable<typeof o> => o !== null)
|
||||
offers.value = exchangeOffers.map((offer) => ({
|
||||
uuid: offer.uuid,
|
||||
productUuid: offer.productUuid,
|
||||
productName: offer.productName,
|
||||
teamUuid: offer.teamUuid,
|
||||
quantity: offer.quantity,
|
||||
unit: offer.unit,
|
||||
pricePerUnit: offer.pricePerUnit,
|
||||
currency: offer.currency,
|
||||
locationName: offer.locationName,
|
||||
locationCountry: offer.locationCountry
|
||||
}))
|
||||
quoteCalculations.value = buildCalculationsFromOffers(offers.value)
|
||||
|
||||
// Update labels from response
|
||||
const first = offers.value[0]
|
||||
if (first) {
|
||||
if (productId.value && first.productName) {
|
||||
setLabel('product', productId.value, first.productName)
|
||||
}
|
||||
if (hubId.value && first.locationName) {
|
||||
setLabel('hub', hubId.value, first.locationName)
|
||||
}
|
||||
}
|
||||
// Note: teamName not included in GetOffers query, supplier label cannot be updated from offer
|
||||
}
|
||||
} finally {
|
||||
offersLoading.value = false
|
||||
@@ -599,8 +775,9 @@ const onSearch = async () => {
|
||||
|
||||
// Select offer - navigate to detail page
|
||||
const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
|
||||
if (offer.uuid && offer.productUuid) {
|
||||
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`))
|
||||
const productUuid = offer.productUuid
|
||||
if (offer.uuid && productUuid) {
|
||||
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
146
app/pages/catalog/product.vue
Normal file
146
app/pages/catalog/product.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Fullscreen Map -->
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="step-product-map"
|
||||
:items="[]"
|
||||
:clustered-points="clusteredNodes"
|
||||
:use-server-clustering="true"
|
||||
point-color="#f97316"
|
||||
entity-type="offer"
|
||||
@select-item="onMapSelect"
|
||||
@bounds-change="onBoundsChange"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet card -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
||||
<article
|
||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
||||
style="max-height: 60vh"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 1 }) }}</p>
|
||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectProduct') }}</h2>
|
||||
</div>
|
||||
<span class="badge badge-neutral">{{ products.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
|
||||
<Icon name="lucide:search" size="16" class="text-base-content/40" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('catalog.search.searchProducts')"
|
||||
class="grow bg-transparent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Product list -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredProducts.length === 0" class="text-center py-8 text-base-content/50">
|
||||
<Icon name="lucide:package-x" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noProducts') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.uuid"
|
||||
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
|
||||
@click="selectProduct(product)"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 shadow-lg">
|
||||
<Icon name="lucide:package" size="20" class="text-white" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-base font-bold text-base-content block truncate">{{ product.name || product.uuid }}</span>
|
||||
<span v-if="product.offersCount" class="text-sm text-base-content/50">{{ product.offersCount }} {{ $t('catalog.offers') }}</span>
|
||||
</div>
|
||||
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||
|
||||
definePageMeta({ layout: 'topnav' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
|
||||
const mapRef = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Load products
|
||||
const { items: products, isLoading, init: initProducts } = useCatalogProducts()
|
||||
|
||||
// Clustering for map background
|
||||
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, ref('offer'))
|
||||
|
||||
const onBoundsChange = (bounds: MapBounds) => {
|
||||
fetchClusters(bounds)
|
||||
}
|
||||
|
||||
const onMapSelect = (uuid: string) => {
|
||||
// Map click — ignore for product step
|
||||
}
|
||||
|
||||
// Filter products by search
|
||||
const filteredProducts = computed(() => {
|
||||
if (!searchQuery.value.trim()) return products.value
|
||||
const q = searchQuery.value.toLowerCase().trim()
|
||||
return products.value.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
(p.uuid || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// Select product → navigate to hub step
|
||||
const selectProduct = (product: { uuid: string; name?: string | null }) => {
|
||||
const query: Record<string, string> = {
|
||||
...route.query as Record<string, string>,
|
||||
product: product.uuid,
|
||||
}
|
||||
if (product.name) query.productName = product.name
|
||||
|
||||
router.push({
|
||||
path: localePath('/catalog/destination'),
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
// Init
|
||||
onMounted(() => {
|
||||
initProducts()
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalog.steps.selectProduct')
|
||||
}))
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
</IconCircle>
|
||||
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath('/catalog'))">
|
||||
<Button @click="navigateTo(localePath('/catalog?select=product'))">
|
||||
{{ t('catalogProduct.actions.back_to_catalog') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
150
app/pages/catalog/quantity.vue
Normal file
150
app/pages/catalog/quantity.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Fullscreen Map -->
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="step-quantity-map"
|
||||
:items="mapPoints"
|
||||
:use-server-clustering="false"
|
||||
point-color="#22c55e"
|
||||
entity-type="hub"
|
||||
:related-points="relatedPoints"
|
||||
:info-loading="false"
|
||||
@bounds-change="() => {}"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet card -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
||||
<article
|
||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
||||
style="max-height: 60vh"
|
||||
>
|
||||
<div class="shrink-0 p-5 md:px-7 md:pt-7">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 3 }) }}</p>
|
||||
<h2 class="text-2xl font-black tracking-tight text-base-content mb-4">{{ $t('catalog.steps.setQuantity') }}</h2>
|
||||
|
||||
<!-- Selected product + hub chips -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||
<span v-if="productName" class="badge badge-warning gap-1">
|
||||
<Icon name="lucide:package" size="12" />
|
||||
{{ productName }}
|
||||
</span>
|
||||
<Icon name="lucide:arrow-right" size="14" class="text-base-content/30" />
|
||||
<span v-if="hubName" class="badge badge-success gap-1">
|
||||
<Icon name="lucide:warehouse" size="12" />
|
||||
{{ hubName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quantity input -->
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label">
|
||||
<span class="label-text font-bold">{{ $t('catalog.filters.quantity') }}</span>
|
||||
</label>
|
||||
<label class="input input-bordered rounded-xl flex items-center gap-2">
|
||||
<input
|
||||
v-model="qty"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="100"
|
||||
class="grow bg-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span class="text-base-content/50 text-sm">{{ $t('units.t') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Search button -->
|
||||
<button
|
||||
class="btn btn-primary w-full mt-6 rounded-full text-base font-bold"
|
||||
:disabled="!canSearch"
|
||||
@click="goSearch"
|
||||
>
|
||||
<Icon name="lucide:search" size="18" />
|
||||
{{ $t('catalog.quote.findOffers') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetNodeDocument } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
definePageMeta({ layout: 'topnav' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const { execute } = useGraphQL()
|
||||
|
||||
const mapRef = ref(null)
|
||||
const qty = ref('100')
|
||||
|
||||
const productUuid = computed(() => route.query.product as string | undefined)
|
||||
const productName = computed(() => route.query.productName as string | undefined)
|
||||
const hubUuid = computed(() => route.query.hub as string | undefined)
|
||||
const hubName = computed(() => route.query.hubName as string | undefined)
|
||||
|
||||
const canSearch = computed(() => !!(productUuid.value && hubUuid.value))
|
||||
|
||||
// Load hub coordinates for map
|
||||
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
||||
|
||||
const loadHubPoint = async () => {
|
||||
if (!hubUuid.value) return
|
||||
const data = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
||||
const node = data?.node
|
||||
if (node?.latitude != null && node?.longitude != null) {
|
||||
hubPoint.value = {
|
||||
uuid: node.uuid,
|
||||
name: node.name || hubName.value || '',
|
||||
latitude: Number(node.latitude),
|
||||
longitude: Number(node.longitude),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapPoints = computed(() => hubPoint.value ? [hubPoint.value] : [])
|
||||
|
||||
const relatedPoints = computed(() => {
|
||||
if (!hubPoint.value) return []
|
||||
return [{
|
||||
uuid: hubPoint.value.uuid,
|
||||
name: hubPoint.value.name,
|
||||
latitude: hubPoint.value.latitude,
|
||||
longitude: hubPoint.value.longitude,
|
||||
type: 'hub' as const,
|
||||
}]
|
||||
})
|
||||
|
||||
const goSearch = () => {
|
||||
const query: Record<string, string> = {
|
||||
...route.query as Record<string, string>,
|
||||
}
|
||||
if (qty.value) query.qty = qty.value
|
||||
|
||||
router.push({
|
||||
path: localePath('/catalog/results'),
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHubPoint()
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalog.steps.setQuantity')
|
||||
}))
|
||||
</script>
|
||||
242
app/pages/catalog/results.vue
Normal file
242
app/pages/catalog/results.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Fullscreen Map -->
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<CatalogMap
|
||||
ref="mapRef"
|
||||
map-id="step-results-map"
|
||||
:items="[]"
|
||||
:use-server-clustering="false"
|
||||
point-color="#f97316"
|
||||
entity-type="offer"
|
||||
:related-points="relatedPoints"
|
||||
:info-loading="offersLoading"
|
||||
@select-item="onMapSelect"
|
||||
@bounds-change="() => {}"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet card -->
|
||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
||||
<article
|
||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
||||
style="max-height: 60vh"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.steps.results') }}</p>
|
||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.headers.offers') }}</h2>
|
||||
</div>
|
||||
<span v-if="!offersLoading" class="badge badge-neutral">{{ offers.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Selected filters summary -->
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span v-if="productName" class="badge badge-warning gap-1">
|
||||
<Icon name="lucide:package" size="12" />
|
||||
{{ productName }}
|
||||
</span>
|
||||
<Icon name="lucide:arrow-right" size="14" class="text-base-content/30" />
|
||||
<span v-if="hubName" class="badge badge-success gap-1">
|
||||
<Icon name="lucide:warehouse" size="12" />
|
||||
{{ hubName }}
|
||||
</span>
|
||||
<span v-if="qty" class="badge badge-info gap-1">
|
||||
{{ qty }} {{ $t('units.t') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offers list -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
||||
<div v-if="offersLoading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="offers.length === 0" class="text-center py-8 text-base-content/50">
|
||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||
<button class="btn btn-ghost btn-sm mt-3" @click="goBack">
|
||||
<Icon name="lucide:arrow-left" size="16" />
|
||||
{{ $t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
class="cursor-pointer"
|
||||
@click="onSelectOffer(offer)"
|
||||
>
|
||||
<OfferResultCard
|
||||
:supplier-name="offer.supplierName"
|
||||
:location-name="offer.country || ''"
|
||||
:product-name="offer.productName"
|
||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||
:quantity="offer.quantity"
|
||||
:currency="offer.currency"
|
||||
:unit="offer.unit"
|
||||
:stages="getOfferStages(offer)"
|
||||
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New search button -->
|
||||
<div class="shrink-0 p-5 pt-0 md:px-7">
|
||||
<button class="btn btn-outline btn-sm w-full rounded-full" @click="goBack">
|
||||
<Icon name="lucide:refresh-cw" size="14" />
|
||||
{{ $t('catalog.steps.newSearch') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||
|
||||
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
|
||||
|
||||
definePageMeta({ layout: 'topnav' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const { execute } = useGraphQL()
|
||||
|
||||
const mapRef = ref(null)
|
||||
|
||||
const productUuid = computed(() => route.query.product as string | undefined)
|
||||
const productName = computed(() => route.query.productName as string | undefined)
|
||||
const hubUuid = computed(() => route.query.hub as string | undefined)
|
||||
const hubName = computed(() => route.query.hubName as string | undefined)
|
||||
const qty = computed(() => route.query.qty as string | undefined)
|
||||
|
||||
// Offers data
|
||||
const offers = ref<NearestOffer[]>([])
|
||||
const offersLoading = ref(false)
|
||||
|
||||
// Hub point for map
|
||||
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
||||
|
||||
// Related points for map (hub + offer locations)
|
||||
const relatedPoints = computed(() => {
|
||||
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number; type: 'hub' | 'supplier' | 'offer' }> = []
|
||||
|
||||
if (hubPoint.value) {
|
||||
points.push({
|
||||
uuid: hubPoint.value.uuid,
|
||||
name: hubPoint.value.name,
|
||||
latitude: hubPoint.value.latitude,
|
||||
longitude: hubPoint.value.longitude,
|
||||
type: 'hub',
|
||||
})
|
||||
}
|
||||
|
||||
offers.value
|
||||
.filter(o => o.latitude != null && o.longitude != null)
|
||||
.forEach(o => {
|
||||
points.push({
|
||||
uuid: o.uuid,
|
||||
name: o.productName || '',
|
||||
latitude: Number(o.latitude),
|
||||
longitude: Number(o.longitude),
|
||||
type: 'offer',
|
||||
})
|
||||
})
|
||||
|
||||
return points
|
||||
})
|
||||
|
||||
const onMapSelect = (uuid: string) => {
|
||||
const offer = offers.value.find(o => o.uuid === uuid)
|
||||
if (offer) onSelectOffer(offer)
|
||||
}
|
||||
|
||||
const onSelectOffer = (offer: NearestOffer) => {
|
||||
const productUuid = offer.productUuid
|
||||
if (offer.uuid && productUuid) {
|
||||
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
|
||||
}
|
||||
}
|
||||
|
||||
const getOfferStages = (offer: NearestOffer) => {
|
||||
const r = offer.routes?.[0]
|
||||
if (!r?.stages) return []
|
||||
return r.stages
|
||||
.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
.map(s => ({
|
||||
transportType: s.transportType,
|
||||
distanceKm: s.distanceKm,
|
||||
travelTimeSeconds: s.travelTimeSeconds,
|
||||
fromName: s.fromName,
|
||||
}))
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push({
|
||||
path: localePath('/catalog/product'),
|
||||
})
|
||||
}
|
||||
|
||||
// Search for offers
|
||||
const searchOffers = async () => {
|
||||
if (!productUuid.value || !hubUuid.value) return
|
||||
offersLoading.value = true
|
||||
try {
|
||||
// Load hub coordinates
|
||||
const hubData = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
||||
const hub = hubData?.node
|
||||
if (!hub?.latitude || !hub?.longitude) {
|
||||
offers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
hubPoint.value = {
|
||||
uuid: hub.uuid,
|
||||
name: hub.name || hubName.value || '',
|
||||
latitude: Number(hub.latitude),
|
||||
longitude: Number(hub.longitude),
|
||||
}
|
||||
|
||||
// Search nearest offers
|
||||
const data = await execute(
|
||||
NearestOffersDocument,
|
||||
{
|
||||
lat: hub.latitude,
|
||||
lon: hub.longitude,
|
||||
productUuid: productUuid.value,
|
||||
hubUuid: hubUuid.value,
|
||||
limit: 20,
|
||||
},
|
||||
'public',
|
||||
'geo'
|
||||
)
|
||||
|
||||
offers.value = (data?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
|
||||
} finally {
|
||||
offersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchOffers()
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
title: t('catalog.steps.results')
|
||||
}))
|
||||
</script>
|
||||
@@ -12,27 +12,28 @@
|
||||
|
||||
<!-- Bottom Sheet with slide-up animation -->
|
||||
<Transition name="slide-up" appear>
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||
<!-- Glass sheet -->
|
||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4" style="height: 72vh">
|
||||
<div class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent" />
|
||||
<!-- Sheet -->
|
||||
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-6 pb-4 border-b border-white/10">
|
||||
<div class="border-b border-base-300 bg-base-100/90 px-6 pb-4">
|
||||
<!-- Back button -->
|
||||
<NuxtLink :to="localePath('/clientarea/orders')" class="inline-flex items-center gap-1 text-white/60 hover:text-white text-sm mb-3">
|
||||
<NuxtLink :to="localePath('/clientarea/orders')" class="mb-3 inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content">
|
||||
<Icon name="lucide:arrow-left" size="16" />
|
||||
{{ t('common.back') }}
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="hasOrderError">
|
||||
<div class="bg-error/20 border border-error/30 rounded-lg p-4">
|
||||
<div class="font-semibold text-white mb-2">{{ t('common.error') }}</div>
|
||||
<div class="text-sm text-white/70 mb-3">{{ orderError }}</div>
|
||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
||||
<div class="rounded-lg border border-error/30 bg-error/10 p-4">
|
||||
<div class="mb-2 font-black text-base-content">{{ t('common.error') }}</div>
|
||||
<div class="mb-3 text-sm text-base-content/70">{{ orderError }}</div>
|
||||
<button class="btn btn-sm btn-outline" @click="loadOrder">
|
||||
{{ t('ordersDetail.errors.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -40,13 +41,13 @@
|
||||
|
||||
<template v-else-if="!isLoadingOrder && order">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center">
|
||||
<Icon name="lucide:package" size="24" class="text-indigo-400" />
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Icon name="lucide:package" size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-lg text-white truncate">{{ orderTitle }}</div>
|
||||
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-white/50">
|
||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-base-content/55">
|
||||
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,18 +57,18 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="animate-pulse">
|
||||
<div class="h-12 bg-white/10 rounded-xl w-48" />
|
||||
<div class="h-12 w-48 rounded-xl bg-base-300/70" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div v-if="!hasOrderError && order" class="overflow-y-auto h-[calc(70vh-140px)] px-6 py-4 space-y-4">
|
||||
<div v-if="!hasOrderError && order" class="h-[calc(72vh-150px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||
<!-- Route stages -->
|
||||
<div v-if="orderStageItems.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:route" size="18" />
|
||||
{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
@@ -76,15 +77,15 @@
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-indigo-500" />
|
||||
<div v-if="idx < orderStageItems.length - 1" class="w-0.5 flex-1 bg-white/20 my-1" />
|
||||
<div class="h-3 w-3 rounded-full bg-primary" />
|
||||
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
|
||||
</div>
|
||||
<div class="flex-1 pb-3">
|
||||
<div class="text-sm text-white font-medium">{{ stage.from }}</div>
|
||||
<div v-if="stage.to && stage.to !== stage.from" class="text-xs text-white/50 mt-0.5">
|
||||
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
|
||||
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
|
||||
→ {{ stage.to }}
|
||||
</div>
|
||||
<div v-if="stage.meta?.length" class="text-xs text-white/40 mt-1">
|
||||
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
|
||||
{{ stage.meta.join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,10 +94,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div v-if="order.stages?.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:calendar" size="18" />
|
||||
{{ t('ordersDetail.sections.timeline.title') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||
</div>
|
||||
<GanttTimeline
|
||||
:stages="order.stages"
|
||||
@@ -106,10 +107,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Map preview (small) -->
|
||||
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||
<Icon name="lucide:map" size="18" />
|
||||
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
||||
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||
</div>
|
||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap="3">
|
||||
<Button :as="'NuxtLink'" :to="localePath('/catalog')" variant="ghost">
|
||||
<Button :as="'NuxtLink'" :to="localePath('/catalog?select=product')" variant="ghost">
|
||||
{{ t('searchPage.cta.catalog') }}
|
||||
</Button>
|
||||
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const config = useRuntimeConfig()
|
||||
const dsn = config.public.sentryDsn
|
||||
|
||||
if (!dsn) {
|
||||
return
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
app: nuxtApp.vueApp,
|
||||
dsn,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration({ router: nuxtApp.$router as any }),
|
||||
Sentry.replayIntegration()
|
||||
],
|
||||
tracesSampleRate: 0.1,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ const pluginConfig = {
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
allowPartialOutputs: true,
|
||||
generates: {
|
||||
// Public operations (no token)
|
||||
'./app/composables/graphql/public/exchange-generated.ts': {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
query NearestHubs($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String, $limit: Int) {
|
||||
nearestHubs(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid, limit: $limit) {
|
||||
nearestHubs(
|
||||
lat: $lat
|
||||
lon: $lon
|
||||
radius: $radius
|
||||
productUuid: $productUuid
|
||||
limit: $limit
|
||||
) {
|
||||
uuid
|
||||
name
|
||||
latitude
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"selectSupplier": "Select supplier",
|
||||
"enterQty": "Quantity (t)",
|
||||
"search": "Search",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"findOffers": "Find offers"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore the market",
|
||||
@@ -95,6 +96,14 @@
|
||||
},
|
||||
"offers": "offer | offers",
|
||||
"list": "List",
|
||||
"applyFilter": "Apply filter"
|
||||
"applyFilter": "Apply filter",
|
||||
"step": "Step {n}",
|
||||
"steps": {
|
||||
"selectProduct": "What are you looking for?",
|
||||
"selectDestination": "Where to deliver?",
|
||||
"setQuantity": "How much do you need?",
|
||||
"results": "Results",
|
||||
"newSearch": "New search"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
"labels": {
|
||||
"quantity_with_unit": "{quantity} {unit}",
|
||||
"default_unit": "t",
|
||||
"unit_kg": "kg",
|
||||
"distance_km": "{km} km",
|
||||
"duration_label": "ETA",
|
||||
"duration_days": "{days} d",
|
||||
"country_unknown": "Not specified",
|
||||
"supplier_unknown": "Supplier",
|
||||
"origin_label": "From",
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"selectSupplier": "Выберите поставщика",
|
||||
"enterQty": "Количество (т)",
|
||||
"search": "Найти",
|
||||
"clear": "Очистить"
|
||||
"clear": "Очистить",
|
||||
"findOffers": "Найти предложения"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Исследуйте рынок",
|
||||
@@ -95,6 +96,14 @@
|
||||
},
|
||||
"offers": "предложение | предложения | предложений",
|
||||
"list": "Список",
|
||||
"applyFilter": "Применить фильтр"
|
||||
"applyFilter": "Применить фильтр",
|
||||
"step": "Шаг {n}",
|
||||
"steps": {
|
||||
"selectProduct": "Что ищете?",
|
||||
"selectDestination": "Куда доставить?",
|
||||
"setQuantity": "Сколько нужно?",
|
||||
"results": "Результаты",
|
||||
"newSearch": "Новый поиск"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
"labels": {
|
||||
"quantity_with_unit": "{quantity} {unit}",
|
||||
"default_unit": "т",
|
||||
"unit_kg": "кг",
|
||||
"distance_km": "{km} км",
|
||||
"duration_label": "Срок",
|
||||
"duration_days": "{days} дн",
|
||||
"country_unknown": "Не указана",
|
||||
"supplier_unknown": "Поставщик",
|
||||
"origin_label": "Откуда",
|
||||
|
||||
@@ -2,8 +2,6 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const enableSourceMaps = process.env.NUXT_SOURCEMAP === 'true'
|
||||
const enableMinify = process.env.NUXT_MINIFY !== 'false'
|
||||
const enableSentry = process.env.SENTRY_ENABLED !== 'false'
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
@@ -11,7 +9,6 @@ export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxtjs/i18n',
|
||||
'@pinia/nuxt',
|
||||
...(enableSentry ? ['@sentry/nuxt/module'] : []),
|
||||
'@nuxt/eslint',
|
||||
'nuxt-mapbox',
|
||||
'@nuxt/icon',
|
||||
@@ -203,13 +200,9 @@ export default defineNuxtConfig({
|
||||
novuAppId: process.env.NUXT_PUBLIC_NOVU_APP_ID,
|
||||
novuBackendUrl: process.env.NUXT_PUBLIC_NOVU_BACKEND_URL,
|
||||
novuSocketUrl: process.env.NUXT_PUBLIC_NOVU_SOCKET_URL,
|
||||
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
|
||||
mapboxAccessToken: process.env.NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN || ''
|
||||
}
|
||||
},
|
||||
sentry: {
|
||||
// DSN, environment, and tracesSampleRate are configured in sentry.client.config.ts
|
||||
},
|
||||
mapbox: {
|
||||
accessToken: process.env.NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN || ''
|
||||
},
|
||||
@@ -217,39 +210,39 @@ export default defineNuxtConfig({
|
||||
clients: {
|
||||
default: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_EXCHANGE_GRAPHQL_PUBLIC || 'https://exchange.optovia.ru/graphql/public/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
publicGeo: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_GEO_GRAPHQL_PUBLIC || 'https://geo.optovia.ru/graphql/public/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
publicKyc: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_KYC_GRAPHQL_PUBLIC || 'https://kyc.optovia.ru/graphql/public/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
teamsUser: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_TEAMS_GRAPHQL_USER || 'https://teams.optovia.ru/graphql/user/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
teamsTeam: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_TEAMS_GRAPHQL_TEAM || 'https://teams.optovia.ru/graphql/team/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
exchangeTeam: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_EXCHANGE_GRAPHQL_TEAM || 'https://exchange.optovia.ru/graphql/team/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
kycUser: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_KYC_GRAPHQL_USER || 'https://kyc.optovia.ru/graphql/user/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
ordersTeam: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_ORDERS_GRAPHQL_TEAM || 'https://orders.optovia.ru/graphql/team/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
},
|
||||
billingTeam: {
|
||||
httpEndpoint: process.env.NUXT_PUBLIC_BILLING_GRAPHQL_TEAM || 'https://billing.optovia.ru/graphql/team/',
|
||||
connectToDevTools: process.dev
|
||||
devtools: { enabled: process.dev }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
/**
|
||||
* Load secrets from Infisical using Machine Identity (Universal Auth)
|
||||
* Load secrets from Vault HTTP API
|
||||
* Writes secrets to .env.infisical file for sourcing
|
||||
*/
|
||||
import { InfisicalSDK } from "@infisical/sdk";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const INFISICAL_API_URL = process.env.INFISICAL_API_URL;
|
||||
const INFISICAL_CLIENT_ID = process.env.INFISICAL_CLIENT_ID;
|
||||
const INFISICAL_CLIENT_SECRET = process.env.INFISICAL_CLIENT_SECRET;
|
||||
const INFISICAL_PROJECT_ID = process.env.INFISICAL_PROJECT_ID;
|
||||
const INFISICAL_ENV = process.env.INFISICAL_ENV || "prod";
|
||||
const VAULT_ADDR = process.env.VAULT_ADDR;
|
||||
const VAULT_TOKEN = process.env.VAULT_TOKEN;
|
||||
const VAULT_KV_MOUNT = process.env.VAULT_KV_MOUNT || "secret";
|
||||
const VAULT_SHARED_PATH = process.env.VAULT_SHARED_PATH;
|
||||
const VAULT_PROJECT_PATH = process.env.VAULT_PROJECT_PATH;
|
||||
|
||||
if (!INFISICAL_API_URL || !INFISICAL_CLIENT_ID || !INFISICAL_CLIENT_SECRET || !INFISICAL_PROJECT_ID) {
|
||||
process.stderr.write("Missing required Infisical environment variables\n");
|
||||
if (!VAULT_ADDR || !VAULT_TOKEN) {
|
||||
process.stderr.write("Missing required Vault environment variables (VAULT_ADDR, VAULT_TOKEN)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new InfisicalSDK({ siteUrl: INFISICAL_API_URL });
|
||||
|
||||
await client.auth().universalAuth.login({
|
||||
clientId: INFISICAL_CLIENT_ID,
|
||||
clientSecret: INFISICAL_CLIENT_SECRET,
|
||||
});
|
||||
|
||||
process.stderr.write(`Loading secrets from Infisical (env: ${INFISICAL_ENV})...\n`);
|
||||
process.stderr.write(`Loading secrets from Vault...\n`);
|
||||
|
||||
const envLines = [];
|
||||
|
||||
for (const secretPath of ["/webapp", "/shared"]) {
|
||||
const response = await client.secrets().listSecrets({
|
||||
projectId: INFISICAL_PROJECT_ID,
|
||||
environment: INFISICAL_ENV,
|
||||
secretPath: secretPath,
|
||||
expandSecretReferences: true,
|
||||
async function loadPath(path, sourceName) {
|
||||
if (!path) return;
|
||||
|
||||
const url = `${VAULT_ADDR.replace(/\/$/, "")}/v1/${VAULT_KV_MOUNT}/data/${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "X-Vault-Token": VAULT_TOKEN },
|
||||
});
|
||||
|
||||
for (const secret of response.secrets) {
|
||||
// Escape special characters for shell
|
||||
const escapedValue = secret.secretValue.replace(/'/g, "'\\''");
|
||||
envLines.push(`export ${secret.secretKey}='${escapedValue}'`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load Vault path ${VAULT_KV_MOUNT}/${path}: ${response.status}`);
|
||||
}
|
||||
|
||||
process.stderr.write(` ${secretPath}: ${response.secrets.length} secrets loaded\n`);
|
||||
const json = await response.json();
|
||||
const secrets = json?.data?.data || {};
|
||||
const keys = Object.keys(secrets);
|
||||
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
const escapedValue = String(value).replace(/'/g, "'\\''");
|
||||
envLines.push(`export ${key}='${escapedValue}'`);
|
||||
}
|
||||
|
||||
process.stderr.write(` ${sourceName}: ${keys.length} secrets loaded from ${VAULT_KV_MOUNT}/${path}\n`);
|
||||
}
|
||||
|
||||
await loadPath(VAULT_SHARED_PATH, "shared");
|
||||
await loadPath(VAULT_PROJECT_PATH, "project");
|
||||
|
||||
writeFileSync(".env.infisical", envLines.join("\n"));
|
||||
process.stderr.write("Secrets written to .env.infisical\n");
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as Sentry from '@sentry/nuxt'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.public.sentryDsn,
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration()
|
||||
],
|
||||
tracesSampleRate: 0.1
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as Sentry from '@sentry/nuxt'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
tracesSampleRate: 0.1
|
||||
})
|
||||
Reference in New Issue
Block a user