Compare commits
135 Commits
c6abf8ad4a
...
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 | ||
|
|
4585d30d53 | ||
|
|
f80164c912 | ||
|
|
f0c687c3ff | ||
|
|
fa0465fabb | ||
|
|
161a1426e4 | ||
|
|
a3e7c92915 | ||
|
|
1e761ca2a8 | ||
|
|
4bdefc9ce9 | ||
|
|
fb29c2a4f6 | ||
|
|
d262928a09 | ||
|
|
b76c7fce94 | ||
|
|
666423bcf4 | ||
|
|
cf081e7e67 | ||
|
|
05c91ca352 | ||
|
|
adf2a7765c | ||
|
|
4669911162 | ||
|
|
71a27a4ab9 | ||
|
|
0f0b1db394 | ||
|
|
beb02bd3fc | ||
|
|
f1eb7bc746 | ||
|
|
2fc4faaa83 | ||
|
|
9c19d08cf5 | ||
|
|
bd2a063e39 | ||
|
|
2a8ef4b7dc | ||
|
|
8a2a804c58 | ||
|
|
0a63d4b0b2 | ||
|
|
532b9ce78d | ||
|
|
a244589fe5 | ||
|
|
1850d255a7 | ||
|
|
de3ec4c39d | ||
|
|
71e69a7abc | ||
|
|
d5aa47c323 | ||
|
|
d227325d1a | ||
|
|
bd7a1d1b4b | ||
|
|
3a46cfc5dc | ||
|
|
f4afd362eb | ||
|
|
5a780707dc | ||
|
|
886415344d | ||
|
|
6ee8c12e6f | ||
|
|
bc037e85a4 | ||
|
|
72f2e1c39d | ||
|
|
3d5215d967 | ||
|
|
33c406995f | ||
|
|
209d81ec61 | ||
|
|
984daa7a84 | ||
|
|
63e8d47b79 | ||
|
|
f5b95c27ef | ||
|
|
8b0e1900d1 | ||
|
|
45acef9b20 | ||
|
|
1f996d27e5 | ||
|
|
02419abdd1 | ||
|
|
7066c51505 | ||
|
|
88d78e9662 | ||
|
|
3f7b83bb6d | ||
|
|
b5534d1fd5 | ||
|
|
7f8a148aa7 | ||
|
|
f269c0daf0 | ||
|
|
497a80f0c6 | ||
|
|
5aa460fd8a | ||
|
|
805b6795f0 | ||
|
|
c39bc55ebc | ||
|
|
c152a5b14c | ||
|
|
2dbe600d8a | ||
|
|
ff34c564e1 | ||
|
|
80474acc0f | ||
|
|
859eef3761 | ||
|
|
7bd4aa37bd | ||
|
|
20e0e73c58 | ||
|
|
9210f79a3d | ||
|
|
65250f1342 | ||
|
|
3f823b2abc | ||
|
|
75ce64b46e | ||
|
|
70c53da8eb | ||
|
|
839ab4e830 | ||
|
|
19aca61845 | ||
|
|
6545eeabea | ||
|
|
f9eb027ebd | ||
|
|
b02e3882cc | ||
|
|
c56bb57fbf |
@@ -26,12 +26,6 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: gitea.dsrptlab.com/optovia/webapp/webapp:latest
|
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
|
- 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"
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@@ -2,28 +2,21 @@ FROM node:22-slim AS build
|
|||||||
|
|
||||||
ENV PNPM_HOME=/pnpm
|
ENV PNPM_HOME=/pnpm
|
||||||
ENV PATH=$PNPM_HOME:$PATH
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||||
|
ENV NUXT_SOURCEMAP=false
|
||||||
|
ENV NUXT_MINIFY=false
|
||||||
|
ENV SENTRY_ENABLED=false
|
||||||
|
ENV NUXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable
|
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 ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
@@ -41,4 +34,4 @@ COPY --from=build /app/package.json ./package.json
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs"]
|
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && if [ \"$SENTRY_ENABLED\" = \"false\" ] || [ ! -f ./.output/server/sentry.server.config.mjs ]; then node .output/server/index.mjs; else node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs; fi"]
|
||||||
|
|||||||
@@ -36,6 +36,104 @@
|
|||||||
--noise: 0;
|
--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" {
|
@plugin "daisyui/theme" {
|
||||||
name: "silk";
|
name: "silk";
|
||||||
default: false;
|
default: false;
|
||||||
|
|||||||
@@ -47,13 +47,22 @@ interface BankData {
|
|||||||
correspondentAccount: string
|
correspondentAccount: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BankSuggestion {
|
||||||
|
value: string
|
||||||
|
data: {
|
||||||
|
bic: string
|
||||||
|
correspondent_account?: string
|
||||||
|
address?: { value: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: BankData
|
modelValue?: BankData
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value: BankData): void
|
(e: 'update:modelValue', value: BankData): void
|
||||||
(e: 'select', bank: any): void
|
(e: 'select', bank: BankSuggestion): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -66,15 +75,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
interface BankSuggestion {
|
|
||||||
value: string
|
|
||||||
data: {
|
|
||||||
bic: string
|
|
||||||
correspondent_account?: string
|
|
||||||
address?: { value: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const suggestions = ref<BankSuggestion[]>([])
|
const suggestions = ref<BankSuggestion[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -123,7 +123,7 @@ const onInput = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectBank = (bank: any) => {
|
const selectBank = (bank: BankSuggestion) => {
|
||||||
query.value = bank.value
|
query.value = bank.value
|
||||||
showDropdown.value = false
|
showDropdown.value = false
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,15 @@
|
|||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
v-for="(option, index) in productRouteOptions"
|
v-for="(option, index) in productRouteOptions"
|
||||||
:key="option.sourceUuid ?? index"
|
:key="option.sourceUuid ?? index"
|
||||||
|
:supplier-name="getSupplierName(option.sourceUuid)"
|
||||||
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
||||||
:product-name="productName"
|
:product-name="productName"
|
||||||
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
|
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
|
||||||
|
:quantity="getOfferData(option.sourceUuid)?.quantity"
|
||||||
:currency="getOfferData(option.sourceUuid)?.currency"
|
:currency="getOfferData(option.sourceUuid)?.currency"
|
||||||
:unit="getOfferData(option.sourceUuid)?.unit"
|
:unit="getOfferData(option.sourceUuid)?.unit"
|
||||||
:stages="getRouteStages(option)"
|
:stages="getRouteStages(option)"
|
||||||
|
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
|
||||||
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
|
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
|
||||||
@select="navigateToOffer(option.sourceUuid)"
|
@select="navigateToOffer(option.sourceUuid)"
|
||||||
/>
|
/>
|
||||||
@@ -81,7 +84,8 @@ interface RoutePathType {
|
|||||||
totalTimeSeconds?: number | null
|
totalTimeSeconds?: number | null
|
||||||
stages?: (RouteStage | null)[]
|
stages?: (RouteStage | null)[]
|
||||||
}
|
}
|
||||||
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
import type { OfferWithRoute, RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
@@ -90,12 +94,14 @@ const { execute } = useGraphQL()
|
|||||||
|
|
||||||
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
||||||
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
||||||
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
|
const quantity = computed(() => (route.query.quantity as string) || searchStore.searchForm.quantity)
|
||||||
|
|
||||||
// Offer data for prices
|
// Offer data for prices
|
||||||
const offersData = ref<Map<string, any>>(new Map())
|
type OfferData = NonNullable<GetOfferQueryResult['getOffer']>
|
||||||
|
const offersData = ref<Map<string, OfferData>>(new Map())
|
||||||
// Supplier data for KYC profile UUID (by team_uuid)
|
// Supplier data for KYC profile UUID (by team_uuid)
|
||||||
const suppliersData = ref<Map<string, any>>(new Map())
|
type SupplierData = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
|
||||||
|
const suppliersData = ref<Map<string, SupplierData>>(new Map())
|
||||||
|
|
||||||
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
||||||
const summaryMeta = computed(() => {
|
const summaryMeta = computed(() => {
|
||||||
@@ -149,14 +155,16 @@ const fetchOffersByHub = async () => {
|
|||||||
const offers = offersResponse?.nearestOffers || []
|
const offers = offersResponse?.nearestOffers || []
|
||||||
|
|
||||||
// Offers already include routes from backend
|
// Offers already include routes from backend
|
||||||
const offersWithRoutes = offers.map((offer: any) => ({
|
const offersWithRoutes = offers
|
||||||
sourceUuid: offer.uuid,
|
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
|
||||||
sourceName: offer.productName,
|
.map((offer) => ({
|
||||||
sourceLat: offer.latitude,
|
sourceUuid: offer.uuid,
|
||||||
sourceLon: offer.longitude,
|
sourceName: offer.productName,
|
||||||
distanceKm: offer.distanceKm,
|
sourceLat: offer.latitude,
|
||||||
routes: offer.routes || []
|
sourceLon: offer.longitude,
|
||||||
}))
|
distanceKm: offer.distanceKm,
|
||||||
|
routes: offer.routes || []
|
||||||
|
}))
|
||||||
|
|
||||||
return { offersByHub: offersWithRoutes }
|
return { offersByHub: offersWithRoutes }
|
||||||
}
|
}
|
||||||
@@ -198,10 +206,14 @@ const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
|
|||||||
const getRouteStages = (option: ProductRouteOption) => {
|
const getRouteStages = (option: ProductRouteOption) => {
|
||||||
const route = option.routes?.[0]
|
const route = option.routes?.[0]
|
||||||
if (!route?.stages) return []
|
if (!route?.stages) return []
|
||||||
return route.stages.filter(Boolean).map((stage: any) => ({
|
return route.stages
|
||||||
transportType: stage?.transportType,
|
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||||
distanceKm: stage?.distanceKm
|
.map((stage) => ({
|
||||||
}))
|
transportType: stage.transportType,
|
||||||
|
distanceKm: stage.distanceKm,
|
||||||
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get offer data for card
|
// Get offer data for card
|
||||||
@@ -219,6 +231,14 @@ const getKycProfileUuid = (offerUuid?: string | null) => {
|
|||||||
return supplier?.kycProfileUuid || null
|
return supplier?.kycProfileUuid || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSupplierName = (offerUuid?: string | null) => {
|
||||||
|
if (!offerUuid) return null
|
||||||
|
const offer = offersData.value.get(offerUuid)
|
||||||
|
if (!offer?.teamUuid) return null
|
||||||
|
const supplier = suppliersData.value.get(offer.teamUuid)
|
||||||
|
return supplier?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to offer detail page
|
// Navigate to offer detail page
|
||||||
const navigateToOffer = (offerUuid?: string | null) => {
|
const navigateToOffer = (offerUuid?: string | null) => {
|
||||||
if (!offerUuid) return
|
if (!offerUuid) return
|
||||||
@@ -233,8 +253,8 @@ const loadOfferDetails = async (options: ProductRouteOption[]) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newOffersData = new Map<string, any>()
|
const newOffersData = new Map<string, OfferData>()
|
||||||
const newSuppliersData = new Map<string, any>()
|
const newSuppliersData = new Map<string, SupplierData>()
|
||||||
const teamUuidsToLoad = new Set<string>()
|
const teamUuidsToLoad = new Set<string>()
|
||||||
|
|
||||||
// First, load all offers
|
// First, load all offers
|
||||||
|
|||||||
@@ -35,14 +35,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetProductsDocument, type GetProductsQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
|
||||||
|
|
||||||
const searchStore = useSearchStore()
|
const searchStore = useSearchStore()
|
||||||
|
|
||||||
const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
|
const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
|
||||||
const productsData = computed(() => data.value?.getProducts || [])
|
const productsData = computed(() => data.value?.getProducts || [])
|
||||||
|
|
||||||
const selectProduct = (product: any) => {
|
const selectProduct = (product: Product) => {
|
||||||
searchStore.setProduct(product.name)
|
searchStore.setProduct(product.name)
|
||||||
searchStore.setProductUuid(product.uuid)
|
searchStore.setProductUuid(product.uuid)
|
||||||
const locationUuid = searchStore.searchForm.locationUuid
|
const locationUuid = searchStore.searchForm.locationUuid
|
||||||
|
|||||||
@@ -154,10 +154,44 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
interface KycSubmitData {
|
||||||
|
company_name: string
|
||||||
|
company_full_name: string
|
||||||
|
inn: string
|
||||||
|
kpp: string
|
||||||
|
ogrn: string
|
||||||
|
address: string
|
||||||
|
bank_name: string
|
||||||
|
bik: string
|
||||||
|
correspondent_account: string
|
||||||
|
contact_person: string
|
||||||
|
contact_email: string
|
||||||
|
contact_phone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanySuggestion {
|
||||||
|
value: string
|
||||||
|
unrestricted_value: string
|
||||||
|
data: {
|
||||||
|
inn: string
|
||||||
|
kpp?: string
|
||||||
|
ogrn?: string
|
||||||
|
address?: { value: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BankSuggestion {
|
||||||
|
value: string
|
||||||
|
data: {
|
||||||
|
bic: string
|
||||||
|
correspondent_account?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [data: any]
|
submit: [data: KycSubmitData]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -195,7 +229,7 @@ const isFormValid = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const onCompanySelect = (company: any) => {
|
const onCompanySelect = (company: CompanySuggestion) => {
|
||||||
formData.value.company = {
|
formData.value.company = {
|
||||||
companyName: company.value,
|
companyName: company.value,
|
||||||
companyFullName: company.unrestricted_value,
|
companyFullName: company.unrestricted_value,
|
||||||
@@ -206,7 +240,7 @@ const onCompanySelect = (company: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBankSelect = (bank: any) => {
|
const onBankSelect = (bank: BankSuggestion) => {
|
||||||
formData.value.bank = {
|
formData.value.bank = {
|
||||||
bankName: bank.value,
|
bankName: bank.value,
|
||||||
bik: bank.data.bic,
|
bik: bank.data.bic,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
<Card
|
<Card
|
||||||
v-for="addr in teamAddresses"
|
v-for="(addr, index) in teamAddresses"
|
||||||
:key="addr.uuid"
|
:key="addr.uuid ?? index"
|
||||||
padding="small"
|
padding="small"
|
||||||
interactive
|
interactive
|
||||||
@click="selectTeamAddress(addr)"
|
@click="selectTeamAddress(addr)"
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
|
|
||||||
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
|
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
<HubCard
|
<HubCard
|
||||||
v-for="location in locationsData"
|
v-for="(location, index) in locationsData"
|
||||||
:key="location.uuid"
|
:key="location.uuid ?? index"
|
||||||
:hub="location"
|
:hub="location"
|
||||||
selectable
|
selectable
|
||||||
@select="selectLocation(location)"
|
@select="selectLocation(location)"
|
||||||
@@ -69,7 +69,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { HubsListDocument } from '~/composables/graphql/public/geo-generated'
|
import { HubsListDocument, type HubsListQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
type HubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
|
||||||
|
type HubWithDistance = HubItem & { distance?: string }
|
||||||
|
|
||||||
|
interface TeamAddress {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
address?: string | null
|
||||||
|
isDefault?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const searchStore = useSearchStore()
|
const searchStore = useSearchStore()
|
||||||
@@ -85,35 +95,37 @@ const calculateDistance = (lat: number, lng: number) => {
|
|||||||
|
|
||||||
// Load logistics hubs
|
// Load logistics hubs
|
||||||
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', HubsListDocument, { limit: 100 }, 'public', 'geo')
|
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', HubsListDocument, { limit: 100 }, 'public', 'geo')
|
||||||
const locationsData = computed(() => {
|
const locationsData = computed<HubWithDistance[]>(() => {
|
||||||
return (locationsDataRaw.value?.hubsList || []).map((location: any) => ({
|
return (locationsDataRaw.value?.hubsList || [])
|
||||||
...location,
|
.filter((location): location is HubItem => location !== null)
|
||||||
distance: location?.latitude && location?.longitude
|
.map((location) => ({
|
||||||
? calculateDistance(location.latitude, location.longitude)
|
...location,
|
||||||
: undefined,
|
distance: location.latitude && location.longitude
|
||||||
}))
|
? calculateDistance(location.latitude, location.longitude)
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load team addresses (if authenticated)
|
// Load team addresses (if authenticated)
|
||||||
const teamAddresses = ref<any[]>([])
|
const teamAddresses = ref<TeamAddress[]>([])
|
||||||
|
|
||||||
if (isAuthenticated.value) {
|
if (isAuthenticated.value) {
|
||||||
try {
|
try {
|
||||||
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||||
const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
teamAddresses.value = addressData.value?.teamAddresses || []
|
teamAddresses.value = (addressData.value?.teamAddresses || []).filter((a): a is NonNullable<typeof a> => a !== null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Team addresses not available')
|
console.log('Team addresses not available')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectLocation = (location: any) => {
|
const selectLocation = (location: HubWithDistance) => {
|
||||||
searchStore.setLocation(location.name)
|
searchStore.setLocation(location.name)
|
||||||
searchStore.setLocationUuid(location.uuid)
|
searchStore.setLocationUuid(location.uuid)
|
||||||
history.back()
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectTeamAddress = (addr: any) => {
|
const selectTeamAddress = (addr: TeamAddress) => {
|
||||||
searchStore.setLocation(addr.address)
|
searchStore.setLocation(addr.address)
|
||||||
searchStore.setLocationUuid(addr.uuid)
|
searchStore.setLocationUuid(addr.uuid)
|
||||||
history.back()
|
history.back()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Header with back button -->
|
<!-- Header with back button -->
|
||||||
<div class="p-4 border-b border-base-300">
|
<div class="p-4 border-b border-base-300">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="localePath('/catalog')"
|
:to="localePath('/catalog?select=product')"
|
||||||
class="btn btn-sm btn-ghost gap-2"
|
class="btn btn-sm btn-ghost gap-2"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:arrow-left" size="18" />
|
<Icon name="lucide:arrow-left" size="18" />
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
<!-- Hubs Tab -->
|
<!-- Hubs Tab -->
|
||||||
<div v-else-if="activeTab === 'hubs'" class="space-y-2">
|
<div v-else-if="activeTab === 'hubs'" class="space-y-2">
|
||||||
<HubCard
|
<HubCard
|
||||||
v-for="hub in hubs"
|
v-for="(hub, index) in hubs"
|
||||||
:key="hub.uuid"
|
:key="hub.uuid ?? index"
|
||||||
:hub="hub"
|
:hub="hub"
|
||||||
selectable
|
selectable
|
||||||
:is-selected="selectedItemId === hub.uuid"
|
:is-selected="selectedItemId === hub.uuid"
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
<!-- Suppliers Tab -->
|
<!-- Suppliers Tab -->
|
||||||
<div v-else-if="activeTab === 'suppliers'" class="space-y-2">
|
<div v-else-if="activeTab === 'suppliers'" class="space-y-2">
|
||||||
<SupplierCard
|
<SupplierCard
|
||||||
v-for="supplier in suppliers"
|
v-for="(supplier, index) in suppliers"
|
||||||
:key="supplier.uuid"
|
:key="supplier.uuid ?? index"
|
||||||
:supplier="supplier"
|
:supplier="supplier"
|
||||||
selectable
|
selectable
|
||||||
:is-selected="selectedItemId === supplier.uuid"
|
:is-selected="selectedItemId === supplier.uuid"
|
||||||
@@ -81,15 +81,20 @@
|
|||||||
|
|
||||||
<!-- Offers Tab -->
|
<!-- Offers Tab -->
|
||||||
<div v-else-if="activeTab === 'offers'" class="space-y-2">
|
<div v-else-if="activeTab === 'offers'" class="space-y-2">
|
||||||
<OfferCard
|
<OfferResultCard
|
||||||
v-for="offer in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid"
|
:key="offer.uuid ?? index"
|
||||||
:offer="offer"
|
:supplier-name="offer.supplierName"
|
||||||
selectable
|
:location-name="offer.locationName || offer.locationCountry"
|
||||||
:is-selected="selectedItemId === offer.uuid"
|
:product-name="offer.productName"
|
||||||
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
|
:stages="[]"
|
||||||
@select="$emit('select', offer, 'offer')"
|
@select="$emit('select', offer, 'offer')"
|
||||||
/>
|
/>
|
||||||
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
|
<div v-if="offersWithPrice.length === 0" class="text-center text-base-content/50 py-8">
|
||||||
{{ t('catalogMap.empty.offers') }}
|
{{ t('catalogMap.empty.offers') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,20 +103,63 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
interface Hub {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
countryCode?: string | null
|
||||||
|
distance?: string
|
||||||
|
transportTypes?: (string | null)[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
uuid?: string | null
|
||||||
|
teamUuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
countryCode?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
onTimeRate?: number | null
|
||||||
|
offersCount?: number | null
|
||||||
|
isVerified?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offer {
|
||||||
|
uuid?: string | null
|
||||||
|
productUuid?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
categoryName?: string | null
|
||||||
|
supplierName?: string | null
|
||||||
|
locationUuid?: string | null
|
||||||
|
locationName?: string | null
|
||||||
|
locationCountry?: string | null
|
||||||
|
locationCountryCode?: string | null
|
||||||
|
quantity?: number | string | null
|
||||||
|
unit?: string | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
status?: string | null
|
||||||
|
validUntil?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
activeTab: 'hubs' | 'suppliers' | 'offers'
|
activeTab: 'hubs' | 'suppliers' | 'offers'
|
||||||
hubs: any[]
|
hubs: Hub[]
|
||||||
suppliers: any[]
|
suppliers: Supplier[]
|
||||||
offers: any[]
|
offers: Offer[]
|
||||||
selectedItemId: string | null
|
selectedItemId: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
|
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
|
||||||
'select': [item: any, type: string]
|
'select': [item: Hub | Supplier | Offer, type: 'hub' | 'supplier' | 'offer']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const offersWithPrice = computed(() =>
|
||||||
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
import { LngLatBounds, Popup } 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 {
|
interface CurrentHub {
|
||||||
uuid: string
|
uuid: string
|
||||||
@@ -119,8 +119,8 @@ interface RouteGeometry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
autoEdges: EdgeType[]
|
autoEdges: Edge[]
|
||||||
railEdges: EdgeType[]
|
railEdges: Edge[]
|
||||||
hub: CurrentHub
|
hub: CurrentHub
|
||||||
railHub: CurrentHub
|
railHub: CurrentHub
|
||||||
autoRouteGeometries: RouteGeometry[]
|
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,
|
type: 'FeatureCollection' as const,
|
||||||
features: edges
|
features: edges
|
||||||
.filter(e => e.toLatitude && e.toLongitude)
|
.filter(e => e.toLatitude && e.toLongitude)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
import { LngLatBounds, Popup } 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 {
|
interface CurrentHub {
|
||||||
uuid: string
|
uuid: string
|
||||||
@@ -81,7 +81,7 @@ interface RouteGeometry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edges: EdgeType[]
|
edges: Edge[]
|
||||||
currentHub: CurrentHub
|
currentHub: CurrentHub
|
||||||
routeGeometries: RouteGeometry[]
|
routeGeometries: RouteGeometry[]
|
||||||
transportType: 'auto' | 'rail'
|
transportType: 'auto' | 'rail'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<MapboxMap
|
<MapboxMap
|
||||||
:key="mapId"
|
:key="mapId"
|
||||||
:map-id="mapId"
|
:map-id="mapId"
|
||||||
:style="`height: ${height}px; width: 100%;`"
|
:style="`height: ${heightValue}px; width: 100%;`"
|
||||||
class="rounded-lg border border-base-300"
|
class="rounded-lg border border-base-300"
|
||||||
:options="mapOptions"
|
:options="mapOptions"
|
||||||
@load="onMapCreated"
|
@load="onMapCreated"
|
||||||
@@ -26,16 +26,46 @@ import type { Map as MapboxMapType } from 'mapbox-gl'
|
|||||||
import { LngLatBounds, Popup } from 'mapbox-gl'
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
interface StageCompany {
|
||||||
stages: {
|
uuid?: string | null
|
||||||
type: Array,
|
name?: string | null
|
||||||
default: () => []
|
}
|
||||||
},
|
|
||||||
height: {
|
interface StageTrip {
|
||||||
type: Number,
|
uuid?: string | null
|
||||||
default: 400
|
company?: StageCompany | null
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
interface RouteStage {
|
||||||
|
uuid?: string | null
|
||||||
|
stageType?: string | null
|
||||||
|
sourceLatitude?: number | null
|
||||||
|
sourceLongitude?: number | null
|
||||||
|
sourceLocationName?: string | null
|
||||||
|
destinationLatitude?: number | null
|
||||||
|
destinationLongitude?: number | null
|
||||||
|
destinationLocationName?: string | null
|
||||||
|
locationLatitude?: number | null
|
||||||
|
locationLongitude?: number | null
|
||||||
|
locationName?: string | null
|
||||||
|
selectedCompany?: StageCompany | null
|
||||||
|
trips?: StageTrip[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoutePoint {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
companies: StageCompany[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
stages?: RouteStage[]
|
||||||
|
height?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const defaultHeight = 400
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const mapRef = ref<MapboxMapType | null>(null)
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
@@ -44,10 +74,12 @@ const didFitBounds = ref(false)
|
|||||||
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
const mapId = computed(() => `route-map-${instanceId}`)
|
const mapId = computed(() => `route-map-${instanceId}`)
|
||||||
|
|
||||||
const routePoints = computed(() => {
|
const heightValue = computed(() => props.height ?? defaultHeight)
|
||||||
const points: Array<{ id: string; name: string; lat: number; lng: number; companies: any[] }> = []
|
|
||||||
|
|
||||||
props.stages.forEach((stage: any) => {
|
const routePoints = computed(() => {
|
||||||
|
const points: RoutePoint[] = []
|
||||||
|
|
||||||
|
props.stages?.forEach((stage: RouteStage) => {
|
||||||
if (stage.stageType === 'transport') {
|
if (stage.stageType === 'transport') {
|
||||||
if (stage.sourceLatitude && stage.sourceLongitude) {
|
if (stage.sourceLatitude && stage.sourceLongitude) {
|
||||||
const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude)
|
const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude)
|
||||||
@@ -263,16 +295,16 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const getStageCompanies = (stage: any) => {
|
const getStageCompanies = (stage: RouteStage): StageCompany[] => {
|
||||||
const companies: any[] = []
|
const companies: StageCompany[] = []
|
||||||
|
|
||||||
if (stage.selectedCompany) {
|
if (stage.selectedCompany) {
|
||||||
companies.push(stage.selectedCompany)
|
companies.push(stage.selectedCompany)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueCompanies = new Set()
|
const uniqueCompanies = new Set<string>()
|
||||||
stage.trips?.forEach((trip: any) => {
|
stage.trips?.forEach((trip: StageTrip) => {
|
||||||
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) {
|
if (trip.company && trip.company.uuid && !uniqueCompanies.has(trip.company.uuid)) {
|
||||||
uniqueCompanies.add(trip.company.uuid)
|
uniqueCompanies.add(trip.company.uuid)
|
||||||
companies.push(trip.company)
|
companies.push(trip.company)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated'
|
import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const emit = defineEmits(['teamCreated', 'cancel'])
|
const emit = defineEmits(['teamCreated', 'cancel'])
|
||||||
|
|
||||||
const teamName = ref('')
|
const teamName = ref('')
|
||||||
@@ -93,9 +94,9 @@ const handleSubmit = async () => {
|
|||||||
emit('teamCreated', result.createTeam?.team)
|
emit('teamCreated', result.createTeam?.team)
|
||||||
teamName.value = ''
|
teamName.value = ''
|
||||||
teamType.value = 'BUYER'
|
teamType.value = 'BUYER'
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
error.value = err?.message || $t('teams.errors.create_failed')
|
error.value = err instanceof Error ? err.message : t('teams.errors.create_failed')
|
||||||
console.error('Error creating team:', err)
|
console.error('Error creating team:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|||||||
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>
|
||||||
178
app/components/catalog/AddressDetailBottomSheet.vue
Normal file
178
app/components/catalog/AddressDetailBottomSheet.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="address-slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen && addressUuid"
|
||||||
|
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-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sheet content -->
|
||||||
|
<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 border-b border-base-300 bg-base-100/90">
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<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="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="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="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<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 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="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
<!-- Location info -->
|
||||||
|
<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" />
|
||||||
|
<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-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-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="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" />
|
||||||
|
<span class="text-lg font-black">{{ t('profileAddresses.detail.map') }}</span>
|
||||||
|
</div>
|
||||||
|
<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/light-v11',
|
||||||
|
center: [address.longitude, address.latitude],
|
||||||
|
zoom: 14,
|
||||||
|
interactive: false
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<MapboxDefaultMarker
|
||||||
|
:marker-id="'address-marker'"
|
||||||
|
:lnglat="[address.longitude, address.latitude]"
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
</MapboxMap>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
|
||||||
|
<button class="btn btn-sm w-full btn-outline">
|
||||||
|
<Icon name="lucide:pencil" size="14" class="mr-2" />
|
||||||
|
{{ t('profileAddresses.actions.edit') }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm bg-error/20 border-error/30 text-error hover:bg-error/30"
|
||||||
|
@click="handleDelete"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:trash-2" size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="px-6 py-4 space-y-4">
|
||||||
|
<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>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
addressUuid: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close': []
|
||||||
|
'deleted': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const { items, isoToEmoji, deleteAddress } = useTeamAddresses()
|
||||||
|
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
|
const address = computed(() => {
|
||||||
|
if (!props.addressUuid) return null
|
||||||
|
return items.value.find(a => a.uuid === props.addressUuid) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!props.addressUuid) return
|
||||||
|
isDeleting.value = true
|
||||||
|
const success = await deleteAddress(props.addressUuid)
|
||||||
|
isDeleting.value = false
|
||||||
|
if (success) {
|
||||||
|
emit('deleted')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.address-slide-enter-active,
|
||||||
|
.address-slide-leave-active {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-slide-enter-from,
|
||||||
|
.address-slide-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-slide-enter-to,
|
||||||
|
.address-slide-leave-from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Map as MapboxMapType } from 'mapbox-gl'
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
import { LngLatBounds } 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 {
|
interface MapItem {
|
||||||
uuid: string
|
uuid?: string | null
|
||||||
name: string
|
name?: string | null
|
||||||
latitude: number
|
latitude?: number | null
|
||||||
longitude: number
|
longitude?: number | null
|
||||||
country?: string
|
country?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapBounds {
|
export interface MapBounds {
|
||||||
@@ -43,7 +43,8 @@ interface HoveredItem {
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
mapId: string
|
mapId: string
|
||||||
items?: MapItem[]
|
items?: MapItem[]
|
||||||
clusteredPoints?: ClusterPointType[]
|
clusteredPoints?: ClusterPoint[]
|
||||||
|
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPoint[]>>
|
||||||
useServerClustering?: boolean
|
useServerClustering?: boolean
|
||||||
hoveredItemId?: string | null
|
hoveredItemId?: string | null
|
||||||
hoveredItem?: HoveredItem | null
|
hoveredItem?: HoveredItem | null
|
||||||
@@ -51,6 +52,8 @@ const props = withDefaults(defineProps<{
|
|||||||
entityType?: 'offer' | 'hub' | 'supplier'
|
entityType?: 'offer' | 'hub' | 'supplier'
|
||||||
initialCenter?: [number, number]
|
initialCenter?: [number, number]
|
||||||
initialZoom?: number
|
initialZoom?: number
|
||||||
|
infoLoading?: boolean
|
||||||
|
fitPaddingLeft?: number
|
||||||
relatedPoints?: Array<{
|
relatedPoints?: Array<{
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
@@ -64,8 +67,11 @@ const props = withDefaults(defineProps<{
|
|||||||
initialCenter: () => [37.64, 55.76],
|
initialCenter: () => [37.64, 55.76],
|
||||||
initialZoom: 2,
|
initialZoom: 2,
|
||||||
useServerClustering: false,
|
useServerClustering: false,
|
||||||
|
infoLoading: false,
|
||||||
|
fitPaddingLeft: 0,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
clusteredPoints: () => [],
|
clusteredPoints: () => [],
|
||||||
|
clusteredPointsByType: undefined,
|
||||||
relatedPoints: () => []
|
relatedPoints: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,6 +85,21 @@ const { flyThroughSpace } = useMapboxFlyAnimation()
|
|||||||
const didFitBounds = ref(false)
|
const didFitBounds = ref(false)
|
||||||
const mapInitialized = ref(false)
|
const mapInitialized = ref(false)
|
||||||
|
|
||||||
|
const usesTypedClusters = computed(() => {
|
||||||
|
const typed = props.clusteredPointsByType
|
||||||
|
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
|
// Entity type icons - SVG data URLs with specific colors
|
||||||
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
|
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -128,6 +149,55 @@ const loadEntityIcon = async (map: MapboxMapType, type: 'offer' | 'hub' | 'suppl
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard colors for entity types
|
||||||
|
const ENTITY_COLORS = {
|
||||||
|
hub: '#22c55e', // green
|
||||||
|
supplier: '#3b82f6', // blue
|
||||||
|
offer: '#f97316' // orange
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const CLUSTER_TYPES: Array<'offer' | 'hub' | 'supplier'> = ['offer', 'hub', 'supplier']
|
||||||
|
|
||||||
|
// Load all icons for related points (each type with its standard color)
|
||||||
|
const loadRelatedPointIcons = async (map: MapboxMapType) => {
|
||||||
|
const types: Array<'hub' | 'supplier' | 'offer'> = ['hub', 'supplier', 'offer']
|
||||||
|
for (const type of types) {
|
||||||
|
const iconName = `related-icon-${type}`
|
||||||
|
if (map.hasImage(iconName)) {
|
||||||
|
map.removeImage(iconName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = createEntityIcon(type, ENTITY_COLORS[type])
|
||||||
|
const img = new Image(32, 32)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 32
|
||||||
|
canvas.height = 32
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(16, 16, 15, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = ENTITY_COLORS[type]
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'white'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.drawImage(img, 4, 4, 24, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageData = ctx?.getImageData(0, 0, 32, 32)
|
||||||
|
if (imageData) {
|
||||||
|
map.addImage(iconName, { width: 32, height: 32, data: imageData.data })
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mapOptions = computed(() => ({
|
const mapOptions = computed(() => ({
|
||||||
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||||
center: props.initialCenter,
|
center: props.initialCenter,
|
||||||
@@ -139,11 +209,13 @@ const mapOptions = computed(() => ({
|
|||||||
// Client-side clustering GeoJSON (when not using server clustering)
|
// Client-side clustering GeoJSON (when not using server clustering)
|
||||||
const geoJsonData = computed(() => ({
|
const geoJsonData = computed(() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: props.items.map(item => ({
|
features: props.items
|
||||||
type: 'Feature' as const,
|
.filter(item => item.latitude != null && item.longitude != null)
|
||||||
properties: { uuid: item.uuid, name: item.name, country: item.country },
|
.map(item => ({
|
||||||
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] }
|
type: 'Feature' as const,
|
||||||
}))
|
properties: { uuid: item.uuid, name: item.name, country: item.country },
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [item.longitude!, item.latitude!] }
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Server-side clustering GeoJSON
|
// Server-side clustering GeoJSON
|
||||||
@@ -165,6 +237,33 @@ const serverClusteredGeoJson = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const serverClusteredGeoJsonByType = computed(() => {
|
||||||
|
const build = (points: ClusterPoint[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: (points || []).filter(Boolean).map(point => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: point!.id,
|
||||||
|
name: point!.name,
|
||||||
|
count: point!.count ?? 1,
|
||||||
|
expansionZoom: point!.expansionZoom,
|
||||||
|
isCluster: (point!.count ?? 1) > 1,
|
||||||
|
type
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [point!.longitude ?? 0, point!.latitude ?? 0]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
offer: build(props.clusteredPointsByType?.offer, 'offer'),
|
||||||
|
hub: build(props.clusteredPointsByType?.hub, 'hub'),
|
||||||
|
supplier: build(props.clusteredPointsByType?.supplier, 'supplier')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Hovered point GeoJSON (separate layer on top)
|
// Hovered point GeoJSON (separate layer on top)
|
||||||
const hoveredPointGeoJson = computed(() => ({
|
const hoveredPointGeoJson = computed(() => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
@@ -205,6 +304,13 @@ const sourceId = computed(() => `${props.mapId}-points`)
|
|||||||
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
|
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
|
||||||
const relatedSourceId = computed(() => `${props.mapId}-related`)
|
const relatedSourceId = computed(() => `${props.mapId}-related`)
|
||||||
|
|
||||||
|
const getServerSourceId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}`
|
||||||
|
const getServerClusterLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-clusters`
|
||||||
|
const getServerClusterCountLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-cluster-count`
|
||||||
|
const getServerPointLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-points`
|
||||||
|
const getServerPointLabelLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-point-labels`
|
||||||
|
|
||||||
|
|
||||||
const emitBoundsChange = (map: MapboxMapType) => {
|
const emitBoundsChange = (map: MapboxMapType) => {
|
||||||
const bounds = map.getBounds()
|
const bounds = map.getBounds()
|
||||||
if (!bounds) return
|
if (!bounds) return
|
||||||
@@ -228,7 +334,11 @@ const onMapCreated = (map: MapboxMapType) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (props.useServerClustering) {
|
if (props.useServerClustering) {
|
||||||
await initServerClusteringLayers(map)
|
if (usesTypedClusters.value) {
|
||||||
|
await initServerClusteringLayersByType(map)
|
||||||
|
} else {
|
||||||
|
await initServerClusteringLayers(map)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await initClientClusteringLayers(map)
|
await initClientClusteringLayers(map)
|
||||||
}
|
}
|
||||||
@@ -372,27 +482,28 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Related points layer (for Info mode - colored by type)
|
// Related points layer (for Info mode - icons by type)
|
||||||
|
await loadRelatedPointIcons(map)
|
||||||
|
|
||||||
map.addSource(relatedSourceId.value, {
|
map.addSource(relatedSourceId.value, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: relatedPointsGeoJson.value
|
data: relatedPointsGeoJson.value
|
||||||
})
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: `${props.mapId}-related-circles`,
|
id: `${props.mapId}-related-points`,
|
||||||
type: 'circle',
|
type: 'symbol',
|
||||||
source: relatedSourceId.value,
|
source: relatedSourceId.value,
|
||||||
paint: {
|
layout: {
|
||||||
'circle-radius': 8,
|
'icon-image': [
|
||||||
'circle-color': [
|
|
||||||
'match',
|
'match',
|
||||||
['get', 'type'],
|
['get', 'type'],
|
||||||
'hub', '#22c55e', // green
|
'hub', 'related-icon-hub',
|
||||||
'supplier', '#3b82f6', // blue
|
'supplier', 'related-icon-supplier',
|
||||||
'offer', '#f97316', // orange
|
'offer', 'related-icon-offer',
|
||||||
'#06b6d4' // default cyan
|
'related-icon-offer' // default
|
||||||
],
|
],
|
||||||
'circle-stroke-width': 2,
|
'icon-size': 1,
|
||||||
'circle-stroke-color': '#ffffff'
|
'icon-allow-overlap': true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -403,7 +514,7 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
'text-field': ['get', 'name'],
|
'text-field': ['get', 'name'],
|
||||||
'text-size': 11,
|
'text-size': 11,
|
||||||
'text-anchor': 'top',
|
'text-anchor': 'top',
|
||||||
'text-offset': [0, 1.2]
|
'text-offset': [0, 1.5]
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-color': '#ffffff',
|
'text-color': '#ffffff',
|
||||||
@@ -413,19 +524,19 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Click handlers for related points
|
// Click handlers for related points
|
||||||
map.on('click', `${props.mapId}-related-circles`, (e) => {
|
map.on('click', `${props.mapId}-related-points`, (e) => {
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
|
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
|
||||||
const feature = features[0]
|
const feature = features[0]
|
||||||
if (!feature) return
|
if (!feature) return
|
||||||
const props_data = feature.properties as Record<string, any> | undefined
|
const props_data = feature.properties as Record<string, any> | undefined
|
||||||
emit('select-item', props_data?.uuid, props_data)
|
emit('select-item', props_data?.uuid, props_data)
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
|
map.on('mouseenter', `${props.mapId}-related-points`, () => {
|
||||||
map.getCanvas().style.cursor = 'pointer'
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
|
map.on('mouseleave', `${props.mapId}-related-points`, () => {
|
||||||
map.getCanvas().style.cursor = ''
|
map.getCanvas().style.cursor = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -433,9 +544,11 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
if (!didFitBounds.value && props.items.length > 0) {
|
if (!didFitBounds.value && props.items.length > 0) {
|
||||||
const bounds = new LngLatBounds()
|
const bounds = new LngLatBounds()
|
||||||
props.items.forEach(item => {
|
props.items.forEach(item => {
|
||||||
bounds.extend([item.longitude, item.latitude])
|
if (item.longitude != null && item.latitude != null) {
|
||||||
|
bounds.extend([item.longitude, item.latitude])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
map.fitBounds(bounds, { padding: 50, maxZoom: 10 })
|
map.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 10 })
|
||||||
didFitBounds.value = true
|
didFitBounds.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,27 +677,28 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Related points layer (for Info mode - colored by type)
|
// Related points layer (for Info mode - icons by type)
|
||||||
|
await loadRelatedPointIcons(map)
|
||||||
|
|
||||||
map.addSource(relatedSourceId.value, {
|
map.addSource(relatedSourceId.value, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: relatedPointsGeoJson.value
|
data: relatedPointsGeoJson.value
|
||||||
})
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: `${props.mapId}-related-circles`,
|
id: `${props.mapId}-related-points`,
|
||||||
type: 'circle',
|
type: 'symbol',
|
||||||
source: relatedSourceId.value,
|
source: relatedSourceId.value,
|
||||||
paint: {
|
layout: {
|
||||||
'circle-radius': 8,
|
'icon-image': [
|
||||||
'circle-color': [
|
|
||||||
'match',
|
'match',
|
||||||
['get', 'type'],
|
['get', 'type'],
|
||||||
'hub', '#22c55e', // green
|
'hub', 'related-icon-hub',
|
||||||
'supplier', '#3b82f6', // blue
|
'supplier', 'related-icon-supplier',
|
||||||
'offer', '#f97316', // orange
|
'offer', 'related-icon-offer',
|
||||||
'#06b6d4' // default cyan
|
'related-icon-offer' // default
|
||||||
],
|
],
|
||||||
'circle-stroke-width': 2,
|
'icon-size': 1,
|
||||||
'circle-stroke-color': '#ffffff'
|
'icon-allow-overlap': true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -595,7 +709,7 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
'text-field': ['get', 'name'],
|
'text-field': ['get', 'name'],
|
||||||
'text-size': 11,
|
'text-size': 11,
|
||||||
'text-anchor': 'top',
|
'text-anchor': 'top',
|
||||||
'text-offset': [0, 1.2]
|
'text-offset': [0, 1.5]
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-color': '#ffffff',
|
'text-color': '#ffffff',
|
||||||
@@ -605,19 +719,203 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Click handlers for related points
|
// Click handlers for related points
|
||||||
map.on('click', `${props.mapId}-related-circles`, (e) => {
|
map.on('click', `${props.mapId}-related-points`, (e) => {
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
|
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
|
||||||
const feature = features[0]
|
const feature = features[0]
|
||||||
if (!feature) return
|
if (!feature) return
|
||||||
const props_data = feature.properties as Record<string, any> | undefined
|
const props_data = feature.properties as Record<string, any> | undefined
|
||||||
emit('select-item', props_data?.uuid, props_data)
|
emit('select-item', props_data?.uuid, props_data)
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
|
map.on('mouseenter', `${props.mapId}-related-points`, () => {
|
||||||
map.getCanvas().style.cursor = 'pointer'
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
|
map.on('mouseleave', `${props.mapId}-related-points`, () => {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initServerClusteringLayersByType = async (map: MapboxMapType) => {
|
||||||
|
for (const type of CLUSTER_TYPES) {
|
||||||
|
await loadEntityIcon(map, type, ENTITY_COLORS[type])
|
||||||
|
|
||||||
|
const sourceIdByType = getServerSourceId(type)
|
||||||
|
map.addSource(sourceIdByType, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: serverClusteredGeoJsonByType.value[type]
|
||||||
|
})
|
||||||
|
|
||||||
|
const clusterLayerId = getServerClusterLayerId(type)
|
||||||
|
const clusterCountLayerId = getServerClusterCountLayerId(type)
|
||||||
|
const pointLayerId = getServerPointLayerId(type)
|
||||||
|
const pointLabelLayerId = getServerPointLabelLayerId(type)
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: clusterLayerId,
|
||||||
|
type: 'circle',
|
||||||
|
source: sourceIdByType,
|
||||||
|
filter: ['>', ['get', 'count'], 1],
|
||||||
|
paint: {
|
||||||
|
'circle-color': ENTITY_COLORS[type],
|
||||||
|
'circle-radius': ['step', ['get', 'count'], 20, 10, 30, 50, 40],
|
||||||
|
'circle-opacity': 0.8,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
},
|
||||||
|
layout: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: clusterCountLayerId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: sourceIdByType,
|
||||||
|
filter: ['>', ['get', 'count'], 1],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'count'],
|
||||||
|
'text-size': 14
|
||||||
|
},
|
||||||
|
paint: { 'text-color': '#ffffff' }
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: pointLayerId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: sourceIdByType,
|
||||||
|
filter: ['==', ['get', 'count'], 1],
|
||||||
|
layout: {
|
||||||
|
'icon-image': `entity-icon-${type}`,
|
||||||
|
'icon-size': 1,
|
||||||
|
'icon-allow-overlap': true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: pointLabelLayerId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: sourceIdByType,
|
||||||
|
filter: ['==', ['get', 'count'], 1],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-offset': [0, 1.8],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': '#000000',
|
||||||
|
'text-halo-width': 1.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', clusterLayerId, (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId] })
|
||||||
|
const feature = features[0]
|
||||||
|
if (!feature) return
|
||||||
|
const expansionZoom = feature.properties?.expansionZoom
|
||||||
|
const geometry = feature.geometry as GeoJSON.Point
|
||||||
|
map.easeTo({
|
||||||
|
center: geometry.coordinates as [number, number],
|
||||||
|
zoom: expansionZoom || map.getZoom() + 2
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', pointLayerId, (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: [pointLayerId] })
|
||||||
|
const feature = features[0]
|
||||||
|
if (!feature) return
|
||||||
|
const props_data = feature.properties as Record<string, any> | undefined
|
||||||
|
emit('select-item', props_data?.id, props_data)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', clusterLayerId, () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', clusterLayerId, () => { map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mouseenter', pointLayerId, () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', pointLayerId, () => { map.getCanvas().style.cursor = '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hovered point layer (on top of everything)
|
||||||
|
map.addSource(hoveredSourceId.value, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: hoveredPointGeoJson.value
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'hovered-point-ring',
|
||||||
|
type: 'circle',
|
||||||
|
source: hoveredSourceId.value,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 20,
|
||||||
|
'circle-color': 'transparent',
|
||||||
|
'circle-stroke-width': 3,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'hovered-point-layer',
|
||||||
|
type: 'circle',
|
||||||
|
source: hoveredSourceId.value,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 14,
|
||||||
|
'circle-color': props.pointColor,
|
||||||
|
'circle-stroke-width': 3,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Related points layer
|
||||||
|
await loadRelatedPointIcons(map)
|
||||||
|
|
||||||
|
map.addSource(relatedSourceId.value, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: relatedPointsGeoJson.value
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: `${props.mapId}-related-points`,
|
||||||
|
type: 'symbol',
|
||||||
|
source: relatedSourceId.value,
|
||||||
|
layout: {
|
||||||
|
'icon-image': [
|
||||||
|
'match',
|
||||||
|
['get', 'type'],
|
||||||
|
'hub', 'related-icon-hub',
|
||||||
|
'supplier', 'related-icon-supplier',
|
||||||
|
'offer', 'related-icon-offer',
|
||||||
|
'related-icon-offer'
|
||||||
|
],
|
||||||
|
'icon-size': 1,
|
||||||
|
'icon-allow-overlap': true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: `${props.mapId}-related-labels`,
|
||||||
|
type: 'symbol',
|
||||||
|
source: relatedSourceId.value,
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-size': 11,
|
||||||
|
'text-anchor': 'top',
|
||||||
|
'text-offset': [0, 1.5]
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': '#000000',
|
||||||
|
'text-halo-width': 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', `${props.mapId}-related-points`, (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
|
||||||
|
const feature = features[0]
|
||||||
|
if (!feature) return
|
||||||
|
const props_data = feature.properties as Record<string, any> | undefined
|
||||||
|
emit('select-item', props_data?.uuid, props_data)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', `${props.mapId}-related-points`, () => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseleave', `${props.mapId}-related-points`, () => {
|
||||||
map.getCanvas().style.cursor = ''
|
map.getCanvas().style.cursor = ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -625,12 +923,25 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
// Update map data when items or clusteredPoints change
|
// Update map data when items or clusteredPoints change
|
||||||
watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => {
|
watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => {
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
|
if (usesTypedClusters.value) return
|
||||||
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
|
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(newData)
|
source.setData(newData)
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => serverClusteredGeoJsonByType.value, (newData) => {
|
||||||
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
|
if (!usesTypedClusters.value) return
|
||||||
|
for (const type of CLUSTER_TYPES) {
|
||||||
|
const sourceIdByType = getServerSourceId(type)
|
||||||
|
const source = mapRef.value.getSource(sourceIdByType) as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (source) {
|
||||||
|
source.setData(newData[type])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// Update hovered point layer when hoveredItem changes
|
// Update hovered point layer when hoveredItem changes
|
||||||
watch(() => props.hoveredItem, () => {
|
watch(() => props.hoveredItem, () => {
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
@@ -643,12 +954,31 @@ watch(() => props.hoveredItem, () => {
|
|||||||
// Update related points layer when relatedPoints changes
|
// Update related points layer when relatedPoints changes
|
||||||
watch(() => props.relatedPoints, () => {
|
watch(() => props.relatedPoints, () => {
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
|
|
||||||
|
// Update the source data immediately
|
||||||
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
|
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(relatedPointsGeoJson.value)
|
source.setData(relatedPointsGeoJson.value)
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// no visibility toggling; layers are data-driven by query
|
||||||
|
|
||||||
|
// Fit bounds when info loading finishes (all related data loaded)
|
||||||
|
watch(() => props.infoLoading, (loading, wasLoading) => {
|
||||||
|
// Only fit bounds when loading changes from true to false (data finished loading)
|
||||||
|
if (wasLoading && !loading && props.relatedPoints && props.relatedPoints.length > 0) {
|
||||||
|
if (!mapRef.value) return
|
||||||
|
const bounds = new LngLatBounds()
|
||||||
|
props.relatedPoints.forEach(p => {
|
||||||
|
bounds.extend([p.longitude, p.latitude])
|
||||||
|
})
|
||||||
|
if (!bounds.isEmpty()) {
|
||||||
|
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for pointColor or entityType changes - update colors and icons
|
// Watch for pointColor or entityType changes - update colors and icons
|
||||||
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
|
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
@@ -659,10 +989,12 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
|
|||||||
|
|
||||||
// Update cluster circle colors
|
// Update cluster circle colors
|
||||||
if (props.useServerClustering) {
|
if (props.useServerClustering) {
|
||||||
|
if (usesTypedClusters.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (map.getLayer('server-clusters')) {
|
if (map.getLayer('server-clusters')) {
|
||||||
map.setPaintProperty('server-clusters', 'circle-color', newColor)
|
map.setPaintProperty('server-clusters', 'circle-color', newColor)
|
||||||
}
|
}
|
||||||
// Update icon reference for points
|
|
||||||
if (map.getLayer('server-points')) {
|
if (map.getLayer('server-points')) {
|
||||||
map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`)
|
map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`)
|
||||||
}
|
}
|
||||||
@@ -683,6 +1015,7 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
|
|||||||
|
|
||||||
// fitBounds for server clustering when first data arrives
|
// fitBounds for server clustering when first data arrives
|
||||||
watch(() => props.clusteredPoints, (points) => {
|
watch(() => props.clusteredPoints, (points) => {
|
||||||
|
if (usesTypedClusters.value) return
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
if (!didFitBounds.value && points && points.length > 0) {
|
if (!didFitBounds.value && points && points.length > 0) {
|
||||||
const bounds = new LngLatBounds()
|
const bounds = new LngLatBounds()
|
||||||
@@ -692,12 +1025,31 @@ watch(() => props.clusteredPoints, (points) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!bounds.isEmpty()) {
|
if (!bounds.isEmpty()) {
|
||||||
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 })
|
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
|
||||||
didFitBounds.value = true
|
didFitBounds.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => props.clusteredPointsByType, () => {
|
||||||
|
if (!usesTypedClusters.value) return
|
||||||
|
if (!mapRef.value || !mapInitialized.value) return
|
||||||
|
if (didFitBounds.value) return
|
||||||
|
|
||||||
|
const bounds = new LngLatBounds()
|
||||||
|
CLUSTER_TYPES.forEach(type => {
|
||||||
|
const points = serverClusteredGeoJsonByType.value[type]?.features ?? []
|
||||||
|
points.forEach((p) => {
|
||||||
|
const coords = (p.geometry as GeoJSON.Point).coordinates as [number, number]
|
||||||
|
bounds.extend(coords)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!bounds.isEmpty()) {
|
||||||
|
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
// Expose flyTo method for external use (with space fly animation)
|
// Expose flyTo method for external use (with space fly animation)
|
||||||
const flyTo = async (lat: number, lng: number, zoom = 8) => {
|
const flyTo = async (lat: number, lng: number, zoom = 8) => {
|
||||||
if (!mapRef.value) return
|
if (!mapRef.value) return
|
||||||
|
|||||||
@@ -37,16 +37,20 @@
|
|||||||
|
|
||||||
<!-- Offers Tab -->
|
<!-- Offers Tab -->
|
||||||
<template v-if="activeTab === 'offers'">
|
<template v-if="activeTab === 'offers'">
|
||||||
<OfferCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:offer="offer"
|
:supplier-name="offer.supplierName"
|
||||||
selectable
|
:location-name="offer.locationName"
|
||||||
compact
|
:product-name="offer.productName || offer.title || undefined"
|
||||||
:is-selected="selectedId === offer.uuid"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
|
:stages="[]"
|
||||||
@select="selectOffer(offer)"
|
@select="selectOffer(offer)"
|
||||||
/>
|
/>
|
||||||
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
<Text v-if="offersWithPrice.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||||
{{ t('catalogMap.empty.offers') }}
|
{{ t('catalogMap.empty.offers') }}
|
||||||
</Text>
|
</Text>
|
||||||
</template>
|
</template>
|
||||||
@@ -82,11 +86,16 @@ interface Hub {
|
|||||||
interface Offer {
|
interface Offer {
|
||||||
uuid?: string | null
|
uuid?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
|
productName?: string | null
|
||||||
locationName?: string | null
|
locationName?: string | null
|
||||||
|
supplierName?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
latitude?: number | null
|
latitude?: number | null
|
||||||
longitude?: number | null
|
longitude?: number | null
|
||||||
lines?: any[] | null
|
quantity?: number | string | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
@@ -114,9 +123,13 @@ const selectedId = ref<string | null>(null)
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const offersWithPrice = computed(() =>
|
||||||
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
|
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
|
||||||
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: props.offers.length },
|
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: offersWithPrice.value.length },
|
||||||
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
|
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -13,23 +13,30 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
<OfferCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offers"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:offer="offer"
|
:supplier-name="offer.supplierName"
|
||||||
|
: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="[]"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
||||||
<Text tone="muted">
|
<Text tone="muted">
|
||||||
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
|
{{ t('common.pagination.showing', { shown: offersWithPrice.length, total: totalOffers }) }}
|
||||||
</Text>
|
</Text>
|
||||||
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||||
{{ t('common.actions.load_more') }}
|
{{ t('common.actions.load_more') }}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack v-if="offers.length === 0" align="center" gap="2">
|
<Stack v-if="offersWithPrice.length === 0" align="center" gap="2">
|
||||||
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -46,9 +53,14 @@ interface Offer {
|
|||||||
uuid?: string | null
|
uuid?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
locationName?: string | null
|
locationName?: string | null
|
||||||
|
supplierName?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
validUntil?: string | null
|
validUntil?: string | null
|
||||||
lines?: (OfferLine | null)[] | null
|
lines?: (OfferLine | null)[] | null
|
||||||
|
quantity?: number | string | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -61,7 +73,10 @@ const props = defineProps<{
|
|||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const totalOffers = computed(() => props.total ?? props.offers.length)
|
const offersWithPrice = computed(() =>
|
||||||
|
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
||||||
|
)
|
||||||
|
const totalOffers = computed(() => props.total ?? offersWithPrice.value.length)
|
||||||
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
props.onLoadMore?.()
|
props.onLoadMore?.()
|
||||||
|
|||||||
@@ -61,11 +61,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface SelectedItem {
|
interface SelectedItem {
|
||||||
uuid: string
|
uuid: string
|
||||||
name?: string
|
name?: string | null
|
||||||
country?: string
|
country?: string | null
|
||||||
latitude?: number | null
|
latitude?: number | null
|
||||||
longitude?: number | null
|
longitude?: number | null
|
||||||
[key: string]: any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -16,16 +16,28 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<!-- Title -->
|
<!-- Title + distance/compass -->
|
||||||
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
|
<div class="flex items-start justify-between gap-2">
|
||||||
<!-- Country left, distance right -->
|
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</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-base-content/10 bg-base-200/40 flex items-center justify-center">
|
||||||
|
<Icon
|
||||||
|
name="lucide:arrow-up"
|
||||||
|
size="12"
|
||||||
|
class="text-base-content/60"
|
||||||
|
:style="{ transform: `rotate(${bearing}deg)` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Country -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Text tone="muted" size="sm">
|
<Text tone="muted" size="sm">
|
||||||
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
|
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
|
||||||
</Text>
|
</Text>
|
||||||
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
|
|
||||||
{{ hub.distance }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Transport icons bottom -->
|
<!-- Transport icons bottom -->
|
||||||
<div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1">
|
<div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1">
|
||||||
@@ -47,12 +59,16 @@ interface Hub {
|
|||||||
name?: string | null
|
name?: string | null
|
||||||
country?: string | null
|
country?: string | null
|
||||||
countryCode?: string | null
|
countryCode?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
distance?: string
|
distance?: string
|
||||||
transportTypes?: string[] | null
|
distanceKm?: number | null
|
||||||
|
transportTypes?: (string | null)[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hub: Hub
|
hub: Hub
|
||||||
|
origin?: { latitude: number; longitude: number } | null
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
linkTo?: string
|
linkTo?: string
|
||||||
@@ -81,5 +97,33 @@ const countryFlag = computed(() => {
|
|||||||
return '🌍'
|
return '🌍'
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasTransport = (type: string) => props.hub.transportTypes?.includes(type)
|
const hasTransport = (type: string) => props.hub.transportTypes?.some(t => t === type)
|
||||||
|
const distanceLabel = computed(() => {
|
||||||
|
if (props.hub.distance) return props.hub.distance
|
||||||
|
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRadians = (deg: number) => (deg * Math.PI) / 180
|
||||||
|
const toDegrees = (rad: number) => (rad * 180) / Math.PI
|
||||||
|
|
||||||
|
const bearing = computed(() => {
|
||||||
|
const origin = props.origin
|
||||||
|
const lat2 = props.hub.latitude
|
||||||
|
const lon2 = props.hub.longitude
|
||||||
|
if (!origin || lat2 == null || lon2 == null) return null
|
||||||
|
const lat1 = origin.latitude
|
||||||
|
const lon1 = origin.longitude
|
||||||
|
if (lat1 == null || lon1 == null) return null
|
||||||
|
|
||||||
|
const φ1 = toRadians(lat1)
|
||||||
|
const φ2 = toRadians(lat2)
|
||||||
|
const Δλ = toRadians(lon2 - lon1)
|
||||||
|
|
||||||
|
const y = Math.sin(Δλ) * Math.cos(φ2)
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
|
||||||
|
const θ = Math.atan2(y, x)
|
||||||
|
const deg = (toDegrees(θ) + 360) % 360
|
||||||
|
return deg
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card
|
|
||||||
padding="md"
|
|
||||||
interactive
|
|
||||||
class="cursor-pointer overflow-hidden"
|
|
||||||
:class="{ 'bg-base-200': selected }"
|
|
||||||
@click="$emit('select')"
|
|
||||||
>
|
|
||||||
<div class="relative min-h-14">
|
|
||||||
<!-- Sparkline chart background -->
|
|
||||||
<div v-if="priceHistory.length > 1" class="absolute inset-0 opacity-15">
|
|
||||||
<ClientOnly>
|
|
||||||
<apexchart
|
|
||||||
type="area"
|
|
||||||
height="56"
|
|
||||||
:options="chartOptions"
|
|
||||||
:series="chartSeries"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="relative z-10">
|
|
||||||
<Text weight="semibold" size="sm" class="mb-1">{{ name }}</Text>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Text v-if="currentPrice" size="sm" class="text-primary font-bold">
|
|
||||||
{{ formattedPrice }}
|
|
||||||
</Text>
|
|
||||||
<span
|
|
||||||
v-if="trend !== 0"
|
|
||||||
class="text-xs font-medium"
|
|
||||||
:class="trend > 0 ? 'text-success' : 'text-error'"
|
|
||||||
>
|
|
||||||
{{ trend > 0 ? '↑' : '↓' }} {{ Math.abs(trend) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
name: string
|
|
||||||
currentPrice?: number | null
|
|
||||||
currency?: string | null
|
|
||||||
priceHistory?: number[]
|
|
||||||
selected?: boolean
|
|
||||||
}>(), {
|
|
||||||
priceHistory: () => [],
|
|
||||||
selected: false
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
select: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const formattedPrice = computed(() => {
|
|
||||||
if (!props.currentPrice) return ''
|
|
||||||
const symbol = getCurrencySymbol(props.currency)
|
|
||||||
return `${symbol}${props.currentPrice.toLocaleString()}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const getCurrencySymbol = (currency?: string | null) => {
|
|
||||||
switch (currency?.toUpperCase()) {
|
|
||||||
case 'USD': return '$'
|
|
||||||
case 'EUR': return '€'
|
|
||||||
case 'RUB': return '₽'
|
|
||||||
case 'CNY': return '¥'
|
|
||||||
default: return '$'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate trend from price history
|
|
||||||
const trend = computed(() => {
|
|
||||||
if (props.priceHistory.length < 2) return 0
|
|
||||||
const first = props.priceHistory[0]
|
|
||||||
const last = props.priceHistory[props.priceHistory.length - 1]
|
|
||||||
if (!first || first === 0 || !last) return 0
|
|
||||||
return Math.round(((last - first) / first) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Chart configuration
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
chart: {
|
|
||||||
type: 'area',
|
|
||||||
sparkline: { enabled: true },
|
|
||||||
animations: { enabled: false }
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
curve: 'smooth',
|
|
||||||
width: 2
|
|
||||||
},
|
|
||||||
fill: {
|
|
||||||
type: 'gradient',
|
|
||||||
gradient: {
|
|
||||||
shadeIntensity: 1,
|
|
||||||
opacityFrom: 0.4,
|
|
||||||
opacityTo: 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
|
|
||||||
tooltip: { enabled: false },
|
|
||||||
xaxis: { labels: { show: false } },
|
|
||||||
yaxis: { labels: { show: false } }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const chartSeries = computed(() => [{
|
|
||||||
name: 'Price',
|
|
||||||
data: props.priceHistory.length > 0 ? props.priceHistory : [0]
|
|
||||||
}])
|
|
||||||
</script>
|
|
||||||
@@ -12,9 +12,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
|
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
<div class="flex items-center gap-2">
|
||||||
<Icon name="lucide:x" size="16" />
|
<button
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,19 +52,65 @@
|
|||||||
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
|
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Supplier link for offer -->
|
<!-- Supplier for offer (clickable name) -->
|
||||||
<button
|
<button
|
||||||
v-if="entityType === 'offer' && entity?.teamUuid"
|
v-if="entityType === 'offer' && entity?.teamUuid"
|
||||||
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
|
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
|
||||||
@click="emit('open-info', 'supplier', entity.teamUuid)"
|
@click="emit('open-info', 'supplier', entity.teamUuid)"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:factory" size="14" />
|
<Icon name="lucide:factory" size="14" />
|
||||||
{{ entity.teamName || $t('catalog.info.viewSupplier') }}
|
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
|
||||||
|
<span v-else>{{ supplierDisplayName || $t('catalog.info.supplier') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Products Section (for hub/supplier) -->
|
<!-- KYC Teaser Section (for supplier) -->
|
||||||
<section v-if="entityType === 'hub' || entityType === 'supplier'">
|
<section v-if="entityType === 'supplier' && kycTeaser" class="bg-white/5 rounded-lg p-3">
|
||||||
|
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:shield-check" size="16" />
|
||||||
|
{{ $t('catalog.info.kycTeaser') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
|
<!-- Company Type -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/60">{{ $t('catalog.info.companyType') }}</span>
|
||||||
|
<span class="text-white">{{ kycTeaser.companyType }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration Year -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/60">{{ $t('catalog.info.registrationYear') }}</span>
|
||||||
|
<span class="text-white">{{ kycTeaser.registrationYear }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/60">{{ $t('catalog.info.status') }}</span>
|
||||||
|
<span :class="kycTeaser.isActive ? 'text-success' : 'text-error'">
|
||||||
|
{{ kycTeaser.isActive ? $t('catalog.info.active') : $t('catalog.info.inactive') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources Count -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-white/60">{{ $t('catalog.info.sourcesCount') }}</span>
|
||||||
|
<span class="text-white">{{ kycTeaser.sourcesCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Full Profile Button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-primary mt-3 w-full"
|
||||||
|
@click="emit('open-kyc', kycProfileUuid)"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:external-link" size="14" />
|
||||||
|
{{ $t('catalog.info.viewFullKyc') }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Products Section (for hub/supplier) - hide when product selected -->
|
||||||
|
<section v-if="(entityType === 'hub' || entityType === 'supplier') && !selectedProduct">
|
||||||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||||||
<Icon name="lucide:package" size="16" />
|
<Icon name="lucide:package" size="16" />
|
||||||
{{ productsSectionTitle }}
|
{{ productsSectionTitle }}
|
||||||
@@ -65,19 +122,71 @@
|
|||||||
{{ $t('catalog.empty.noProducts') }}
|
{{ $t('catalog.empty.noProducts') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
|
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
|
||||||
<ProductCard
|
<div
|
||||||
v-for="product in relatedProducts"
|
v-for="(product, index) in relatedProducts"
|
||||||
:key="product.uuid"
|
:key="product.uuid ?? index"
|
||||||
:product="product"
|
class="relative group"
|
||||||
compact
|
>
|
||||||
selectable
|
<ProductCard
|
||||||
@select="onProductSelect(product)"
|
: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>
|
||||||
|
|
||||||
|
<!-- Offers Section (after product selected) -->
|
||||||
|
<section v-if="(entityType === 'hub' || entityType === 'supplier') && selectedProduct">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:shopping-bag" size="16" />
|
||||||
|
{{ $t('catalog.headers.offers') }}
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-white/50 text-sm py-2">
|
||||||
|
{{ $t('catalog.empty.noOffers') }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
||||||
|
<OfferResultCard
|
||||||
|
v-for="(offer, index) in offersWithPrice"
|
||||||
|
:key="offer.uuid ?? index"
|
||||||
|
: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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Suppliers Section (for hub only) -->
|
<!-- 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">
|
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||||||
<Icon name="lucide:factory" size="16" />
|
<Icon name="lucide:factory" size="16" />
|
||||||
{{ $t('catalog.info.suppliersNearby') }}
|
{{ $t('catalog.info.suppliersNearby') }}
|
||||||
@@ -89,13 +198,25 @@
|
|||||||
{{ $t('catalog.info.noSuppliers') }}
|
{{ $t('catalog.info.noSuppliers') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
|
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
|
||||||
<SupplierCard
|
<div
|
||||||
v-for="supplier in relatedSuppliers"
|
v-for="(supplier, index) in relatedSuppliers"
|
||||||
:key="supplier.uuid"
|
:key="supplier.uuid ?? index"
|
||||||
:supplier="supplier"
|
class="relative group"
|
||||||
selectable
|
>
|
||||||
@select="onSupplierSelect(supplier)"
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -111,22 +232,75 @@
|
|||||||
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
|
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
|
||||||
{{ $t('catalog.info.noHubs') }}
|
{{ $t('catalog.info.noHubs') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingHubs" class="flex flex-col gap-2">
|
<div v-else-if="!loadingHubs" class="space-y-4">
|
||||||
<HubCard
|
<template v-if="railHubs.length">
|
||||||
v-for="hub in relatedHubs"
|
<div class="grid grid-cols-2 gap-2">
|
||||||
:key="hub.uuid"
|
<Card padding="small" class="border border-white/10 bg-white/5">
|
||||||
:hub="hub"
|
<div class="flex items-center gap-2">
|
||||||
selectable
|
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@select="onHubSelect(hub)"
|
<Icon name="lucide:train-front" size="16" class="text-white/80" />
|
||||||
/>
|
</div>
|
||||||
|
<div class="text-sm text-white/80">{{ $t('catalog.info.railHubs') }}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div
|
||||||
|
v-for="(hub, index) in railHubs"
|
||||||
|
:key="hub.uuid ?? index"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template v-if="seaHubs.length">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Card padding="small" class="border border-white/10 bg-white/5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:ship" size="16" class="text-white/80" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-white/80">{{ $t('catalog.info.seaHubs') }}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div
|
||||||
|
v-for="(hub, index) in seaHubs"
|
||||||
|
:key="hub.uuid ?? index"
|
||||||
|
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>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,15 +308,23 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InfoEntityType } from '~/composables/useCatalogSearch'
|
import type { InfoEntityType } from '~/composables/useCatalogSearch'
|
||||||
|
import type {
|
||||||
|
InfoEntity,
|
||||||
|
InfoProductItem,
|
||||||
|
InfoHubItem,
|
||||||
|
InfoSupplierItem,
|
||||||
|
InfoOfferItem
|
||||||
|
} from '~/composables/useCatalogInfo'
|
||||||
|
import type { RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entityType: InfoEntityType
|
entityType: InfoEntityType
|
||||||
entityId: string
|
entityId: string
|
||||||
entity: any
|
entity: InfoEntity | null
|
||||||
relatedProducts?: any[]
|
relatedProducts?: InfoProductItem[]
|
||||||
relatedHubs?: any[]
|
relatedHubs?: InfoHubItem[]
|
||||||
relatedSuppliers?: any[]
|
relatedSuppliers?: InfoSupplierItem[]
|
||||||
relatedOffers?: any[]
|
relatedOffers?: InfoOfferItem[]
|
||||||
selectedProduct?: string | null
|
selectedProduct?: string | null
|
||||||
currentTab?: string
|
currentTab?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
@@ -154,10 +336,12 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'close': []
|
'close': []
|
||||||
'add-to-filter': []
|
|
||||||
'open-info': [type: InfoEntityType, uuid: string]
|
'open-info': [type: InfoEntityType, uuid: string]
|
||||||
'select-product': [uuid: string | null]
|
'select-product': [uuid: string | null]
|
||||||
|
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
|
||||||
'update:current-tab': [tab: string]
|
'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()
|
const { t } = useI18n()
|
||||||
@@ -167,6 +351,37 @@ const { entityColors } = useCatalogSearch()
|
|||||||
const relatedProducts = computed(() => props.relatedProducts ?? [])
|
const relatedProducts = computed(() => props.relatedProducts ?? [])
|
||||||
const relatedHubs = computed(() => props.relatedHubs ?? [])
|
const relatedHubs = computed(() => props.relatedHubs ?? [])
|
||||||
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
|
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
|
||||||
|
const relatedOffers = computed(() => props.relatedOffers ?? [])
|
||||||
|
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
|
// Entity name
|
||||||
const entityName = computed(() => {
|
const entityName = computed(() => {
|
||||||
@@ -180,6 +395,13 @@ const entityLocation = computed(() => {
|
|||||||
return parts.length > 0 ? parts.join(', ') : null
|
return parts.length > 0 ? parts.join(', ') : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const originCoords = computed(() => {
|
||||||
|
const lat = props.entity?.locationLatitude ?? props.entity?.latitude
|
||||||
|
const lon = props.entity?.locationLongitude ?? props.entity?.longitude
|
||||||
|
if (lat == null || lon == null) return null
|
||||||
|
return { latitude: Number(lat), longitude: Number(lon) }
|
||||||
|
})
|
||||||
|
|
||||||
// Products section title based on entity type
|
// Products section title based on entity type
|
||||||
const productsSectionTitle = computed(() => {
|
const productsSectionTitle = computed(() => {
|
||||||
return props.entityType === 'hub'
|
return props.entityType === 'hub'
|
||||||
@@ -202,29 +424,81 @@ const entityIcon = computed(() => {
|
|||||||
return 'lucide:info'
|
return 'lucide:info'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Supplier name for offer (from entity or relatedSuppliers)
|
||||||
|
const supplierDisplayName = computed(() => {
|
||||||
|
if (props.entity?.supplierName) return props.entity.supplierName
|
||||||
|
if (props.entity?.teamName) return props.entity.teamName
|
||||||
|
if (relatedSuppliers.value.length > 0 && relatedSuppliers.value[0]?.name) {
|
||||||
|
return relatedSuppliers.value[0].name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
// Format price
|
// Format price
|
||||||
const formatPrice = (price: number | string) => {
|
const formatPrice = (price: number | string) => {
|
||||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||||
return new Intl.NumberFormat('ru-RU').format(num)
|
return new Intl.NumberFormat('ru-RU').format(num)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const railHubs = computed(() =>
|
||||||
|
relatedHubs.value.filter(h => h.transportTypes?.includes('rail'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const seaHubs = computed(() =>
|
||||||
|
relatedHubs.value.filter(h => h.transportTypes?.includes('sea'))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mock KYC teaser data (will be replaced with real data later)
|
||||||
|
const kycTeaser = computed(() => {
|
||||||
|
if (props.entityType !== 'supplier') return null
|
||||||
|
// Mock data for now
|
||||||
|
return {
|
||||||
|
companyType: 'ООО',
|
||||||
|
registrationYear: 2018,
|
||||||
|
isActive: true,
|
||||||
|
sourcesCount: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// KYC Profile UUID - use real if available, otherwise mock for demo
|
||||||
|
const MOCK_KYC_UUID = 'demo-kyc-profile'
|
||||||
|
const kycProfileUuid = computed(() => {
|
||||||
|
return props.entity?.kycProfileUuid || MOCK_KYC_UUID
|
||||||
|
})
|
||||||
|
|
||||||
// Handlers for selecting related items
|
// Handlers for selecting related items
|
||||||
const onProductSelect = (product: any) => {
|
const onProductSelect = (product: InfoProductItem) => {
|
||||||
if (product.uuid) {
|
emit('select-product', product.uuid)
|
||||||
// Navigate to offer info for this product
|
}
|
||||||
emit('select-product', product.uuid)
|
|
||||||
|
const onOfferSelect = (offer: InfoOfferItem) => {
|
||||||
|
if (offer.uuid) {
|
||||||
|
emit('select-offer', { uuid: offer.uuid, productUuid: offer.productUuid })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onHubSelect = (hub: any) => {
|
const onHubSelect = (hub: InfoHubItem) => {
|
||||||
if (hub.uuid) {
|
if (hub.uuid) {
|
||||||
emit('open-info', 'hub', hub.uuid)
|
emit('open-info', 'hub', hub.uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSupplierSelect = (supplier: any) => {
|
const onSupplierSelect = (supplier: InfoSupplierItem) => {
|
||||||
if (supplier.uuid) {
|
if (supplier.uuid) {
|
||||||
emit('open-info', 'supplier', supplier.uuid)
|
emit('open-info', 'supplier', supplier.uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOfferStages = (offer: InfoOfferItem) => {
|
||||||
|
const route = offer.routes?.[0]
|
||||||
|
if (!route?.stages) return []
|
||||||
|
return route.stages
|
||||||
|
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||||
|
.map(stage => ({
|
||||||
|
transportType: stage.transportType,
|
||||||
|
distanceKm: stage.distanceKm,
|
||||||
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName
|
||||||
|
}))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
304
app/components/catalog/KycBottomSheet.vue
Normal file
304
app/components/catalog/KycBottomSheet.vue
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="kyc-slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
||||||
|
style="height: 70vh"
|
||||||
|
>
|
||||||
|
<!-- Backdrop (clickable to close) -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 -top-[30vh] bg-black/30"
|
||||||
|
@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">
|
||||||
|
<!-- 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="flex justify-center py-2">
|
||||||
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between px-6 pb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-primary/20 rounded-xl flex items-center justify-center">
|
||||||
|
<Icon name="lucide:building-2" size="24" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="bold" size="lg" class="text-white">{{ companyName }}</Text>
|
||||||
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
|
<span class="badge badge-success badge-sm">{{ $t('catalog.info.active') }}</span>
|
||||||
|
<span class="badge badge-outline badge-sm text-white/60">{{ companyType }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
||||||
|
<Icon name="lucide:x" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Реквизиты -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:file-text" size="18" />
|
||||||
|
Реквизиты
|
||||||
|
</Text>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="xs" class="text-white/50">ИНН</Text>
|
||||||
|
<Text class="text-white font-mono">{{ inn }}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="xs" class="text-white/50">КПП</Text>
|
||||||
|
<Text class="text-white font-mono">{{ kpp }}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="xs" class="text-white/50">ОГРН</Text>
|
||||||
|
<Text class="text-white font-mono">{{ ogrn }}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="xs" class="text-white/50">Год регистрации</Text>
|
||||||
|
<Text class="text-white">{{ registrationYear }}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Руководство -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:user-cog" size="18" />
|
||||||
|
Руководство
|
||||||
|
</Text>
|
||||||
|
<div class="flex items-center gap-3 p-2 bg-white/5 rounded-lg">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-9 h-9 rounded-full bg-primary text-primary-content text-sm">
|
||||||
|
<span>{{ directorInitials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="medium" size="sm" class="text-white">{{ directorName }}</Text>
|
||||||
|
<Text size="xs" class="text-white/50">Генеральный директор</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Учредители -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:users" size="18" />
|
||||||
|
Учредители
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(founder, i) in founders"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center justify-between p-2 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-secondary text-secondary-content text-xs">
|
||||||
|
<span>{{ founder.initials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" class="text-white">{{ founder.name }}</Text>
|
||||||
|
<Text size="xs" class="text-white/50">Физ. лицо</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary badge-sm">{{ founder.share }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-white/10 flex justify-between">
|
||||||
|
<Text size="xs" class="text-white/50">Уставный капитал</Text>
|
||||||
|
<Text weight="semibold" size="sm" class="text-white">{{ authorizedCapital }}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Контакты -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:contact" size="18" />
|
||||||
|
Контакты
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:map-pin" size="14" class="text-white/50" />
|
||||||
|
<span>{{ address }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:phone" size="14" class="text-white/50" />
|
||||||
|
<span>{{ phone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:mail" size="14" class="text-white/50" />
|
||||||
|
<span>{{ email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Финансы -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:bar-chart-3" size="18" />
|
||||||
|
Финансы (2024)
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<Text size="xs" class="text-white/50">Выручка</Text>
|
||||||
|
<Text size="xs" class="text-success">↑ 15%</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="bold" class="text-white">{{ revenue }}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<Text size="xs" class="text-white/50">Чистая прибыль</Text>
|
||||||
|
<Text size="xs" class="text-success">↑ 23%</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="bold" class="text-white">{{ profit }}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="pt-2 border-t border-white/10 flex justify-between">
|
||||||
|
<Text size="xs" class="text-white/50">Сотрудников</Text>
|
||||||
|
<Text weight="medium" size="sm" class="text-white">{{ employees }}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Арбитраж -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:scale" size="18" />
|
||||||
|
Арбитражные дела
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-warning badge-xs">Истец</span>
|
||||||
|
<Text class="text-white/80">{{ arbitration.plaintiff.count }} дела</Text>
|
||||||
|
</div>
|
||||||
|
<Text class="text-white">{{ arbitration.plaintiff.amount }}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-error badge-xs">Ответчик</span>
|
||||||
|
<Text class="text-white/80">{{ arbitration.defendant.count }} дело</Text>
|
||||||
|
</div>
|
||||||
|
<Text class="text-white">{{ arbitration.defendant.amount }}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ОКВЭД (full width) -->
|
||||||
|
<div class="mt-4 bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:briefcase" size="18" />
|
||||||
|
Виды деятельности (ОКВЭД)
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-2 p-2 bg-primary/10 rounded-lg border border-primary/20">
|
||||||
|
<span class="badge badge-primary badge-xs mt-0.5">Осн.</span>
|
||||||
|
<Text size="sm" class="text-white">{{ mainActivity }}</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(activity, i) in additionalActivities"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 p-2 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<span class="badge badge-ghost badge-xs mt-0.5 text-white/50">Доп.</span>
|
||||||
|
<Text size="sm" class="text-white/80">{{ activity }}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources footer -->
|
||||||
|
<div class="mt-4 flex items-center justify-between text-xs text-white/40 px-1">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Icon name="lucide:database" size="12" />
|
||||||
|
Источники: ЕГРЮЛ, ФНС, Росстат
|
||||||
|
</span>
|
||||||
|
<span>Обновлено: {{ lastUpdated }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo notice -->
|
||||||
|
<div class="mt-4 alert bg-info/20 border border-info/30 text-info text-sm">
|
||||||
|
<Icon name="lucide:info" size="16" />
|
||||||
|
<span>{{ $t('kyc.demo.notice') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
uuid: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Demo data (will be replaced with real data from API)
|
||||||
|
const isDemo = computed(() => props.uuid === 'demo-kyc-profile')
|
||||||
|
|
||||||
|
const companyName = computed(() => isDemo.value ? 'ООО "АГРОТОРГ ПЛЮС"' : 'Загрузка...')
|
||||||
|
const companyType = computed(() => 'ООО')
|
||||||
|
const inn = computed(() => '7707456789')
|
||||||
|
const kpp = computed(() => '770701001')
|
||||||
|
const ogrn = computed(() => '1157746123456')
|
||||||
|
const registrationYear = computed(() => '2015')
|
||||||
|
const directorName = computed(() => 'Петров Сергей Александрович')
|
||||||
|
const directorInitials = computed(() => 'ПС')
|
||||||
|
const founders = computed(() => [
|
||||||
|
{ name: 'Петров Сергей Александрович', initials: 'ПС', share: 60 },
|
||||||
|
{ name: 'Иванова Анна Петровна', initials: 'ИА', share: 40 }
|
||||||
|
])
|
||||||
|
const authorizedCapital = computed(() => '500 000 ₽')
|
||||||
|
const address = computed(() => 'г. Москва, ул. Складская, д. 15, оф. 301')
|
||||||
|
const phone = computed(() => '+7 (495) 123-45-67')
|
||||||
|
const email = computed(() => 'info@agrotorg-plus.ru')
|
||||||
|
const revenue = computed(() => '245 800 000 ₽')
|
||||||
|
const profit = computed(() => '18 450 000 ₽')
|
||||||
|
const employees = computed(() => '47 человек')
|
||||||
|
const arbitration = computed(() => ({
|
||||||
|
plaintiff: { count: 3, amount: '1 250 000 ₽' },
|
||||||
|
defendant: { count: 1, amount: '320 000 ₽' }
|
||||||
|
}))
|
||||||
|
const mainActivity = computed(() => '46.21 - Торговля оптовая зерном, семенами и кормами')
|
||||||
|
const additionalActivities = computed(() => [
|
||||||
|
'46.11 - Деятельность агентов по оптовой торговле',
|
||||||
|
'52.10 - Деятельность по складированию и хранению'
|
||||||
|
])
|
||||||
|
const lastUpdated = computed(() => new Date().toLocaleDateString('ru-RU'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kyc-slide-enter-active,
|
||||||
|
.kyc-slide-leave-active {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kyc-slide-enter-from,
|
||||||
|
.kyc-slide-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kyc-slide-enter-to,
|
||||||
|
.kyc-slide-leave-from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component
|
|
||||||
:is="linkable ? NuxtLink : 'div'"
|
|
||||||
:to="linkable ? localePath(`/catalog/offers/detail/${offer.uuid}`) : undefined"
|
|
||||||
class="block"
|
|
||||||
:class="{ 'cursor-pointer': selectable }"
|
|
||||||
@click="selectable && $emit('select')"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
padding="small"
|
|
||||||
:interactive="linkable || selectable"
|
|
||||||
:class="[
|
|
||||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<!-- Product title -->
|
|
||||||
<Text size="base" weight="semibold" class="truncate">{{ offer.productName }}</Text>
|
|
||||||
<!-- Quantity -->
|
|
||||||
<div v-if="offer.quantity" class="flex">
|
|
||||||
<span class="badge badge-neutral badge-dash text-xs">
|
|
||||||
{{ t('catalogOfferCard.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Price -->
|
|
||||||
<div v-if="offer.pricePerUnit" class="font-semibold text-primary text-sm">
|
|
||||||
{{ formatPrice(offer.pricePerUnit, offer.currency) }}/{{ displayUnit }}
|
|
||||||
</div>
|
|
||||||
<!-- Country below -->
|
|
||||||
<Text v-if="!compact" tone="muted" size="sm">
|
|
||||||
{{ countryFlag }} {{ offer.locationCountry || offer.locationName || t('catalogOfferCard.labels.country_unknown') }}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { NuxtLink } from '#components'
|
|
||||||
|
|
||||||
interface Offer {
|
|
||||||
uuid?: string | null
|
|
||||||
// Product
|
|
||||||
productUuid?: string | null
|
|
||||||
productName?: string | null
|
|
||||||
categoryName?: string | null
|
|
||||||
// Location
|
|
||||||
locationUuid?: string | null
|
|
||||||
locationName?: string | null
|
|
||||||
locationCountry?: string | null
|
|
||||||
locationCountryCode?: string | null
|
|
||||||
// Price
|
|
||||||
quantity?: number | string | null
|
|
||||||
unit?: string | null
|
|
||||||
pricePerUnit?: number | string | null
|
|
||||||
currency?: string | null
|
|
||||||
// Misc
|
|
||||||
status?: string | null
|
|
||||||
validUntil?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
offer: Offer
|
|
||||||
selectable?: boolean
|
|
||||||
isSelected?: boolean
|
|
||||||
compact?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
(e: 'select'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const linkable = computed(() => !props.selectable && !!props.offer.uuid)
|
|
||||||
|
|
||||||
const formattedDate = computed(() => {
|
|
||||||
if (!props.offer.validUntil) return ''
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('ru', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
}).format(new Date(props.offer.validUntil))
|
|
||||||
} catch {
|
|
||||||
return props.offer.validUntil
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatPrice = (price: number | string | null | undefined, currency: string | null | undefined) => {
|
|
||||||
if (!price) return ''
|
|
||||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
|
||||||
const curr = currency || 'USD'
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat('ru', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: curr,
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
}).format(num)
|
|
||||||
} catch {
|
|
||||||
return `${num} ${curr}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISO code to emoji flag
|
|
||||||
const isoToEmoji = (code: string): string => {
|
|
||||||
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const countryFlag = computed(() => {
|
|
||||||
if (props.offer.locationCountryCode) {
|
|
||||||
return isoToEmoji(props.offer.locationCountryCode)
|
|
||||||
}
|
|
||||||
return '🌍'
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayUnit = computed(() => props.offer.unit || t('catalogOfferCard.labels.default_unit'))
|
|
||||||
</script>
|
|
||||||
@@ -1,58 +1,116 @@
|
|||||||
<template>
|
<template>
|
||||||
<Card padding="md" interactive @click="$emit('select')">
|
<Card padding="md" interactive :class="groupClass" @click="$emit('select')">
|
||||||
<!-- Header: Location + Price -->
|
<!-- Header: Supplier + Price -->
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div class="flex flex-col gap-1">
|
||||||
<Text weight="semibold">{{ locationName || 'Локация' }}</Text>
|
<div class="flex items-center gap-2">
|
||||||
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
|
<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>
|
</div>
|
||||||
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
|
|
||||||
{{ priceDisplay }}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Supplier info -->
|
<!-- Supplier info -->
|
||||||
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
|
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
|
||||||
|
|
||||||
<!-- Route stepper -->
|
<!-- Route lines -->
|
||||||
<RouteStepper
|
<div v-if="routeRows.length" class="mt-3 pt-2 border-t border-base-200/60">
|
||||||
v-if="stages.length > 0"
|
<div v-for="(row, index) in routeRows" :key="index" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
:stages="stages"
|
<Icon :name="row.icon" size="14" class="text-base-content/60" />
|
||||||
:start-name="startName"
|
<span>{{ row.distanceLabel }}</span>
|
||||||
:end-name="endName"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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<{
|
const props = withDefaults(defineProps<{
|
||||||
locationName?: string
|
locationName?: string
|
||||||
|
supplierName?: string
|
||||||
productName?: string
|
productName?: string
|
||||||
pricePerUnit?: number | null
|
pricePerUnit?: number | null
|
||||||
|
quantity?: number | string | null
|
||||||
currency?: string | null
|
currency?: string | null
|
||||||
unit?: string | null
|
unit?: string | null
|
||||||
stages?: RouteStage[]
|
stages?: RouteStage[]
|
||||||
startName?: string
|
totalTimeSeconds?: number | null
|
||||||
endName?: string
|
|
||||||
kycProfileUuid?: string | null
|
kycProfileUuid?: string | null
|
||||||
|
grouped?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
stages: () => []
|
stages: () => [],
|
||||||
|
grouped: false
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: []
|
select: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const supplierDisplay = computed(() => {
|
||||||
|
return props.supplierName || t('catalogOfferCard.labels.supplier_unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
const originDisplay = computed(() => {
|
||||||
|
const fromStage = props.stages?.find(stage => stage?.fromName)?.fromName
|
||||||
|
return props.locationName || fromStage || t('catalogOfferCard.labels.origin_unknown')
|
||||||
|
})
|
||||||
|
|
||||||
const priceDisplay = computed(() => {
|
const priceDisplay = computed(() => {
|
||||||
if (!props.pricePerUnit) return null
|
if (props.pricePerUnit == null) return null
|
||||||
const currSymbol = getCurrencySymbol(props.currency)
|
const currSymbol = getCurrencySymbol(props.currency)
|
||||||
const unitName = getUnitName(props.unit)
|
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}`
|
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) => {
|
const getCurrencySymbol = (currency?: string | null) => {
|
||||||
switch (currency?.toUpperCase()) {
|
switch (currency?.toUpperCase()) {
|
||||||
case 'USD': return '$'
|
case 'USD': return '$'
|
||||||
@@ -66,14 +124,104 @@ const getCurrencySymbol = (currency?: string | null) => {
|
|||||||
const getUnitName = (unit?: string | null) => {
|
const getUnitName = (unit?: string | null) => {
|
||||||
switch (unit?.toLowerCase()) {
|
switch (unit?.toLowerCase()) {
|
||||||
case 'т':
|
case 'т':
|
||||||
|
case 't':
|
||||||
case 'ton':
|
case 'ton':
|
||||||
case 'tonne':
|
case 'tonne':
|
||||||
return 'тонна'
|
return t('catalogOfferCard.labels.default_unit')
|
||||||
case 'кг':
|
case 'кг':
|
||||||
case 'kg':
|
case 'kg':
|
||||||
return 'кг'
|
return t('catalogOfferCard.labels.unit_kg')
|
||||||
default:
|
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>
|
</script>
|
||||||
|
|||||||
391
app/components/catalog/OrderDetailBottomSheet.vue
Normal file
391
app/components/catalog/OrderDetailBottomSheet.vue
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="order-slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen && orderUuid"
|
||||||
|
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-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sheet content -->
|
||||||
|
<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 border-b border-base-300 bg-base-100/90">
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<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-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="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="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">{{ order.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-3 flex-1">
|
||||||
|
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<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 flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||||
|
<Icon name="lucide:x" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-if="hasOrderError" class="px-6 py-8 text-center">
|
||||||
|
<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="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="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="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" />
|
||||||
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(stage, idx) in orderStageItems"
|
||||||
|
:key="stage.key || idx"
|
||||||
|
class="flex gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<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 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="mt-1 text-xs text-base-content/50">
|
||||||
|
{{ stage.meta.join(' · ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<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" />
|
||||||
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<GanttTimeline
|
||||||
|
:stages="order.stages"
|
||||||
|
:showLoading="true"
|
||||||
|
:showUnloading="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map preview (small) -->
|
||||||
|
<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" />
|
||||||
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||||
|
</div>
|
||||||
|
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="px-6 py-4 space-y-4">
|
||||||
|
<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>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
|
||||||
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
||||||
|
|
||||||
|
// Types from GraphQL
|
||||||
|
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
|
||||||
|
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
|
||||||
|
type CompanyType = NonNullable<StageType['selectedCompany']>
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
orderUuid: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const order = ref<OrderType | null>(null)
|
||||||
|
const isLoadingOrder = ref(false)
|
||||||
|
const hasOrderError = ref(false)
|
||||||
|
const orderError = ref('')
|
||||||
|
|
||||||
|
// Load order when uuid changes
|
||||||
|
watch(() => props.orderUuid, async (uuid) => {
|
||||||
|
if (uuid && props.isOpen) {
|
||||||
|
await loadOrder()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Also load when opening
|
||||||
|
watch(() => props.isOpen, async (open) => {
|
||||||
|
if (open && props.orderUuid) {
|
||||||
|
await loadOrder()
|
||||||
|
} else if (!open) {
|
||||||
|
// Reset state when closing
|
||||||
|
order.value = null
|
||||||
|
hasOrderError.value = false
|
||||||
|
orderError.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderTitle = computed(() => {
|
||||||
|
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
||||||
|
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
||||||
|
return `${source} → ${destination}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderMeta = computed(() => {
|
||||||
|
const meta: string[] = []
|
||||||
|
|
||||||
|
const line = order.value?.orderLines?.[0]
|
||||||
|
if (line?.quantity) {
|
||||||
|
meta.push(`${line.quantity} ${line.unit || t('ordersDetail.labels.unit_tons')}`)
|
||||||
|
}
|
||||||
|
if (line?.productName) {
|
||||||
|
meta.push(line.productName)
|
||||||
|
}
|
||||||
|
if (order.value?.totalAmount) {
|
||||||
|
meta.push(formatPrice(order.value.totalAmount, order.value?.currency))
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationDays = getOrderDuration()
|
||||||
|
if (durationDays) {
|
||||||
|
meta.push(`${durationDays} ${t('ordersDetail.labels.delivery_days')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderRoutesForMap = computed(() => {
|
||||||
|
const stages = (order.value?.stages || [])
|
||||||
|
.filter((stage): stage is StageType => stage !== null)
|
||||||
|
.map((stage) => {
|
||||||
|
if (stage.stageType === 'transport') {
|
||||||
|
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
|
||||||
|
return {
|
||||||
|
fromLat: stage.sourceLatitude,
|
||||||
|
fromLon: stage.sourceLongitude,
|
||||||
|
fromName: stage.sourceLocationName,
|
||||||
|
toLat: stage.destinationLatitude,
|
||||||
|
toLon: stage.destinationLongitude,
|
||||||
|
toName: stage.destinationLocationName,
|
||||||
|
transportType: stage.transportType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!stages.length) return []
|
||||||
|
return [{ stages }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Company summary type
|
||||||
|
interface CompanySummary {
|
||||||
|
name: string | null | undefined
|
||||||
|
totalWeight: number
|
||||||
|
tripsCount: number
|
||||||
|
company: CompanyType | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderStageItems = computed<RouteStageItem[]>(() => {
|
||||||
|
return (order.value?.stages || [])
|
||||||
|
.filter((stage): stage is StageType => stage !== null)
|
||||||
|
.map((stage) => {
|
||||||
|
const isTransport = stage.stageType === 'transport'
|
||||||
|
const from = isTransport ? stage.sourceLocationName : stage.locationName
|
||||||
|
const to = isTransport ? stage.destinationLocationName : stage.locationName
|
||||||
|
|
||||||
|
const meta: string[] = []
|
||||||
|
const dateRange = getStageDateRange(stage)
|
||||||
|
if (dateRange) {
|
||||||
|
meta.push(dateRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = getCompaniesSummary(stage)
|
||||||
|
companies.forEach((company: CompanySummary) => {
|
||||||
|
meta.push(
|
||||||
|
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: stage.uuid ?? undefined,
|
||||||
|
from: from ?? undefined,
|
||||||
|
to: to ?? undefined,
|
||||||
|
label: stage.name ?? undefined,
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadOrder = async () => {
|
||||||
|
if (!props.orderUuid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoadingOrder.value = true
|
||||||
|
hasOrderError.value = false
|
||||||
|
const { data, error: orderErrorResp } = await useServerQuery('order-detail-sheet', GetOrderDocument, { orderUuid: props.orderUuid }, 'team', 'orders')
|
||||||
|
if (orderErrorResp.value) throw orderErrorResp.value
|
||||||
|
order.value = data.value?.getOrder ?? null
|
||||||
|
} catch (err: unknown) {
|
||||||
|
hasOrderError.value = true
|
||||||
|
orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
|
||||||
|
} finally {
|
||||||
|
isLoadingOrder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number, currency?: string | null) => {
|
||||||
|
if (!price) return t('ordersDetail.labels.price_zero')
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'RUB',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
|
||||||
|
const companies: CompanySummary[] = []
|
||||||
|
if (stage.stageType === 'service' && stage.selectedCompany) {
|
||||||
|
companies.push({
|
||||||
|
name: stage.selectedCompany.name,
|
||||||
|
totalWeight: 0,
|
||||||
|
tripsCount: 0,
|
||||||
|
company: stage.selectedCompany
|
||||||
|
})
|
||||||
|
return companies
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.stageType === 'transport' && stage.trips?.length) {
|
||||||
|
const companiesMap = new Map<string, CompanySummary>()
|
||||||
|
stage.trips.forEach((trip) => {
|
||||||
|
if (!trip) return
|
||||||
|
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
|
||||||
|
const weight = trip.plannedWeight || 0
|
||||||
|
if (companiesMap.has(companyName)) {
|
||||||
|
const existing = companiesMap.get(companyName)!
|
||||||
|
existing.totalWeight += weight
|
||||||
|
existing.tripsCount += 1
|
||||||
|
} else {
|
||||||
|
companiesMap.set(companyName, {
|
||||||
|
name: companyName,
|
||||||
|
totalWeight: weight,
|
||||||
|
tripsCount: 1,
|
||||||
|
company: trip.company
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(companiesMap.values())
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOrderDuration = () => {
|
||||||
|
if (!order.value?.stages?.length) return 0
|
||||||
|
let minDate: Date | null = null
|
||||||
|
let maxDate: Date | null = null
|
||||||
|
order.value.stages.forEach((stage) => {
|
||||||
|
if (!stage) return
|
||||||
|
stage.trips?.forEach((trip) => {
|
||||||
|
if (!trip) return
|
||||||
|
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
|
||||||
|
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
|
||||||
|
if (!minDate || startDate < minDate) minDate = startDate
|
||||||
|
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!minDate || !maxDate) return 0
|
||||||
|
const diffTime = Math.abs((maxDate as Date).getTime() - (minDate as Date).getTime())
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStageDateRange = (stage: StageType) => {
|
||||||
|
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
|
||||||
|
let minDate: Date | null = null
|
||||||
|
let maxDate: Date | null = null
|
||||||
|
stage.trips.forEach((trip) => {
|
||||||
|
if (!trip) return
|
||||||
|
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
|
||||||
|
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
|
||||||
|
if (!minDate || startDate < minDate) minDate = startDate
|
||||||
|
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||||
|
})
|
||||||
|
if (!minDate || !maxDate) return t('ordersDetail.labels.dates_undefined')
|
||||||
|
const formatDate = (date: Date) => date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||||
|
if ((minDate as Date).toDateString() === (maxDate as Date).toDateString()) return formatDate(minDate as Date)
|
||||||
|
return `${formatDate(minDate as Date)} - ${formatDate(maxDate as Date)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.order-slide-enter-active,
|
||||||
|
.order-slide-leave-active {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-slide-enter-from,
|
||||||
|
.order-slide-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-slide-enter-to,
|
||||||
|
.order-slide-leave-from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-20 h-10">
|
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Line } from 'vue-chartjs'
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
LinearScale,
|
|
||||||
CategoryScale,
|
|
||||||
Filler
|
|
||||||
} from 'chart.js'
|
|
||||||
|
|
||||||
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler)
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: number[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isUptrend = computed(() => {
|
|
||||||
if (props.data.length < 2) return true
|
|
||||||
const last = props.data[props.data.length - 1]
|
|
||||||
const first = props.data[0]
|
|
||||||
return (last ?? 0) >= (first ?? 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const lineColor = computed(() => isUptrend.value ? '#22c55e' : '#ef4444')
|
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
|
||||||
labels: props.data.map((_, i) => i.toString()),
|
|
||||||
datasets: [{
|
|
||||||
data: props.data,
|
|
||||||
borderColor: lineColor.value,
|
|
||||||
backgroundColor: `${lineColor.value}20`,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
pointRadius: 0
|
|
||||||
}]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const chartOptions = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: { enabled: false }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: { display: false },
|
|
||||||
y: { display: false }
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
line: { borderCapStyle: 'round' as const }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -9,17 +9,59 @@
|
|||||||
<Card
|
<Card
|
||||||
padding="sm"
|
padding="sm"
|
||||||
:interactive="linkable || selectable"
|
:interactive="linkable || selectable"
|
||||||
|
class="relative overflow-hidden"
|
||||||
:class="[
|
:class="[
|
||||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Stack gap="2">
|
<!-- Sparkline background -->
|
||||||
<Text size="base" weight="semibold">{{ product.name }}</Text>
|
<div v-if="effectivePriceHistory.length > 1" class="absolute inset-0 opacity-15">
|
||||||
<Text v-if="product.offersCount" tone="muted" size="sm">
|
<ClientOnly>
|
||||||
{{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }}
|
<apexchart
|
||||||
</Text>
|
type="area"
|
||||||
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
|
height="100%"
|
||||||
</Stack>
|
:options="chartOptions"
|
||||||
|
:series="chartSeries"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Product icon -->
|
||||||
|
<div class="w-10 h-10 shrink-0 bg-primary/10 text-primary rounded-lg flex items-center justify-center">
|
||||||
|
<Icon name="lucide:package" size="20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<Text size="base" weight="semibold" class="truncate">{{ product.name }}</Text>
|
||||||
|
<span
|
||||||
|
v-if="trend !== 0"
|
||||||
|
class="text-xs font-medium shrink-0"
|
||||||
|
:class="trend > 0 ? 'text-success' : 'text-error'"
|
||||||
|
>
|
||||||
|
{{ trend > 0 ? '↑' : '↓' }} {{ Math.abs(trend) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Text v-if="product.offersCount" tone="muted" size="sm">
|
||||||
|
{{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }}
|
||||||
|
</Text>
|
||||||
|
<Text v-if="effectivePrice" size="sm" class="text-primary font-bold shrink-0">
|
||||||
|
{{ formattedPrice }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text v-if="product.description && !compact" tone="muted" size="sm" class="mt-1">
|
||||||
|
{{ product.description }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,12 +76,17 @@ interface Product {
|
|||||||
offersCount?: number | null
|
offersCount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
product: Product
|
product: Product
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
priceHistory?: number[]
|
||||||
|
currentPrice?: number | null
|
||||||
|
currency?: string | null
|
||||||
|
}>(), {
|
||||||
|
priceHistory: () => []
|
||||||
|
})
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'select'): void
|
(e: 'select'): void
|
||||||
@@ -49,4 +96,83 @@ const localePath = useLocalePath()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const linkable = computed(() => !props.selectable && !!props.product.uuid)
|
const linkable = computed(() => !props.selectable && !!props.product.uuid)
|
||||||
|
|
||||||
|
// Generate mock price history based on uuid for consistency
|
||||||
|
const effectivePriceHistory = computed(() => {
|
||||||
|
if (props.priceHistory && props.priceHistory.length > 0) {
|
||||||
|
return props.priceHistory
|
||||||
|
}
|
||||||
|
if (!props.product.uuid) return []
|
||||||
|
const seed = props.product.uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
|
const basePrice = 100 + (seed % 200)
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const variation = Math.sin(seed + i * 0.5) * 20 + Math.cos(seed * 0.3 + i) * 10
|
||||||
|
return Math.round(basePrice + variation)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Effective price - use provided or last from history
|
||||||
|
const effectivePrice = computed(() => {
|
||||||
|
if (props.currentPrice) return props.currentPrice
|
||||||
|
if (effectivePriceHistory.value.length > 0) {
|
||||||
|
return effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Price formatting
|
||||||
|
const formattedPrice = computed(() => {
|
||||||
|
if (!effectivePrice.value) return ''
|
||||||
|
const symbol = getCurrencySymbol(props.currency)
|
||||||
|
return `${symbol}${effectivePrice.value.toLocaleString()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCurrencySymbol = (currency?: string | null) => {
|
||||||
|
switch (currency?.toUpperCase()) {
|
||||||
|
case 'USD': return '$'
|
||||||
|
case 'EUR': return '€'
|
||||||
|
case 'RUB': return '₽'
|
||||||
|
case 'CNY': return '¥'
|
||||||
|
default: return '$'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend from price history
|
||||||
|
const trend = computed(() => {
|
||||||
|
if (effectivePriceHistory.value.length < 2) return 0
|
||||||
|
const first = effectivePriceHistory.value[0]
|
||||||
|
const last = effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
|
||||||
|
if (!first || first === 0 || !last) return 0
|
||||||
|
return Math.round(((last - first) / first) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chart configuration
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
sparkline: { enabled: true },
|
||||||
|
animations: { enabled: false }
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.4,
|
||||||
|
opacityTo: 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
xaxis: { labels: { show: false } },
|
||||||
|
yaxis: { labels: { show: false } }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartSeries = computed(() => [{
|
||||||
|
name: 'Price',
|
||||||
|
data: effectivePriceHistory.value.length > 0 ? effectivePriceHistory.value : [0]
|
||||||
|
}])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,49 +1,148 @@
|
|||||||
<template>
|
<template>
|
||||||
<MapPanel>
|
<div class="flex flex-col h-full">
|
||||||
<template #header>
|
<!-- Header -->
|
||||||
|
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-base text-base-content">{{ $t('catalog.headers.offers') }}</h3>
|
<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>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
|
||||||
<span class="loading loading-spinner loading-md" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="offers.length === 0" class="text-center py-8 text-white/60">
|
<!-- Content (scrollable) -->
|
||||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
</div>
|
<span class="loading loading-spinner loading-md text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-3">
|
<div v-else-if="offersWithPrice.length === 0" class="text-center py-8 text-white/60">
|
||||||
|
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||||
|
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||||
|
</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
|
<div
|
||||||
v-for="offer in offers"
|
v-for="offer in group.offers"
|
||||||
:key="offer.uuid"
|
:key="offer.uuid"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@click="emit('select-offer', offer)"
|
@click="emit('select-offer', offer)"
|
||||||
>
|
>
|
||||||
<slot name="offer-card" :offer="offer">
|
<OfferResultCard
|
||||||
<OfferCard :offer="offer" linkable />
|
grouped
|
||||||
</slot>
|
: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>
|
||||||
</MapPanel>
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Offer {
|
interface Offer {
|
||||||
uuid: string
|
uuid: string
|
||||||
[key: string]: any
|
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
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
interface OfferGroup {
|
||||||
loading: boolean
|
id: string
|
||||||
offers: Offer[]
|
offers: Offer[]
|
||||||
}>()
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select-offer': [offer: Offer]
|
'select-offer': [offer: Offer]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
<Icon name="lucide:x" size="16" />
|
<Icon name="lucide:x" size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="searchPlaceholder"
|
|
||||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content (scrollable) -->
|
<!-- Content (scrollable) -->
|
||||||
@@ -22,7 +16,7 @@
|
|||||||
<span class="loading loading-spinner loading-md text-white" />
|
<span class="loading loading-spinner loading-md text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60">
|
<div v-else-if="items.length === 0" class="text-center py-8 text-white/60">
|
||||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||||
<p>{{ $t('catalog.empty.noResults') }}</p>
|
<p>{{ $t('catalog.empty.noResults') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,8 +25,9 @@
|
|||||||
<!-- Products -->
|
<!-- Products -->
|
||||||
<template v-if="selectMode === 'product'">
|
<template v-if="selectMode === 'product'">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in filteredItems"
|
v-for="(item, index) in items"
|
||||||
:key="item.uuid ?? index"
|
:key="item.uuid ?? index"
|
||||||
|
class="relative group"
|
||||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||||
@mouseleave="emit('hover', null)"
|
@mouseleave="emit('hover', null)"
|
||||||
>
|
>
|
||||||
@@ -42,14 +37,23 @@
|
|||||||
compact
|
compact
|
||||||
@select="onSelect(item)"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Hubs -->
|
<!-- Hubs -->
|
||||||
<template v-else-if="selectMode === 'hub'">
|
<template v-else-if="selectMode === 'hub'">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in filteredItems"
|
v-for="(item, index) in items"
|
||||||
:key="item.uuid ?? index"
|
:key="item.uuid ?? index"
|
||||||
|
class="relative group"
|
||||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||||
@mouseleave="emit('hover', null)"
|
@mouseleave="emit('hover', null)"
|
||||||
>
|
>
|
||||||
@@ -58,14 +62,23 @@
|
|||||||
selectable
|
selectable
|
||||||
@select="onSelect(item)"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Suppliers -->
|
<!-- Suppliers -->
|
||||||
<template v-else-if="selectMode === 'supplier'">
|
<template v-else-if="selectMode === 'supplier'">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in filteredItems"
|
v-for="(item, index) in items"
|
||||||
:key="item.uuid ?? index"
|
:key="item.uuid ?? index"
|
||||||
|
class="relative group"
|
||||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||||
@mouseleave="emit('hover', null)"
|
@mouseleave="emit('hover', null)"
|
||||||
>
|
>
|
||||||
@@ -74,12 +87,20 @@
|
|||||||
selectable
|
selectable
|
||||||
@select="onSelect(item)"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Infinite scroll sentinel -->
|
<!-- Infinite scroll sentinel -->
|
||||||
<div
|
<div
|
||||||
v-if="hasMore && !searchQuery"
|
v-if="hasMore"
|
||||||
ref="loadMoreSentinel"
|
ref="loadMoreSentinel"
|
||||||
class="flex items-center justify-center py-4"
|
class="flex items-center justify-center py-4"
|
||||||
>
|
>
|
||||||
@@ -96,7 +117,7 @@ import type { SelectMode } from '~/composables/useCatalogSearch'
|
|||||||
interface Item {
|
interface Item {
|
||||||
uuid?: string | null
|
uuid?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
[key: string]: any
|
country?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -114,11 +135,11 @@ const emit = defineEmits<{
|
|||||||
'close': []
|
'close': []
|
||||||
'load-more': []
|
'load-more': []
|
||||||
'hover': [uuid: string | null]
|
'hover': [uuid: string | null]
|
||||||
|
'pin': [type: 'product' | 'hub' | 'supplier', item: Item]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Infinite scroll using IntersectionObserver
|
// Infinite scroll using IntersectionObserver
|
||||||
@@ -128,7 +149,7 @@ onMounted(() => {
|
|||||||
observer = new IntersectionObserver(
|
observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
if (entry?.isIntersecting && props.hasMore && !props.loadingMore && !searchQuery.value) {
|
if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
|
||||||
emit('load-more')
|
emit('load-more')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -158,15 +179,6 @@ const title = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchPlaceholder = computed(() => {
|
|
||||||
switch (props.selectMode) {
|
|
||||||
case 'product': return t('catalog.search.searchProducts')
|
|
||||||
case 'hub': return t('catalog.search.searchHubs')
|
|
||||||
case 'supplier': return t('catalog.search.searchSuppliers')
|
|
||||||
default: return t('catalog.search.placeholder')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed(() => {
|
||||||
switch (props.selectMode) {
|
switch (props.selectMode) {
|
||||||
case 'product': return props.products || []
|
case 'product': return props.products || []
|
||||||
@@ -176,16 +188,6 @@ const items = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
|
||||||
if (!searchQuery.value.trim()) return items.value
|
|
||||||
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
return items.value.filter(item =>
|
|
||||||
item.name?.toLowerCase().includes(query) ||
|
|
||||||
item.country?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Select item and emit
|
// Select item and emit
|
||||||
const onSelect = (item: Item) => {
|
const onSelect = (item: Item) => {
|
||||||
if (props.selectMode && item.uuid) {
|
if (props.selectMode && item.uuid) {
|
||||||
|
|||||||
@@ -9,35 +9,45 @@
|
|||||||
<Card
|
<Card
|
||||||
padding="small"
|
padding="small"
|
||||||
:interactive="linkable || selectable"
|
:interactive="linkable || selectable"
|
||||||
class="relative"
|
|
||||||
:class="[
|
:class="[
|
||||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- Verified badge top-right -->
|
<div class="flex flex-col gap-3">
|
||||||
<span v-if="supplier.isVerified" class="absolute -top-2 -right-2 badge badge-neutral badge-sm">
|
<!-- Top row: Info + Logo (logo on right) -->
|
||||||
{{ t('catalogSupplier.badges.verified') }}
|
<div class="flex gap-3 items-start">
|
||||||
</span>
|
<!-- Info (left) -->
|
||||||
<div class="flex flex-col gap-1">
|
<div class="min-w-0 flex-1">
|
||||||
<!-- Logo -->
|
<!-- Name with verified badge -->
|
||||||
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
|
<div class="flex items-center gap-1.5">
|
||||||
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
|
<span v-if="supplier.isVerified" class="text-primary text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
|
||||||
|
<path fill-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
|
||||||
|
</div>
|
||||||
|
<!-- Country -->
|
||||||
|
<Text tone="muted" size="sm">
|
||||||
|
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logo (right) -->
|
||||||
|
<div v-if="supplier.logo" class="w-12 h-12 shrink-0">
|
||||||
|
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-12 h-12 shrink-0 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg">
|
||||||
|
{{ supplier.name?.charAt(0) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
|
|
||||||
{{ supplier.name?.charAt(0) }}
|
<!-- Bottom row: Badges/Chips -->
|
||||||
</div>
|
|
||||||
<!-- Title -->
|
|
||||||
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
|
|
||||||
<!-- Badges -->
|
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<span class="badge badge-neutral badge-dash text-xs">
|
<span v-if="reliabilityLabel" class="badge badge-neutral badge-sm">
|
||||||
{{ reliabilityLabel }}
|
{{ reliabilityLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Country below -->
|
|
||||||
<Text tone="muted" size="sm">
|
|
||||||
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
34
app/components/hero/HeroBackground.vue
Normal file
34
app/components/hero/HeroBackground.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="absolute inset-0 overflow-hidden bg-slate-900">
|
||||||
|
<!-- Lottie animation -->
|
||||||
|
<ClientOnly>
|
||||||
|
<DotLottieVue
|
||||||
|
src="/animations/supply-chain.lottie"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
:layout="{ fit: 'cover', align: [0.5, 0.5] }"
|
||||||
|
class="absolute top-0 left-0 w-full"
|
||||||
|
:style="{
|
||||||
|
height: '100vh',
|
||||||
|
opacity: 1 - collapseProgress * 0.7,
|
||||||
|
transform: `scale(${1 + collapseProgress * 0.1})`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Overlay for text readability - only when hero starts collapsing -->
|
||||||
|
<div
|
||||||
|
v-if="collapseProgress > 0.5"
|
||||||
|
class="absolute inset-0 bg-gradient-to-b from-slate-900/60 via-slate-900/40 to-slate-900/70"
|
||||||
|
:style="{ opacity: (collapseProgress - 0.5) * 2 }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
collapseProgress: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -1,86 +1,177 @@
|
|||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="shadow-lg"
|
class="relative"
|
||||||
:class="glassStyle ? 'bg-black/30 backdrop-blur-md border-b border-white/10' : 'bg-base-100 border-b border-base-300'"
|
|
||||||
:style="{ height: `${height}px` }"
|
:style="{ height: `${height}px` }"
|
||||||
>
|
>
|
||||||
<!-- Single row: Logo + Search + Icons -->
|
<div class="relative mx-auto max-w-[2200px] px-3 py-2 md:px-4">
|
||||||
<div class="flex items-stretch h-full px-4 lg:px-6 gap-4">
|
<div
|
||||||
<!-- Left: Logo + Nav links (top aligned) -->
|
class="flex items-center gap-2"
|
||||||
<div class="flex items-start gap-6 flex-shrink-0 pt-4">
|
:class="isHeroLayout ? 'items-start' : ''"
|
||||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
:style="rowStyle"
|
||||||
<span class="font-bold text-xl" :class="glassStyle ? 'text-white' : 'text-base-content'">Optovia</span>
|
>
|
||||||
</NuxtLink>
|
<!-- 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 -->
|
<!-- Service nav links -->
|
||||||
<nav v-if="showModeToggle" class="flex items-center gap-1">
|
<div v-if="showModeToggle" class="w-px h-6 bg-white/20 self-center" />
|
||||||
<button
|
<div v-if="showModeToggle" class="flex items-center px-3 py-2">
|
||||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
<nav class="flex items-center gap-1">
|
||||||
:class="showActiveMode && catalogMode === 'explore'
|
<button
|
||||||
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||||
: (glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
:class="showActiveMode && catalogMode === 'explore' && !isClientArea
|
||||||
@click="$emit('set-catalog-mode', 'explore')"
|
? (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')"
|
||||||
{{ $t('catalog.modes.explore') }}
|
@click="$emit('set-catalog-mode', 'explore')"
|
||||||
</button>
|
>
|
||||||
<button
|
{{ $t('catalog.modes.explore') }}
|
||||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
</button>
|
||||||
:class="showActiveMode && catalogMode === 'quote'
|
<NuxtLink
|
||||||
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
:to="localePath('/catalog/product')"
|
||||||
: (glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||||
@click="$emit('set-catalog-mode', 'quote')"
|
:class="isQuoteStepPage
|
||||||
>
|
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||||
{{ $t('catalog.modes.quote') }}
|
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||||
</button>
|
>
|
||||||
</nav>
|
{{ $t('catalog.modes.quote') }}
|
||||||
|
</NuxtLink>
|
||||||
|
<!-- 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')"
|
||||||
|
>
|
||||||
|
{{ 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>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Search input (vertically centered) -->
|
<!-- 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 -->
|
<!-- Hero slot for home page title -->
|
||||||
<slot name="hero" />
|
<slot name="hero" />
|
||||||
|
|
||||||
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
|
<!-- Client Area tabs -->
|
||||||
<template v-if="catalogMode === 'quote'">
|
<template v-if="isClientArea">
|
||||||
<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 gap-1 rounded-full pill-glass p-1">
|
||||||
|
<!-- BUYER tabs -->
|
||||||
|
<template v-if="currentRole !== 'SELLER'">
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/orders')"
|
||||||
|
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
|
||||||
|
:class="isClientAreaTabActive('/clientarea/orders') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
|
||||||
|
>
|
||||||
|
{{ $t('cabinetNav.orders') }}
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/addresses')"
|
||||||
|
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
|
||||||
|
:class="isClientAreaTabActive('/clientarea/addresses') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
|
||||||
|
>
|
||||||
|
{{ $t('cabinetNav.addresses') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- SELLER tabs -->
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/offers')"
|
||||||
|
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
|
||||||
|
:class="isClientAreaTabActive('/clientarea/offers') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
|
||||||
|
>
|
||||||
|
{{ $t('cabinetNav.myOffers') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Quote mode: Step-based capsule navigation (like logistics) -->
|
||||||
|
<template v-else-if="catalogMode === 'quote'">
|
||||||
|
<div class="flex items-center w-full rounded-full pill-glass overflow-hidden">
|
||||||
<!-- Product segment -->
|
<!-- Product segment -->
|
||||||
<button
|
<NuxtLink
|
||||||
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 rounded-l-full transition-colors min-w-0"
|
:to="localePath('/catalog/product')"
|
||||||
@click="$emit('edit-token', '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>
|
<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 -->
|
<!-- Hub segment -->
|
||||||
<button
|
<NuxtLink
|
||||||
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 transition-colors min-w-0"
|
:to="localePath('/catalog/destination')"
|
||||||
@click="$emit('edit-token', 'hub')"
|
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>
|
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
|
||||||
</button>
|
</NuxtLink>
|
||||||
<!-- Quantity segment (inline input) -->
|
<div class="w-px h-8 bg-base-300/40 self-center" />
|
||||||
<div class="flex-1 px-4 py-2 min-w-0">
|
<!-- Quantity segment -->
|
||||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div>
|
<NuxtLink
|
||||||
<div class="flex items-center gap-1">
|
:to="localePath('/catalog/quantity')"
|
||||||
<input
|
class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
|
||||||
v-model="localQuantity"
|
>
|
||||||
type="number"
|
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.quantity') }}</span>
|
||||||
min="0"
|
<div class="font-medium truncate text-base-content">{{ quantity || '—' }} {{ quantity ? $t('units.t') : '' }}</div>
|
||||||
step="0.1"
|
</NuxtLink>
|
||||||
placeholder="—"
|
<!-- Search button -->
|
||||||
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 -->
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-circle m-1"
|
class="btn btn-primary btn-circle m-1"
|
||||||
:disabled="!canSearch"
|
@click="navigateToSearch"
|
||||||
@click="$emit('search')"
|
|
||||||
>
|
>
|
||||||
<Icon name="lucide:search" size="18" />
|
<Icon name="lucide:search" size="18" />
|
||||||
</button>
|
</button>
|
||||||
@@ -91,7 +182,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Big pill input -->
|
<!-- Big pill input -->
|
||||||
<div
|
<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"
|
@click="focusInput"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
|
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
|
||||||
@@ -135,57 +226,52 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: AI + Globe + Team + User (top aligned like logo) -->
|
<!-- Right: Globe + Team + User (top aligned like logo) -->
|
||||||
<div class="flex items-start gap-1 flex-shrink-0 pt-4">
|
<div class="flex items-center flex-shrink-0 rounded-full pill-glass">
|
||||||
<!-- AI Assistant button -->
|
<div class="w-px h-6 bg-white/20 self-center" />
|
||||||
<NuxtLink
|
<div class="flex items-center px-2 py-2">
|
||||||
:to="localePath('/clientarea/ai')"
|
<!-- Globe (language/currency) dropdown -->
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
<div class="dropdown dropdown-end">
|
||||||
:class="glassStyle ? '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="glassStyle ? '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>
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost w-full justify-start"
|
tabindex="0"
|
||||||
@click="$emit('toggle-theme')"
|
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" />
|
<Icon name="lucide:globe" size="18" />
|
||||||
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
|
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team dropdown -->
|
<!-- 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">
|
<div class="dropdown dropdown-end">
|
||||||
<button
|
<button
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
|
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
|
||||||
:class="glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
: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:building-2" size="16" />
|
<Icon name="lucide:building-2" size="16" />
|
||||||
<span class="hidden lg:inline max-w-24 truncate text-xs">
|
<span class="hidden lg:inline max-w-24 truncate text-xs">
|
||||||
@@ -212,23 +298,24 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<!-- User menu -->
|
<!-- 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">
|
<template v-if="loggedIn">
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer"
|
class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer"
|
||||||
:class="glassStyle ? 'ring-white/20 hover:ring-white/40' : 'ring-base-300 hover:ring-primary'"
|
:class="useWhiteText ? 'ring-white/20 hover:ring-white/40' : 'ring-base-300 hover:ring-primary'"
|
||||||
>
|
>
|
||||||
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-full h-full flex items-center justify-center font-bold text-xs"
|
class="w-full h-full flex items-center justify-center font-bold text-xs"
|
||||||
:class="glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'"
|
:class="useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'"
|
||||||
>
|
>
|
||||||
{{ userInitials }}
|
{{ userInitials }}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,14 +347,15 @@
|
|||||||
<button
|
<button
|
||||||
@click="$emit('sign-in')"
|
@click="$emit('sign-in')"
|
||||||
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
|
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
|
||||||
:class="glassStyle ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'"
|
:class="useWhiteText ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'"
|
||||||
>
|
>
|
||||||
{{ $t('auth.login') }}
|
{{ $t('auth.login') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -292,6 +380,9 @@ const props = withDefaults(defineProps<{
|
|||||||
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
||||||
} | null
|
} | null
|
||||||
isSeller?: boolean
|
isSeller?: boolean
|
||||||
|
// Role switching props
|
||||||
|
hasMultipleRoles?: boolean
|
||||||
|
currentRole?: string
|
||||||
// Search props
|
// Search props
|
||||||
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
|
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
|
||||||
availableChips?: Array<{ type: string; label: string }>
|
availableChips?: Array<{ type: string; label: string }>
|
||||||
@@ -306,19 +397,30 @@ const props = withDefaults(defineProps<{
|
|||||||
canSearch?: boolean
|
canSearch?: boolean
|
||||||
showModeToggle?: boolean
|
showModeToggle?: boolean
|
||||||
showActiveMode?: boolean // Whether to show active state on mode toggle
|
showActiveMode?: boolean // Whether to show active state on mode toggle
|
||||||
// Glass style (transparent) for map pages
|
// Glass style applied when header is collapsed
|
||||||
glassStyle?: boolean
|
isCollapsed?: boolean
|
||||||
|
// Home page flag for transparent background
|
||||||
|
isHomePage?: boolean
|
||||||
|
// Client area flag - shows cabinet tabs instead of search
|
||||||
|
isClientArea?: boolean
|
||||||
|
// AI chat sidebar state
|
||||||
|
chatOpen?: boolean
|
||||||
// Dynamic height for hero effect
|
// Dynamic height for hero effect
|
||||||
height?: number
|
height?: number
|
||||||
|
// Collapse progress for hero layout
|
||||||
|
collapseProgress?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
height: 100
|
height: 100,
|
||||||
|
collapseProgress: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits([
|
defineEmits([
|
||||||
|
'toggle-chat',
|
||||||
'toggle-theme',
|
'toggle-theme',
|
||||||
'sign-out',
|
'sign-out',
|
||||||
'sign-in',
|
'sign-in',
|
||||||
'switch-team',
|
'switch-team',
|
||||||
|
'switch-role',
|
||||||
// Search events
|
// Search events
|
||||||
'start-select',
|
'start-select',
|
||||||
'cancel-select',
|
'cancel-select',
|
||||||
@@ -332,9 +434,34 @@ defineEmits([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
const { locale, locales } = useI18n()
|
const { locale, locales } = useI18n()
|
||||||
const switchLocalePath = useSwitchLocalePath()
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
const { t } = useI18n()
|
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) => {
|
||||||
|
const currentPath = route.path
|
||||||
|
const localizedPath = localePath(path)
|
||||||
|
return currentPath === localizedPath || currentPath.startsWith(localizedPath + '/')
|
||||||
|
}
|
||||||
|
|
||||||
const inputRef = ref<HTMLInputElement>()
|
const inputRef = ref<HTMLInputElement>()
|
||||||
const localSearchQuery = ref(props.searchQuery || '')
|
const localSearchQuery = ref(props.searchQuery || '')
|
||||||
@@ -386,5 +513,27 @@ const getTokenIcon = (type: string) => {
|
|||||||
}
|
}
|
||||||
return icons[type] || 'lucide:tag'
|
return icons[type] || 'lucide:tag'
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
|
||||||
|
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
|
||||||
|
const topRowHeight = 100
|
||||||
|
|
||||||
|
const rowStyle = computed(() => {
|
||||||
|
if (isHeroLayout.value) {
|
||||||
|
return { height: `${topRowHeight}px` }
|
||||||
|
}
|
||||||
|
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>
|
<template>
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<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 -->
|
<!-- Fullscreen Map -->
|
||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
@@ -17,13 +7,16 @@
|
|||||||
ref="mapRef"
|
ref="mapRef"
|
||||||
:map-id="mapId"
|
:map-id="mapId"
|
||||||
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
|
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
|
||||||
:clustered-points="isInfoMode ? [] : (useServerClustering ? clusteredNodes : [])"
|
:clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])"
|
||||||
|
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
|
||||||
:use-server-clustering="useServerClustering && !isInfoMode"
|
:use-server-clustering="useServerClustering && !isInfoMode"
|
||||||
:point-color="activePointColor"
|
:point-color="activePointColor"
|
||||||
:entity-type="activeEntityType"
|
:entity-type="activeEntityType"
|
||||||
:hovered-item-id="hoveredId"
|
:hovered-item-id="hoveredId"
|
||||||
:hovered-item="hoveredItem"
|
:hovered-item="hoveredItem"
|
||||||
:related-points="relatedPoints"
|
:related-points="relatedPoints"
|
||||||
|
:info-loading="infoLoading"
|
||||||
|
:fit-padding-left="fitPaddingLeft"
|
||||||
@select-item="onMapSelect"
|
@select-item="onMapSelect"
|
||||||
@bounds-change="onBoundsChange"
|
@bounds-change="onBoundsChange"
|
||||||
/>
|
/>
|
||||||
@@ -32,17 +25,17 @@
|
|||||||
|
|
||||||
<!-- View mode loading indicator -->
|
<!-- View mode loading indicator -->
|
||||||
<div
|
<div
|
||||||
v-if="clusterLoading"
|
v-if="clusterLoading || loading"
|
||||||
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"
|
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="loading loading-spinner loading-sm text-base-content" />
|
||||||
<span class="text-white text-sm">{{ $t('common.loading') }}</span>
|
<span class="text-base-content text-sm font-medium">{{ $t('common.loading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List button (LEFT, opens panel) - hide when panel is open -->
|
<!-- List button (LEFT, opens panel) - hide when panel is open -->
|
||||||
<button
|
<button
|
||||||
v-if="!isPanelOpen"
|
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"
|
@click="openPanel"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:menu" size="16" />
|
<Icon name="lucide:menu" size="16" />
|
||||||
@@ -52,7 +45,7 @@
|
|||||||
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
|
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
|
||||||
<label
|
<label
|
||||||
v-if="selectMode !== null"
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -64,13 +57,14 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
||||||
<!-- View toggle (top RIGHT overlay, below header) -->
|
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
|
||||||
<div class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
|
<div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
|
||||||
<!-- View mode toggle -->
|
<!-- 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
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
v-if="showOffersToggle"
|
||||||
:class="mapViewMode === 'offers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
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')"
|
@click="setMapViewMode('offers')"
|
||||||
>
|
>
|
||||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
|
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
|
||||||
@@ -79,8 +73,9 @@
|
|||||||
{{ $t('catalog.views.offers') }}
|
{{ $t('catalog.views.offers') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
v-if="showHubsToggle"
|
||||||
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
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')"
|
@click="setMapViewMode('hubs')"
|
||||||
>
|
>
|
||||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
|
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
|
||||||
@@ -89,8 +84,9 @@
|
|||||||
{{ $t('catalog.views.hubs') }}
|
{{ $t('catalog.views.hubs') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
|
v-if="showSuppliersToggle"
|
||||||
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
|
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')"
|
@click="setMapViewMode('suppliers')"
|
||||||
>
|
>
|
||||||
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
|
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
|
||||||
@@ -105,9 +101,10 @@
|
|||||||
<Transition name="slide-left">
|
<Transition name="slide-left">
|
||||||
<div
|
<div
|
||||||
v-if="isPanelOpen"
|
v-if="isPanelOpen"
|
||||||
class="absolute top-[116px] left-4 bottom-4 z-30 w-96 max-w-[calc(100vw-2rem)] hidden lg:block"
|
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" />
|
<slot name="panel" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,17 +116,18 @@
|
|||||||
<div class="flex justify-between px-4 mb-2">
|
<div class="flex justify-between px-4 mb-2">
|
||||||
<!-- List button (mobile) -->
|
<!-- List button (mobile) -->
|
||||||
<button
|
<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"
|
@click="openPanel"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:menu" size="16" />
|
<Icon name="lucide:menu" size="16" />
|
||||||
<span>{{ $t('catalog.list') }}</span>
|
<span>{{ $t('catalog.list') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile view toggle -->
|
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
|
||||||
<div 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
|
<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'"
|
:class="mapViewMode === 'offers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||||
@click="setMapViewMode('offers')"
|
@click="setMapViewMode('offers')"
|
||||||
>
|
>
|
||||||
@@ -138,7 +136,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<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'"
|
:class="mapViewMode === 'hubs' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||||
@click="setMapViewMode('hubs')"
|
@click="setMapViewMode('hubs')"
|
||||||
>
|
>
|
||||||
@@ -147,7 +146,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<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'"
|
:class="mapViewMode === 'suppliers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
||||||
@click="setMapViewMode('suppliers')"
|
@click="setMapViewMode('suppliers')"
|
||||||
>
|
>
|
||||||
@@ -162,14 +162,14 @@
|
|||||||
<Transition name="slide-up">
|
<Transition name="slide-up">
|
||||||
<div
|
<div
|
||||||
v-if="isPanelOpen"
|
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 -->
|
<!-- Drag handle / close -->
|
||||||
<div
|
<div
|
||||||
class="flex justify-center py-2 cursor-pointer"
|
class="flex justify-center py-2 cursor-pointer"
|
||||||
@click="closePanel"
|
@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>
|
||||||
|
|
||||||
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
|
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
|
||||||
@@ -189,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)
|
// Panel is open when selectMode is set OR when showPanel prop is true (info/quote)
|
||||||
const isPanelOpen = computed(() => props.showPanel || selectMode.value !== null)
|
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
|
// Open panel based on current mapViewMode
|
||||||
const openPanel = () => {
|
const openPanel = () => {
|
||||||
const newSelectMode = mapViewMode.value === 'hubs' ? 'hub'
|
const newSelectMode = mapViewMode.value === 'hubs' ? 'hub'
|
||||||
@@ -233,24 +261,34 @@ const activeClusterNodeType = computed(() => VIEW_MODE_NODE_TYPES[mapViewMode.va
|
|||||||
const currentBounds = ref<MapBounds | null>(null)
|
const currentBounds = ref<MapBounds | null>(null)
|
||||||
|
|
||||||
interface MapItem {
|
interface MapItem {
|
||||||
uuid: string
|
uuid?: string | null
|
||||||
latitude?: number | null
|
latitude?: number | null
|
||||||
longitude?: number | null
|
longitude?: number | null
|
||||||
name?: string
|
name?: string | null
|
||||||
country?: string
|
country?: string | null
|
||||||
[key: string]: any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
useServerClustering?: boolean
|
useServerClustering?: boolean
|
||||||
clusterNodeType?: string
|
clusterNodeType?: string
|
||||||
|
useTypedClusters?: boolean
|
||||||
mapId?: string
|
mapId?: string
|
||||||
pointColor?: string
|
pointColor?: string
|
||||||
hoveredId?: string
|
hoveredId?: string
|
||||||
items?: MapItem[]
|
items?: MapItem[]
|
||||||
showPanel?: boolean
|
showPanel?: boolean
|
||||||
filterByBounds?: 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<{
|
relatedPoints?: Array<{
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
@@ -262,11 +300,22 @@ const props = withDefaults(defineProps<{
|
|||||||
loading: false,
|
loading: false,
|
||||||
useServerClustering: true,
|
useServerClustering: true,
|
||||||
clusterNodeType: 'offer',
|
clusterNodeType: 'offer',
|
||||||
|
useTypedClusters: false,
|
||||||
mapId: 'catalog-map',
|
mapId: 'catalog-map',
|
||||||
pointColor: '#f97316',
|
pointColor: '#f97316',
|
||||||
items: () => [],
|
items: () => [],
|
||||||
showPanel: false,
|
showPanel: false,
|
||||||
filterByBounds: 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: () => []
|
relatedPoints: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -277,11 +326,73 @@ const emit = defineEmits<{
|
|||||||
'update:filter-by-bounds': [value: boolean]
|
'update:filter-by-bounds': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Server-side clustering - use computed node type based on view mode
|
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
|
||||||
const { clusteredNodes, fetchClusters, loading: clusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server-side clustering (typed mode)
|
||||||
|
const offerClusters = useClusteredNodes(undefined, ref('offer'))
|
||||||
|
const hubClusters = useClusteredNodes(undefined, ref('logistics'))
|
||||||
|
const supplierClusters = useClusteredNodes(undefined, ref('supplier'))
|
||||||
|
|
||||||
|
const clusteredPointsByType = computed(() => ({
|
||||||
|
offer: offerClusters.clusteredNodes.value,
|
||||||
|
hub: hubClusters.clusteredNodes.value,
|
||||||
|
supplier: supplierClusters.clusteredNodes.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const activeClusterType = computed<'offer' | 'hub' | 'supplier'>(() => {
|
||||||
|
if (mapViewMode.value === 'hubs') return 'hub'
|
||||||
|
if (mapViewMode.value === 'suppliers') return 'supplier'
|
||||||
|
return 'offer'
|
||||||
|
})
|
||||||
|
|
||||||
|
const clusterLoading = computed(() => {
|
||||||
|
if (!useTypedClusters.value) return singleClusterLoading.value
|
||||||
|
if (activeClusterType.value === 'hub') return hubClusters.loading.value
|
||||||
|
if (activeClusterType.value === 'supplier') return supplierClusters.loading.value
|
||||||
|
return offerClusters.loading.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearInactiveClusters = (active: 'offer' | 'hub' | 'supplier') => {
|
||||||
|
if (active !== 'offer') offerClusters.clearNodes()
|
||||||
|
if (active !== 'hub') hubClusters.clearNodes()
|
||||||
|
if (active !== 'supplier') supplierClusters.clearNodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchActiveClusters = async () => {
|
||||||
|
if (!currentBounds.value) return
|
||||||
|
clearInactiveClusters(activeClusterType.value)
|
||||||
|
if (activeClusterType.value === 'hub') {
|
||||||
|
await hubClusters.fetchClusters(currentBounds.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeClusterType.value === 'supplier') {
|
||||||
|
await supplierClusters.fetchClusters(currentBounds.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await offerClusters.fetchClusters(currentBounds.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Refetch clusters when view mode changes
|
// Refetch clusters when view mode changes
|
||||||
watch(mapViewMode, async () => {
|
watch(mapViewMode, async () => {
|
||||||
|
if (!props.useServerClustering) return
|
||||||
|
if (isInfoMode.value) return
|
||||||
|
if (useTypedClusters.value) {
|
||||||
|
clearInactiveClusters(activeClusterType.value)
|
||||||
|
if (currentBounds.value) {
|
||||||
|
await fetchActiveClusters()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
// Clear old data first
|
// Clear old data first
|
||||||
clearNodes()
|
clearNodes()
|
||||||
// Refetch with current bounds if available
|
// Refetch with current bounds if available
|
||||||
@@ -290,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
|
// Map refs
|
||||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||||
|
|
||||||
@@ -300,7 +422,7 @@ const selectedMapItem = ref<MapItem | null>(null)
|
|||||||
const mobilePanelExpanded = ref(false)
|
const mobilePanelExpanded = ref(false)
|
||||||
|
|
||||||
// Info mode - when relatedPoints are present, hide clusters and show only related points
|
// 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
|
// Hovered item with coordinates for map highlight
|
||||||
const hoveredItem = computed(() => {
|
const hoveredItem = computed(() => {
|
||||||
@@ -331,7 +453,11 @@ const onBoundsChange = (bounds: MapBounds) => {
|
|||||||
emit('bounds-change', bounds)
|
emit('bounds-change', bounds)
|
||||||
// Don't fetch clusters when in info mode
|
// Don't fetch clusters when in info mode
|
||||||
if (props.useServerClustering && !isInfoMode.value) {
|
if (props.useServerClustering && !isInfoMode.value) {
|
||||||
fetchClusters(bounds)
|
if (useTypedClusters.value) {
|
||||||
|
fetchActiveClusters()
|
||||||
|
} else {
|
||||||
|
fetchClusters(bounds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,96 +13,61 @@ export type Scalars = {
|
|||||||
Boolean: { input: boolean; output: boolean; }
|
Boolean: { input: boolean; output: boolean; }
|
||||||
Int: { input: number; output: number; }
|
Int: { input: number; output: number; }
|
||||||
Float: { input: number; output: number; }
|
Float: { input: number; output: number; }
|
||||||
Date: { input: any; output: any; }
|
|
||||||
DateTime: { input: string; output: string; }
|
|
||||||
Decimal: { input: any; output: any; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OfferType = {
|
export type Offer = {
|
||||||
__typename?: 'OfferType';
|
__typename?: 'Offer';
|
||||||
categoryName: Scalars['String']['output'];
|
categoryName?: Maybe<Scalars['String']['output']>;
|
||||||
createdAt: Scalars['DateTime']['output'];
|
createdAt: Scalars['String']['output'];
|
||||||
currency: Scalars['String']['output'];
|
currency: Scalars['String']['output'];
|
||||||
description: Scalars['String']['output'];
|
description?: Maybe<Scalars['String']['output']>;
|
||||||
id: Scalars['ID']['output'];
|
locationCountry?: Maybe<Scalars['String']['output']>;
|
||||||
locationCountry: Scalars['String']['output'];
|
locationCountryCode?: Maybe<Scalars['String']['output']>;
|
||||||
locationCountryCode: Scalars['String']['output'];
|
|
||||||
locationLatitude?: Maybe<Scalars['Float']['output']>;
|
locationLatitude?: Maybe<Scalars['Float']['output']>;
|
||||||
locationLongitude?: Maybe<Scalars['Float']['output']>;
|
locationLongitude?: Maybe<Scalars['Float']['output']>;
|
||||||
locationName: Scalars['String']['output'];
|
locationName?: Maybe<Scalars['String']['output']>;
|
||||||
locationUuid: Scalars['String']['output'];
|
locationUuid?: Maybe<Scalars['String']['output']>;
|
||||||
pricePerUnit?: Maybe<Scalars['Decimal']['output']>;
|
pricePerUnit: Scalars['Float']['output'];
|
||||||
productName: Scalars['String']['output'];
|
productName: Scalars['String']['output'];
|
||||||
productUuid: Scalars['String']['output'];
|
productUuid: Scalars['String']['output'];
|
||||||
quantity: Scalars['Decimal']['output'];
|
quantity: Scalars['Float']['output'];
|
||||||
status: OffersOfferStatusChoices;
|
status: Scalars['String']['output'];
|
||||||
teamUuid: Scalars['String']['output'];
|
teamUuid: Scalars['String']['output'];
|
||||||
terminusDocumentId: Scalars['String']['output'];
|
|
||||||
terminusSchemaId: Scalars['String']['output'];
|
|
||||||
unit: Scalars['String']['output'];
|
unit: Scalars['String']['output'];
|
||||||
updatedAt: Scalars['DateTime']['output'];
|
updatedAt: Scalars['String']['output'];
|
||||||
uuid: Scalars['String']['output'];
|
uuid: Scalars['String']['output'];
|
||||||
validUntil?: Maybe<Scalars['Date']['output']>;
|
validUntil?: Maybe<Scalars['String']['output']>;
|
||||||
workflowError: Scalars['String']['output'];
|
|
||||||
workflowStatus: OffersOfferWorkflowStatusChoices;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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 = {
|
export type Product = {
|
||||||
__typename?: 'Product';
|
__typename?: 'Product';
|
||||||
categoryId?: Maybe<Scalars['Int']['output']>;
|
categoryId?: Maybe<Scalars['String']['output']>;
|
||||||
categoryName?: Maybe<Scalars['String']['output']>;
|
categoryName?: Maybe<Scalars['String']['output']>;
|
||||||
name?: Maybe<Scalars['String']['output']>;
|
name?: Maybe<Scalars['String']['output']>;
|
||||||
terminusSchemaId?: Maybe<Scalars['String']['output']>;
|
terminusSchemaId?: Maybe<Scalars['String']['output']>;
|
||||||
uuid?: Maybe<Scalars['String']['output']>;
|
uuid?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type Query = {
|
||||||
export type PublicQuery = {
|
__typename?: 'Query';
|
||||||
__typename?: 'PublicQuery';
|
|
||||||
/** Get products that have active offers */
|
|
||||||
getAvailableProducts?: Maybe<Array<Maybe<Product>>>;
|
getAvailableProducts?: Maybe<Array<Maybe<Product>>>;
|
||||||
getOffer?: Maybe<OfferType>;
|
getOffer?: Maybe<Offer>;
|
||||||
getOffers?: Maybe<Array<Maybe<OfferType>>>;
|
getOffers?: Maybe<Array<Maybe<Offer>>>;
|
||||||
getOffersCount?: Maybe<Scalars['Int']['output']>;
|
getOffersCount?: Maybe<Scalars['Int']['output']>;
|
||||||
getProducts?: Maybe<Array<Maybe<Product>>>;
|
getProducts?: Maybe<Array<Maybe<Product>>>;
|
||||||
getSupplierProfile?: Maybe<SupplierProfileType>;
|
getSupplierProfile?: Maybe<SupplierProfile>;
|
||||||
/** Get supplier profile by team UUID */
|
getSupplierProfileByTeam?: Maybe<SupplierProfile>;
|
||||||
getSupplierProfileByTeam?: Maybe<SupplierProfileType>;
|
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfile>>>;
|
||||||
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfileType>>>;
|
|
||||||
getSupplierProfilesCount?: Maybe<Scalars['Int']['output']>;
|
getSupplierProfilesCount?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetOfferArgs = {
|
||||||
export type PublicQueryGetOfferArgs = {
|
|
||||||
uuid: Scalars['String']['input'];
|
uuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetOffersArgs = {
|
||||||
export type PublicQueryGetOffersArgs = {
|
|
||||||
categoryName?: InputMaybe<Scalars['String']['input']>;
|
categoryName?: InputMaybe<Scalars['String']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
@@ -113,8 +78,7 @@ export type PublicQueryGetOffersArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetOffersCountArgs = {
|
||||||
export type PublicQueryGetOffersCountArgs = {
|
|
||||||
categoryName?: InputMaybe<Scalars['String']['input']>;
|
categoryName?: InputMaybe<Scalars['String']['input']>;
|
||||||
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
locationUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
@@ -123,20 +87,17 @@ export type PublicQueryGetOffersCountArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetSupplierProfileArgs = {
|
||||||
export type PublicQueryGetSupplierProfileArgs = {
|
|
||||||
uuid: Scalars['String']['input'];
|
uuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetSupplierProfileByTeamArgs = {
|
||||||
export type PublicQueryGetSupplierProfileByTeamArgs = {
|
|
||||||
teamUuid: Scalars['String']['input'];
|
teamUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetSupplierProfilesArgs = {
|
||||||
export type PublicQueryGetSupplierProfilesArgs = {
|
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -144,51 +105,46 @@ export type PublicQueryGetSupplierProfilesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public schema - no authentication required */
|
export type QueryGetSupplierProfilesCountArgs = {
|
||||||
export type PublicQueryGetSupplierProfilesCountArgs = {
|
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Профиль поставщика на бирже */
|
export type SupplierProfile = {
|
||||||
export type SupplierProfileType = {
|
__typename?: 'SupplierProfile';
|
||||||
__typename?: 'SupplierProfileType';
|
country?: Maybe<Scalars['String']['output']>;
|
||||||
country: Scalars['String']['output'];
|
|
||||||
countryCode?: Maybe<Scalars['String']['output']>;
|
countryCode?: Maybe<Scalars['String']['output']>;
|
||||||
createdAt: Scalars['DateTime']['output'];
|
description?: Maybe<Scalars['String']['output']>;
|
||||||
description: Scalars['String']['output'];
|
|
||||||
id: Scalars['ID']['output'];
|
|
||||||
isActive: Scalars['Boolean']['output'];
|
isActive: Scalars['Boolean']['output'];
|
||||||
isVerified: Scalars['Boolean']['output'];
|
isVerified: Scalars['Boolean']['output'];
|
||||||
kycProfileUuid: Scalars['String']['output'];
|
kycProfileUuid?: Maybe<Scalars['String']['output']>;
|
||||||
latitude?: Maybe<Scalars['Float']['output']>;
|
latitude?: Maybe<Scalars['Float']['output']>;
|
||||||
logoUrl: Scalars['String']['output'];
|
logoUrl?: Maybe<Scalars['String']['output']>;
|
||||||
longitude?: Maybe<Scalars['Float']['output']>;
|
longitude?: Maybe<Scalars['Float']['output']>;
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
offersCount?: Maybe<Scalars['Int']['output']>;
|
offersCount?: Maybe<Scalars['Int']['output']>;
|
||||||
teamUuid: Scalars['String']['output'];
|
teamUuid: Scalars['String']['output'];
|
||||||
updatedAt: Scalars['DateTime']['output'];
|
|
||||||
uuid: Scalars['String']['output'];
|
uuid: Scalars['String']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetAvailableProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
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<{
|
export type GetLocationOffersQueryVariables = Exact<{
|
||||||
locationUuid: Scalars['String']['input'];
|
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: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | 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<{
|
export type GetOfferQueryVariables = Exact<{
|
||||||
uuid: Scalars['String']['input'];
|
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: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | 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<{
|
export type GetOffersQueryVariables = Exact<{
|
||||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
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: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | 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<{
|
export type GetProductQueryVariables = Exact<{
|
||||||
uuid: Scalars['String']['input'];
|
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<{
|
export type GetProductOffersQueryVariables = Exact<{
|
||||||
productUuid: Scalars['String']['input'];
|
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: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | 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 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<{
|
export type GetSupplierOffersQueryVariables = Exact<{
|
||||||
teamUuid: Scalars['String']['input'];
|
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: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | 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<{
|
export type GetSupplierProfileQueryVariables = Exact<{
|
||||||
uuid: Scalars['String']['input'];
|
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<{
|
export type GetSupplierProfileByTeamQueryVariables = Exact<{
|
||||||
teamUuid: Scalars['String']['input'];
|
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<{
|
export type GetSupplierProfilesQueryVariables = Exact<{
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
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>;
|
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; }
|
Boolean: { input: boolean; output: boolean; }
|
||||||
Int: { input: number; output: number; }
|
Int: { input: number; output: number; }
|
||||||
Float: { input: number; output: number; }
|
Float: { input: number; output: number; }
|
||||||
JSONString: { input: any; output: any; }
|
JSON: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cluster or individual point for map display. */
|
export type ClusterPoint = {
|
||||||
export type ClusterPointType = {
|
__typename?: 'ClusterPoint';
|
||||||
__typename?: 'ClusterPointType';
|
|
||||||
/** 1 for single point, >1 for cluster */
|
|
||||||
count?: Maybe<Scalars['Int']['output']>;
|
count?: Maybe<Scalars['Int']['output']>;
|
||||||
/** Zoom level to expand cluster */
|
|
||||||
expansionZoom?: Maybe<Scalars['Int']['output']>;
|
expansionZoom?: Maybe<Scalars['Int']['output']>;
|
||||||
/** UUID for points, 'cluster-N' for clusters */
|
|
||||||
id?: Maybe<Scalars['String']['output']>;
|
id?: Maybe<Scalars['String']['output']>;
|
||||||
latitude?: Maybe<Scalars['Float']['output']>;
|
latitude?: Maybe<Scalars['Float']['output']>;
|
||||||
longitude?: Maybe<Scalars['Float']['output']>;
|
longitude?: Maybe<Scalars['Float']['output']>;
|
||||||
/** Node name (only for single points) */
|
|
||||||
name?: Maybe<Scalars['String']['output']>;
|
name?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Edge between two nodes (route). */
|
export type Edge = {
|
||||||
export type EdgeType = {
|
__typename?: 'Edge';
|
||||||
__typename?: 'EdgeType';
|
|
||||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
toLatitude?: Maybe<Scalars['Float']['output']>;
|
toLatitude?: Maybe<Scalars['Float']['output']>;
|
||||||
toLongitude?: Maybe<Scalars['Float']['output']>;
|
toLongitude?: Maybe<Scalars['Float']['output']>;
|
||||||
@@ -43,22 +37,12 @@ export type EdgeType = {
|
|||||||
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Auto + rail edges for a node, rail uses nearest rail node. */
|
export type Node = {
|
||||||
export type NodeConnectionsType = {
|
__typename?: 'Node';
|
||||||
__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';
|
|
||||||
country?: Maybe<Scalars['String']['output']>;
|
country?: Maybe<Scalars['String']['output']>;
|
||||||
countryCode?: Maybe<Scalars['String']['output']>;
|
countryCode?: Maybe<Scalars['String']['output']>;
|
||||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
edges?: Maybe<Array<Maybe<EdgeType>>>;
|
edges?: Maybe<Array<Maybe<Edge>>>;
|
||||||
latitude?: Maybe<Scalars['Float']['output']>;
|
latitude?: Maybe<Scalars['Float']['output']>;
|
||||||
longitude?: Maybe<Scalars['Float']['output']>;
|
longitude?: Maybe<Scalars['Float']['output']>;
|
||||||
name?: Maybe<Scalars['String']['output']>;
|
name?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -67,9 +51,16 @@ export type NodeType = {
|
|||||||
uuid?: Maybe<Scalars['String']['output']>;
|
uuid?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Offer node with location and product info. */
|
export type NodeConnections = {
|
||||||
export type OfferNodeType = {
|
__typename?: 'NodeConnections';
|
||||||
__typename?: 'OfferNodeType';
|
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']>;
|
country?: Maybe<Scalars['String']['output']>;
|
||||||
countryCode?: Maybe<Scalars['String']['output']>;
|
countryCode?: Maybe<Scalars['String']['output']>;
|
||||||
currency?: Maybe<Scalars['String']['output']>;
|
currency?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -86,9 +77,8 @@ export type OfferNodeType = {
|
|||||||
uuid?: Maybe<Scalars['String']['output']>;
|
uuid?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Offer with route information to destination. */
|
export type OfferWithRoute = {
|
||||||
export type OfferWithRouteType = {
|
__typename?: 'OfferWithRoute';
|
||||||
__typename?: 'OfferWithRouteType';
|
|
||||||
country?: Maybe<Scalars['String']['output']>;
|
country?: Maybe<Scalars['String']['output']>;
|
||||||
countryCode?: Maybe<Scalars['String']['output']>;
|
countryCode?: Maybe<Scalars['String']['output']>;
|
||||||
currency?: Maybe<Scalars['String']['output']>;
|
currency?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -99,94 +89,62 @@ export type OfferWithRouteType = {
|
|||||||
productName?: Maybe<Scalars['String']['output']>;
|
productName?: Maybe<Scalars['String']['output']>;
|
||||||
productUuid?: Maybe<Scalars['String']['output']>;
|
productUuid?: Maybe<Scalars['String']['output']>;
|
||||||
quantity?: Maybe<Scalars['String']['output']>;
|
quantity?: Maybe<Scalars['String']['output']>;
|
||||||
routes?: Maybe<Array<Maybe<RoutePathType>>>;
|
routes?: Maybe<Array<Maybe<RoutePath>>>;
|
||||||
supplierName?: Maybe<Scalars['String']['output']>;
|
supplierName?: Maybe<Scalars['String']['output']>;
|
||||||
supplierUuid?: Maybe<Scalars['String']['output']>;
|
supplierUuid?: Maybe<Scalars['String']['output']>;
|
||||||
unit?: Maybe<Scalars['String']['output']>;
|
unit?: Maybe<Scalars['String']['output']>;
|
||||||
uuid?: Maybe<Scalars['String']['output']>;
|
uuid?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Route options for a product source to the destination. */
|
export type Product = {
|
||||||
export type ProductRouteOptionType = {
|
__typename?: 'Product';
|
||||||
__typename?: 'ProductRouteOptionType';
|
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']>;
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
routes?: Maybe<Array<Maybe<RoutePathType>>>;
|
routes?: Maybe<Array<Maybe<RoutePath>>>;
|
||||||
sourceLat?: Maybe<Scalars['Float']['output']>;
|
sourceLat?: Maybe<Scalars['Float']['output']>;
|
||||||
sourceLon?: Maybe<Scalars['Float']['output']>;
|
sourceLon?: Maybe<Scalars['Float']['output']>;
|
||||||
sourceName?: Maybe<Scalars['String']['output']>;
|
sourceName?: Maybe<Scalars['String']['output']>;
|
||||||
sourceUuid?: 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 = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
/** Get auto route between two points via GraphHopper */
|
autoRoute?: Maybe<Route>;
|
||||||
autoRoute?: Maybe<RouteType>;
|
clusteredNodes: Array<ClusterPoint>;
|
||||||
/** Get clustered nodes for map display (server-side clustering) */
|
hubCountries: Array<Scalars['String']['output']>;
|
||||||
clusteredNodes?: Maybe<Array<Maybe<ClusterPointType>>>;
|
hubsForProduct: Array<Node>;
|
||||||
/** List of countries that have logistics hubs */
|
hubsList: Array<Node>;
|
||||||
hubCountries?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
hubsNearOffer: Array<Node>;
|
||||||
/** Get hubs where a product is available nearby */
|
nearestHubs: Array<Node>;
|
||||||
hubsForProduct?: Maybe<Array<Maybe<NodeType>>>;
|
nearestNodes: Array<Node>;
|
||||||
/** Get paginated list of logistics hubs */
|
nearestOffers: Array<OfferWithRoute>;
|
||||||
hubsList?: Maybe<Array<Maybe<NodeType>>>;
|
nearestSuppliers: Array<Supplier>;
|
||||||
/** Get nearest hubs to an offer location */
|
node?: Maybe<Node>;
|
||||||
hubsNearOffer?: Maybe<Array<Maybe<NodeType>>>;
|
nodeConnections?: Maybe<NodeConnections>;
|
||||||
/** Find nearest hubs to coordinates (optionally filtered by product) */
|
nodes: Array<Node>;
|
||||||
nearestHubs?: Maybe<Array<Maybe<NodeType>>>;
|
nodesCount: Scalars['Int']['output'];
|
||||||
/** Find nearest logistics nodes to given coordinates */
|
offerToHub?: Maybe<ProductRouteOption>;
|
||||||
nearestNodes?: Maybe<Array<Maybe<NodeType>>>;
|
offersByHub: Array<ProductRouteOption>;
|
||||||
/** Find nearest offers to coordinates with optional routes to hub */
|
offersByProduct: Array<OfferNode>;
|
||||||
nearestOffers?: Maybe<Array<Maybe<OfferWithRouteType>>>;
|
offersBySupplierProduct: Array<OfferNode>;
|
||||||
/** Find nearest suppliers to coordinates (optionally filtered by product) */
|
products: Array<Product>;
|
||||||
nearestSuppliers?: Maybe<Array<Maybe<SupplierType>>>;
|
productsBySupplier: Array<Product>;
|
||||||
/** Get node by UUID with all edges to neighbors */
|
productsList: Array<Product>;
|
||||||
node?: Maybe<NodeType>;
|
productsNearHub: Array<Product>;
|
||||||
/** Get auto + rail edges for a node (rail uses nearest rail node) */
|
railRoute?: Maybe<Route>;
|
||||||
nodeConnections?: Maybe<NodeConnectionsType>;
|
routeToCoordinate?: Maybe<ProductRouteOption>;
|
||||||
/** Get all nodes (without edges for performance) */
|
suppliers: Array<Supplier>;
|
||||||
nodes?: Maybe<Array<Maybe<NodeType>>>;
|
suppliersForProduct: Array<Supplier>;
|
||||||
/** Get total count of nodes (with optional transport/country/bounds filter) */
|
suppliersList: Array<Supplier>;
|
||||||
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>>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryAutoRouteArgs = {
|
export type QueryAutoRouteArgs = {
|
||||||
fromLat: Scalars['Float']['input'];
|
fromLat: Scalars['Float']['input'];
|
||||||
fromLon: Scalars['Float']['input'];
|
fromLon: Scalars['Float']['input'];
|
||||||
@@ -195,7 +153,6 @@ export type QueryAutoRouteArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryClusteredNodesArgs = {
|
export type QueryClusteredNodesArgs = {
|
||||||
east: Scalars['Float']['input'];
|
east: Scalars['Float']['input'];
|
||||||
nodeType?: InputMaybe<Scalars['String']['input']>;
|
nodeType?: InputMaybe<Scalars['String']['input']>;
|
||||||
@@ -207,30 +164,30 @@ export type QueryClusteredNodesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryHubsForProductArgs = {
|
export type QueryHubsForProductArgs = {
|
||||||
productUuid: Scalars['String']['input'];
|
productUuid: Scalars['String']['input'];
|
||||||
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryHubsListArgs = {
|
export type QueryHubsListArgs = {
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
transportType?: InputMaybe<Scalars['String']['input']>;
|
transportType?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryHubsNearOfferArgs = {
|
export type QueryHubsNearOfferArgs = {
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
offerUuid: Scalars['String']['input'];
|
offerUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNearestHubsArgs = {
|
export type QueryNearestHubsArgs = {
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -240,7 +197,6 @@ export type QueryNearestHubsArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNearestNodesArgs = {
|
export type QueryNearestNodesArgs = {
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -248,7 +204,6 @@ export type QueryNearestNodesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNearestOffersArgs = {
|
export type QueryNearestOffersArgs = {
|
||||||
hubUuid?: InputMaybe<Scalars['String']['input']>;
|
hubUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -259,7 +214,6 @@ export type QueryNearestOffersArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNearestSuppliersArgs = {
|
export type QueryNearestSuppliersArgs = {
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -269,13 +223,11 @@ export type QueryNearestSuppliersArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNodeArgs = {
|
export type QueryNodeArgs = {
|
||||||
uuid: Scalars['String']['input'];
|
uuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNodeConnectionsArgs = {
|
export type QueryNodeConnectionsArgs = {
|
||||||
limitAuto?: InputMaybe<Scalars['Int']['input']>;
|
limitAuto?: InputMaybe<Scalars['Int']['input']>;
|
||||||
limitRail?: InputMaybe<Scalars['Int']['input']>;
|
limitRail?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -283,7 +235,6 @@ export type QueryNodeConnectionsArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNodesArgs = {
|
export type QueryNodesArgs = {
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
east?: InputMaybe<Scalars['Float']['input']>;
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
@@ -297,7 +248,6 @@ export type QueryNodesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryNodesCountArgs = {
|
export type QueryNodesCountArgs = {
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
east?: InputMaybe<Scalars['Float']['input']>;
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
@@ -308,14 +258,12 @@ export type QueryNodesCountArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryOfferToHubArgs = {
|
export type QueryOfferToHubArgs = {
|
||||||
hubUuid: Scalars['String']['input'];
|
hubUuid: Scalars['String']['input'];
|
||||||
offerUuid: Scalars['String']['input'];
|
offerUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryOffersByHubArgs = {
|
export type QueryOffersByHubArgs = {
|
||||||
hubUuid: Scalars['String']['input'];
|
hubUuid: Scalars['String']['input'];
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -323,40 +271,38 @@ export type QueryOffersByHubArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryOffersByProductArgs = {
|
export type QueryOffersByProductArgs = {
|
||||||
productUuid: Scalars['String']['input'];
|
productUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryOffersBySupplierProductArgs = {
|
export type QueryOffersBySupplierProductArgs = {
|
||||||
productUuid: Scalars['String']['input'];
|
productUuid: Scalars['String']['input'];
|
||||||
supplierUuid: Scalars['String']['input'];
|
supplierUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryProductsBySupplierArgs = {
|
export type QueryProductsBySupplierArgs = {
|
||||||
supplierUuid: Scalars['String']['input'];
|
supplierUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryProductsListArgs = {
|
export type QueryProductsListArgs = {
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryProductsNearHubArgs = {
|
export type QueryProductsNearHubArgs = {
|
||||||
hubUuid: Scalars['String']['input'];
|
hubUuid: Scalars['String']['input'];
|
||||||
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
radiusKm?: InputMaybe<Scalars['Float']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryRailRouteArgs = {
|
export type QueryRailRouteArgs = {
|
||||||
fromLat: Scalars['Float']['input'];
|
fromLat: Scalars['Float']['input'];
|
||||||
fromLon: Scalars['Float']['input'];
|
fromLon: Scalars['Float']['input'];
|
||||||
@@ -365,7 +311,6 @@ export type QueryRailRouteArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QueryRouteToCoordinateArgs = {
|
export type QueryRouteToCoordinateArgs = {
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
lon: Scalars['Float']['input'];
|
lon: Scalars['Float']['input'];
|
||||||
@@ -373,30 +318,36 @@ export type QueryRouteToCoordinateArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QuerySuppliersForProductArgs = {
|
export type QuerySuppliersForProductArgs = {
|
||||||
productUuid: Scalars['String']['input'];
|
productUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Root query. */
|
|
||||||
export type QuerySuppliersListArgs = {
|
export type QuerySuppliersListArgs = {
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Complete route through graph with multiple stages. */
|
export type Route = {
|
||||||
export type RoutePathType = {
|
__typename?: 'Route';
|
||||||
__typename?: 'RoutePathType';
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
stages?: Maybe<Array<Maybe<RouteStageType>>>;
|
geometry?: Maybe<Scalars['JSON']['output']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoutePath = {
|
||||||
|
__typename?: 'RoutePath';
|
||||||
|
stages?: Maybe<Array<Maybe<RouteStage>>>;
|
||||||
totalDistanceKm?: Maybe<Scalars['Float']['output']>;
|
totalDistanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
totalTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
totalTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Single stage in a multi-hop route. */
|
export type RouteStage = {
|
||||||
export type RouteStageType = {
|
__typename?: 'RouteStage';
|
||||||
__typename?: 'RouteStageType';
|
|
||||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
fromLat?: Maybe<Scalars['Float']['output']>;
|
fromLat?: Maybe<Scalars['Float']['output']>;
|
||||||
fromLon?: Maybe<Scalars['Float']['output']>;
|
fromLon?: Maybe<Scalars['Float']['output']>;
|
||||||
@@ -410,17 +361,8 @@ export type RouteStageType = {
|
|||||||
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Route between two points with geometry. */
|
export type Supplier = {
|
||||||
export type RouteType = {
|
__typename?: 'Supplier';
|
||||||
__typename?: 'RouteType';
|
|
||||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
|
||||||
/** GeoJSON LineString coordinates */
|
|
||||||
geometry?: Maybe<Scalars['JSONString']['output']>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unique supplier from offers. */
|
|
||||||
export type SupplierType = {
|
|
||||||
__typename?: 'SupplierType';
|
|
||||||
distanceKm?: Maybe<Scalars['Float']['output']>;
|
distanceKm?: Maybe<Scalars['Float']['output']>;
|
||||||
latitude?: Maybe<Scalars['Float']['output']>;
|
latitude?: Maybe<Scalars['Float']['output']>;
|
||||||
longitude?: Maybe<Scalars['Float']['output']>;
|
longitude?: Maybe<Scalars['Float']['output']>;
|
||||||
@@ -436,7 +378,7 @@ export type GetAutoRouteQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: any | null } | null };
|
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||||
|
|
||||||
export type GetClusteredNodesQueryVariables = Exact<{
|
export type GetClusteredNodesQueryVariables = Exact<{
|
||||||
west: Scalars['Float']['input'];
|
west: Scalars['Float']['input'];
|
||||||
@@ -449,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 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<{
|
export type GetNodeQueryVariables = Exact<{
|
||||||
uuid: Scalars['String']['input'];
|
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<{
|
export type GetRailRouteQueryVariables = Exact<{
|
||||||
fromLat: Scalars['Float']['input'];
|
fromLat: Scalars['Float']['input'];
|
||||||
@@ -471,17 +413,21 @@ export type GetRailRouteQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: any | null } | null };
|
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||||
|
|
||||||
export type HubsListQueryVariables = Exact<{
|
export type HubsListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
transportType?: InputMaybe<Scalars['String']['input']>;
|
transportType?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
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<{
|
export type NearestHubsQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -492,7 +438,7 @@ export type NearestHubsQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
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 } | 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<{
|
export type NearestOffersQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -504,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<{
|
export type NearestSuppliersQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -515,24 +461,32 @@ 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<{
|
export type ProductsListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
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<{
|
export type SuppliersListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||||
country?: InputMaybe<Scalars['String']['input']>;
|
country?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
west?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
south?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
|
north?: InputMaybe<Scalars['Float']['input']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
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>;
|
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>;
|
||||||
@@ -540,9 +494,9 @@ export const GetClusteredNodesDocument = {"kind":"Document","definitions":[{"kin
|
|||||||
export const GetHubCountriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetHubCountries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hubCountries"}}]}}]} as unknown as DocumentNode<GetHubCountriesQueryResult, GetHubCountriesQueryVariables>;
|
export const GetHubCountriesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetHubCountries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hubCountries"}}]}}]} as unknown as DocumentNode<GetHubCountriesQueryResult, GetHubCountriesQueryVariables>;
|
||||||
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 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 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"}}}],"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"}}}],"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 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"}}]}}]}}]} 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 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 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"}}}],"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"}}}],"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 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"}}}],"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"}}}],"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; }
|
Boolean: { input: boolean; output: boolean; }
|
||||||
Int: { input: number; output: number; }
|
Int: { input: number; output: number; }
|
||||||
Float: { input: number; output: number; }
|
Float: { input: number; output: number; }
|
||||||
DateTime: { input: string; output: string; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Full company data (requires auth). */
|
export type CompanyFull = {
|
||||||
export type CompanyFullType = {
|
__typename?: 'CompanyFull';
|
||||||
__typename?: 'CompanyFullType';
|
|
||||||
activities?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
activities?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||||
address?: Maybe<Scalars['String']['output']>;
|
address?: Maybe<Scalars['String']['output']>;
|
||||||
capital?: Maybe<Scalars['String']['output']>;
|
capital?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -26,45 +24,35 @@ export type CompanyFullType = {
|
|||||||
director?: Maybe<Scalars['String']['output']>;
|
director?: Maybe<Scalars['String']['output']>;
|
||||||
inn?: Maybe<Scalars['String']['output']>;
|
inn?: Maybe<Scalars['String']['output']>;
|
||||||
isActive?: Maybe<Scalars['Boolean']['output']>;
|
isActive?: Maybe<Scalars['Boolean']['output']>;
|
||||||
lastUpdated?: Maybe<Scalars['DateTime']['output']>;
|
lastUpdated?: Maybe<Scalars['String']['output']>;
|
||||||
name?: Maybe<Scalars['String']['output']>;
|
name?: Maybe<Scalars['String']['output']>;
|
||||||
ogrn?: Maybe<Scalars['String']['output']>;
|
ogrn?: Maybe<Scalars['String']['output']>;
|
||||||
registrationYear?: Maybe<Scalars['Int']['output']>;
|
registrationYear?: Maybe<Scalars['Int']['output']>;
|
||||||
sources?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
sources?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Public company data (teaser). */
|
export type CompanyTeaser = {
|
||||||
export type CompanyTeaserType = {
|
__typename?: 'CompanyTeaser';
|
||||||
__typename?: 'CompanyTeaserType';
|
|
||||||
/** Company type: ООО, АО, ИП, etc. */
|
|
||||||
companyType?: Maybe<Scalars['String']['output']>;
|
companyType?: Maybe<Scalars['String']['output']>;
|
||||||
/** Is company active */
|
|
||||||
isActive?: Maybe<Scalars['Boolean']['output']>;
|
isActive?: Maybe<Scalars['Boolean']['output']>;
|
||||||
/** Year of registration */
|
|
||||||
registrationYear?: Maybe<Scalars['Int']['output']>;
|
registrationYear?: Maybe<Scalars['Int']['output']>;
|
||||||
/** Number of data sources */
|
|
||||||
sourcesCount?: Maybe<Scalars['Int']['output']>;
|
sourcesCount?: Maybe<Scalars['Int']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Public queries - no authentication required. */
|
export type Query = {
|
||||||
export type PublicQuery = {
|
__typename?: 'Query';
|
||||||
__typename?: 'PublicQuery';
|
health: Scalars['String']['output'];
|
||||||
health?: Maybe<Scalars['String']['output']>;
|
kycProfileFull?: Maybe<CompanyFull>;
|
||||||
/** Get full KYC profile data by UUID (requires auth) */
|
kycProfileTeaser?: Maybe<CompanyTeaser>;
|
||||||
kycProfileFull?: Maybe<CompanyFullType>;
|
|
||||||
/** Get public KYC profile teaser data by UUID */
|
|
||||||
kycProfileTeaser?: Maybe<CompanyTeaserType>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public queries - no authentication required. */
|
export type QueryKycProfileFullArgs = {
|
||||||
export type PublicQueryKycProfileFullArgs = {
|
|
||||||
profileUuid: Scalars['String']['input'];
|
profileUuid: Scalars['String']['input'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** Public queries - no authentication required. */
|
export type QueryKycProfileTeaserArgs = {
|
||||||
export type PublicQueryKycProfileTeaserArgs = {
|
|
||||||
profileUuid: Scalars['String']['input'];
|
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<{
|
export type GetKycProfileTeaserQueryVariables = Exact<{
|
||||||
profileUuid: Scalars['String']['input'];
|
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>;
|
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>;
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export type Scalars = {
|
|||||||
Boolean: { input: boolean; output: boolean; }
|
Boolean: { input: boolean; output: boolean; }
|
||||||
Int: { input: number; output: number; }
|
Int: { input: number; output: number; }
|
||||||
Float: { input: number; output: number; }
|
Float: { input: number; output: number; }
|
||||||
Date: { input: any; output: any; }
|
Date: { input: string; output: string; }
|
||||||
DateTime: { input: string; output: string; }
|
DateTime: { input: string; output: string; }
|
||||||
Decimal: { input: any; output: any; }
|
Decimal: { input: string; output: string; }
|
||||||
JSONString: { input: any; output: any; }
|
JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateOffer = {
|
export type CreateOffer = {
|
||||||
@@ -205,14 +205,14 @@ export type CreateRequestMutationVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type CreateRequestMutationResult = { __typename?: 'TeamMutation', createRequest?: { __typename?: 'CreateRequest', request?: { __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null } | null };
|
export type CreateRequestMutationResult = { __typename?: 'TeamMutation', createRequest?: { __typename?: 'CreateRequest', request?: { __typename?: 'RequestType', uuid: string, productUuid: string, quantity: string, sourceLocationUuid: string, userId: string } | null } | null };
|
||||||
|
|
||||||
export type GetRequestsQueryVariables = Exact<{
|
export type GetRequestsQueryVariables = Exact<{
|
||||||
userId: Scalars['String']['input'];
|
userId: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetRequestsQueryResult = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null> | null };
|
export type GetRequestsQueryResult = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: string, sourceLocationUuid: string, userId: string } | null> | null };
|
||||||
|
|
||||||
|
|
||||||
export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}},{"kind":"Field","name":{"kind":"Name","value":"offerUuid"}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutationResult, CreateOfferMutationVariables>;
|
export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}},{"kind":"Field","name":{"kind":"Name","value":"offerUuid"}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutationResult, CreateOfferMutationVariables>;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type Scalars = {
|
|||||||
Int: { input: number; output: number; }
|
Int: { input: number; output: number; }
|
||||||
Float: { input: number; output: number; }
|
Float: { input: number; output: number; }
|
||||||
DateTime: { input: string; output: string; }
|
DateTime: { input: string; output: string; }
|
||||||
JSONString: { input: any; output: any; }
|
JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Create KYC Application for Russian company. */
|
/** Create KYC Application for Russian company. */
|
||||||
@@ -110,19 +110,19 @@ export type CreateKycApplicationRussiaMutationVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type CreateKycApplicationRussiaMutationResult = { __typename?: 'UserMutation', createKycApplicationRussia?: { __typename?: 'CreateKYCApplicationRussia', success?: boolean | null, kycApplication?: { __typename?: 'KYCApplicationType', uuid: string, contactEmail: string, createdAt: string, countryData?: any | null } | null } | null };
|
export type CreateKycApplicationRussiaMutationResult = { __typename?: 'UserMutation', createKycApplicationRussia?: { __typename?: 'CreateKYCApplicationRussia', success?: boolean | null, kycApplication?: { __typename?: 'KYCApplicationType', uuid: string, contactEmail: string, createdAt: string, countryData?: Record<string, unknown> | null } | null } | null };
|
||||||
|
|
||||||
export type GetKycRequestRussiaQueryVariables = Exact<{
|
export type GetKycRequestRussiaQueryVariables = Exact<{
|
||||||
uuid: Scalars['String']['input'];
|
uuid: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetKycRequestRussiaQueryResult = { __typename?: 'UserQuery', kycRequest?: { __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: any | null } | null };
|
export type GetKycRequestRussiaQueryResult = { __typename?: 'UserQuery', kycRequest?: { __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: Record<string, unknown> | null } | null };
|
||||||
|
|
||||||
export type GetKycRequestsRussiaQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetKycRequestsRussiaQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type GetKycRequestsRussiaQueryResult = { __typename?: 'UserQuery', kycRequests?: Array<{ __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: any | null } | null> | null };
|
export type GetKycRequestsRussiaQueryResult = { __typename?: 'UserQuery', kycRequests?: Array<{ __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: Record<string, unknown> | null } | null> | null };
|
||||||
|
|
||||||
|
|
||||||
export const CreateKycApplicationRussiaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateKYCApplicationRussia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"KYCApplicationRussiaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createKycApplicationRussia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"kycApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"contactEmail"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"countryData"}}]}}]}}]}}]} as unknown as DocumentNode<CreateKycApplicationRussiaMutationResult, CreateKycApplicationRussiaMutationVariables>;
|
export const CreateKycApplicationRussiaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateKYCApplicationRussia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"KYCApplicationRussiaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createKycApplicationRussia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"kycApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"contactEmail"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"countryData"}}]}}]}}]}}]} as unknown as DocumentNode<CreateKycApplicationRussiaMutationResult, CreateKycApplicationRussiaMutationVariables>;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } 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]>
|
||||||
|
export type CatalogNearestHubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
|
||||||
|
|
||||||
|
// Internal aliases
|
||||||
|
type HubItem = CatalogHubItem
|
||||||
|
type NearestHubItem = CatalogNearestHubItem
|
||||||
|
|
||||||
// Shared state across list and map views
|
// Shared state across list and map views
|
||||||
const items = ref<any[]>([])
|
const items = ref<Array<HubItem | NearestHubItem>>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const selectedFilter = ref('all')
|
const selectedFilter = ref('all')
|
||||||
const selectedCountry = ref('all')
|
const selectedCountry = ref('all')
|
||||||
@@ -36,7 +45,7 @@ export function useCatalogHubs() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const itemsByCountry = computed(() => {
|
const itemsByCountry = computed(() => {
|
||||||
const grouped = new Map<string, any[]>()
|
const grouped = new Map<string, Array<HubItem | NearestHubItem>>()
|
||||||
items.value.forEach(hub => {
|
items.value.forEach(hub => {
|
||||||
const country = hub.country || t('catalogMap.labels.country_unknown')
|
const country = hub.country || t('catalogMap.labels.country_unknown')
|
||||||
if (!grouped.has(country)) grouped.set(country, [])
|
if (!grouped.has(country)) grouped.set(country, [])
|
||||||
@@ -52,22 +61,21 @@ export function useCatalogHubs() {
|
|||||||
const fetchPage = async (offset: number, replace = false) => {
|
const fetchPage = async (offset: number, replace = false) => {
|
||||||
if (replace) isLoading.value = true
|
if (replace) isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// If filtering by product, use nearestHubs with global search
|
// If filtering by product, use nearestHubs (graph-based)
|
||||||
// (center point 0,0 with very large radius to cover entire globe)
|
|
||||||
if (filterProductUuid.value) {
|
if (filterProductUuid.value) {
|
||||||
const data = await execute(
|
const data = await execute(
|
||||||
NearestHubsDocument,
|
NearestHubsDocument,
|
||||||
{
|
{
|
||||||
lat: 0,
|
lat: 0,
|
||||||
lon: 0,
|
lon: 0,
|
||||||
radius: 20000, // 20000 km radius covers entire Earth
|
|
||||||
productUuid: filterProductUuid.value,
|
productUuid: filterProductUuid.value,
|
||||||
|
useGraph: true,
|
||||||
limit: 500 // Increased limit for global search
|
limit: 500 // Increased limit for global search
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
const next = data?.nearestHubs || []
|
const next = (data?.nearestHubs || []).filter((h): h is NearestHubItem => h !== null)
|
||||||
items.value = next
|
items.value = next
|
||||||
total.value = next.length
|
total.value = next.length
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
@@ -84,12 +92,18 @@ export function useCatalogHubs() {
|
|||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
transportType,
|
transportType,
|
||||||
country
|
country,
|
||||||
|
...(filterBounds.value && {
|
||||||
|
west: filterBounds.value.west,
|
||||||
|
south: filterBounds.value.south,
|
||||||
|
east: filterBounds.value.east,
|
||||||
|
north: filterBounds.value.north
|
||||||
|
})
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
const next = data?.hubsList || []
|
const next = (data?.hubsList || []).filter((h): h is HubItem => h !== null)
|
||||||
items.value = replace ? next : items.value.concat(next)
|
items.value = replace ? next : items.value.concat(next)
|
||||||
// hubsList doesn't return total count, estimate from fetched items
|
// hubsList doesn't return total count, estimate from fetched items
|
||||||
if (replace) {
|
if (replace) {
|
||||||
@@ -132,12 +146,21 @@ export function useCatalogHubs() {
|
|||||||
const setProductFilter = (uuid: string | null) => {
|
const setProductFilter = (uuid: string | null) => {
|
||||||
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
||||||
filterProductUuid.value = uuid
|
filterProductUuid.value = uuid
|
||||||
if (isInitialized.value) {
|
fetchPage(0, true)
|
||||||
fetchPage(0, true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||||
|
// Early return if bounds haven't changed
|
||||||
|
const prev = filterBounds.value
|
||||||
|
const same = prev === bounds || (
|
||||||
|
prev && bounds &&
|
||||||
|
prev.west === bounds.west &&
|
||||||
|
prev.south === bounds.south &&
|
||||||
|
prev.east === bounds.east &&
|
||||||
|
prev.north === bounds.north
|
||||||
|
)
|
||||||
|
if (same) return
|
||||||
|
|
||||||
filterBounds.value = bounds
|
filterBounds.value = bounds
|
||||||
if (isInitialized.value) {
|
if (isInitialized.value) {
|
||||||
fetchPage(0, true)
|
fetchPage(0, true)
|
||||||
|
|||||||
@@ -1,24 +1,95 @@
|
|||||||
import type { InfoEntityType } from './useCatalogSearch'
|
import type { InfoEntityType } from './useCatalogSearch'
|
||||||
|
import type {
|
||||||
|
GetNodeQueryResult,
|
||||||
|
NearestHubsQueryResult,
|
||||||
|
NearestOffersQueryResult
|
||||||
|
} from '~/composables/graphql/public/geo-generated'
|
||||||
import {
|
import {
|
||||||
GetNodeDocument,
|
GetNodeDocument,
|
||||||
NearestOffersDocument,
|
NearestOffersDocument,
|
||||||
NearestHubsDocument
|
NearestHubsDocument
|
||||||
} from '~/composables/graphql/public/geo-generated'
|
} from '~/composables/graphql/public/geo-generated'
|
||||||
|
import type {
|
||||||
|
GetOfferQueryResult,
|
||||||
|
GetSupplierProfileQueryResult
|
||||||
|
} from '~/composables/graphql/public/exchange-generated'
|
||||||
import {
|
import {
|
||||||
GetOfferDocument,
|
GetOfferDocument,
|
||||||
GetSupplierProfileDocument
|
GetSupplierProfileDocument,
|
||||||
|
GetSupplierOffersDocument
|
||||||
} from '~/composables/graphql/public/exchange-generated'
|
} from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
// Types from codegen
|
||||||
|
type NodeEntity = NonNullable<GetNodeQueryResult['node']>
|
||||||
|
type OfferEntity = NonNullable<GetOfferQueryResult['getOffer']>
|
||||||
|
type SupplierProfile = NonNullable<GetSupplierProfileQueryResult['getSupplierProfile']>
|
||||||
|
type HubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
|
||||||
|
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
|
||||||
|
|
||||||
|
// Product type (aggregated from offers)
|
||||||
|
export interface InfoProductItem {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
offersCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types for InfoPanel
|
||||||
|
export type InfoHubItem = HubItem
|
||||||
|
export type InfoSupplierItem = SupplierProfile
|
||||||
|
export type InfoOfferItem = OfferItem
|
||||||
|
|
||||||
|
// Extended entity type with all known fields (NO index signature!)
|
||||||
|
export interface InfoEntity {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
// Node coordinates
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
// Location fields
|
||||||
|
address?: string | null
|
||||||
|
city?: string | null
|
||||||
|
country?: string | null
|
||||||
|
// Offer coordinates (different field names)
|
||||||
|
locationLatitude?: number | null
|
||||||
|
locationLongitude?: number | null
|
||||||
|
locationUuid?: string | null
|
||||||
|
locationName?: string | null
|
||||||
|
// Offer fields
|
||||||
|
productUuid?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
teamUuid?: string | null
|
||||||
|
teamName?: string | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
unit?: string | null
|
||||||
|
// Enriched field from supplier profile
|
||||||
|
supplierName?: string | null
|
||||||
|
// KYC profile reference
|
||||||
|
kycProfileUuid?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get coordinates from entity (handles both node and offer patterns)
|
||||||
|
function getEntityCoords(e: InfoEntity | null): { lat: number; lon: number } | null {
|
||||||
|
if (!e) return null
|
||||||
|
// Try offer coords first (locationLatitude/locationLongitude)
|
||||||
|
const lat = e.locationLatitude ?? e.latitude
|
||||||
|
const lon = e.locationLongitude ?? e.longitude
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
return { lat, lon }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function useCatalogInfo() {
|
export function useCatalogInfo() {
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
// State
|
// State with proper types
|
||||||
const entity = ref<any>(null)
|
const entity = ref<InfoEntity | null>(null)
|
||||||
const entityType = ref<InfoEntityType | null>(null) // Track entity type explicitly
|
const entityType = ref<InfoEntityType | null>(null)
|
||||||
const relatedProducts = ref<any[]>([])
|
const relatedProducts = ref<InfoProductItem[]>([])
|
||||||
const relatedHubs = ref<any[]>([])
|
const relatedHubs = ref<HubItem[]>([])
|
||||||
const relatedSuppliers = ref<any[]>([])
|
const relatedSuppliers = ref<SupplierProfile[]>([])
|
||||||
const relatedOffers = ref<any[]>([])
|
const relatedOffers = ref<OfferItem[]>([])
|
||||||
const selectedProduct = ref<string | null>(null)
|
const selectedProduct = ref<string | null>(null)
|
||||||
const activeTab = ref<string>('products')
|
const activeTab = ref<string>('products')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -34,9 +105,10 @@ export function useCatalogInfo() {
|
|||||||
try {
|
try {
|
||||||
// Load hub node details
|
// Load hub node details
|
||||||
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
|
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
|
||||||
entity.value = nodeData?.node
|
entity.value = nodeData?.node ?? null
|
||||||
|
|
||||||
if (!entity.value?.latitude || !entity.value?.longitude) {
|
const coords = getEntityCoords(entity.value)
|
||||||
|
if (!coords) {
|
||||||
console.warn('Hub has no coordinates')
|
console.warn('Hub has no coordinates')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -52,40 +124,42 @@ export function useCatalogInfo() {
|
|||||||
execute(
|
execute(
|
||||||
NearestOffersDocument,
|
NearestOffersDocument,
|
||||||
{
|
{
|
||||||
lat: entity.value.latitude,
|
lat: coords.lat,
|
||||||
lon: entity.value.longitude,
|
lon: coords.lon,
|
||||||
radius: 500
|
hubUuid: uuid,
|
||||||
|
limit: 500
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
).then(offersData => {
|
).then(offersData => {
|
||||||
// Group offers by product
|
// Group offers by product
|
||||||
const productsMap = new Map<string, any>()
|
const productsMap = new Map<string, InfoProductItem>()
|
||||||
const suppliersMap = new Map<string, any>()
|
const suppliersMap = new Map<string, { uuid: string; name: string; latitude?: number | null; longitude?: number | null }>()
|
||||||
|
|
||||||
offersData?.nearestOffers?.forEach((offer: any) => {
|
offersData?.nearestOffers?.forEach(offer => {
|
||||||
|
if (!offer) return
|
||||||
// Products
|
// Products
|
||||||
if (offer?.productUuid) {
|
if (offer.productUuid && offer.productName) {
|
||||||
if (!productsMap.has(offer.productUuid)) {
|
const existing = productsMap.get(offer.productUuid)
|
||||||
|
if (existing) {
|
||||||
|
existing.offersCount = (existing.offersCount || 0) + 1
|
||||||
|
} else {
|
||||||
productsMap.set(offer.productUuid, {
|
productsMap.set(offer.productUuid, {
|
||||||
uuid: offer.productUuid,
|
uuid: offer.productUuid,
|
||||||
name: offer.productName,
|
name: offer.productName,
|
||||||
offersCount: 0
|
offersCount: 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
productsMap.get(offer.productUuid)!.offersCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppliers (extract from offers)
|
// Suppliers (extract from offers)
|
||||||
if (offer?.supplierUuid) {
|
if (offer.supplierUuid && !suppliersMap.has(offer.supplierUuid)) {
|
||||||
if (!suppliersMap.has(offer.supplierUuid)) {
|
suppliersMap.set(offer.supplierUuid, {
|
||||||
suppliersMap.set(offer.supplierUuid, {
|
uuid: offer.supplierUuid,
|
||||||
uuid: offer.supplierUuid,
|
name: offer.supplierName || 'Supplier',
|
||||||
name: offer.supplierName || 'Supplier',
|
latitude: offer.latitude,
|
||||||
latitude: offer.latitude,
|
longitude: offer.longitude
|
||||||
longitude: offer.longitude
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,7 +175,7 @@ export function useCatalogInfo() {
|
|||||||
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
|
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
|
||||||
)
|
)
|
||||||
).then(profiles => {
|
).then(profiles => {
|
||||||
relatedSuppliers.value = profiles.filter(Boolean)
|
relatedSuppliers.value = profiles.filter((p): p is SupplierProfile => p != null)
|
||||||
isLoadingSuppliers.value = false
|
isLoadingSuppliers.value = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -123,7 +197,7 @@ export function useCatalogInfo() {
|
|||||||
try {
|
try {
|
||||||
// Load supplier node details (might be geo node)
|
// Load supplier node details (might be geo node)
|
||||||
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
|
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
|
||||||
entity.value = nodeData?.node
|
entity.value = nodeData?.node ?? null
|
||||||
|
|
||||||
// Also try to get supplier profile from exchange API for additional details
|
// Also try to get supplier profile from exchange API for additional details
|
||||||
try {
|
try {
|
||||||
@@ -152,29 +226,25 @@ export function useCatalogInfo() {
|
|||||||
isLoadingProducts.value = true
|
isLoadingProducts.value = true
|
||||||
isLoadingHubs.value = true
|
isLoadingHubs.value = true
|
||||||
|
|
||||||
// Load products (offers grouped by product)
|
// Load products from supplier offers (no geo radius)
|
||||||
execute(
|
execute(
|
||||||
NearestOffersDocument,
|
GetSupplierOffersDocument,
|
||||||
{
|
{ teamUuid: uuid },
|
||||||
lat: entity.value.latitude,
|
|
||||||
lon: entity.value.longitude,
|
|
||||||
radius: 500
|
|
||||||
},
|
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'exchange'
|
||||||
).then(offersData => {
|
).then(offersData => {
|
||||||
// Group offers by product
|
const productsMap = new Map<string, InfoProductItem>()
|
||||||
const productsMap = new Map<string, any>()
|
offersData?.getOffers?.forEach(offer => {
|
||||||
offersData?.nearestOffers?.forEach((offer: any) => {
|
if (!offer?.productUuid || !offer.productName) return
|
||||||
if (offer?.productUuid) {
|
const existing = productsMap.get(offer.productUuid)
|
||||||
if (!productsMap.has(offer.productUuid)) {
|
if (existing) {
|
||||||
productsMap.set(offer.productUuid, {
|
existing.offersCount = (existing.offersCount || 0) + 1
|
||||||
uuid: offer.productUuid,
|
} else {
|
||||||
name: offer.productName,
|
productsMap.set(offer.productUuid, {
|
||||||
offersCount: 0
|
uuid: offer.productUuid,
|
||||||
})
|
name: offer.productName,
|
||||||
}
|
offersCount: 1
|
||||||
productsMap.get(offer.productUuid)!.offersCount++
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
relatedProducts.value = Array.from(productsMap.values())
|
relatedProducts.value = Array.from(productsMap.values())
|
||||||
@@ -188,13 +258,13 @@ export function useCatalogInfo() {
|
|||||||
{
|
{
|
||||||
lat: entity.value.latitude,
|
lat: entity.value.latitude,
|
||||||
lon: entity.value.longitude,
|
lon: entity.value.longitude,
|
||||||
radius: 1000,
|
sourceUuid: entity.value.uuid,
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
).then(hubsData => {
|
).then(hubsData => {
|
||||||
relatedHubs.value = hubsData?.nearestHubs || []
|
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
isLoadingHubs.value = false
|
isLoadingHubs.value = false
|
||||||
})
|
})
|
||||||
@@ -210,9 +280,10 @@ export function useCatalogInfo() {
|
|||||||
try {
|
try {
|
||||||
// Load offer details from exchange API
|
// Load offer details from exchange API
|
||||||
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
|
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
|
||||||
entity.value = offerData?.getOffer
|
entity.value = offerData?.getOffer ?? null
|
||||||
|
|
||||||
if (!entity.value?.latitude || !entity.value?.longitude) {
|
const coords = getEntityCoords(entity.value)
|
||||||
|
if (!coords) {
|
||||||
console.warn('Offer has no coordinates')
|
console.warn('Offer has no coordinates')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -235,15 +306,15 @@ export function useCatalogInfo() {
|
|||||||
execute(
|
execute(
|
||||||
NearestHubsDocument,
|
NearestHubsDocument,
|
||||||
{
|
{
|
||||||
lat: entity.value.latitude,
|
lat: coords.lat,
|
||||||
lon: entity.value.longitude,
|
lon: coords.lon,
|
||||||
radius: 1000,
|
sourceUuid: entity.value?.uuid ?? null,
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
).then(hubsData => {
|
).then(hubsData => {
|
||||||
relatedHubs.value = hubsData?.nearestHubs || []
|
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
isLoadingHubs.value = false
|
isLoadingHubs.value = false
|
||||||
})
|
})
|
||||||
@@ -257,9 +328,12 @@ export function useCatalogInfo() {
|
|||||||
'public',
|
'public',
|
||||||
'exchange'
|
'exchange'
|
||||||
).then(supplierData => {
|
).then(supplierData => {
|
||||||
relatedSuppliers.value = supplierData?.getSupplierProfile
|
const supplier = supplierData?.getSupplierProfile
|
||||||
? [supplierData.getSupplierProfile]
|
relatedSuppliers.value = supplier ? [supplier] : []
|
||||||
: []
|
// Enrich entity with supplier name for display
|
||||||
|
if (supplier?.name && entity.value) {
|
||||||
|
entity.value = { ...entity.value, supplierName: supplier.name }
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Supplier might not exist
|
// Supplier might not exist
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -293,7 +367,6 @@ export function useCatalogInfo() {
|
|||||||
lon: hub.longitude,
|
lon: hub.longitude,
|
||||||
productUuid,
|
productUuid,
|
||||||
hubUuid, // Pass hubUuid to get routes calculated on backend
|
hubUuid, // Pass hubUuid to get routes calculated on backend
|
||||||
radius: 500,
|
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
@@ -301,19 +374,19 @@ export function useCatalogInfo() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Offers already include routes from backend
|
// Offers already include routes from backend
|
||||||
relatedOffers.value = offersData?.nearestOffers || []
|
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
|
||||||
isLoadingOffers.value = false
|
isLoadingOffers.value = false
|
||||||
|
|
||||||
// Extract unique suppliers from offers (use supplierUuid from offers)
|
// Extract unique suppliers from offers (use supplierUuid from offers)
|
||||||
const supplierUuids = new Set<string>()
|
const supplierUuids = new Set<string>()
|
||||||
relatedOffers.value.forEach((offer: any) => {
|
relatedOffers.value.forEach(offer => {
|
||||||
if (offer.supplierUuid) {
|
if (offer.supplierUuid) {
|
||||||
supplierUuids.add(offer.supplierUuid)
|
supplierUuids.add(offer.supplierUuid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load supplier profiles (limit to 12)
|
// Load supplier profiles (limit to 12)
|
||||||
const suppliers: any[] = []
|
const suppliers: SupplierProfile[] = []
|
||||||
for (const uuid of Array.from(supplierUuids).slice(0, 12)) {
|
for (const uuid of Array.from(supplierUuids).slice(0, 12)) {
|
||||||
try {
|
try {
|
||||||
const supplierData = await execute(
|
const supplierData = await execute(
|
||||||
@@ -352,6 +425,28 @@ export function useCatalogInfo() {
|
|||||||
isLoadingHubs.value = true
|
isLoadingHubs.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let hubUuid: string | null = relatedHubs.value?.[0]?.uuid ?? null
|
||||||
|
if (!hubUuid && supplier.uuid) {
|
||||||
|
const hubsData = await execute(
|
||||||
|
NearestHubsDocument,
|
||||||
|
{
|
||||||
|
lat: supplier.latitude,
|
||||||
|
lon: supplier.longitude,
|
||||||
|
sourceUuid: supplier.uuid,
|
||||||
|
limit: 1
|
||||||
|
},
|
||||||
|
'public',
|
||||||
|
'geo'
|
||||||
|
)
|
||||||
|
const hub = (hubsData?.nearestHubs || []).find((h): h is HubItem => h !== null)
|
||||||
|
if (hub?.uuid) {
|
||||||
|
hubUuid = hub.uuid
|
||||||
|
if (!relatedHubs.value.length) {
|
||||||
|
relatedHubs.value = [hub]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find offers near supplier for this product
|
// Find offers near supplier for this product
|
||||||
const offersData = await execute(
|
const offersData = await execute(
|
||||||
NearestOffersDocument,
|
NearestOffersDocument,
|
||||||
@@ -359,46 +454,19 @@ export function useCatalogInfo() {
|
|||||||
lat: supplier.latitude,
|
lat: supplier.latitude,
|
||||||
lon: supplier.longitude,
|
lon: supplier.longitude,
|
||||||
productUuid,
|
productUuid,
|
||||||
radius: 500,
|
...(hubUuid ? { hubUuid } : {}),
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
|
|
||||||
relatedOffers.value = offersData?.nearestOffers || []
|
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
|
isLoadingOffers.value = false
|
||||||
|
|
||||||
// Load hubs near each offer and aggregate (limit to 12)
|
|
||||||
const allHubs = new Map<string, any>()
|
|
||||||
for (const offer of relatedOffers.value.slice(0, 3)) {
|
|
||||||
// Check first 3 offers
|
|
||||||
if (!offer.latitude || !offer.longitude) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hubsData = await execute(
|
|
||||||
NearestHubsDocument,
|
|
||||||
{
|
|
||||||
lat: offer.latitude,
|
|
||||||
lon: offer.longitude,
|
|
||||||
radius: 1000,
|
|
||||||
limit: 5
|
|
||||||
},
|
|
||||||
'public',
|
|
||||||
'geo'
|
|
||||||
)
|
|
||||||
hubsData?.nearestHubs?.forEach((hub: any) => {
|
|
||||||
if (!allHubs.has(hub.uuid)) {
|
|
||||||
allHubs.set(hub.uuid, hub)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error loading hubs for offer:', offer.uuid, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allHubs.size >= 12) break
|
|
||||||
}
|
|
||||||
relatedHubs.value = Array.from(allHubs.values()).slice(0, 12)
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingOffers.value = false
|
isLoadingOffers.value = false
|
||||||
isLoadingHubs.value = false
|
isLoadingHubs.value = false
|
||||||
@@ -415,10 +483,10 @@ export function useCatalogInfo() {
|
|||||||
if (!entity.value) return
|
if (!entity.value) return
|
||||||
|
|
||||||
// Use stored entity type instead of inferring from properties
|
// Use stored entity type instead of inferring from properties
|
||||||
if (entityType.value === 'hub') {
|
if (entityType.value === 'hub' && entity.value.uuid) {
|
||||||
await loadOffersForHub(entity.value.uuid, productUuid)
|
await loadOffersForHub(entity.value.uuid, productUuid)
|
||||||
activeTab.value = 'offers'
|
activeTab.value = 'offers'
|
||||||
} else if (entityType.value === 'supplier') {
|
} else if (entityType.value === 'supplier' && entity.value.uuid) {
|
||||||
await loadOffersForSupplier(entity.value.uuid, productUuid)
|
await loadOffersForSupplier(entity.value.uuid, productUuid)
|
||||||
activeTab.value = 'offers'
|
activeTab.value = 'offers'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import type { GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
|
// Type from codegen
|
||||||
|
type OfferItem = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
|
||||||
|
|
||||||
// Shared state across list and map views
|
// Shared state across list and map views
|
||||||
const items = ref<any[]>([])
|
const items = ref<OfferItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const selectedProductUuid = ref<string | null>(null)
|
const selectedProductUuid = ref<string | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -18,7 +22,7 @@ export function useCatalogOffers() {
|
|||||||
.filter(offer => offer.locationLatitude && offer.locationLongitude)
|
.filter(offer => offer.locationLatitude && offer.locationLongitude)
|
||||||
.map(offer => ({
|
.map(offer => ({
|
||||||
uuid: offer.uuid,
|
uuid: offer.uuid,
|
||||||
name: offer.productName || offer.title,
|
name: offer.productName || offer.locationName,
|
||||||
latitude: offer.locationLatitude,
|
latitude: offer.locationLatitude,
|
||||||
longitude: offer.locationLongitude,
|
longitude: offer.locationLongitude,
|
||||||
country: offer.locationCountry
|
country: offer.locationCountry
|
||||||
@@ -40,7 +44,7 @@ export function useCatalogOffers() {
|
|||||||
'public',
|
'public',
|
||||||
'exchange'
|
'exchange'
|
||||||
)
|
)
|
||||||
const next = data?.getOffers || []
|
const next = (data?.getOffers || []).filter((o): o is OfferItem => o !== null)
|
||||||
items.value = replace ? next : items.value.concat(next)
|
items.value = replace ? next : items.value.concat(next)
|
||||||
total.value = data?.getOffersCount ?? total.value
|
total.value = data?.getOffersCount ?? total.value
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
|
import type { ProductsListQueryResult, NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
import {
|
import {
|
||||||
ProductsListDocument,
|
ProductsListDocument,
|
||||||
GetNodeDocument,
|
GetNodeDocument,
|
||||||
NearestOffersDocument
|
NearestOffersDocument
|
||||||
} from '~/composables/graphql/public/geo-generated'
|
} from '~/composables/graphql/public/geo-generated'
|
||||||
import {
|
import {
|
||||||
GetSupplierProfileDocument
|
GetSupplierOffersDocument
|
||||||
} from '~/composables/graphql/public/exchange-generated'
|
} from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
// Type from codegen
|
||||||
|
type ProductItem = NonNullable<NonNullable<ProductsListQueryResult['productsList']>[number]>
|
||||||
|
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
|
||||||
|
|
||||||
|
// Product aggregated from offers
|
||||||
|
interface AggregatedProduct {
|
||||||
|
uuid: string
|
||||||
|
name: string | null | undefined
|
||||||
|
offersCount: number
|
||||||
|
}
|
||||||
|
|
||||||
// Shared state
|
// Shared state
|
||||||
const items = ref<any[]>([])
|
const items = ref<ProductItem[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
@@ -16,6 +28,7 @@ const isInitialized = ref(false)
|
|||||||
// Filter state
|
// Filter state
|
||||||
const filterSupplierUuid = ref<string | null>(null)
|
const filterSupplierUuid = ref<string | null>(null)
|
||||||
const filterHubUuid = ref<string | null>(null)
|
const filterHubUuid = ref<string | null>(null)
|
||||||
|
const filterBounds = ref<{ west: number; south: number; east: number; north: number } | null>(null)
|
||||||
|
|
||||||
export function useCatalogProducts() {
|
export function useCatalogProducts() {
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
@@ -30,47 +43,26 @@ export function useCatalogProducts() {
|
|||||||
let data
|
let data
|
||||||
|
|
||||||
if (filterSupplierUuid.value) {
|
if (filterSupplierUuid.value) {
|
||||||
// Products from specific supplier - get supplier coordinates first
|
// Products from specific supplier - get offers directly (no geo radius)
|
||||||
const supplierData = await execute(
|
const offersData = await execute(
|
||||||
GetSupplierProfileDocument,
|
GetSupplierOffersDocument,
|
||||||
{ uuid: filterSupplierUuid.value },
|
{ teamUuid: filterSupplierUuid.value },
|
||||||
'public',
|
'public',
|
||||||
'exchange'
|
'exchange'
|
||||||
)
|
)
|
||||||
const supplier = supplierData?.getSupplierProfile
|
const productsMap = new Map<string, AggregatedProduct>()
|
||||||
|
offersData?.getOffers?.forEach((offer) => {
|
||||||
if (!supplier?.latitude || !supplier?.longitude) {
|
if (!offer?.productUuid) return
|
||||||
console.warn('Supplier has no coordinates')
|
if (!productsMap.has(offer.productUuid)) {
|
||||||
items.value = []
|
productsMap.set(offer.productUuid, {
|
||||||
} else {
|
uuid: offer.productUuid,
|
||||||
// Get offers near supplier and group by product
|
name: offer.productName,
|
||||||
const offersData = await execute(
|
offersCount: 0
|
||||||
NearestOffersDocument,
|
})
|
||||||
{
|
}
|
||||||
lat: supplier.latitude,
|
productsMap.get(offer.productUuid)!.offersCount++
|
||||||
lon: supplier.longitude,
|
})
|
||||||
radius: 500
|
items.value = Array.from(productsMap.values()) as ProductItem[]
|
||||||
},
|
|
||||||
'public',
|
|
||||||
'geo'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Group offers by product
|
|
||||||
const productsMap = new Map<string, any>()
|
|
||||||
offersData?.nearestOffers?.forEach((offer: any) => {
|
|
||||||
if (offer?.productUuid) {
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
} else if (filterHubUuid.value) {
|
} else if (filterHubUuid.value) {
|
||||||
// Products near hub - get hub coordinates first
|
// Products near hub - get hub coordinates first
|
||||||
const hubData = await execute(
|
const hubData = await execute(
|
||||||
@@ -85,43 +77,51 @@ export function useCatalogProducts() {
|
|||||||
console.warn('Hub has no coordinates')
|
console.warn('Hub has no coordinates')
|
||||||
items.value = []
|
items.value = []
|
||||||
} else {
|
} else {
|
||||||
// Get offers near hub and group by product
|
// Get offers by graph from hub and group by product
|
||||||
const offersData = await execute(
|
const offersData = await execute(
|
||||||
NearestOffersDocument,
|
NearestOffersDocument,
|
||||||
{
|
{
|
||||||
lat: hub.latitude,
|
lat: hub.latitude,
|
||||||
lon: hub.longitude,
|
lon: hub.longitude,
|
||||||
radius: 500
|
hubUuid: filterHubUuid.value,
|
||||||
|
limit: 500
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Group offers by product
|
// Group offers by product
|
||||||
const productsMap = new Map<string, any>()
|
const productsMap = new Map<string, AggregatedProduct>()
|
||||||
offersData?.nearestOffers?.forEach((offer: any) => {
|
offersData?.nearestOffers?.forEach((offer) => {
|
||||||
if (offer?.productUuid) {
|
if (!offer?.productUuid) return
|
||||||
if (!productsMap.has(offer.productUuid)) {
|
if (!productsMap.has(offer.productUuid)) {
|
||||||
productsMap.set(offer.productUuid, {
|
productsMap.set(offer.productUuid, {
|
||||||
uuid: offer.productUuid,
|
uuid: offer.productUuid,
|
||||||
name: offer.productName,
|
name: offer.productName,
|
||||||
offersCount: 0
|
offersCount: 0
|
||||||
})
|
})
|
||||||
}
|
|
||||||
productsMap.get(offer.productUuid)!.offersCount++
|
|
||||||
}
|
}
|
||||||
|
productsMap.get(offer.productUuid)!.offersCount++
|
||||||
})
|
})
|
||||||
items.value = Array.from(productsMap.values())
|
items.value = Array.from(productsMap.values()) as ProductItem[]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// All products from graph
|
// All products from graph
|
||||||
data = await execute(
|
data = await execute(
|
||||||
ProductsListDocument,
|
ProductsListDocument,
|
||||||
{ limit: 500 },
|
{
|
||||||
|
limit: 500,
|
||||||
|
...(filterBounds.value && {
|
||||||
|
west: filterBounds.value.west,
|
||||||
|
south: filterBounds.value.south,
|
||||||
|
east: filterBounds.value.east,
|
||||||
|
north: filterBounds.value.north
|
||||||
|
})
|
||||||
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
items.value = data?.productsList || []
|
items.value = (data?.productsList || []).filter((p): p is ProductItem => p !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
@@ -168,11 +168,23 @@ export function useCatalogProducts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Products don't have coordinates directly (they're an aggregation of offers)
|
// Products are filtered by offer locations within bounds
|
||||||
// Bounds filtering would require a new backend query that filters by offer locations
|
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||||
// For now, this is a no-op - products show all regardless of map bounds
|
// Early return if bounds haven't changed
|
||||||
const setBoundsFilter = (_bounds: { west: number; south: number; east: number; north: number } | null) => {
|
const prev = filterBounds.value
|
||||||
// No-op: products are not filterable by map bounds in current implementation
|
const same = prev === bounds || (
|
||||||
|
prev && bounds &&
|
||||||
|
prev.west === bounds.west &&
|
||||||
|
prev.south === bounds.south &&
|
||||||
|
prev.east === bounds.east &&
|
||||||
|
prev.north === bounds.north
|
||||||
|
)
|
||||||
|
if (same) return
|
||||||
|
|
||||||
|
filterBounds.value = bounds
|
||||||
|
if (isInitialized.value) {
|
||||||
|
fetchProducts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ export function useCatalogSearch() {
|
|||||||
const hubId = computed(() => route.query.hub as string | undefined)
|
const hubId = computed(() => route.query.hub as string | undefined)
|
||||||
const quantity = computed(() => route.query.qty as string | undefined)
|
const quantity = computed(() => route.query.qty as string | undefined)
|
||||||
|
|
||||||
|
// Map bounds from URL (format: west,south,east,north)
|
||||||
|
const urlBounds = computed((): { west: number; south: number; east: number; north: number } | null => {
|
||||||
|
const b = route.query.bounds as string | undefined
|
||||||
|
if (!b) return null
|
||||||
|
const parts = b.split(',').map(Number)
|
||||||
|
if (parts.length !== 4 || parts.some(isNaN)) return null
|
||||||
|
return { west: parts[0]!, south: parts[1]!, east: parts[2]!, north: parts[3]! }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter by bounds checkbox state from URL
|
||||||
|
// 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)
|
// Get label for a filter (from cache or fallback to ID)
|
||||||
const getLabel = (type: string, id: string | undefined): string | null => {
|
const getLabel = (type: string, id: string | undefined): string | null => {
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
@@ -208,19 +221,26 @@ export function useCatalogSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startSelect = (type: SelectMode) => {
|
const startSelect = (type: SelectMode) => {
|
||||||
|
if (!selectMode.value) {
|
||||||
|
lastViewMode.value = mapViewMode.value
|
||||||
|
}
|
||||||
updateQuery({ select: type })
|
updateQuery({ select: type })
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelSelect = () => {
|
const cancelSelect = () => {
|
||||||
updateQuery({ select: null })
|
updateQuery({
|
||||||
|
select: null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectItem = (type: string, id: string, label: string) => {
|
const selectItem = (type: string, id: string, label: string) => {
|
||||||
setLabel(type, id, label)
|
setLabel(type, id, label)
|
||||||
|
const forcedView = (type === 'hub' || type === 'supplier') ? null : (lastViewMode.value === 'offers' ? null : lastViewMode.value)
|
||||||
updateQuery({
|
updateQuery({
|
||||||
[type]: id,
|
[type]: id,
|
||||||
select: null, // Exit selection mode
|
select: null, // Exit selection mode
|
||||||
info: null // Exit info mode
|
info: null, // Exit info mode
|
||||||
|
view: forcedView
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +257,26 @@ export function useCatalogSearch() {
|
|||||||
updateQuery({ qty })
|
updateQuery({ qty })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set map bounds in URL (for filter by map feature)
|
||||||
|
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, boundsFilter: '1' })
|
||||||
|
} else {
|
||||||
|
updateQuery({ bounds: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear bounds from URL
|
||||||
|
const clearBoundsFromUrl = () => {
|
||||||
|
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) => {
|
const openInfo = (type: InfoEntityType, uuid: string) => {
|
||||||
updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null })
|
updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null })
|
||||||
}
|
}
|
||||||
@@ -272,8 +312,15 @@ export function useCatalogSearch() {
|
|||||||
}
|
}
|
||||||
return 'offers' // default
|
return 'offers' // default
|
||||||
})
|
})
|
||||||
|
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
|
||||||
const setMapViewMode = (mode: MapViewMode) => {
|
const setMapViewMode = (mode: MapViewMode) => {
|
||||||
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
|
// Drawer state for list view
|
||||||
@@ -323,7 +370,15 @@ export function useCatalogSearch() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setCatalogMode = (newMode: CatalogMode) => {
|
const setCatalogMode = (newMode: CatalogMode) => {
|
||||||
updateQuery({ mode: newMode === 'explore' ? null : 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)
|
// Can search for offers (product + hub or product + supplier required)
|
||||||
@@ -350,6 +405,8 @@ export function useCatalogSearch() {
|
|||||||
quantity,
|
quantity,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
mapViewMode,
|
mapViewMode,
|
||||||
|
urlBounds,
|
||||||
|
filterByBounds,
|
||||||
|
|
||||||
// Drawer state
|
// Drawer state
|
||||||
isDrawerOpen,
|
isDrawerOpen,
|
||||||
@@ -373,6 +430,9 @@ export function useCatalogSearch() {
|
|||||||
removeFilter,
|
removeFilter,
|
||||||
editFilter,
|
editFilter,
|
||||||
setQuantity,
|
setQuantity,
|
||||||
|
setBoundsInUrl,
|
||||||
|
clearBoundsFromUrl,
|
||||||
|
setBoundsFilterEnabled,
|
||||||
openInfo,
|
openInfo,
|
||||||
closeInfo,
|
closeInfo,
|
||||||
setInfoTab,
|
setInfoTab,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
import { SuppliersListDocument, NearestSuppliersDocument } 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]>
|
||||||
|
type NearestSupplierItem = NonNullable<NonNullable<NearestSuppliersQueryResult['nearestSuppliers']>[number]>
|
||||||
|
|
||||||
// Shared state across list and map views
|
// Shared state across list and map views
|
||||||
const items = ref<any[]>([])
|
const items = ref<Array<SupplierItem | NearestSupplierItem>>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
@@ -15,7 +20,7 @@ export function useCatalogSuppliers() {
|
|||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
const itemsWithCoords = computed(() =>
|
const itemsWithCoords = computed(() =>
|
||||||
items.value.filter(s => s.latitude && s.longitude)
|
items.value.filter((s): s is NearestSupplierItem => 'latitude' in s && 'longitude' in s && s.latitude != null && s.longitude != null)
|
||||||
)
|
)
|
||||||
|
|
||||||
const canLoadMore = computed(() => items.value.length < total.value)
|
const canLoadMore = computed(() => items.value.length < total.value)
|
||||||
@@ -23,22 +28,20 @@ export function useCatalogSuppliers() {
|
|||||||
const fetchPage = async (offset: number, replace = false) => {
|
const fetchPage = async (offset: number, replace = false) => {
|
||||||
if (replace) isLoading.value = true
|
if (replace) isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// If filtering by product, use nearestSuppliers with global search
|
// If filtering by product, use nearestSuppliers (product-only list)
|
||||||
// (center point 0,0 with very large radius to cover entire globe)
|
|
||||||
if (filterProductUuid.value) {
|
if (filterProductUuid.value) {
|
||||||
const data = await execute(
|
const data = await execute(
|
||||||
NearestSuppliersDocument,
|
NearestSuppliersDocument,
|
||||||
{
|
{
|
||||||
lat: 0,
|
lat: 0,
|
||||||
lon: 0,
|
lon: 0,
|
||||||
radius: 20000, // 20000 km radius covers entire Earth
|
|
||||||
productUuid: filterProductUuid.value,
|
productUuid: filterProductUuid.value,
|
||||||
limit: 500 // Increased limit for global search
|
limit: 500 // Increased limit for global search
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
items.value = data?.nearestSuppliers || []
|
items.value = (data?.nearestSuppliers || []).filter((s): s is NearestSupplierItem => s !== null)
|
||||||
total.value = items.value.length
|
total.value = items.value.length
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
return
|
return
|
||||||
@@ -47,11 +50,20 @@ export function useCatalogSuppliers() {
|
|||||||
// Default: fetch all suppliers from GEO (graph-based)
|
// Default: fetch all suppliers from GEO (graph-based)
|
||||||
const data = await execute(
|
const data = await execute(
|
||||||
SuppliersListDocument,
|
SuppliersListDocument,
|
||||||
{ limit: PAGE_SIZE, offset },
|
{
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
...(filterBounds.value && {
|
||||||
|
west: filterBounds.value.west,
|
||||||
|
south: filterBounds.value.south,
|
||||||
|
east: filterBounds.value.east,
|
||||||
|
north: filterBounds.value.north
|
||||||
|
})
|
||||||
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
const next = data?.suppliersList || []
|
const next = (data?.suppliersList || []).filter((s): s is SupplierItem => s !== null)
|
||||||
|
|
||||||
items.value = replace ? next : items.value.concat(next)
|
items.value = replace ? next : items.value.concat(next)
|
||||||
// suppliersList doesn't return total count, estimate from fetched items
|
// suppliersList doesn't return total count, estimate from fetched items
|
||||||
@@ -86,12 +98,21 @@ export function useCatalogSuppliers() {
|
|||||||
const setProductFilter = (uuid: string | null) => {
|
const setProductFilter = (uuid: string | null) => {
|
||||||
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
if (filterProductUuid.value === uuid) return // Early return if unchanged
|
||||||
filterProductUuid.value = uuid
|
filterProductUuid.value = uuid
|
||||||
if (isInitialized.value) {
|
fetchPage(0, true)
|
||||||
fetchPage(0, true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||||
|
// Early return if bounds haven't changed
|
||||||
|
const prev = filterBounds.value
|
||||||
|
const same = prev === bounds || (
|
||||||
|
prev && bounds &&
|
||||||
|
prev.west === bounds.west &&
|
||||||
|
prev.south === bounds.south &&
|
||||||
|
prev.east === bounds.east &&
|
||||||
|
prev.north === bounds.north
|
||||||
|
)
|
||||||
|
if (same) return
|
||||||
|
|
||||||
filterBounds.value = bounds
|
filterBounds.value = bounds
|
||||||
if (isInitialized.value) {
|
if (isInitialized.value) {
|
||||||
fetchPage(0, true)
|
fetchPage(0, true)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GetClusteredNodesDocument } from './graphql/public/geo-generated'
|
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 {
|
export interface MapBounds {
|
||||||
west: number
|
west: number
|
||||||
@@ -11,11 +11,11 @@ export interface MapBounds {
|
|||||||
|
|
||||||
export function useClusteredNodes(
|
export function useClusteredNodes(
|
||||||
transportType?: Ref<string | undefined>,
|
transportType?: Ref<string | undefined>,
|
||||||
nodeType?: Ref<string | undefined>
|
nodeType?: Ref<string | undefined>,
|
||||||
) {
|
) {
|
||||||
const { client } = useApolloClient('publicGeo')
|
const { client } = useApolloClient('publicGeo')
|
||||||
|
|
||||||
const clusteredNodes = ref<ClusterPointType[]>([])
|
const clusteredNodes = ref<ClusterPoint[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const fetchClusters = async (bounds: MapBounds) => {
|
const fetchClusters = async (bounds: MapBounds) => {
|
||||||
@@ -30,12 +30,12 @@ export function useClusteredNodes(
|
|||||||
north: bounds.north,
|
north: bounds.north,
|
||||||
zoom: Math.floor(bounds.zoom),
|
zoom: Math.floor(bounds.zoom),
|
||||||
transportType: transportType?.value,
|
transportType: transportType?.value,
|
||||||
nodeType: nodeType?.value
|
nodeType: nodeType?.value,
|
||||||
},
|
},
|
||||||
fetchPolicy: 'network-only'
|
fetchPolicy: 'network-only'
|
||||||
})
|
})
|
||||||
|
|
||||||
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPointType[]
|
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPoint[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch clustered nodes:', error)
|
console.error('Failed to fetch clustered nodes:', error)
|
||||||
clusteredNodes.value = []
|
clusteredNodes.value = []
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const items = ref<any[]>([])
|
import type { GetTeamAddressesQueryResult } from '~/composables/graphql/team/teams-generated'
|
||||||
|
|
||||||
|
type TeamAddress = NonNullable<NonNullable<GetTeamAddressesQueryResult['teamAddresses']>[number]>
|
||||||
|
|
||||||
|
const items = ref<TeamAddress[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
@@ -23,7 +27,7 @@ export function useTeamAddresses() {
|
|||||||
try {
|
try {
|
||||||
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||||
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
items.value = data?.teamAddresses || []
|
items.value = (data?.teamAddresses || []).filter((a): a is TeamAddress => a !== null)
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load addresses', e)
|
console.error('Failed to load addresses', e)
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
const items = ref<any[]>([])
|
import type { GetTeamOrdersQueryResult } from '~/composables/graphql/team/orders-generated'
|
||||||
|
|
||||||
|
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
|
||||||
|
type TeamOrderStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
|
||||||
|
|
||||||
|
const items = ref<TeamOrder[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
@@ -23,13 +28,14 @@ export function useTeamOrders() {
|
|||||||
|
|
||||||
const routesForMap = computed(() =>
|
const routesForMap = computed(() =>
|
||||||
filteredItems.value
|
filteredItems.value
|
||||||
|
.filter(order => order.uuid && order.name)
|
||||||
.map(order => ({
|
.map(order => ({
|
||||||
uuid: order.uuid,
|
uuid: order.uuid!,
|
||||||
name: order.name,
|
name: order.name!,
|
||||||
status: order.status,
|
status: order.status ?? undefined,
|
||||||
stages: (order.stages || [])
|
stages: (order.stages || [])
|
||||||
.filter((s: any) => s.stageType === 'transport' && s.sourceLatitude && s.sourceLongitude && s.destinationLatitude && s.destinationLongitude)
|
.filter((s): s is TeamOrderStage => s !== null && s.stageType === 'transport' && !!s.sourceLatitude && !!s.sourceLongitude && !!s.destinationLatitude && !!s.destinationLongitude)
|
||||||
.map((s: any) => ({
|
.map((s) => ({
|
||||||
fromLat: s.sourceLatitude,
|
fromLat: s.sourceLatitude,
|
||||||
fromLon: s.sourceLongitude,
|
fromLon: s.sourceLongitude,
|
||||||
toLat: s.destinationLatitude,
|
toLat: s.destinationLatitude,
|
||||||
@@ -47,7 +53,7 @@ export function useTeamOrders() {
|
|||||||
try {
|
try {
|
||||||
const { GetTeamOrdersDocument } = await import('~/composables/graphql/team/orders-generated')
|
const { GetTeamOrdersDocument } = await import('~/composables/graphql/team/orders-generated')
|
||||||
const data = await execute(GetTeamOrdersDocument, {}, 'team', 'orders')
|
const data = await execute(GetTeamOrdersDocument, {}, 'team', 'orders')
|
||||||
items.value = data?.getTeamOrders || []
|
items.value = (data?.getTeamOrders || []).filter((o): o is TeamOrder => o !== null)
|
||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load orders', e)
|
console.error('Failed to load orders', e)
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col bg-base-300">
|
<div class="min-h-screen flex flex-col bg-base-300">
|
||||||
<!-- Fixed Header Container -->
|
<AiChatSidebar
|
||||||
<div class="fixed top-0 left-0 right-0 z-40" :style="headerContainerStyle">
|
:open="isChatOpen"
|
||||||
<!-- Dark gradient background for home page -->
|
:width="chatWidth"
|
||||||
<template v-if="isHomePage">
|
@close="isChatOpen = false"
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900" />
|
/>
|
||||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-primary/20 via-transparent to-transparent" />
|
|
||||||
</template>
|
<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" />
|
||||||
|
|
||||||
<!-- MainNavigation - dynamic height on home page -->
|
<!-- MainNavigation - dynamic height on home page -->
|
||||||
<MainNavigation
|
<MainNavigation
|
||||||
class="relative z-10"
|
class="relative z-10"
|
||||||
:height="isHomePage ? heroHeight : 100"
|
:height="isHomePage ? heroHeight : 100"
|
||||||
|
:collapse-progress="isHomePage ? collapseProgress : 1"
|
||||||
:session-checked="sessionChecked"
|
:session-checked="sessionChecked"
|
||||||
:logged-in="isLoggedIn"
|
:logged-in="isLoggedIn"
|
||||||
:user-avatar-svg="userAvatarSvg"
|
:user-avatar-svg="userAvatarSvg"
|
||||||
@@ -20,6 +26,8 @@
|
|||||||
:theme="theme"
|
:theme="theme"
|
||||||
:user-data="userData"
|
:user-data="userData"
|
||||||
:is-seller="isSeller"
|
:is-seller="isSeller"
|
||||||
|
:has-multiple-roles="hasMultipleRoles"
|
||||||
|
:current-role="currentRole"
|
||||||
:active-tokens="activeTokens"
|
:active-tokens="activeTokens"
|
||||||
:available-chips="availableChips"
|
:available-chips="availableChips"
|
||||||
:select-mode="selectMode"
|
:select-mode="selectMode"
|
||||||
@@ -31,12 +39,17 @@
|
|||||||
:can-search="canSearch"
|
:can-search="canSearch"
|
||||||
:show-mode-toggle="true"
|
:show-mode-toggle="true"
|
||||||
:show-active-mode="isCatalogSection"
|
:show-active-mode="isCatalogSection"
|
||||||
:glass-style="isHomePage || isCatalogSection"
|
:is-collapsed="isHomePage ? heroIsCollapsed : (isCatalogSection || isClientArea)"
|
||||||
|
:is-home-page="isHomePage"
|
||||||
|
:is-client-area="isClientArea"
|
||||||
|
:chat-open="isChatOpen"
|
||||||
@toggle-theme="toggleTheme"
|
@toggle-theme="toggleTheme"
|
||||||
|
@toggle-chat="isChatOpen = !isChatOpen"
|
||||||
@set-catalog-mode="setCatalogMode"
|
@set-catalog-mode="setCatalogMode"
|
||||||
@sign-out="onClickSignOut"
|
@sign-out="onClickSignOut"
|
||||||
@sign-in="signIn()"
|
@sign-in="signIn()"
|
||||||
@switch-team="switchToTeam"
|
@switch-team="switchToTeam"
|
||||||
|
@switch-role="switchToRole"
|
||||||
@start-select="startSelect"
|
@start-select="startSelect"
|
||||||
@cancel-select="cancelSelect"
|
@cancel-select="cancelSelect"
|
||||||
@edit-token="editFilter"
|
@edit-token="editFilter"
|
||||||
@@ -48,7 +61,7 @@
|
|||||||
<!-- Hero content for home page -->
|
<!-- Hero content for home page -->
|
||||||
<template v-if="isHomePage && collapseProgress < 1" #hero>
|
<template v-if="isHomePage && collapseProgress < 1" #hero>
|
||||||
<h1
|
<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 }"
|
:style="{ opacity: 1 - collapseProgress }"
|
||||||
>
|
>
|
||||||
{{ $t('hero.tagline', 'Make trade easy') }}
|
{{ $t('hero.tagline', 'Make trade easy') }}
|
||||||
@@ -56,17 +69,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</MainNavigation>
|
</MainNavigation>
|
||||||
|
|
||||||
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home sections -->
|
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home/non-clientarea sections -->
|
||||||
<SubNavigation
|
<SubNavigation
|
||||||
v-if="!isHomePage && !isCatalogSection"
|
v-if="!isHomePage && !isCatalogSection && !isClientArea"
|
||||||
:section="currentSection"
|
:section="currentSection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page content - padding-top compensates for fixed header -->
|
<!-- 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">
|
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -80,6 +94,14 @@ const localePath = useLocalePath()
|
|||||||
const { locale, locales } = useI18n()
|
const { locale, locales } = useI18n()
|
||||||
const switchLocalePath = useSwitchLocalePath()
|
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
|
// Catalog search state
|
||||||
const {
|
const {
|
||||||
selectMode,
|
selectMode,
|
||||||
@@ -115,14 +137,22 @@ const {
|
|||||||
const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake')
|
const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake')
|
||||||
|
|
||||||
// User data state (shared across layouts)
|
// User data state (shared across layouts)
|
||||||
|
interface SelectedLocation {
|
||||||
|
type: string
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
const userData = useState<{
|
const userData = useState<{
|
||||||
id?: string
|
id?: string
|
||||||
firstName?: string
|
firstName?: string
|
||||||
lastName?: string
|
lastName?: string
|
||||||
avatarId?: string
|
avatarId?: string
|
||||||
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: any }
|
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: SelectedLocation | null }
|
||||||
activeTeamId?: string
|
activeTeamId?: string
|
||||||
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string; teamType?: string }>
|
||||||
} | null>('me', () => null)
|
} | null>('me', () => null)
|
||||||
|
|
||||||
const sessionChecked = ref(false)
|
const sessionChecked = ref(false)
|
||||||
@@ -133,6 +163,20 @@ const isSeller = computed(() => {
|
|||||||
return userData.value?.activeTeam?.teamType === 'SELLER'
|
return userData.value?.activeTeam?.teamType === 'SELLER'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Role switching support
|
||||||
|
const buyerTeam = computed(() =>
|
||||||
|
userData.value?.teams?.find(t => t?.teamType === 'BUYER')
|
||||||
|
)
|
||||||
|
const sellerTeam = computed(() =>
|
||||||
|
userData.value?.teams?.find(t => t?.teamType === 'SELLER')
|
||||||
|
)
|
||||||
|
const hasBuyerTeam = computed(() => !!buyerTeam.value)
|
||||||
|
const hasSellerTeam = computed(() => !!sellerTeam.value)
|
||||||
|
const hasMultipleRoles = computed(() => hasBuyerTeam.value && hasSellerTeam.value)
|
||||||
|
const currentRole = computed(() =>
|
||||||
|
userData.value?.activeTeam?.teamType || 'BUYER'
|
||||||
|
)
|
||||||
|
|
||||||
const isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
|
const isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
|
||||||
|
|
||||||
const userName = computed(() => {
|
const userName = computed(() => {
|
||||||
@@ -168,8 +212,13 @@ const isCatalogSection = computed(() => {
|
|||||||
route.path.startsWith('/ru/catalog')
|
route.path.startsWith('/ru/catalog')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Client area detection (cabinet tabs in MainNavigation, no SubNav needed)
|
||||||
|
const isClientArea = computed(() => {
|
||||||
|
return route.path.includes('/clientarea')
|
||||||
|
})
|
||||||
|
|
||||||
// Collapsible header logic - only for pages with SubNav
|
// Collapsible header logic - only for pages with SubNav
|
||||||
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value)
|
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value && !isClientArea.value)
|
||||||
const canCollapse = computed(() => hasSubNav.value)
|
const canCollapse = computed(() => hasSubNav.value)
|
||||||
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
|
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
|
||||||
|
|
||||||
@@ -187,6 +236,7 @@ const headerContainerStyle = computed(() => {
|
|||||||
const mainStyle = computed(() => {
|
const mainStyle = computed(() => {
|
||||||
if (isCatalogSection.value) return { paddingTop: '0' }
|
if (isCatalogSection.value) return { paddingTop: '0' }
|
||||||
if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` }
|
if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` }
|
||||||
|
if (isClientArea.value) return { paddingTop: '116px' } // Header only, no SubNav
|
||||||
return { paddingTop: '154px' }
|
return { paddingTop: '154px' }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -239,7 +289,7 @@ watch(userData, () => {
|
|||||||
await fetchSession().catch(() => {})
|
await fetchSession().catch(() => {})
|
||||||
sessionChecked.value = true
|
sessionChecked.value = true
|
||||||
|
|
||||||
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string }) => {
|
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string; teamType?: string }) => {
|
||||||
if (!team?.id) return
|
if (!team?.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -258,6 +308,20 @@ const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const switchToRole = async (role: 'BUYER' | 'SELLER') => {
|
||||||
|
const targetTeam = role === 'SELLER' ? sellerTeam.value : buyerTeam.value
|
||||||
|
if (targetTeam?.id) {
|
||||||
|
await switchToTeam(targetTeam)
|
||||||
|
// Redirect to appropriate page when in client area
|
||||||
|
if (isClientArea.value) {
|
||||||
|
const targetPath = role === 'SELLER'
|
||||||
|
? '/clientarea/offers'
|
||||||
|
: '/clientarea/orders'
|
||||||
|
await navigateTo(localePath(targetPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onClickSignOut = () => {
|
const onClickSignOut = () => {
|
||||||
signOut(siteUrl)
|
signOut(siteUrl)
|
||||||
}
|
}
|
||||||
@@ -288,9 +352,10 @@ const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
|
|||||||
const onSearch = () => {
|
const onSearch = () => {
|
||||||
// Navigate to catalog page if not there
|
// Navigate to catalog page if not there
|
||||||
if (!route.path.includes('/catalog')) {
|
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)
|
// Trigger search by incrementing the counter (page watches this)
|
||||||
searchTrigger.value++
|
searchTrigger.value++
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
@@ -26,9 +26,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #card="{ item }">
|
<template #card="{ item }">
|
||||||
<HubProductCard
|
<ProductCard
|
||||||
:name="item.name"
|
:product="item"
|
||||||
:price-history="getMockPriceHistory(item.uuid)"
|
:price-history="getMockPriceHistory(item.uuid)"
|
||||||
|
selectable
|
||||||
@select="goToProduct(item.uuid)"
|
@select="goToProduct(item.uuid)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,7 +44,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetNodeDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated'
|
import { GetNodeDocument, NearestOffersDocument, type OfferWithRoute, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
type Hub = NonNullable<GetNodeQueryResult['node']>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
@@ -55,7 +58,7 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const hoveredId = ref<string>()
|
const hoveredId = ref<string>()
|
||||||
const hub = ref<any>(null)
|
const hub = ref<Hub | null>(null)
|
||||||
const products = ref<Array<{ uuid: string; name: string }>>([])
|
const products = ref<Array<{ uuid: string; name: string }>>([])
|
||||||
|
|
||||||
const hubId = computed(() => route.params.id as string)
|
const hubId = computed(() => route.params.id as string)
|
||||||
@@ -149,7 +152,7 @@ try {
|
|||||||
|
|
||||||
// Group offers by product
|
// Group offers by product
|
||||||
const productsMap = new Map<string, { uuid: string; name: string }>()
|
const productsMap = new Map<string, { uuid: string; name: string }>()
|
||||||
offersData.value?.nearestOffers?.forEach((offer: any) => {
|
offersData.value?.nearestOffers?.forEach((offer) => {
|
||||||
if (offer?.productUuid) {
|
if (offer?.productUuid) {
|
||||||
if (!productsMap.has(offer.productUuid)) {
|
if (!productsMap.has(offer.productUuid)) {
|
||||||
productsMap.set(offer.productUuid, {
|
productsMap.set(offer.productUuid, {
|
||||||
|
|||||||
@@ -1,75 +1,102 @@
|
|||||||
<template>
|
<template>
|
||||||
<CatalogPage
|
<div>
|
||||||
ref="catalogPageRef"
|
<CatalogPage
|
||||||
:loading="isLoading"
|
ref="catalogPageRef"
|
||||||
:use-server-clustering="true"
|
:loading="isLoading"
|
||||||
:cluster-node-type="clusterNodeType"
|
:use-server-clustering="useServerClustering"
|
||||||
map-id="unified-catalog-map"
|
:use-typed-clusters="useServerClustering"
|
||||||
:point-color="mapPointColor"
|
:cluster-node-type="clusterNodeType"
|
||||||
:items="currentSelectionItems"
|
panel-width="w-[32rem]"
|
||||||
:hovered-id="hoveredItemId ?? undefined"
|
map-id="unified-catalog-map"
|
||||||
:show-panel="showPanel"
|
:point-color="mapPointColor"
|
||||||
:filter-by-bounds="filterByBounds"
|
:items="mapItems"
|
||||||
:related-points="relatedPoints"
|
:hovered-id="hoveredItemId ?? undefined"
|
||||||
@select="onMapSelect"
|
:show-panel="showPanel && !kycSheetUuid"
|
||||||
@bounds-change="onBoundsChange"
|
:filter-by-bounds="filterByBounds"
|
||||||
@update:filter-by-bounds="filterByBounds = $event"
|
:related-points="relatedPoints"
|
||||||
>
|
:info-loading="mapInfoLoading"
|
||||||
<!-- Panel slot - shows selection list OR info OR quote results -->
|
:force-info-mode="forceInfoMode"
|
||||||
<template #panel>
|
:hide-view-toggle="hideViewToggle"
|
||||||
<!-- Selection mode: show list for picking product/hub/supplier -->
|
:show-offers-toggle="showOffersToggle"
|
||||||
<SelectionPanel
|
:show-hubs-toggle="showHubsToggle"
|
||||||
v-if="selectMode"
|
:show-suppliers-toggle="showSuppliersToggle"
|
||||||
:select-mode="selectMode"
|
:cluster-product-uuid="clusterProductUuid"
|
||||||
:products="filteredProducts"
|
:cluster-hub-uuid="clusterHubUuid"
|
||||||
:hubs="filteredHubs"
|
:cluster-supplier-uuid="clusterSupplierUuid"
|
||||||
:suppliers="filteredSuppliers"
|
@select="onMapSelect"
|
||||||
:loading="selectionLoading"
|
@bounds-change="onBoundsChange"
|
||||||
:loading-more="selectionLoadingMore"
|
@update:filter-by-bounds="onToggleBoundsFilter"
|
||||||
:has-more="selectionHasMore && !filterByBounds"
|
>
|
||||||
@select="onSelectItem"
|
<!-- Panel slot - shows selection list OR info OR quote results -->
|
||||||
@close="onClosePanel"
|
<template #panel>
|
||||||
@load-more="onLoadMore"
|
<!-- Selection mode: show list for picking product/hub/supplier -->
|
||||||
@hover="onHoverItem"
|
<SelectionPanel
|
||||||
/>
|
v-if="selectMode"
|
||||||
|
:select-mode="selectMode"
|
||||||
|
:products="filteredProducts"
|
||||||
|
:hubs="filteredHubs"
|
||||||
|
:suppliers="filteredSuppliers"
|
||||||
|
:loading="selectionLoading"
|
||||||
|
:loading-more="selectionLoadingMore"
|
||||||
|
:has-more="selectionHasMore && !filterByBounds"
|
||||||
|
@select="onSelectItem"
|
||||||
|
@pin="onPinItem"
|
||||||
|
@close="onClosePanel"
|
||||||
|
@load-more="onLoadMore"
|
||||||
|
@hover="onHoverItem"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Info mode: show detailed info about selected entity -->
|
<!-- Info mode: show detailed info about selected entity -->
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
v-else-if="infoId"
|
v-else-if="infoId"
|
||||||
:entity-type="infoId.type"
|
:entity-type="infoId.type"
|
||||||
:entity-id="infoId.uuid"
|
:entity-id="infoId.uuid"
|
||||||
:entity="entity"
|
:entity="entity"
|
||||||
:related-products="relatedProducts"
|
:related-products="relatedProducts"
|
||||||
:related-hubs="relatedHubs"
|
:related-hubs="relatedHubs"
|
||||||
:related-suppliers="relatedSuppliers"
|
:related-suppliers="relatedSuppliers"
|
||||||
:related-offers="relatedOffers"
|
:related-offers="relatedOffers"
|
||||||
:selected-product="infoProduct ?? null"
|
:selected-product="infoProduct ?? null"
|
||||||
:loading="infoLoading"
|
:loading="infoLoading"
|
||||||
:loading-products="isLoadingProducts"
|
:loading-products="isLoadingProducts"
|
||||||
:loading-hubs="isLoadingHubs"
|
:loading-hubs="isLoadingHubs"
|
||||||
:loading-suppliers="isLoadingSuppliers"
|
:loading-suppliers="isLoadingSuppliers"
|
||||||
:loading-offers="isLoadingOffers"
|
:loading-offers="isLoadingOffers"
|
||||||
@close="onInfoClose"
|
@close="onInfoClose"
|
||||||
@add-to-filter="onInfoAddToFilter"
|
@open-info="onInfoOpenRelated"
|
||||||
@open-info="onInfoOpenRelated"
|
@select-product="onInfoSelectProduct"
|
||||||
@select-product="onInfoSelectProduct"
|
@select-offer="onSelectOffer"
|
||||||
/>
|
@open-kyc="onOpenKyc"
|
||||||
|
@pin="onPinItem"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Quote results: show offers after search -->
|
<!-- Quote results: show offers after search -->
|
||||||
<QuotePanel
|
<QuotePanel
|
||||||
v-else-if="showQuoteResults"
|
v-else-if="showQuoteResults"
|
||||||
:loading="offersLoading"
|
:loading="offersLoading"
|
||||||
:offers="offers"
|
:offers="offers"
|
||||||
@select-offer="onSelectOffer"
|
:calculations="quoteCalculations"
|
||||||
/>
|
@select-offer="onSelectOffer"
|
||||||
</template>
|
/>
|
||||||
</CatalogPage>
|
</template>
|
||||||
|
</CatalogPage>
|
||||||
|
|
||||||
|
<!-- KYC Bottom Sheet (overlays everything) -->
|
||||||
|
<KycBottomSheet
|
||||||
|
:is-open="!!kycSheetUuid"
|
||||||
|
:uuid="kycSheetUuid"
|
||||||
|
@close="onCloseKycSheet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetOffersDocument, GetOfferDocument } 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'
|
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||||
|
|
||||||
|
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
})
|
})
|
||||||
@@ -82,9 +109,9 @@ const localePath = useLocalePath()
|
|||||||
// Ref to CatalogPage for accessing bounds
|
// Ref to CatalogPage for accessing bounds
|
||||||
const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null)
|
const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null)
|
||||||
|
|
||||||
// Filter by map bounds state
|
// Current map bounds (local state, updated when map moves)
|
||||||
const filterByBounds = ref(false)
|
|
||||||
const currentMapBounds = ref<MapBounds | null>(null)
|
const currentMapBounds = ref<MapBounds | null>(null)
|
||||||
|
const selectionBoundsBackup = ref<{ hadBounds: boolean; bounds: { west: number; south: number; east: number; north: number } | null } | null>(null)
|
||||||
|
|
||||||
// Hovered item for map highlight
|
// Hovered item for map highlight
|
||||||
const hoveredItemId = ref<string | null>(null)
|
const hoveredItemId = ref<string | null>(null)
|
||||||
@@ -92,17 +119,38 @@ const onHoverItem = (uuid: string | null) => {
|
|||||||
hoveredItemId.value = uuid
|
hoveredItemId.value = uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type for map items - must have required string uuid and number coordinates
|
||||||
|
type MapItemWithCoords = { uuid: string; name: string; latitude: number; longitude: number; country?: string }
|
||||||
|
|
||||||
|
// Helper to convert items to map-compatible format (filter null values)
|
||||||
|
const toMapItems = <T extends { uuid?: string | null; name?: string | null; latitude?: number | null; longitude?: number | null }>(
|
||||||
|
items: T[]
|
||||||
|
): MapItemWithCoords[] =>
|
||||||
|
items.filter((item): item is T & { uuid: string; latitude: number; longitude: number } =>
|
||||||
|
item.uuid != null && item.latitude != null && item.longitude != null
|
||||||
|
).map(item => ({
|
||||||
|
uuid: item.uuid,
|
||||||
|
name: item.name || '',
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude
|
||||||
|
}))
|
||||||
|
|
||||||
// Current selection items for hover highlighting on map
|
// Current selection items for hover highlighting on map
|
||||||
const currentSelectionItems = computed(() => {
|
const currentSelectionItems = computed((): MapItemWithCoords[] => {
|
||||||
if (selectMode.value === 'product') return filteredProducts.value
|
if (showQuoteResults.value) return []
|
||||||
if (selectMode.value === 'hub') return filteredHubs.value
|
if (selectMode.value === 'product') return [] // Products don't have coordinates
|
||||||
if (selectMode.value === 'supplier') return filteredSuppliers.value
|
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
|
||||||
|
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle bounds change from map
|
// Handle bounds change from map
|
||||||
const onBoundsChange = (bounds: MapBounds) => {
|
const onBoundsChange = (bounds: MapBounds) => {
|
||||||
currentMapBounds.value = bounds
|
currentMapBounds.value = bounds
|
||||||
|
// If filter by bounds is enabled, write to URL
|
||||||
|
if (filterByBounds.value) {
|
||||||
|
setBoundsInUrl(bounds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -122,7 +170,12 @@ const {
|
|||||||
openInfo,
|
openInfo,
|
||||||
closeInfo,
|
closeInfo,
|
||||||
setInfoProduct,
|
setInfoProduct,
|
||||||
setLabel
|
setLabel,
|
||||||
|
urlBounds,
|
||||||
|
filterByBounds,
|
||||||
|
setBoundsInUrl,
|
||||||
|
clearBoundsFromUrl,
|
||||||
|
setBoundsFilterEnabled
|
||||||
} = useCatalogSearch()
|
} = useCatalogSearch()
|
||||||
|
|
||||||
// Info panel composable
|
// Info panel composable
|
||||||
@@ -196,8 +249,56 @@ const onLoadMore = () => {
|
|||||||
if (selectMode.value === 'supplier') loadMoreSuppliers()
|
if (selectMode.value === 'supplier') loadMoreSuppliers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSelectionBounds = () => {
|
||||||
|
const bounds = currentMapBounds.value ?? catalogPageRef.value?.currentBounds?.value ?? null
|
||||||
|
if (!bounds) return null
|
||||||
|
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,
|
||||||
|
bounds: urlBounds.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bounds = getSelectionBounds()
|
||||||
|
if (bounds) {
|
||||||
|
setBoundsInUrl(bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreSelectionBounds = () => {
|
||||||
|
const prev = selectionBoundsBackup.value
|
||||||
|
if (!prev) return
|
||||||
|
if (prev.hadBounds && prev.bounds) {
|
||||||
|
setBoundsInUrl(prev.bounds)
|
||||||
|
} else {
|
||||||
|
clearBoundsFromUrl()
|
||||||
|
}
|
||||||
|
selectionBoundsBackup.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize data and sync map view when selectMode changes
|
// Initialize data and sync map view when selectMode changes
|
||||||
watch(selectMode, async (mode) => {
|
watch(selectMode, async (mode) => {
|
||||||
|
if (mode) {
|
||||||
|
applySelectionBounds()
|
||||||
|
} else {
|
||||||
|
restoreSelectionBounds()
|
||||||
|
}
|
||||||
if (mode === 'product') {
|
if (mode === 'product') {
|
||||||
await initProducts()
|
await initProducts()
|
||||||
setMapViewMode('offers')
|
setMapViewMode('offers')
|
||||||
@@ -229,13 +330,25 @@ watch(productId, (newProductId) => {
|
|||||||
setSupplierProductFilter(newProductId || null)
|
setSupplierProductFilter(newProductId || null)
|
||||||
}, { immediate: true })
|
}, { 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
|
// Apply bounds filter when "filter by map bounds" is enabled
|
||||||
watch([filterByBounds, currentMapBounds], ([enabled, bounds]) => {
|
// Only watch URL bounds - currentMapBounds changes too often (every map move)
|
||||||
const boundsToApply = enabled && bounds ? bounds : null
|
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
|
||||||
|
// Apply bounds filter only when checkbox is ON and bounds are in URL
|
||||||
|
const boundsToApply = enabled && urlB ? urlB : null
|
||||||
setHubBoundsFilter(boundsToApply)
|
setHubBoundsFilter(boundsToApply)
|
||||||
setSupplierBoundsFilter(boundsToApply)
|
setSupplierBoundsFilter(boundsToApply)
|
||||||
setProductBoundsFilter(boundsToApply)
|
setProductBoundsFilter(boundsToApply)
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
// Watch infoId to load info data
|
// Watch infoId to load info data
|
||||||
watch(infoId, async (info) => {
|
watch(infoId, async (info) => {
|
||||||
@@ -257,8 +370,8 @@ watch(infoProduct, async (productUuid) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Related points for Info mode (shown on map) - show all related entities
|
// Related points for Info mode (shown on map) - show current entity + all related entities
|
||||||
const relatedPoints = computed(() => {
|
const infoRelatedPoints = computed(() => {
|
||||||
if (!infoId.value) return []
|
if (!infoId.value) return []
|
||||||
|
|
||||||
const points: Array<{
|
const points: Array<{
|
||||||
@@ -269,12 +382,26 @@ const relatedPoints = computed(() => {
|
|||||||
type: 'hub' | 'supplier' | 'offer'
|
type: 'hub' | 'supplier' | 'offer'
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
// Add all hubs
|
// Add current entity first (the one we're viewing in InfoPanel)
|
||||||
|
// For offers, coordinates are in locationLatitude/locationLongitude
|
||||||
|
const lat = entity.value?.latitude ?? entity.value?.locationLatitude
|
||||||
|
const lon = entity.value?.longitude ?? entity.value?.locationLongitude
|
||||||
|
if (lat && lon) {
|
||||||
|
points.push({
|
||||||
|
uuid: infoId.value.uuid,
|
||||||
|
name: entity.value.name || entity.value.productName || '',
|
||||||
|
latitude: Number(lat),
|
||||||
|
longitude: Number(lon),
|
||||||
|
type: infoId.value.type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all related hubs
|
||||||
relatedHubs.value.forEach(hub => {
|
relatedHubs.value.forEach(hub => {
|
||||||
if (hub.latitude && hub.longitude) {
|
if (hub.uuid && hub.latitude && hub.longitude) {
|
||||||
points.push({
|
points.push({
|
||||||
uuid: hub.uuid,
|
uuid: hub.uuid,
|
||||||
name: hub.name,
|
name: hub.name || '',
|
||||||
latitude: hub.latitude,
|
latitude: hub.latitude,
|
||||||
longitude: hub.longitude,
|
longitude: hub.longitude,
|
||||||
type: 'hub'
|
type: 'hub'
|
||||||
@@ -282,12 +409,12 @@ const relatedPoints = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add all suppliers
|
// Add all related suppliers
|
||||||
relatedSuppliers.value.forEach(supplier => {
|
relatedSuppliers.value.forEach(supplier => {
|
||||||
if (supplier.latitude && supplier.longitude) {
|
if (supplier.uuid && supplier.latitude && supplier.longitude) {
|
||||||
points.push({
|
points.push({
|
||||||
uuid: supplier.uuid,
|
uuid: supplier.uuid,
|
||||||
name: supplier.name,
|
name: supplier.name || '',
|
||||||
latitude: supplier.latitude,
|
latitude: supplier.latitude,
|
||||||
longitude: supplier.longitude,
|
longitude: supplier.longitude,
|
||||||
type: 'supplier'
|
type: 'supplier'
|
||||||
@@ -298,11 +425,60 @@ const relatedPoints = computed(() => {
|
|||||||
return points
|
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
|
// Offers data for quote results
|
||||||
const offers = ref<any[]>([])
|
const offers = ref<NearestOffer[]>([])
|
||||||
|
const quoteCalculations = ref<{ offers: NearestOffer[] }[]>([])
|
||||||
|
|
||||||
|
const buildCalculationsFromOffers = (list: NearestOffer[]) =>
|
||||||
|
list.map((offer) => ({ offers: [offer] }))
|
||||||
const offersLoading = ref(false)
|
const offersLoading = ref(false)
|
||||||
const showQuoteResults = ref(false)
|
const showQuoteResults = ref(false)
|
||||||
|
|
||||||
|
|
||||||
// Watch for search trigger from topnav
|
// Watch for search trigger from topnav
|
||||||
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
|
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
|
||||||
watch(searchTrigger, () => {
|
watch(searchTrigger, () => {
|
||||||
@@ -312,7 +488,98 @@ watch(searchTrigger, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Loading state
|
// 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
|
// Show panel when selecting OR when showing info OR when showing quote results
|
||||||
const showPanel = computed(() => {
|
const showPanel = computed(() => {
|
||||||
@@ -335,61 +602,52 @@ const mapPointColor = computed(() => {
|
|||||||
return entityColors.offer
|
return entityColors.offer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Map item type from CatalogMap
|
||||||
|
interface MapSelectItem {
|
||||||
|
uuid?: string | null
|
||||||
|
id?: string
|
||||||
|
name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// Handle map item selection
|
// Handle map item selection
|
||||||
const onMapSelect = async (item: any) => {
|
const onMapSelect = (item: MapSelectItem) => {
|
||||||
// Get uuid from item - clusters use 'id', regular items use 'uuid'
|
// Get uuid from item - clusters use 'id', regular items use 'uuid'
|
||||||
const itemId = item.uuid || item.id
|
const itemId = item.uuid || item.id
|
||||||
if (!itemId || itemId.startsWith('cluster-')) return
|
if (!itemId || itemId.startsWith('cluster-')) return
|
||||||
|
|
||||||
const itemName = item.name || itemId.slice(0, 8) + '...'
|
const itemName = item.name || itemId.slice(0, 8) + '...'
|
||||||
|
|
||||||
// If in selection mode, use map click to fill the selector
|
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
|
||||||
if (selectMode.value) {
|
|
||||||
// For hubs selection - click on hub fills hub selector
|
|
||||||
if (selectMode.value === 'hub' && mapViewMode.value === 'hubs') {
|
|
||||||
selectItem('hub', itemId, itemName)
|
|
||||||
showQuoteResults.value = false
|
|
||||||
offers.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For supplier selection - click on supplier fills supplier selector
|
// Default behavior - open Info directly
|
||||||
if (selectMode.value === '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' && 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
|
|
||||||
let infoType: 'hub' | 'supplier' | 'offer'
|
let infoType: 'hub' | 'supplier' | 'offer'
|
||||||
if (mapViewMode.value === 'hubs') infoType = 'hub'
|
if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
|
||||||
else if (mapViewMode.value === 'suppliers') infoType = 'supplier'
|
else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
|
||||||
else infoType = 'offer'
|
else infoType = 'offer'
|
||||||
|
|
||||||
openInfo(infoType, itemId)
|
openInfo(infoType, itemId)
|
||||||
setLabel(infoType, itemId, itemName)
|
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: any) => {
|
const onSelectItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
|
||||||
if (item.uuid && item.name) {
|
if (!item.uuid) return
|
||||||
selectItem(type, item.uuid, item.name)
|
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: '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)
|
// Close panel (cancel select mode)
|
||||||
@@ -403,23 +661,6 @@ const onInfoClose = () => {
|
|||||||
clearInfo()
|
clearInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onInfoAddToFilter = () => {
|
|
||||||
if (!infoId.value || !entity.value) return
|
|
||||||
const { type, uuid } = infoId.value
|
|
||||||
|
|
||||||
// For offers, add the product to filter (not the offer itself)
|
|
||||||
if (type === 'offer' && entity.value.productUuid) {
|
|
||||||
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
|
|
||||||
selectItem('product', entity.value.productUuid, productName)
|
|
||||||
} 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) => {
|
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
|
||||||
openInfo(type, uuid)
|
openInfo(type, uuid)
|
||||||
@@ -430,33 +671,101 @@ const onInfoSelectProduct = (uuid: string | null) => {
|
|||||||
setInfoProduct(uuid)
|
setInfoProduct(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KYC Bottom Sheet state
|
||||||
|
const kycSheetUuid = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Handle KYC profile open - show bottom sheet instead of navigating
|
||||||
|
const onOpenKyc = (uuid: string | undefined) => {
|
||||||
|
if (!uuid) return
|
||||||
|
kycSheetUuid.value = uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close KYC bottom sheet
|
||||||
|
const onCloseKycSheet = () => {
|
||||||
|
kycSheetUuid.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Search for offers
|
// Search for offers
|
||||||
const onSearch = async () => {
|
const onSearch = async () => {
|
||||||
if (!canSearch.value) return
|
if (!canSearch.value) return
|
||||||
|
|
||||||
offersLoading.value = true
|
offersLoading.value = true
|
||||||
showQuoteResults.value = true
|
showQuoteResults.value = true
|
||||||
|
searchHubPoint.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const vars: any = {}
|
// Prefer geo-based offers with routes when hub + product are selected
|
||||||
if (productId.value) vars.productUuid = productId.value
|
if (hubId.value && productId.value) {
|
||||||
if (supplierId.value) vars.teamUuid = supplierId.value
|
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
|
||||||
if (hubId.value) vars.locationUuid = hubId.value
|
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')
|
let nearest = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
|
||||||
offers.value = data?.getOffers || []
|
if (supplierId.value) {
|
||||||
|
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Update labels from response
|
offers.value = nearest
|
||||||
if (offers.value.length > 0) {
|
quoteCalculations.value = buildCalculationsFromOffers(nearest)
|
||||||
|
|
||||||
|
const first = offers.value[0]
|
||||||
|
if (first?.productName) {
|
||||||
|
setLabel('product', productId.value, first.productName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offers.value = []
|
||||||
|
quoteCalculations.value = []
|
||||||
|
}
|
||||||
|
} 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]
|
const first = offers.value[0]
|
||||||
if (productId.value && first.productName) {
|
if (first) {
|
||||||
setLabel('product', productId.value, first.productName)
|
if (productId.value && first.productName) {
|
||||||
}
|
setLabel('product', productId.value, first.productName)
|
||||||
if (hubId.value && first.locationName) {
|
}
|
||||||
setLabel('hub', hubId.value, first.locationName)
|
if (hubId.value && first.locationName) {
|
||||||
}
|
setLabel('hub', hubId.value, first.locationName)
|
||||||
if (supplierId.value && first.teamName) {
|
}
|
||||||
setLabel('supplier', supplierId.value, first.teamName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -465,9 +774,10 @@ const onSearch = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select offer - navigate to detail page
|
// Select offer - navigate to detail page
|
||||||
const onSelectOffer = (offer: any) => {
|
const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
|
||||||
if (offer.uuid && offer.productUuid) {
|
const productUuid = offer.productUuid
|
||||||
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`))
|
if (offer.uuid && productUuid) {
|
||||||
|
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,19 +37,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location on map -->
|
<!-- Location on map -->
|
||||||
<div v-if="offer.latitude && offer.longitude" class="h-48 rounded-lg overflow-hidden">
|
<div v-if="offer.locationLatitude && offer.locationLongitude" class="h-48 rounded-lg overflow-hidden">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<MapboxMap
|
<MapboxMap
|
||||||
map-id="offer-location-map"
|
map-id="offer-location-map"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
:options="{
|
:options="{
|
||||||
style: 'mapbox://styles/mapbox/streets-v12',
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
center: [offer.longitude, offer.latitude],
|
center: [offer.locationLongitude, offer.locationLatitude],
|
||||||
zoom: 8
|
zoom: 8
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<MapboxDefaultMarker
|
<MapboxDefaultMarker
|
||||||
:lnglat="[offer.longitude, offer.latitude]"
|
:lnglat="[offer.locationLongitude, offer.locationLatitude]"
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
/>
|
/>
|
||||||
</MapboxMap>
|
</MapboxMap>
|
||||||
@@ -101,7 +101,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
type Offer = NonNullable<GetOfferQueryResult['getOffer']>
|
||||||
|
type SupplierProfile = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
@@ -114,8 +117,8 @@ const { execute } = useGraphQL()
|
|||||||
const offerId = computed(() => route.params.offerId as string)
|
const offerId = computed(() => route.params.offerId as string)
|
||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const offer = ref<any>(null)
|
const offer = ref<Offer | null>(null)
|
||||||
const supplier = ref<any>(null)
|
const supplier = ref<SupplierProfile | null>(null)
|
||||||
|
|
||||||
// Load offer data
|
// Load offer data
|
||||||
const loadOffer = async () => {
|
const loadOffer = async () => {
|
||||||
|
|||||||
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>
|
</IconCircle>
|
||||||
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
|
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
|
||||||
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
|
<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') }}
|
{{ t('catalogProduct.actions.back_to_catalog') }}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<!-- Line with this product -->
|
<!-- Line with this product -->
|
||||||
<template v-for="line in getProductLines(offer)" :key="line?.uuid">
|
<template v-for="(line, lineIndex) in getProductLines(offer)" :key="line?.uuid ?? lineIndex">
|
||||||
<Card padding="sm" class="bg-base-200">
|
<Card padding="sm" class="bg-base-200">
|
||||||
<Stack direction="row" align="center" justify="between">
|
<Stack direction="row" align="center" justify="between">
|
||||||
<Stack gap="0">
|
<Stack gap="0">
|
||||||
@@ -204,8 +204,14 @@ import {
|
|||||||
GetProductsDocument,
|
GetProductsDocument,
|
||||||
GetProductOffersDocument,
|
GetProductOffersDocument,
|
||||||
GetSupplierProfilesDocument,
|
GetSupplierProfilesDocument,
|
||||||
|
type GetProductsQueryResult,
|
||||||
|
type GetProductOffersQueryResult
|
||||||
} from '~/composables/graphql/public/exchange-generated'
|
} from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
// Types from GraphQL
|
||||||
|
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
|
||||||
|
type ProductOffer = NonNullable<NonNullable<GetProductOffersQueryResult['getOffers']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
})
|
})
|
||||||
@@ -237,7 +243,7 @@ const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles ||
|
|||||||
const productId = computed(() => route.params.id as string)
|
const productId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
// Find product by uuid from list
|
// Find product by uuid from list
|
||||||
const findProduct = (products: any[]) => {
|
const findProduct = (products: (Product | null)[]) => {
|
||||||
return products.find(p => p?.uuid === productId.value)
|
return products.find(p => p?.uuid === productId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,11 +301,13 @@ const mapLocations = computed(() => {
|
|||||||
const priceRange = computed(() => {
|
const priceRange = computed(() => {
|
||||||
const prices: number[] = []
|
const prices: number[] = []
|
||||||
offers.value.forEach(offer => {
|
offers.value.forEach(offer => {
|
||||||
(offer as any).lines?.forEach((line: any) => {
|
// Offers for this product already filtered by productUuid
|
||||||
if (line?.productUuid === productId.value && line?.pricePerUnit) {
|
if (offer.pricePerUnit) {
|
||||||
prices.push(Number(line.pricePerUnit))
|
const price = typeof offer.pricePerUnit === 'string' ? parseFloat(offer.pricePerUnit) : Number(offer.pricePerUnit)
|
||||||
|
if (!isNaN(price)) {
|
||||||
|
prices.push(price)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
if (prices.length === 0) return t('common.values.not_available')
|
if (prices.length === 0) return t('common.values.not_available')
|
||||||
const min = Math.min(...prices)
|
const min = Math.min(...prices)
|
||||||
@@ -308,9 +316,24 @@ const priceRange = computed(() => {
|
|||||||
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
|
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get lines with this product
|
// Get offer as "line" - offers already have quantity/unit/price directly
|
||||||
const getProductLines = (offer: any) => {
|
interface OfferLine {
|
||||||
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
|
uuid?: string | null
|
||||||
|
quantity?: string | number | null
|
||||||
|
unit?: string | null
|
||||||
|
pricePerUnit?: string | number | null
|
||||||
|
currency?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductLines = (offer: ProductOffer): OfferLine[] => {
|
||||||
|
// Each offer is a single "line" with quantity, unit, and price
|
||||||
|
return [{
|
||||||
|
uuid: offer.uuid,
|
||||||
|
quantity: offer.quantity,
|
||||||
|
unit: offer.unit,
|
||||||
|
pricePerUnit: offer.pricePerUnit,
|
||||||
|
currency: offer.currency
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryIcon = (categoryName: string | null | undefined) => {
|
const getCategoryIcon = (categoryName: string | null | undefined) => {
|
||||||
@@ -355,9 +378,10 @@ const formatDate = (dateStr: string | null | undefined) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPrice = (price: any, currency: string | null | undefined) => {
|
const formatPrice = (price: string | number | null | undefined, currency: string | null | undefined) => {
|
||||||
if (!price) return '—'
|
if (!price) return '—'
|
||||||
const num = Number(price)
|
const num = typeof price === 'string' ? parseFloat(price) : Number(price)
|
||||||
|
if (isNaN(num)) return '—'
|
||||||
const curr = currency || 'USD'
|
const curr = currency || 'USD'
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat('ru', {
|
return new Intl.NumberFormat('ru', {
|
||||||
|
|||||||
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>
|
||||||
@@ -94,6 +94,10 @@
|
|||||||
import { NuxtLink } from '#components'
|
import { NuxtLink } from '#components'
|
||||||
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
|
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
|
||||||
|
interface MapboxSearchBox {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
@@ -112,7 +116,7 @@ const isSaving = ref(false)
|
|||||||
const isDeleting = ref(false)
|
const isDeleting = ref(false)
|
||||||
const searchBoxContainer = ref<HTMLElement | null>(null)
|
const searchBoxContainer = ref<HTMLElement | null>(null)
|
||||||
const mapInstance = ref<MapboxMapType | null>(null)
|
const mapInstance = ref<MapboxMapType | null>(null)
|
||||||
const searchBoxRef = ref<any>(null)
|
const searchBoxRef = ref<MapboxSearchBox | null>(null)
|
||||||
|
|
||||||
const addressData = ref<{
|
const addressData = ref<{
|
||||||
uuid: string
|
uuid: string
|
||||||
@@ -130,7 +134,7 @@ const loadAddress = async () => {
|
|||||||
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||||
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
const addresses = data?.teamAddresses || []
|
const addresses = data?.teamAddresses || []
|
||||||
const found = addresses.find((a: any) => a.uuid === uuid.value)
|
const found = addresses.find((a) => a?.uuid === uuid.value)
|
||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
addressData.value = {
|
addressData.value = {
|
||||||
@@ -167,7 +171,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
|
|||||||
if (!feature) return { address: null, countryCode: null }
|
if (!feature) return { address: null, countryCode: null }
|
||||||
|
|
||||||
// Extract country code from context
|
// Extract country code from context
|
||||||
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
|
const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
|
||||||
const countryCode = countryContext?.short_code?.toUpperCase() || null
|
const countryCode = countryContext?.short_code?.toUpperCase() || null
|
||||||
|
|
||||||
return { address: feature.place_name, countryCode }
|
return { address: feature.place_name, countryCode }
|
||||||
@@ -215,7 +219,7 @@ onMounted(async () => {
|
|||||||
searchBox.value = addressData.value.address
|
searchBox.value = addressData.value.address
|
||||||
}
|
}
|
||||||
|
|
||||||
searchBox.addEventListener('retrieve', (event: any) => {
|
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
|
||||||
if (!addressData.value) return
|
if (!addressData.value) return
|
||||||
|
|
||||||
const feature = event.detail.features?.[0]
|
const feature = event.detail.features?.[0]
|
||||||
|
|||||||
@@ -1,66 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<CatalogPage
|
<div>
|
||||||
:items="displayItems"
|
<CatalogPage
|
||||||
:map-items="itemsWithCoords"
|
:items="mapPoints"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
with-map
|
:use-server-clustering="false"
|
||||||
map-id="addresses-map"
|
map-id="addresses-map"
|
||||||
point-color="#10b981"
|
point-color="#10b981"
|
||||||
:selected-id="selectedAddressId"
|
:hovered-id="hoveredAddressId"
|
||||||
:hovered-id="hoveredAddressId"
|
:show-panel="!selectedAddressId"
|
||||||
:total-count="items.length"
|
panel-width="w-96"
|
||||||
@select="onSelectAddress"
|
:hide-view-toggle="true"
|
||||||
@update:hovered-id="hoveredAddressId = $event"
|
@select="onMapSelect"
|
||||||
>
|
@update:hovered-id="hoveredAddressId = $event"
|
||||||
<template #searchBar="{ displayedCount, totalCount }">
|
>
|
||||||
<CatalogSearchBar
|
<template #panel>
|
||||||
v-model:search-query="searchQuery"
|
<!-- Panel header -->
|
||||||
:active-filters="[]"
|
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
||||||
:displayed-count="displayedCount"
|
<div class="flex items-center justify-between mb-3">
|
||||||
:total-count="totalCount"
|
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
|
||||||
@search="onSearch"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #header>
|
|
||||||
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
|
||||||
<Button variant="outline" class="w-full">
|
|
||||||
<Icon name="lucide:plus" size="16" class="mr-2" />
|
|
||||||
{{ t('profileAddresses.actions.add') }}
|
|
||||||
</Button>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #card="{ item }">
|
|
||||||
<NuxtLink :to="localePath(`/clientarea/addresses/${item.uuid}`)" class="block">
|
|
||||||
<Card padding="sm" interactive>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<Text size="base" weight="semibold" class="truncate">{{ item.name }}</Text>
|
|
||||||
<Text tone="muted" size="sm" class="line-clamp-2">{{ item.address }}</Text>
|
|
||||||
<div class="flex items-center mt-1">
|
|
||||||
<span class="text-lg">{{ isoToEmoji(item.countryCode) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty>
|
<!-- Search -->
|
||||||
<EmptyState
|
<div class="relative mb-3">
|
||||||
icon="📍"
|
<input
|
||||||
:title="t('profileAddresses.empty.title')"
|
v-model="searchQuery"
|
||||||
:description="t('profileAddresses.empty.description')"
|
type="text"
|
||||||
:action-label="t('profileAddresses.empty.cta')"
|
:placeholder="t('common.search')"
|
||||||
:action-to="localePath('/clientarea/addresses/new')"
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
||||||
action-icon="lucide:plus"
|
/>
|
||||||
/>
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
||||||
</template>
|
</div>
|
||||||
</CatalogPage>
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
||||||
|
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
|
{{ t('profileAddresses.actions.add') }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addresses list -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
<template v-if="displayItems.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="item in displayItems"
|
||||||
|
:key="item.uuid"
|
||||||
|
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'ring-2 ring-emerald-500': selectedAddressId === item.uuid }"
|
||||||
|
@click="selectedAddressId = item.uuid"
|
||||||
|
@mouseenter="hoveredAddressId = item.uuid"
|
||||||
|
@mouseleave="hoveredAddressId = undefined"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-xl">{{ isoToEmoji(item.countryCode) }}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold text-sm truncate">{{ item.name }}</div>
|
||||||
|
<div class="text-xs text-white/60 line-clamp-2">{{ item.address }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-3xl mb-2">📍</div>
|
||||||
|
<div class="font-semibold text-sm mb-1">{{ t('profileAddresses.empty.title') }}</div>
|
||||||
|
<div class="text-xs text-white/60 mb-3">{{ t('profileAddresses.empty.description') }}</div>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
||||||
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
|
{{ t('profileAddresses.empty.cta') }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
||||||
|
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CatalogPage>
|
||||||
|
|
||||||
|
<!-- Address Detail Bottom Sheet -->
|
||||||
|
<AddressDetailBottomSheet
|
||||||
|
:is-open="!!selectedAddressId"
|
||||||
|
:address-uuid="selectedAddressId"
|
||||||
|
@close="selectedAddressId = null"
|
||||||
|
@deleted="selectedAddressId = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
@@ -76,50 +109,37 @@ const {
|
|||||||
init
|
init
|
||||||
} = useTeamAddresses()
|
} = useTeamAddresses()
|
||||||
|
|
||||||
const selectedAddressId = ref<string>()
|
|
||||||
const hoveredAddressId = ref<string>()
|
const hoveredAddressId = ref<string>()
|
||||||
|
|
||||||
// Search bar
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedAddressId = ref<string | null>(null)
|
||||||
|
|
||||||
// Search with map checkbox
|
// Map points
|
||||||
const searchWithMap = ref(false)
|
const mapPoints = computed(() => {
|
||||||
const currentBounds = ref<MapBounds | null>(null)
|
return items.value
|
||||||
|
.filter(addr => addr.uuid && addr.latitude && addr.longitude)
|
||||||
// Map items
|
.map(addr => ({
|
||||||
const itemsWithCoords = computed(() => {
|
uuid: addr.uuid!,
|
||||||
return items.value.filter(addr =>
|
name: addr.name || '',
|
||||||
addr.latitude != null &&
|
latitude: Number(addr.latitude),
|
||||||
addr.longitude != null &&
|
longitude: Number(addr.longitude)
|
||||||
!isNaN(Number(addr.latitude)) &&
|
}))
|
||||||
!isNaN(Number(addr.longitude))
|
|
||||||
).map(addr => ({
|
|
||||||
uuid: addr.uuid,
|
|
||||||
name: addr.name,
|
|
||||||
latitude: Number(addr.latitude),
|
|
||||||
longitude: Number(addr.longitude)
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtered items when searchWithMap is enabled
|
// Display items with search filter
|
||||||
const displayItems = computed(() => {
|
const displayItems = computed(() => {
|
||||||
if (!searchWithMap.value || !currentBounds.value) return items.value
|
if (!searchQuery.value) return items.value
|
||||||
return items.value.filter(item => {
|
|
||||||
if (item.latitude == null || item.longitude == null) return false
|
const query = searchQuery.value.toLowerCase()
|
||||||
const { west, east, north, south } = currentBounds.value!
|
return items.value.filter(item =>
|
||||||
const lng = Number(item.longitude)
|
item.name?.toLowerCase().includes(query) ||
|
||||||
const lat = Number(item.latitude)
|
item.address?.toLowerCase().includes(query)
|
||||||
return lng >= west && lng <= east && lat >= south && lat <= north
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Search handler
|
const onMapSelect = (item: { uuid?: string | null }) => {
|
||||||
const onSearch = () => {
|
if (item.uuid) {
|
||||||
// TODO: Implement search
|
selectedAddressId.value = item.uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectAddress = (item: any) => {
|
|
||||||
selectedAddressId.value = item.uuid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await init()
|
await init()
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ await init()
|
|||||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||||
const selectedItemId = ref<string | null>(null)
|
const selectedItemId = ref<string | null>(null)
|
||||||
|
|
||||||
const selectItem = (item: any) => {
|
const selectItem = (item: { uuid?: string | null; latitude?: number | null; longitude?: number | null }) => {
|
||||||
selectedItemId.value = item.uuid
|
if (item.uuid) {
|
||||||
if (item.latitude && item.longitude) {
|
selectedItemId.value = item.uuid
|
||||||
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
if (item.latitude && item.longitude) {
|
||||||
|
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@
|
|||||||
import { NuxtLink } from '#components'
|
import { NuxtLink } from '#components'
|
||||||
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
|
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
|
||||||
|
interface MapboxSearchBox {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
@@ -84,7 +88,7 @@ const config = useRuntimeConfig()
|
|||||||
const isCreating = ref(false)
|
const isCreating = ref(false)
|
||||||
const searchBoxContainer = ref<HTMLElement | null>(null)
|
const searchBoxContainer = ref<HTMLElement | null>(null)
|
||||||
const mapInstance = ref<MapboxMapType | null>(null)
|
const mapInstance = ref<MapboxMapType | null>(null)
|
||||||
const searchBoxRef = ref<any>(null)
|
const searchBoxRef = ref<MapboxSearchBox | null>(null)
|
||||||
|
|
||||||
const newAddress = reactive({
|
const newAddress = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -110,7 +114,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
|
|||||||
if (!feature) return { address: null, countryCode: null }
|
if (!feature) return { address: null, countryCode: null }
|
||||||
|
|
||||||
// Extract country code from context
|
// Extract country code from context
|
||||||
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
|
const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
|
||||||
const countryCode = countryContext?.short_code?.toUpperCase() || null
|
const countryCode = countryContext?.short_code?.toUpperCase() || null
|
||||||
|
|
||||||
return { address: feature.place_name, countryCode }
|
return { address: feature.place_name, countryCode }
|
||||||
@@ -151,7 +155,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
|
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
|
||||||
|
|
||||||
searchBox.addEventListener('retrieve', (event: any) => {
|
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
|
||||||
const feature = event.detail.features?.[0]
|
const feature = event.detail.features?.[0]
|
||||||
if (feature) {
|
if (feature) {
|
||||||
const [lng, lat] = feature.geometry.coordinates
|
const [lng, lat] = feature.geometry.coordinates
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ const handleSend = async () => {
|
|||||||
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
|
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
|
||||||
chat.value.push({ role: 'assistant', content })
|
chat.value.push({ role: 'assistant', content })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error('Agent error', e)
|
console.error('Agent error', e)
|
||||||
error.value = e?.message || t('aiAssistants.view.error')
|
error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
|
||||||
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
|
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -95,6 +95,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { GetTeamTransactionsQueryResult } from '~/composables/graphql/team/billing-generated'
|
||||||
|
|
||||||
|
type Transaction = NonNullable<NonNullable<GetTeamTransactionsQueryResult['teamTransactions']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
@@ -112,7 +116,7 @@ const balance = ref({
|
|||||||
exists: false
|
exists: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const transactions = ref<any[]>([])
|
const transactions = ref<Transaction[]>([])
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
// Amount is in kopecks, convert to base units
|
// Amount is in kopecks, convert to base units
|
||||||
@@ -130,7 +134,7 @@ const formatAmount = (amount: number) => {
|
|||||||
}).format(amount / 100)
|
}).format(amount / 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: number) => {
|
const formatTimestamp = (timestamp: number | null | undefined) => {
|
||||||
if (!timestamp) return '—'
|
if (!timestamp) return '—'
|
||||||
// TigerBeetle timestamp is in nanoseconds since epoch
|
// TigerBeetle timestamp is in nanoseconds since epoch
|
||||||
const date = new Date(timestamp / 1000000)
|
const date = new Date(timestamp / 1000000)
|
||||||
@@ -157,8 +161,8 @@ const loadBalance = async () => {
|
|||||||
if (data.value?.teamBalance) {
|
if (data.value?.teamBalance) {
|
||||||
balance.value = data.value.teamBalance
|
balance.value = data.value.teamBalance
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
error.value = e.message || t('billing.errors.load_failed')
|
error.value = e instanceof Error ? e.message : t('billing.errors.load_failed')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -171,7 +175,7 @@ const loadTransactions = async () => {
|
|||||||
|
|
||||||
if (txError.value) throw txError.value
|
if (txError.value) throw txError.value
|
||||||
|
|
||||||
transactions.value = data.value?.teamTransactions || []
|
transactions.value = (data.value?.teamTransactions || []).filter((tx): tx is Transaction => tx !== null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load transactions', e)
|
console.error('Failed to load transactions', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ const switchToTeam = async (teamId: string) => {
|
|||||||
markActiveTeam(newActiveId)
|
markActiveTeam(newActiveId)
|
||||||
navigateTo(localePath('/clientarea/team'))
|
navigateTo(localePath('/clientarea/team'))
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error.value = err.message || t('clientTeamSwitch.error.switch')
|
error.value = err instanceof Error ? err.message : t('clientTeamSwitch.error.switch')
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<Card v-for="request in kycRequests" :key="request.uuid" padding="lg">
|
<Card v-for="request in kycRequests" :key="request.uuid" padding="lg">
|
||||||
<Stack gap="3">
|
<Stack gap="3">
|
||||||
<Stack direction="row" gap="2" align="center" justify="between">
|
<Stack direction="row" gap="2" align="center" justify="between">
|
||||||
<Heading :level="4" weight="semibold">{{ request.companyName || t('kycOverview.list.unnamed') }}</Heading>
|
<Heading :level="4" weight="semibold">{{ request.teamName || t('kycOverview.list.unnamed') }}</Heading>
|
||||||
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
|
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
|
||||||
{{ getStatusText(request) }}
|
{{ getStatusText(request) }}
|
||||||
</Pill>
|
</Pill>
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
<Text tone="muted" size="base">
|
<Text tone="muted" size="base">
|
||||||
{{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }}
|
{{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }}
|
||||||
</Text>
|
</Text>
|
||||||
<Text v-if="request.inn" tone="muted" size="base">
|
<Text tone="muted" size="base">
|
||||||
{{ t('kycOverview.list.inn') }}: {{ request.inn }}
|
{{ t('kycOverview.list.country') }}: {{ request.countryCode }}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -91,7 +91,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetKycRequestsRussiaDocument } from '~/composables/graphql/user/kyc-generated'
|
import { GetKycRequestsRussiaDocument, type GetKycRequestsRussiaQueryResult } from '~/composables/graphql/user/kyc-generated'
|
||||||
|
|
||||||
|
type KycRequest = NonNullable<NonNullable<GetKycRequestsRussiaQueryResult['kycRequests']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
@@ -102,7 +104,7 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const kycRequests = ref<any[]>([])
|
const kycRequests = ref<KycRequest[]>([])
|
||||||
|
|
||||||
const selectCountry = (country: string) => {
|
const selectCountry = (country: string) => {
|
||||||
if (country === 'russia') {
|
if (country === 'russia') {
|
||||||
@@ -110,21 +112,18 @@ const selectCountry = (country: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (request: any) => {
|
const getStatusVariant = (request: KycRequest) => {
|
||||||
if (request.approvedAt) return 'primary'
|
if (request.approvedAt) return 'primary'
|
||||||
if (request.rejectedAt) return 'outline'
|
|
||||||
return 'outline'
|
return 'outline'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusTone = (request: any) => {
|
const getStatusTone = (request: KycRequest) => {
|
||||||
if (request.approvedAt) return 'success'
|
if (request.approvedAt) return 'success'
|
||||||
if (request.rejectedAt) return 'error'
|
|
||||||
return 'warning'
|
return 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (request: any) => {
|
const getStatusText = (request: KycRequest) => {
|
||||||
if (request.approvedAt) return t('kycOverview.list.status.approved')
|
if (request.approvedAt) return t('kycOverview.list.status.approved')
|
||||||
if (request.rejectedAt) return t('kycOverview.list.status.rejected')
|
|
||||||
return t('kycOverview.list.status.pending')
|
return t('kycOverview.list.status.pending')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +142,10 @@ const loadKYCStatus = async () => {
|
|||||||
if (kycError.value) throw kycError.value
|
if (kycError.value) throw kycError.value
|
||||||
const requests = data.value?.kycRequests || []
|
const requests = data.value?.kycRequests || []
|
||||||
// Сортируем по дате создания (новые первые)
|
// Сортируем по дате создания (новые первые)
|
||||||
kycRequests.value = [...requests].sort((a: any, b: any) =>
|
kycRequests.value = [...requests]
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
.filter((r): r is KycRequest => r !== null)
|
||||||
)
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error.value = t('kycOverview.errors.load_failed')
|
error.value = t('kycOverview.errors.load_failed')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -57,24 +57,39 @@ const submitting = ref(false)
|
|||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
const submitSuccess = ref(false)
|
const submitSuccess = ref(false)
|
||||||
|
|
||||||
const handleSubmit = async (formData: any) => {
|
interface KycFormData {
|
||||||
|
company_name?: string
|
||||||
|
company_full_name?: string
|
||||||
|
inn?: string
|
||||||
|
kpp?: string
|
||||||
|
ogrn?: string
|
||||||
|
address?: string
|
||||||
|
bank_name?: string
|
||||||
|
bik?: string
|
||||||
|
correspondent_account?: string
|
||||||
|
contact_person?: string
|
||||||
|
contact_email?: string
|
||||||
|
contact_phone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: KycFormData) => {
|
||||||
try {
|
try {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
|
|
||||||
const submitData = {
|
const submitData = {
|
||||||
companyName: formData.company_name,
|
companyName: formData.company_name || '',
|
||||||
companyFullName: formData.company_full_name,
|
companyFullName: formData.company_full_name || '',
|
||||||
inn: formData.inn,
|
inn: formData.inn || '',
|
||||||
kpp: formData.kpp || '',
|
kpp: formData.kpp || '',
|
||||||
ogrn: formData.ogrn || '',
|
ogrn: formData.ogrn || '',
|
||||||
address: formData.address,
|
address: formData.address || '',
|
||||||
bankName: formData.bank_name,
|
bankName: formData.bank_name || '',
|
||||||
bik: formData.bik,
|
bik: formData.bik || '',
|
||||||
correspondentAccount: formData.correspondent_account || '',
|
correspondentAccount: formData.correspondent_account || '',
|
||||||
contactPerson: formData.contact_person,
|
contactPerson: formData.contact_person || '',
|
||||||
contactEmail: formData.contact_email,
|
contactEmail: formData.contact_email || '',
|
||||||
contactPhone: formData.contact_phone,
|
contactPhone: formData.contact_phone || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await mutate(CreateKycApplicationRussiaDocument, { input: submitData }, 'user', 'kyc')
|
const result = await mutate(CreateKycApplicationRussiaDocument, { input: submitData }, 'user', 'kyc')
|
||||||
@@ -85,8 +100,8 @@ const handleSubmit = async (formData: any) => {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(t('kycRussia.errors.create_failed'))
|
throw new Error(t('kycRussia.errors.create_failed'))
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
submitError.value = err.message || t('kycRussia.errors.submit_failed')
|
submitError.value = err instanceof Error ? err.message : t('kycRussia.errors.submit_failed')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ import { FormKitSchema } from '@formkit/vue'
|
|||||||
import type { FormKitSchemaNode } from '@formkit/core'
|
import type { FormKitSchemaNode } from '@formkit/core'
|
||||||
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
||||||
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
|
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
|
||||||
import { GetTeamAddressesDocument } from '~/composables/graphql/team/teams-generated'
|
import { GetTeamAddressesDocument, type GetTeamAddressesQueryResult } from '~/composables/graphql/team/teams-generated'
|
||||||
|
|
||||||
|
type TeamAddress = NonNullable<NonNullable<GetTeamAddressesQueryResult['teamAddresses']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
@@ -147,7 +149,7 @@ const productName = ref<string>('')
|
|||||||
const schemaId = ref<string | null>(null)
|
const schemaId = ref<string | null>(null)
|
||||||
const schemaDescription = ref<string | null>(null)
|
const schemaDescription = ref<string | null>(null)
|
||||||
const formkitSchema = ref<FormKitSchemaNode[]>([])
|
const formkitSchema = ref<FormKitSchemaNode[]>([])
|
||||||
const addresses = ref<any[]>([])
|
const addresses = ref<TeamAddress[]>([])
|
||||||
const selectedAddressUuid = ref<string | null>(null)
|
const selectedAddressUuid = ref<string | null>(null)
|
||||||
const formKitConfig = {
|
const formKitConfig = {
|
||||||
classes: {
|
classes: {
|
||||||
@@ -169,8 +171,8 @@ const loadAddresses = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
if (addressesError.value) throw addressesError.value
|
if (addressesError.value) throw addressesError.value
|
||||||
addresses.value = data.value?.teamAddresses || []
|
addresses.value = (data.value?.teamAddresses || []).filter((a): a is TeamAddress => a !== null)
|
||||||
const defaultAddress = addresses.value.find((address: any) => address.isDefault)
|
const defaultAddress = addresses.value.find((address) => address.isDefault)
|
||||||
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
|
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load addresses:', err)
|
console.error('Failed to load addresses:', err)
|
||||||
@@ -189,7 +191,7 @@ const loadData = async () => {
|
|||||||
const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange')
|
const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange')
|
||||||
if (productsError.value) throw productsError.value
|
if (productsError.value) throw productsError.value
|
||||||
const products = productsData.value?.getProducts || []
|
const products = productsData.value?.getProducts || []
|
||||||
const product = products.find((p: any) => p.uuid === productUuid.value)
|
const product = products.find((p) => p?.uuid === productUuid.value)
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
|
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
|
||||||
@@ -219,9 +221,9 @@ const loadData = async () => {
|
|||||||
formkitSchema.value = schemaToFormKit(terminusClass, enums)
|
formkitSchema.value = schemaToFormKit(terminusClass, enums)
|
||||||
await loadAddresses()
|
await loadAddresses()
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
error.value = err.message || t('clientOfferForm.error.load')
|
error.value = err instanceof Error ? err.message : t('clientOfferForm.error.load')
|
||||||
console.error('Load error:', err)
|
console.error('Load error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -237,7 +239,7 @@ const handleSubmit = async (data: Record<string, unknown>) => {
|
|||||||
throw new Error(t('clientOfferForm.error.load'))
|
throw new Error(t('clientOfferForm.error.load'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
|
const selectedAddress = addresses.value.find((address) => address?.uuid === selectedAddressUuid.value)
|
||||||
if (!selectedAddress) {
|
if (!selectedAddress) {
|
||||||
throw new Error(t('clientOfferForm.error.save'))
|
throw new Error(t('clientOfferForm.error.save'))
|
||||||
}
|
}
|
||||||
@@ -253,14 +255,14 @@ const handleSubmit = async (data: Record<string, unknown>) => {
|
|||||||
locationCountryCode: selectedAddress.countryCode || '',
|
locationCountryCode: selectedAddress.countryCode || '',
|
||||||
locationLatitude: selectedAddress.latitude,
|
locationLatitude: selectedAddress.latitude,
|
||||||
locationLongitude: selectedAddress.longitude,
|
locationLongitude: selectedAddress.longitude,
|
||||||
quantity: data.quantity || 0,
|
quantity: String(data.quantity || '0'),
|
||||||
unit: String(data.unit || 'ton'),
|
unit: String(data.unit || 'ton'),
|
||||||
pricePerUnit: data.price_per_unit || data.pricePerUnit || null,
|
pricePerUnit: String(data.price_per_unit || data.pricePerUnit || ''),
|
||||||
currency: String(data.currency || 'USD'),
|
currency: String(data.currency || 'USD'),
|
||||||
description: String(data.description || ''),
|
description: String(data.description || ''),
|
||||||
validUntil: data.valid_until || data.validUntil || null,
|
validUntil: (data.valid_until as string | undefined) ?? (data.validUntil as string | undefined) ?? undefined,
|
||||||
terminusSchemaId: schemaId.value,
|
terminusSchemaId: schemaId.value,
|
||||||
terminusPayload: JSON.stringify(data),
|
terminusPayload: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
|
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
|
||||||
@@ -270,8 +272,8 @@ const handleSubmit = async (data: Record<string, unknown>) => {
|
|||||||
|
|
||||||
await navigateTo(localePath('/clientarea/offers'))
|
await navigateTo(localePath('/clientarea/offers'))
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
error.value = err.message || t('clientOfferForm.error.save')
|
error.value = err instanceof Error ? err.message : t('clientOfferForm.error.save')
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
|
|||||||
@@ -122,7 +122,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||||
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetOffersDocument, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
type Offer = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
@@ -135,7 +137,7 @@ const { activeTeamId } = useActiveTeam()
|
|||||||
|
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
const offers = ref<any[]>([])
|
const offers = ref<Offer[]>([])
|
||||||
const totalOffers = ref(0)
|
const totalOffers = ref(0)
|
||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ const {
|
|||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (offersData.value?.getOffers) {
|
if (offersData.value?.getOffers) {
|
||||||
offers.value = offersData.value.getOffers
|
offers.value = offersData.value.getOffers.filter((o): o is Offer => o !== null)
|
||||||
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
|
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -231,9 +233,11 @@ const onSearch = () => {
|
|||||||
// TODO: Implement search
|
// TODO: Implement search
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectOffer = (offer: any) => {
|
const onSelectOffer = (offer: { uuid?: string | null }) => {
|
||||||
selectedOfferId.value = offer.uuid
|
if (offer.uuid) {
|
||||||
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
|
selectedOfferId.value = offer.uuid
|
||||||
|
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (status: string) => {
|
const getStatusVariant = (status: string) => {
|
||||||
@@ -293,7 +297,7 @@ const fetchOffers = async (offset = 0, replace = false) => {
|
|||||||
'public',
|
'public',
|
||||||
'exchange'
|
'exchange'
|
||||||
)
|
)
|
||||||
const next = data?.getOffers || []
|
const next = (data?.getOffers || []).filter((o): o is Offer => o !== null)
|
||||||
offers.value = replace ? next : offers.value.concat(next)
|
offers.value = replace ? next : offers.value.concat(next)
|
||||||
totalOffers.value = data?.getOffersCount ?? totalOffers.value
|
totalOffers.value = data?.getOffersCount ?? totalOffers.value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4">
|
<Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
<Card
|
<Card
|
||||||
v-for="product in products"
|
v-for="(product, index) in products"
|
||||||
:key="product.uuid"
|
:key="product.uuid ?? index"
|
||||||
padding="lg"
|
padding="lg"
|
||||||
class="cursor-pointer hover:shadow-md transition-shadow"
|
class="cursor-pointer hover:shadow-md transition-shadow"
|
||||||
@click="selectProduct(product)"
|
@click="selectProduct(product)"
|
||||||
@@ -51,7 +51,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
import { GetProductsDocument, type GetProductsQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
@@ -62,7 +64,7 @@ const localePath = useLocalePath()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
const products = ref<any[]>([])
|
const products = ref<Product[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -73,19 +75,19 @@ const loadProducts = async () => {
|
|||||||
hasError.value = false
|
hasError.value = false
|
||||||
const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange')
|
const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange')
|
||||||
if (productsError.value) throw productsError.value
|
if (productsError.value) throw productsError.value
|
||||||
products.value = data.value?.getProducts || []
|
products.value = (data.value?.getProducts || []).filter((p): p is Product => p !== null)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
error.value = err.message || t('offersNew.errors.load_failed')
|
error.value = err instanceof Error ? err.message : t('offersNew.errors.load_failed')
|
||||||
products.value = []
|
products.value = []
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectProduct = (product: any) => {
|
const selectProduct = (product: { uuid?: string | null }) => {
|
||||||
// Navigate to product details page
|
// Navigate to product details page
|
||||||
navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
|
if (product.uuid) navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadProducts()
|
await loadProducts()
|
||||||
|
|||||||
@@ -1,54 +1,147 @@
|
|||||||
<template>
|
<template>
|
||||||
<Section variant="plain">
|
<div>
|
||||||
<Stack gap="8">
|
<CatalogPage
|
||||||
<template v-if="hasOrderError">
|
:items="mapPoints"
|
||||||
<div class="text-sm text-error">
|
:loading="isLoadingOrder"
|
||||||
{{ orderError }}
|
:use-server-clustering="false"
|
||||||
|
map-id="order-detail-map"
|
||||||
|
point-color="#6366f1"
|
||||||
|
:show-panel="false"
|
||||||
|
:hide-view-toggle="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Bottom Sheet with slide-up animation -->
|
||||||
|
<Transition name="slide-up" appear>
|
||||||
|
<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="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||||
</div>
|
</div>
|
||||||
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
|
<!-- Header -->
|
||||||
{{ t('ordersDetail.states.loading') }}
|
<div class="border-b border-base-300 bg-base-100/90 px-6 pb-4">
|
||||||
</div>
|
<!-- Back button -->
|
||||||
|
<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-else>
|
<template v-if="hasOrderError">
|
||||||
<Card padding="lg" class="border border-base-300">
|
<div class="rounded-lg border border-error/30 bg-error/10 p-4">
|
||||||
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
|
<div class="mb-2 font-black text-base-content">{{ t('common.error') }}</div>
|
||||||
</Card>
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
|
<template v-else-if="!isLoadingOrder && order">
|
||||||
<Stack gap="4">
|
<div class="flex items-center gap-3">
|
||||||
<RouteStagesList
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15">
|
||||||
:stages="orderStageItems"
|
<Icon name="lucide:package" size="24" class="text-primary" />
|
||||||
:empty-text="t('ordersDetail.sections.stages.empty')"
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<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-base-content/55">
|
||||||
|
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="h-12 w-48 rounded-xl bg-base-300/70" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<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="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" />
|
||||||
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(stage, idx) in orderStageItems"
|
||||||
|
:key="stage.key || idx"
|
||||||
|
class="flex gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<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 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="mt-1 text-xs text-base-content/50">
|
||||||
|
{{ stage.meta.join(' · ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<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" />
|
||||||
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<GanttTimeline
|
||||||
|
:stages="order.stages"
|
||||||
|
:showLoading="showLoading"
|
||||||
|
:showUnloading="showUnloading"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
<!-- Map preview (small) -->
|
||||||
|
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" />
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
</Stack>
|
<Icon name="lucide:map" size="18" />
|
||||||
</Card>
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||||
|
</div>
|
||||||
<div class="space-y-3">
|
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||||
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading>
|
</div>
|
||||||
<GanttTimeline
|
|
||||||
v-if="order?.stages"
|
|
||||||
:stages="order.stages"
|
|
||||||
:showLoading="showLoading"
|
|
||||||
:showUnloading="showUnloading"
|
|
||||||
/>
|
|
||||||
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Section>
|
</Transition>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated'
|
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
|
||||||
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
||||||
|
|
||||||
|
// Types from GraphQL
|
||||||
|
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
|
||||||
|
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
|
||||||
|
type CompanyType = NonNullable<StageType['selectedCompany']>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
@@ -56,14 +149,44 @@ definePageMeta({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
const order = ref<any>(null)
|
const order = ref<OrderType | null>(null)
|
||||||
const isLoadingOrder = ref(true)
|
const isLoadingOrder = ref(true)
|
||||||
const hasOrderError = ref(false)
|
const hasOrderError = ref(false)
|
||||||
const orderError = ref('')
|
const orderError = ref('')
|
||||||
const showLoading = ref(true)
|
const showLoading = ref(true)
|
||||||
const showUnloading = ref(true)
|
const showUnloading = ref(true)
|
||||||
|
|
||||||
|
// Map points for route visualization
|
||||||
|
const mapPoints = computed(() => {
|
||||||
|
if (!order.value) return []
|
||||||
|
|
||||||
|
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number }> = []
|
||||||
|
|
||||||
|
// Add source
|
||||||
|
if (order.value.sourceLatitude && order.value.sourceLongitude) {
|
||||||
|
points.push({
|
||||||
|
uuid: 'source',
|
||||||
|
name: order.value.sourceLocationName || t('ordersDetail.labels.source'),
|
||||||
|
latitude: order.value.sourceLatitude,
|
||||||
|
longitude: order.value.sourceLongitude
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add destination
|
||||||
|
if (order.value.destinationLatitude && order.value.destinationLongitude) {
|
||||||
|
points.push({
|
||||||
|
uuid: 'destination',
|
||||||
|
name: order.value.destinationLocationName || t('ordersDetail.labels.destination'),
|
||||||
|
latitude: order.value.destinationLatitude,
|
||||||
|
longitude: order.value.destinationLongitude
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
const orderTitle = computed(() => {
|
const orderTitle = computed(() => {
|
||||||
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
||||||
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
||||||
@@ -96,8 +219,8 @@ const orderMeta = computed(() => {
|
|||||||
|
|
||||||
const orderRoutesForMap = computed(() => {
|
const orderRoutesForMap = computed(() => {
|
||||||
const stages = (order.value?.stages || [])
|
const stages = (order.value?.stages || [])
|
||||||
.filter(Boolean)
|
.filter((stage): stage is StageType => stage !== null)
|
||||||
.map((stage: any) => {
|
.map((stage) => {
|
||||||
if (stage.stageType === 'transport') {
|
if (stage.stageType === 'transport') {
|
||||||
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
|
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
|
||||||
return {
|
return {
|
||||||
@@ -118,33 +241,43 @@ const orderRoutesForMap = computed(() => {
|
|||||||
return [{ stages }]
|
return [{ stages }]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Company summary type
|
||||||
|
interface CompanySummary {
|
||||||
|
name: string | null | undefined
|
||||||
|
totalWeight: number
|
||||||
|
tripsCount: number
|
||||||
|
company: CompanyType | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
const orderStageItems = computed<RouteStageItem[]>(() => {
|
const orderStageItems = computed<RouteStageItem[]>(() => {
|
||||||
return (order.value?.stages || []).map((stage: any) => {
|
return (order.value?.stages || [])
|
||||||
const isTransport = stage.stageType === 'transport'
|
.filter((stage): stage is StageType => stage !== null)
|
||||||
const from = isTransport ? stage.sourceLocationName : stage.locationName
|
.map((stage) => {
|
||||||
const to = isTransport ? stage.destinationLocationName : stage.locationName
|
const isTransport = stage.stageType === 'transport'
|
||||||
|
const from = isTransport ? stage.sourceLocationName : stage.locationName
|
||||||
|
const to = isTransport ? stage.destinationLocationName : stage.locationName
|
||||||
|
|
||||||
const meta: string[] = []
|
const meta: string[] = []
|
||||||
const dateRange = getStageDateRange(stage)
|
const dateRange = getStageDateRange(stage)
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
meta.push(dateRange)
|
meta.push(dateRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
const companies = getCompaniesSummary(stage)
|
const companies = getCompaniesSummary(stage)
|
||||||
companies.forEach((company: any) => {
|
companies.forEach((company: CompanySummary) => {
|
||||||
meta.push(
|
meta.push(
|
||||||
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
|
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: stage.uuid ?? undefined,
|
||||||
|
from: from ?? undefined,
|
||||||
|
to: to ?? undefined,
|
||||||
|
label: stage.name ?? undefined,
|
||||||
|
meta
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
|
||||||
key: stage.uuid,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
label: stage.name,
|
|
||||||
meta
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadOrder = async () => {
|
const loadOrder = async () => {
|
||||||
@@ -154,10 +287,10 @@ const loadOrder = async () => {
|
|||||||
const orderUuid = route.params.id as string
|
const orderUuid = route.params.id as string
|
||||||
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
|
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
|
||||||
if (orderErrorResp.value) throw orderErrorResp.value
|
if (orderErrorResp.value) throw orderErrorResp.value
|
||||||
order.value = data.value?.getOrder
|
order.value = data.value?.getOrder ?? null
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
hasOrderError.value = true
|
hasOrderError.value = true
|
||||||
orderError.value = err.message || t('ordersDetail.errors.load_failed')
|
orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingOrder.value = false
|
isLoadingOrder.value = false
|
||||||
}
|
}
|
||||||
@@ -172,8 +305,8 @@ const formatPrice = (price: number, currency?: string | null) => {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCompaniesSummary = (stage: any) => {
|
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
|
||||||
const companies = []
|
const companies: CompanySummary[] = []
|
||||||
if (stage.stageType === 'service' && stage.selectedCompany) {
|
if (stage.stageType === 'service' && stage.selectedCompany) {
|
||||||
companies.push({
|
companies.push({
|
||||||
name: stage.selectedCompany.name,
|
name: stage.selectedCompany.name,
|
||||||
@@ -185,12 +318,13 @@ const getCompaniesSummary = (stage: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stage.stageType === 'transport' && stage.trips?.length) {
|
if (stage.stageType === 'transport' && stage.trips?.length) {
|
||||||
const companiesMap = new Map()
|
const companiesMap = new Map<string, CompanySummary>()
|
||||||
stage.trips.forEach((trip: any) => {
|
stage.trips.forEach((trip) => {
|
||||||
|
if (!trip) return
|
||||||
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
|
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
|
||||||
const weight = trip.plannedWeight || 0
|
const weight = trip.plannedWeight || 0
|
||||||
if (companiesMap.has(companyName)) {
|
if (companiesMap.has(companyName)) {
|
||||||
const existing = companiesMap.get(companyName)
|
const existing = companiesMap.get(companyName)!
|
||||||
existing.totalWeight += weight
|
existing.totalWeight += weight
|
||||||
existing.tripsCount += 1
|
existing.tripsCount += 1
|
||||||
} else {
|
} else {
|
||||||
@@ -211,10 +345,12 @@ const getOrderDuration = () => {
|
|||||||
if (!order.value?.stages?.length) return 0
|
if (!order.value?.stages?.length) return 0
|
||||||
let minDate: Date | null = null
|
let minDate: Date | null = null
|
||||||
let maxDate: Date | null = null
|
let maxDate: Date | null = null
|
||||||
order.value.stages.forEach((stage: any) => {
|
order.value.stages.forEach((stage) => {
|
||||||
stage.trips?.forEach((trip: any) => {
|
if (!stage) return
|
||||||
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
|
stage.trips?.forEach((trip) => {
|
||||||
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
|
if (!trip) return
|
||||||
|
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
|
||||||
|
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
|
||||||
if (!minDate || startDate < minDate) minDate = startDate
|
if (!minDate || startDate < minDate) minDate = startDate
|
||||||
if (!maxDate || endDate > maxDate) maxDate = endDate
|
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||||
})
|
})
|
||||||
@@ -224,13 +360,14 @@ const getOrderDuration = () => {
|
|||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStageDateRange = (stage: any) => {
|
const getStageDateRange = (stage: StageType) => {
|
||||||
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
|
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
|
||||||
let minDate: Date | null = null
|
let minDate: Date | null = null
|
||||||
let maxDate: Date | null = null
|
let maxDate: Date | null = null
|
||||||
stage.trips.forEach((trip: any) => {
|
stage.trips.forEach((trip) => {
|
||||||
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
|
if (!trip) return
|
||||||
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
|
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
|
||||||
|
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
|
||||||
if (!minDate || startDate < minDate) minDate = startDate
|
if (!minDate || startDate < minDate) minDate = startDate
|
||||||
if (!maxDate || endDate > maxDate) maxDate = endDate
|
if (!maxDate || endDate > maxDate) maxDate = endDate
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,112 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<CatalogPage
|
<div>
|
||||||
:items="displayItems"
|
<CatalogPage
|
||||||
:map-items="mapPoints"
|
:items="mapPoints"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
with-map
|
:use-server-clustering="false"
|
||||||
map-id="orders-map"
|
map-id="orders-map"
|
||||||
point-color="#6366f1"
|
point-color="#6366f1"
|
||||||
:selected-id="selectedOrderId"
|
:hovered-id="hoveredOrderId"
|
||||||
:hovered-id="hoveredOrderId"
|
:show-panel="!selectedOrderId"
|
||||||
:total-count="filteredItems.length"
|
panel-width="w-96"
|
||||||
@select="onSelectOrder"
|
:hide-view-toggle="true"
|
||||||
@update:hovered-id="hoveredOrderId = $event"
|
@select="onMapSelect"
|
||||||
>
|
@update:hovered-id="hoveredOrderId = $event"
|
||||||
<template #searchBar="{ displayedCount, totalCount }">
|
>
|
||||||
<CatalogSearchBar
|
<template #panel>
|
||||||
v-model:search-query="searchQuery"
|
<!-- Panel header -->
|
||||||
:active-filters="activeFilterBadges"
|
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
||||||
:displayed-count="displayedCount"
|
<div class="flex items-center justify-between mb-3">
|
||||||
:total-count="totalCount"
|
<div class="flex items-center gap-2">
|
||||||
@remove-filter="onRemoveFilter"
|
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
|
||||||
@search="onSearch"
|
<Icon name="lucide:package" size="16" class="text-indigo-400" />
|
||||||
>
|
</div>
|
||||||
<template #filters>
|
|
||||||
<div class="p-2 space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('ordersList.filters.status') }}</div>
|
<span class="font-semibold text-sm">{{ t('cabinetNav.orders') }}</span>
|
||||||
<ul class="menu menu-compact">
|
<div class="text-xs text-white/50">{{ filteredItems.length }} {{ t('orders.total', 'total') }}</div>
|
||||||
<li v-for="filter in filters" :key="filter.id">
|
</div>
|
||||||
<a
|
</div>
|
||||||
:class="{ 'active': selectedFilter === filter.id }"
|
</div>
|
||||||
@click="selectedFilter = filter.id"
|
|
||||||
>{{ filter.label }}</a>
|
<!-- Search -->
|
||||||
</li>
|
<div class="relative mb-3">
|
||||||
</ul>
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('common.search')"
|
||||||
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter dropdown -->
|
||||||
|
<div class="dropdown dropdown-end w-full">
|
||||||
|
<label tabindex="0" class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20 justify-between">
|
||||||
|
<span>{{ selectedFilterLabel }}</span>
|
||||||
|
<Icon name="lucide:chevron-down" size="14" />
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-base-200 rounded-box w-full mt-2">
|
||||||
|
<li v-for="filter in filters" :key="filter.id">
|
||||||
|
<a
|
||||||
|
:class="{ 'active': selectedFilter === filter.id }"
|
||||||
|
@click="selectedFilter = filter.id"
|
||||||
|
>{{ filter.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders list -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
<template v-if="displayItems.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="item in displayItems"
|
||||||
|
:key="item.uuid"
|
||||||
|
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'ring-2 ring-indigo-500': selectedOrderId === item.uuid }"
|
||||||
|
@click="selectedOrderId = item.uuid"
|
||||||
|
@mouseenter="hoveredOrderId = item.uuid"
|
||||||
|
@mouseleave="hoveredOrderId = undefined"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold text-sm">#{{ item.name }}</span>
|
||||||
|
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
||||||
|
{{ getStatusText(item.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-white/70 space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:map-pin" size="12" class="text-white/40" />
|
||||||
|
<span class="truncate">{{ item.sourceLocationName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:navigation" size="12" class="text-white/40" />
|
||||||
|
<span class="truncate">{{ item.destinationLocationName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-white/50 mt-2">
|
||||||
|
{{ getOrderDate(item) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CatalogSearchBar>
|
<template v-else>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-3xl mb-2">📦</div>
|
||||||
|
<div class="font-semibold text-sm mb-1">{{ t('orders.no_orders') }}</div>
|
||||||
|
<div class="text-xs text-white/60">{{ t('orders.no_orders_desc') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
||||||
|
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</CatalogPage>
|
||||||
|
|
||||||
<template #card="{ item }">
|
<!-- Order Detail Bottom Sheet -->
|
||||||
<Card padding="lg" class="cursor-pointer">
|
<OrderDetailBottomSheet
|
||||||
<Stack gap="4">
|
:is-open="!!selectedOrderId"
|
||||||
<Stack direction="row" justify="between" align="center">
|
:order-uuid="selectedOrderId"
|
||||||
<Stack gap="1">
|
@close="selectedOrderId = null"
|
||||||
<Text size="sm" tone="muted">{{ t('ordersList.card.order_label') }}</Text>
|
/>
|
||||||
<Heading :level="3">#{{ item.name }}</Heading>
|
</div>
|
||||||
</Stack>
|
|
||||||
<div class="badge badge-outline">
|
|
||||||
{{ getOrderStartDate(item) }} → {{ getOrderEndDate(item) }}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
|
||||||
|
|
||||||
<Grid :cols="1" :md="3" :gap="3">
|
|
||||||
<Stack gap="1">
|
|
||||||
<Text size="sm" tone="muted">{{ t('ordersList.card.route') }}</Text>
|
|
||||||
<Text weight="semibold">{{ item.sourceLocationName }} → {{ item.destinationLocationName }}</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="1">
|
|
||||||
<Text size="sm" tone="muted">{{ t('ordersList.card.product') }}</Text>
|
|
||||||
<Text>
|
|
||||||
{{ item.orderLines?.[0]?.productName || t('ordersList.card.product_loading') }}
|
|
||||||
<template v-if="item.orderLines?.length > 1">
|
|
||||||
<span class="badge badge-ghost ml-2">+{{ item.orderLines.length - 1 }}</span>
|
|
||||||
</template>
|
|
||||||
</Text>
|
|
||||||
<Text tone="muted" size="sm">
|
|
||||||
{{ item.orderLines?.[0]?.quantity || 0 }} {{ item.orderLines?.[0]?.unit || t('ordersList.card.unit_tons') }}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="1">
|
|
||||||
<Text size="sm" tone="muted">{{ t('ordersList.card.status') }}</Text>
|
|
||||||
<Badge :variant="getStatusVariant(item.status)">
|
|
||||||
{{ getStatusText(item.status) }}
|
|
||||||
</Badge>
|
|
||||||
<Text tone="muted" size="sm">{{ t('ordersList.card.stages_completed', { done: getCompletedStages(item), total: item.stages?.length || 0 }) }}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty>
|
|
||||||
<EmptyState
|
|
||||||
icon="📦"
|
|
||||||
:title="t('orders.no_orders')"
|
|
||||||
:description="t('orders.no_orders_desc')"
|
|
||||||
:action-label="t('orders.create_new')"
|
|
||||||
:action-to="localePath('/clientarea')"
|
|
||||||
action-icon="lucide:plus"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</CatalogPage>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
import type { GetTeamOrdersQueryResult } from '~/composables/graphql/team/orders-generated'
|
||||||
|
|
||||||
|
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav',
|
layout: 'topnav',
|
||||||
middleware: ['auth-oidc']
|
middleware: ['auth-oidc']
|
||||||
})
|
})
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -119,140 +136,71 @@ const {
|
|||||||
getStatusText
|
getStatusText
|
||||||
} = useTeamOrders()
|
} = useTeamOrders()
|
||||||
|
|
||||||
const selectedOrderId = ref<string>()
|
|
||||||
const hoveredOrderId = ref<string>()
|
const hoveredOrderId = ref<string>()
|
||||||
|
|
||||||
// Search bar
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedOrderId = ref<string | null>(null)
|
||||||
|
|
||||||
// Search with map checkbox
|
// Selected filter label
|
||||||
const searchWithMap = ref(false)
|
const selectedFilterLabel = computed(() => {
|
||||||
const currentBounds = ref<MapBounds | null>(null)
|
const filter = filters.value.find(f => f.id === selectedFilter.value)
|
||||||
|
return filter?.label || t('ordersList.filters.status')
|
||||||
// List items - one per order
|
|
||||||
const listItems = computed(() => {
|
|
||||||
return filteredItems.value.map(order => ({
|
|
||||||
...order,
|
|
||||||
uuid: order.uuid,
|
|
||||||
name: order.name || `#${order.uuid.slice(0, 8)}`,
|
|
||||||
latitude: order.sourceLatitude,
|
|
||||||
longitude: order.sourceLongitude,
|
|
||||||
country: order.sourceLocationName
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map points - two per order (source + destination)
|
// Map points - source locations
|
||||||
const mapPoints = computed(() => {
|
const mapPoints = computed(() => {
|
||||||
const result: any[] = []
|
return filteredItems.value
|
||||||
filteredItems.value.forEach(order => {
|
.filter(order => order.uuid && order.sourceLatitude && order.sourceLongitude)
|
||||||
// Source point
|
.map(order => ({
|
||||||
if (order.sourceLatitude && order.sourceLongitude) {
|
uuid: order.uuid!,
|
||||||
result.push({
|
name: order.name || `#${order.uuid!.slice(0, 8)}`,
|
||||||
uuid: `${order.uuid}-source`,
|
latitude: order.sourceLatitude!,
|
||||||
name: `📦 ${order.sourceLocationName}`,
|
longitude: order.sourceLongitude!
|
||||||
latitude: order.sourceLatitude,
|
}))
|
||||||
longitude: order.sourceLongitude
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Destination point - get from last stage
|
|
||||||
const lastStage = order.stages?.[order.stages.length - 1]
|
|
||||||
if (lastStage?.destinationLatitude && lastStage?.destinationLongitude) {
|
|
||||||
result.push({
|
|
||||||
uuid: `${order.uuid}-dest`,
|
|
||||||
name: `🏁 ${order.destinationLocationName}`,
|
|
||||||
latitude: lastStage.destinationLatitude,
|
|
||||||
longitude: lastStage.destinationLongitude
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtered items when searchWithMap is enabled
|
// Display items with search filter
|
||||||
const displayItems = computed(() => {
|
const displayItems = computed(() => {
|
||||||
if (!searchWithMap.value || !currentBounds.value) return listItems.value
|
let items = filteredItems.value.filter(order => order.uuid)
|
||||||
return listItems.value.filter(item => {
|
|
||||||
if (item.latitude == null || item.longitude == null) return false
|
if (searchQuery.value) {
|
||||||
const { west, east, north, south } = currentBounds.value!
|
const query = searchQuery.value.toLowerCase()
|
||||||
const lng = Number(item.longitude)
|
items = items.filter(item =>
|
||||||
const lat = Number(item.latitude)
|
item.name?.toLowerCase().includes(query) ||
|
||||||
return lng >= west && lng <= east && lat >= south && lat <= north
|
item.sourceLocationName?.toLowerCase().includes(query) ||
|
||||||
})
|
item.destinationLocationName?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
// Active filter badges
|
const onMapSelect = (item: { uuid?: string | null }) => {
|
||||||
const activeFilterBadges = computed(() => {
|
if (item.uuid) {
|
||||||
const badges: { id: string; label: string }[] = []
|
selectedOrderId.value = item.uuid
|
||||||
if (selectedFilter.value && selectedFilter.value !== 'all') {
|
|
||||||
const filter = filters.value.find(f => f.id === selectedFilter.value)
|
|
||||||
if (filter) badges.push({ id: `status:${filter.id}`, label: filter.label })
|
|
||||||
}
|
}
|
||||||
return badges
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove filter badge
|
|
||||||
const onRemoveFilter = (id: string) => {
|
|
||||||
if (id.startsWith('status:')) {
|
|
||||||
selectedFilter.value = 'all'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search handler
|
|
||||||
const onSearch = () => {
|
|
||||||
// TODO: Implement search
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSelectOrder = (item: any) => {
|
|
||||||
selectedOrderId.value = item.uuid
|
|
||||||
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await init()
|
await init()
|
||||||
|
|
||||||
const getOrderStartDate = (order: any) => {
|
const getOrderDate = (order: TeamOrder) => {
|
||||||
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined')
|
if (!order.createdAt) return ''
|
||||||
return formatDate(order.createdAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOrderEndDate = (order: any) => {
|
|
||||||
let latestDate: Date | null = null
|
|
||||||
order.stages?.forEach((stage: any) => {
|
|
||||||
stage.trips?.forEach((trip: any) => {
|
|
||||||
const endDate = trip.actualUnloadingDate || trip.plannedUnloadingDate
|
|
||||||
if (endDate) {
|
|
||||||
const date = new Date(endDate)
|
|
||||||
if (!latestDate || date > latestDate) {
|
|
||||||
latestDate = date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (latestDate) return formatDate((latestDate as Date).toISOString())
|
|
||||||
if (order.createdAt) {
|
|
||||||
const fallbackDate = new Date(order.createdAt)
|
|
||||||
fallbackDate.setMonth(fallbackDate.getMonth() + 1)
|
|
||||||
return formatDate(fallbackDate.toISOString())
|
|
||||||
}
|
|
||||||
return t('ordersDetail.labels.dates_undefined')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCompletedStages = (order: any) => {
|
|
||||||
if (!order.stages?.length) return 0
|
|
||||||
return order.stages.filter((stage: any) => stage.status === 'completed').length
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
|
||||||
if (!date) return t('ordersDetail.labels.dates_undefined')
|
|
||||||
try {
|
try {
|
||||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
|
||||||
if (isNaN(dateObj.getTime())) return t('ordersDetail.labels.dates_undefined')
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'short'
|
||||||
year: 'numeric'
|
}).format(new Date(order.createdAt))
|
||||||
}).format(dateObj)
|
|
||||||
} catch {
|
} catch {
|
||||||
return t('ordersDetail.labels.dates_undefined')
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadgeClass = (status?: string) => {
|
||||||
|
const variant = getStatusVariant(status)
|
||||||
|
switch (variant) {
|
||||||
|
case 'success': return 'badge-success'
|
||||||
|
case 'warning': return 'badge-warning'
|
||||||
|
case 'error': return 'badge-error'
|
||||||
|
default: return 'badge-ghost'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
>
|
>
|
||||||
<template #cards>
|
<template #cards>
|
||||||
<Card
|
<Card
|
||||||
v-for="order in filteredItems"
|
v-for="(order, index) in filteredItems"
|
||||||
:key="order.uuid"
|
:key="order.uuid ?? index"
|
||||||
padding="small"
|
padding="small"
|
||||||
interactive
|
interactive
|
||||||
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
|
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
<Stack gap="2">
|
<Stack gap="2">
|
||||||
<Stack direction="row" justify="between" align="center">
|
<Stack direction="row" justify="between" align="center">
|
||||||
<Text weight="semibold">#{{ order.name }}</Text>
|
<Text weight="semibold">#{{ order.name }}</Text>
|
||||||
<Badge :variant="getStatusVariant(order.status)" size="sm">
|
<Badge :variant="getStatusVariant(order.status || '')" size="sm">
|
||||||
{{ getStatusText(order.status) }}
|
{{ getStatusText(order.status || '') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text tone="muted" size="sm" class="truncate">
|
<Text tone="muted" size="sm" class="truncate">
|
||||||
@@ -74,9 +74,11 @@ await init()
|
|||||||
const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null)
|
const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null)
|
||||||
const selectedOrderId = ref<string | null>(null)
|
const selectedOrderId = ref<string | null>(null)
|
||||||
|
|
||||||
const selectOrder = (order: any) => {
|
const selectOrder = (order: { uuid?: string | null }) => {
|
||||||
selectedOrderId.value = order.uuid
|
if (order.uuid) {
|
||||||
mapRef.value?.flyTo(order.uuid)
|
selectedOrderId.value = order.uuid
|
||||||
|
mapRef.value?.flyTo(order.uuid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMapSelectOrder = (uuid: string) => {
|
const onMapSelectOrder = (uuid: string) => {
|
||||||
|
|||||||
@@ -1,76 +1,135 @@
|
|||||||
<template>
|
<template>
|
||||||
<Section variant="plain" paddingY="md">
|
<div>
|
||||||
<Stack gap="6">
|
<CatalogPage
|
||||||
<PageHeader
|
:items="[]"
|
||||||
:title="$t('dashboard.profile')"
|
:loading="false"
|
||||||
:actions="[{ label: t('clientProfile.actions.debugTokens'), icon: 'lucide:bug', to: localePath('/clientarea/profile/debug-tokens') }]"
|
:use-server-clustering="false"
|
||||||
/>
|
map-id="profile-map"
|
||||||
|
point-color="#10b981"
|
||||||
|
:show-panel="false"
|
||||||
|
:hide-view-toggle="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert v-if="hasError" variant="error">
|
<!-- Bottom Sheet -->
|
||||||
<Stack gap="1">
|
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||||
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
|
<!-- Glass sheet -->
|
||||||
<Text tone="muted">{{ error }}</Text>
|
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||||
</Stack>
|
<!-- Drag handle -->
|
||||||
</Alert>
|
<div class="flex justify-center py-2">
|
||||||
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack v-if="isLoading" align="center" justify="center" gap="3">
|
<!-- Header -->
|
||||||
<Spinner />
|
<div class="px-6 pb-4 border-b border-white/10">
|
||||||
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text>
|
<!-- Error state -->
|
||||||
</Stack>
|
<div v-if="hasError" 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">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile header -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Card padding="lg">
|
<div class="flex items-center gap-3">
|
||||||
<Grid :cols="1" :lg="3" :gap="8">
|
<UserAvatar
|
||||||
<GridItem :lg="2">
|
:userId="userData?.id ?? undefined"
|
||||||
<Stack gap="4">
|
:firstName="userData?.firstName ?? undefined"
|
||||||
<form @submit.prevent="updateProfile">
|
:lastName="userData?.lastName ?? undefined"
|
||||||
<Stack gap="4">
|
:avatarId="userData?.avatarId ?? undefined"
|
||||||
<Input
|
size="lg"
|
||||||
v-model="profileForm.firstName"
|
@avatar-changed="handleAvatarChange"
|
||||||
type="text"
|
/>
|
||||||
:label="$t('profile.first_name')"
|
<div>
|
||||||
:placeholder="$t('profile.first_name_placeholder')"
|
<div class="font-bold text-lg text-white">
|
||||||
/>
|
{{ userData?.firstName || '' }} {{ userData?.lastName || '' }}
|
||||||
<Input
|
</div>
|
||||||
v-model="profileForm.lastName"
|
<div class="text-xs text-white/50">{{ $t('dashboard.profile') }}</div>
|
||||||
type="text"
|
</div>
|
||||||
:label="$t('profile.last_name')"
|
</div>
|
||||||
:placeholder="$t('profile.last_name_placeholder')"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
v-model="profileForm.phone"
|
|
||||||
type="tel"
|
|
||||||
:label="$t('profile.phone')"
|
|
||||||
:placeholder="$t('profile.phone_placeholder')"
|
|
||||||
/>
|
|
||||||
<Button type="submit" :full-width="true" :disabled="isUpdating">
|
|
||||||
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
|
|
||||||
<template v-else>{{ $t('profile.save') }}</template>
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
</GridItem>
|
|
||||||
|
|
||||||
<GridItem>
|
|
||||||
<Stack gap="6" align="center">
|
|
||||||
<Stack gap="3" align="center">
|
|
||||||
<Heading :level="3">{{ $t('profile.avatar') }}</Heading>
|
|
||||||
<UserAvatar
|
|
||||||
:userId="userData?.id ?? undefined"
|
|
||||||
:firstName="userData?.firstName ?? undefined"
|
|
||||||
:lastName="userData?.lastName ?? undefined"
|
|
||||||
:avatarId="userData?.avatarId ?? undefined"
|
|
||||||
@avatar-changed="handleAvatarChange"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</GridItem>
|
|
||||||
</Grid>
|
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
</Stack>
|
</div>
|
||||||
</Section>
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div v-if="!hasError" class="overflow-y-auto h-[calc(70vh-120px)] px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<!-- Profile form -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:user" size="18" />
|
||||||
|
{{ $t('profile.personal_info', 'Personal Information') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="updateProfile">
|
||||||
|
<!-- First name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.first_name') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="profileForm.firstName"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('profile.first_name_placeholder')"
|
||||||
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.last_name') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="profileForm.lastName"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('profile.last_name_placeholder')"
|
||||||
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.phone') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="profileForm.phone"
|
||||||
|
type="tel"
|
||||||
|
:placeholder="$t('profile.phone_placeholder')"
|
||||||
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save button -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-sm w-full bg-primary border-primary text-primary-content hover:bg-primary/80"
|
||||||
|
:disabled="isUpdating"
|
||||||
|
>
|
||||||
|
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
|
||||||
|
<template v-else>{{ $t('profile.save') }}</template>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:settings" size="18" />
|
||||||
|
{{ $t('profile.settings', 'Settings') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Debug tokens link -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/profile/debug-tokens')"
|
||||||
|
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:bug" size="18" class="text-white/50" />
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-white">{{ t('clientProfile.actions.debugTokens') }}</div>
|
||||||
|
<div class="text-xs text-white/50">{{ t('clientProfile.actions.debugTokensDesc', 'View authentication tokens') }}</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -159,7 +218,6 @@ const updateProfile = async () => {
|
|||||||
|
|
||||||
const handleAvatarChange = async (newAvatarId?: string) => {
|
const handleAvatarChange = async (newAvatarId?: string) => {
|
||||||
if (!newAvatarId) return
|
if (!newAvatarId) return
|
||||||
// Only stage avatar change; will be saved on form submit
|
|
||||||
avatarDraftId.value = newAvatarId
|
avatarDraftId.value = newAvatarId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +1,175 @@
|
|||||||
<template>
|
<template>
|
||||||
<Section variant="plain">
|
<div>
|
||||||
<Stack gap="6">
|
<CatalogPage
|
||||||
<PageHeader :title="t('clientTeam.header.title')" :actions="teamHeaderActions" />
|
:items="[]"
|
||||||
|
:loading="false"
|
||||||
|
:use-server-clustering="false"
|
||||||
|
map-id="team-map"
|
||||||
|
point-color="#8b5cf6"
|
||||||
|
:show-panel="false"
|
||||||
|
:hide-view-toggle="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert v-if="hasError" variant="error">
|
<!-- Bottom Sheet -->
|
||||||
<Stack gap="2">
|
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||||
<Heading :level="4" weight="semibold">{{ t('clientTeam.error.title') }}</Heading>
|
<!-- Glass sheet -->
|
||||||
<Text tone="muted">{{ error }}</Text>
|
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||||
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button>
|
<!-- Drag handle -->
|
||||||
</Stack>
|
<div class="flex justify-center py-2">
|
||||||
</Alert>
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card v-else-if="isLoading" tone="muted" padding="lg">
|
<!-- Header -->
|
||||||
<Stack align="center" justify="center" gap="3">
|
<div class="px-6 pb-4 border-b border-white/10">
|
||||||
<Spinner />
|
<!-- Error state -->
|
||||||
<Text tone="muted">{{ t('clientTeam.loading.message') }}</Text>
|
<div v-if="hasError" class="bg-error/20 border border-error/30 rounded-lg p-4">
|
||||||
</Stack>
|
<div class="font-semibold text-white mb-2">{{ t('clientTeam.error.title') }}</div>
|
||||||
</Card>
|
<div class="text-sm text-white/70 mb-3">{{ error }}</div>
|
||||||
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadUserTeams">
|
||||||
|
{{ t('clientTeam.error.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- No team - prompt to create KYC application -->
|
<!-- No team -->
|
||||||
<EmptyState
|
<div v-else-if="!currentTeam && !isLoading" class="text-center py-4">
|
||||||
v-else-if="!currentTeam"
|
<div class="text-4xl mb-3">👥</div>
|
||||||
icon="👥"
|
<div class="font-semibold text-white mb-2">{{ t('clientTeam.empty.title') }}</div>
|
||||||
:title="t('clientTeam.empty.title')"
|
<div class="text-sm text-white/60 mb-4">{{ t('clientTeam.empty.description') }}</div>
|
||||||
:description="t('clientTeam.empty.description')"
|
<NuxtLink :to="localePath('/clientarea/kyc')">
|
||||||
:action-label="t('clientTeam.empty.cta')"
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
:action-to="localePath('/clientarea/kyc')"
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
action-icon="lucide:plus"
|
{{ t('clientTeam.empty.cta') }}
|
||||||
/>
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<!-- Team header -->
|
||||||
<Card padding="lg">
|
<template v-else>
|
||||||
<Stack gap="4">
|
<div class="flex items-center justify-between">
|
||||||
<Stack direction="row" gap="4" align="start" justify="between">
|
<div class="flex items-center gap-3">
|
||||||
<Stack direction="row" gap="3" align="center">
|
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||||
<IconCircle tone="neutral" size="lg">
|
<Icon name="lucide:building-2" size="24" class="text-primary" />
|
||||||
{{ currentTeam.name?.charAt(0)?.toUpperCase() || '?' }}
|
|
||||||
</IconCircle>
|
|
||||||
<Heading :level="2" weight="semibold">{{ currentTeam.name }}</Heading>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Stack gap="3">
|
|
||||||
<Heading :level="2">{{ t('clientTeam.members.title') }}</Heading>
|
|
||||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
|
||||||
<Card
|
|
||||||
v-for="member in currentTeam?.members || []"
|
|
||||||
:key="member.user?.id"
|
|
||||||
padding="lg"
|
|
||||||
>
|
|
||||||
<Stack gap="3">
|
|
||||||
<Stack direction="row" gap="3" align="center">
|
|
||||||
<IconCircle tone="neutral">{{ getMemberInitials(member.user) }}</IconCircle>
|
|
||||||
<Stack gap="1">
|
|
||||||
<Text weight="semibold">{{ member.user?.firstName }} {{ member.user?.lastName || '—' }}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" gap="2" wrap>
|
|
||||||
<Pill variant="primary">{{ roleText(member.role) }}</Pill>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Pending invitations -->
|
|
||||||
<Card
|
|
||||||
v-for="invitation in currentTeam?.invitations || []"
|
|
||||||
:key="invitation.uuid"
|
|
||||||
padding="lg"
|
|
||||||
class="border-dashed border-warning"
|
|
||||||
>
|
|
||||||
<Stack gap="3">
|
|
||||||
<Stack direction="row" gap="3" align="center">
|
|
||||||
<IconCircle tone="warning">
|
|
||||||
<Icon name="lucide:mail" size="16" />
|
|
||||||
</IconCircle>
|
|
||||||
<Stack gap="1">
|
|
||||||
<Text weight="semibold">{{ invitation.email }}</Text>
|
|
||||||
<Text tone="muted" size="sm">{{ t('clientTeam.invitations.pending') }}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" gap="2" wrap>
|
|
||||||
<Pill variant="outline" tone="warning">{{ roleText(invitation.role) }}</Pill>
|
|
||||||
<Pill variant="ghost" tone="muted">{{ t('clientTeam.invitations.sent') }}</Pill>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
padding="lg"
|
|
||||||
class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors"
|
|
||||||
@click="inviteMember"
|
|
||||||
>
|
|
||||||
<Stack gap="3" align="center" justify="center" class="h-full min-h-[100px]">
|
|
||||||
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center text-base-content/50">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<Text weight="semibold" tone="muted">{{ t('clientTeam.inviteCard.title') }}</Text>
|
<div>
|
||||||
</Stack>
|
<div class="font-bold text-lg text-white">{{ currentTeam?.name }}</div>
|
||||||
</Card>
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
</Grid>
|
<span class="badge badge-success badge-sm">{{ t('catalog.info.active') }}</span>
|
||||||
</Stack>
|
<span class="text-xs text-white/50">{{ t('clientTeam.members.title') }}</span>
|
||||||
</template>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<NuxtLink :to="localePath('/clientarea/kyc')">
|
||||||
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:plus" size="14" />
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink v-if="userTeams.length > 1" :to="localePath('/clientarea/company-switch')">
|
||||||
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:arrow-left-right" size="14" />
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div v-if="currentTeam" class="overflow-y-auto h-[calc(70vh-120px)] px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<!-- Team members -->
|
||||||
|
<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">
|
||||||
|
<Icon name="lucide:users" size="18" />
|
||||||
|
{{ t('clientTeam.members.title') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(member, index) in currentTeamMembers"
|
||||||
|
:key="member.user?.id ?? `member-${index}`"
|
||||||
|
class="flex items-center justify-between p-2 bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary text-primary-content text-xs">
|
||||||
|
<span>{{ getMemberInitials(member.user) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-white">{{ member.user?.firstName }} {{ member.user?.lastName || '' }}</div>
|
||||||
|
<div class="text-xs text-white/50">{{ roleText(member.role) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary badge-sm">{{ roleText(member.role) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="currentTeamMembers.length === 0" class="text-center py-4 text-white/50 text-sm">
|
||||||
|
{{ t('clientTeam.members.empty', 'No members yet') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invitations -->
|
||||||
|
<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">
|
||||||
|
<Icon name="lucide:mail" size="18" />
|
||||||
|
{{ t('clientTeam.invitations.title', 'Pending Invitations') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="invitation in currentTeamInvitations"
|
||||||
|
:key="invitation.uuid"
|
||||||
|
class="flex items-center justify-between p-2 bg-warning/10 rounded-lg border border-warning/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-warning/20 text-warning flex items-center justify-center">
|
||||||
|
<Icon name="lucide:mail" size="14" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-white">{{ invitation.email }}</div>
|
||||||
|
<div class="text-xs text-warning">{{ t('clientTeam.invitations.pending') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-warning badge-outline badge-sm">{{ roleText(invitation.role) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="currentTeamInvitations.length === 0" class="text-center py-4 text-white/50 text-sm">
|
||||||
|
{{ t('clientTeam.invitations.empty', 'No pending invitations') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invite button -->
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full bg-white/5 border border-dashed border-white/20 rounded-lg p-3 hover:bg-white/10 transition-colors"
|
||||||
|
@click="inviteMember"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-white/50">
|
||||||
|
<Icon name="lucide:user-plus" size="16" />
|
||||||
|
<span class="text-sm">{{ t('clientTeam.inviteCard.title') }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetTeamDocument } from '~/composables/graphql/user/teams-generated'
|
import { GetTeamDocument, type GetTeamQueryResult } from '~/composables/graphql/user/teams-generated'
|
||||||
|
|
||||||
|
interface UserTeam {
|
||||||
|
id?: string | null
|
||||||
|
name: string
|
||||||
|
logtoOrgId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamWithMembers = NonNullable<GetTeamQueryResult['getTeam']>
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -129,21 +187,13 @@ const me = useState<{
|
|||||||
} | null>('me', () => null)
|
} | null>('me', () => null)
|
||||||
const { setActiveTeam } = useActiveTeam()
|
const { setActiveTeam } = useActiveTeam()
|
||||||
|
|
||||||
const userTeams = ref<any[]>([])
|
const userTeams = ref<UserTeam[]>([])
|
||||||
const currentTeam = ref<any>(null)
|
const currentTeam = ref<TeamWithMembers | UserTeam | null>(null)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
const teamHeaderActions = computed(() => {
|
const roleText = (role?: string | null) => {
|
||||||
const actions: Array<{ label: string; icon?: string; to?: string }> = []
|
|
||||||
actions.push({ label: t('clientTeam.actions.addCompany'), icon: 'lucide:plus', to: localePath('/clientarea/kyc') })
|
|
||||||
if (userTeams.value.length > 1) {
|
|
||||||
actions.push({ label: t('clientTeam.actions.switch'), icon: 'lucide:arrow-left-right', to: localePath('/clientarea/company-switch') })
|
|
||||||
}
|
|
||||||
return actions
|
|
||||||
})
|
|
||||||
const roleText = (role?: string) => {
|
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
OWNER: t('clientTeam.roles.owner'),
|
OWNER: t('clientTeam.roles.owner'),
|
||||||
ADMIN: t('clientTeam.roles.admin'),
|
ADMIN: t('clientTeam.roles.admin'),
|
||||||
@@ -153,13 +203,29 @@ const roleText = (role?: string) => {
|
|||||||
return map[role || ''] || role || t('clientTeam.roles.member')
|
return map[role || ''] || role || t('clientTeam.roles.member')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMemberInitials = (user?: any) => {
|
interface TeamMember {
|
||||||
|
id?: string | null
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMemberInitials = (user?: TeamMember | null) => {
|
||||||
if (!user) return '??'
|
if (!user) return '??'
|
||||||
const first = user.firstName?.charAt(0) || ''
|
const first = user.firstName?.charAt(0) || ''
|
||||||
const last = user.lastName?.charAt(0) || ''
|
const last = user.lastName?.charAt(0) || ''
|
||||||
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
|
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentTeamMembers = computed(() => {
|
||||||
|
const team = currentTeam.value
|
||||||
|
return team && 'members' in team ? (team.members || []).filter((m): m is NonNullable<typeof m> => m !== null) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTeamInvitations = computed(() => {
|
||||||
|
const team = currentTeam.value
|
||||||
|
return team && 'invitations' in team ? (team.invitations || []).filter((i): i is NonNullable<typeof i> => i !== null) : []
|
||||||
|
})
|
||||||
|
|
||||||
const loadUserTeams = async () => {
|
const loadUserTeams = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -177,13 +243,14 @@ const loadUserTeams = async () => {
|
|||||||
currentTeam.value = teamData.value?.getTeam || null
|
currentTeam.value = teamData.value?.getTeam || null
|
||||||
} else if (userTeams.value.length > 0) {
|
} else if (userTeams.value.length > 0) {
|
||||||
const firstTeam = userTeams.value[0]
|
const firstTeam = userTeams.value[0]
|
||||||
setActiveTeam(firstTeam?.id || null, firstTeam?.logtoOrgId)
|
if (firstTeam) {
|
||||||
currentTeam.value = firstTeam
|
setActiveTeam(firstTeam.id || null, firstTeam.logtoOrgId)
|
||||||
|
currentTeam.value = firstTeam
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Если нет команды - currentTeam остаётся null, показываем EmptyState
|
} catch (err: unknown) {
|
||||||
} catch (err: any) {
|
|
||||||
hasError.value = true
|
hasError.value = true
|
||||||
error.value = err.message || t('clientTeam.error.load')
|
error.value = err instanceof Error ? err.message : t('clientTeam.error.load')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ const submitInvite = async () => {
|
|||||||
} else {
|
} else {
|
||||||
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
|
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
inviteError.value = err.message || t('clientTeam.invite.error')
|
inviteError.value = err instanceof Error ? err.message : t('clientTeam.invite.error')
|
||||||
} finally {
|
} finally {
|
||||||
inviteLoading.value = false
|
inviteLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,428 @@
|
|||||||
<template>
|
<template>
|
||||||
<Stack gap="12">
|
<div class="pb-0 -mx-3 lg:-mx-6">
|
||||||
<!-- How it works -->
|
<!-- Section: How it works -->
|
||||||
<Section variant="plain">
|
<section class="container mx-auto px-4 mb-20 mt-12">
|
||||||
<Stack gap="6" align="center">
|
<!-- Section header with line -->
|
||||||
<Heading :level="2">{{ $t('howto.title') }}</Heading>
|
<div class="flex items-center gap-6 mb-10">
|
||||||
<Grid :cols="1" :md="3" :gap="6">
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
|
||||||
<Card padding="lg">
|
<h2 class="text-sm font-medium uppercase tracking-[0.2em] text-base-content/60">
|
||||||
<Stack gap="3" align="center">
|
{{ $t('howto.title') }}
|
||||||
<IconCircle tone="primary">🔍</IconCircle>
|
</h2>
|
||||||
<Heading :level="3" weight="semibold">{{ $t('howto.step1.title') }}</Heading>
|
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
|
||||||
<Text tone="muted" align="center">{{ $t('howto.step1.description') }}</Text>
|
</div>
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
<Card padding="lg">
|
|
||||||
<Stack gap="3" align="center">
|
|
||||||
<IconCircle tone="primary">🤝</IconCircle>
|
|
||||||
<Heading :level="3" weight="semibold">{{ $t('howto.step2.title') }}</Heading>
|
|
||||||
<Text tone="muted" align="center">{{ $t('howto.step2.description') }}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
<Card padding="lg">
|
|
||||||
<Stack gap="3" align="center">
|
|
||||||
<IconCircle tone="primary">⚡</IconCircle>
|
|
||||||
<Heading :level="3" weight="semibold">{{ $t('howto.step3.title') }}</Heading>
|
|
||||||
<Text tone="muted" align="center">{{ $t('howto.step3.description') }}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<!-- Who it's for -->
|
<!-- Magazine layout -->
|
||||||
<Section variant="plain">
|
<div class="grid grid-cols-12 gap-6 md:gap-8">
|
||||||
<Stack gap="8" align="center">
|
<!-- Large hero image (8 cols) -->
|
||||||
<Heading :level="2">{{ $t('roles.title') }}</Heading>
|
<div class="col-span-12 md:col-span-7 relative overflow-hidden rounded-3xl h-[400px] md:h-[500px] group cursor-pointer">
|
||||||
|
<img
|
||||||
|
src="/images/promo/search.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-8">
|
||||||
|
<span class="inline-block px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-medium mb-4">
|
||||||
|
{{ $t('howto.step', 'Шаг') }} 01
|
||||||
|
</span>
|
||||||
|
<h3 class="text-3xl md:text-4xl font-bold text-white mb-3">{{ $t('howto.step1.title') }}</h3>
|
||||||
|
<p class="text-white/70 text-lg max-w-md">{{ $t('howto.step1.description') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Grid :cols="1" :md="3" :gap="6">
|
<!-- Right column: text + small cards -->
|
||||||
<Card padding="lg">
|
<div class="col-span-12 md:col-span-5 flex flex-col gap-6">
|
||||||
<Stack gap="4" align="center">
|
<!-- Text block -->
|
||||||
<IconCircle tone="primary">🏭</IconCircle>
|
<div class="p-8 rounded-3xl bg-base-200/50 border border-base-300/50">
|
||||||
<Heading :level="3">{{ $t('roles.producers.title') }}</Heading>
|
<div class="text-6xl font-black text-primary/20 mb-2">500+</div>
|
||||||
<Text tone="muted" align="center">{{ $t('roles.producers.description') }}</Text>
|
<div class="text-xl font-semibold text-base-content mb-2">{{ $t('stats.suppliers', 'Поставщиков') }}</div>
|
||||||
<Stack tag="ul" gap="1">
|
<p class="text-base-content/60 text-sm">
|
||||||
<li>✓ {{ $t('roles.producers.benefit1') }}</li>
|
{{ $t('stats.suppliersDesc', 'Проверенные производители из России, Казахстана и других стран СНГ') }}
|
||||||
<li>✓ {{ $t('roles.producers.benefit2') }}</li>
|
</p>
|
||||||
<li>✓ {{ $t('roles.producers.benefit3') }}</li>
|
</div>
|
||||||
<li>✓ {{ $t('roles.producers.benefit4') }}</li>
|
|
||||||
</Stack>
|
|
||||||
<Button :full-width="true" variant="outline">{{ $t('roles.producers.cta') }}</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card padding="lg">
|
<!-- Compare card -->
|
||||||
<Stack gap="4" align="center">
|
<div class="flex-1 relative overflow-hidden rounded-3xl group cursor-pointer min-h-[200px]">
|
||||||
<IconCircle tone="primary">🏢</IconCircle>
|
<img
|
||||||
<Heading :level="3">{{ $t('roles.buyers.title') }}</Heading>
|
src="/images/promo/compare.jpg"
|
||||||
<Text tone="muted" align="center">{{ $t('roles.buyers.description') }}</Text>
|
alt=""
|
||||||
<Stack tag="ul" gap="1">
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
<li>✓ {{ $t('roles.buyers.benefit1') }}</li>
|
/>
|
||||||
<li>✓ {{ $t('roles.buyers.benefit2') }}</li>
|
<div class="absolute inset-0 bg-gradient-to-t from-cyan-900/90 via-cyan-900/40 to-transparent" />
|
||||||
<li>✓ {{ $t('roles.buyers.benefit3') }}</li>
|
<div class="absolute bottom-0 left-0 right-0 p-6">
|
||||||
<li>✓ {{ $t('roles.buyers.benefit4') }}</li>
|
<span class="inline-block px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-300 text-xs font-medium mb-3">
|
||||||
</Stack>
|
{{ $t('howto.step', 'Шаг') }} 02
|
||||||
<Button :full-width="true" variant="outline">{{ $t('roles.buyers.cta') }}</Button>
|
</span>
|
||||||
</Stack>
|
<h3 class="text-xl font-bold text-white">{{ $t('howto.step2.title') }}</h3>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Card padding="lg">
|
<!-- Full-width quote/testimonial -->
|
||||||
<Stack gap="4" align="center">
|
<section class="bg-slate-900 py-16 mb-20">
|
||||||
<IconCircle tone="primary">⚙️</IconCircle>
|
<div class="container mx-auto px-4">
|
||||||
<Heading :level="3">{{ $t('roles.services.title') }}</Heading>
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
<Text tone="muted" align="center">{{ $t('roles.services.description') }}</Text>
|
<Icon name="lucide:quote" size="48" class="text-white/20 mb-6 mx-auto" />
|
||||||
<Stack tag="ul" gap="1">
|
<blockquote class="text-2xl md:text-3xl font-light text-white mb-6 leading-relaxed">
|
||||||
<li>✓ {{ $t('roles.services.benefit1') }}</li>
|
{{ $t('testimonial.quote', 'Optovia помогла нам найти надёжных поставщиков за считанные дни. Раньше на это уходили месяцы.') }}
|
||||||
<li>✓ {{ $t('roles.services.benefit2') }}</li>
|
</blockquote>
|
||||||
<li>✓ {{ $t('roles.services.benefit3') }}</li>
|
<div class="flex items-center justify-center gap-3">
|
||||||
<li>✓ {{ $t('roles.services.benefit4') }}</li>
|
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500" />
|
||||||
</Stack>
|
<div class="text-left">
|
||||||
<Button :full-width="true" variant="outline">{{ $t('roles.services.cta') }}</Button>
|
<div class="text-white font-medium">{{ $t('testimonial.author', 'Алексей Петров') }}</div>
|
||||||
</Stack>
|
<div class="text-white/50 text-sm">{{ $t('testimonial.role', 'Директор по закупкам, АгроХолдинг') }}</div>
|
||||||
</Card>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
</Stack>
|
</section>
|
||||||
|
|
||||||
|
<!-- Section: Who it's for -->
|
||||||
|
<section class="container mx-auto px-4 mb-20">
|
||||||
|
<!-- Section header -->
|
||||||
|
<div class="mb-12">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-base-content mb-4">{{ $t('roles.title') }}</h2>
|
||||||
|
<p class="text-base-content/60 text-lg max-w-2xl">
|
||||||
|
{{ $t('roles.subtitle', 'Платформа для всех участников рынка сельхозпродукции') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asymmetric grid -->
|
||||||
|
<div class="grid grid-cols-12 gap-6">
|
||||||
|
<!-- Producers: large card -->
|
||||||
|
<div class="col-span-12 md:col-span-8 relative overflow-hidden rounded-3xl h-[450px] group">
|
||||||
|
<img
|
||||||
|
src="/images/promo/producer.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-violet-900/95 via-violet-900/70 to-transparent" />
|
||||||
|
<div class="absolute inset-0 p-8 flex flex-col justify-center max-w-md">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-violet-500/30 backdrop-blur-sm flex items-center justify-center mb-4">
|
||||||
|
<Icon name="lucide:factory" size="28" class="text-violet-200" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-3xl font-bold text-white mb-3">{{ $t('roles.producers.title') }}</h3>
|
||||||
|
<p class="text-white/70 mb-4">{{ $t('roles.producers.description') }}</p>
|
||||||
|
<ul class="space-y-2 mb-6">
|
||||||
|
<li class="flex items-center gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:check-circle" size="18" class="text-violet-400" />
|
||||||
|
{{ $t('roles.producers.benefit1') }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:check-circle" size="18" class="text-violet-400" />
|
||||||
|
{{ $t('roles.producers.benefit2') }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn bg-violet-500 hover:bg-violet-600 border-0 text-white w-fit">
|
||||||
|
{{ $t('roles.producers.cta') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: stats + services -->
|
||||||
|
<div class="col-span-12 md:col-span-4 flex flex-col gap-6">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="p-6 rounded-3xl bg-gradient-to-br from-rose-500 to-pink-600 text-white">
|
||||||
|
<div class="text-5xl font-black mb-2">24/7</div>
|
||||||
|
<div class="text-white/80">{{ $t('stats.support', 'Поддержка') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services mini -->
|
||||||
|
<div class="flex-1 relative overflow-hidden rounded-3xl group cursor-pointer min-h-[200px]">
|
||||||
|
<img
|
||||||
|
src="/images/promo/services.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-amber-900/90 to-transparent" />
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-amber-500/30 backdrop-blur-sm flex items-center justify-center mb-2">
|
||||||
|
<Icon name="lucide:sparkles" size="20" class="text-amber-200" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-white">{{ $t('howto.step3.title') }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom row: 3 equal cards -->
|
||||||
|
<div class="col-span-12 md:col-span-4 relative overflow-hidden rounded-3xl h-[300px] group">
|
||||||
|
<img
|
||||||
|
src="/images/promo/buyer.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-cyan-900/95 via-cyan-900/50 to-transparent" />
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-cyan-500/30 backdrop-blur-sm flex items-center justify-center mb-3">
|
||||||
|
<Icon name="lucide:building-2" size="24" class="text-cyan-200" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">{{ $t('roles.buyers.title') }}</h3>
|
||||||
|
<p class="text-white/60 text-sm mb-3">{{ $t('roles.buyers.description') }}</p>
|
||||||
|
<button class="btn btn-sm bg-cyan-500 hover:bg-cyan-600 border-0 text-white">
|
||||||
|
{{ $t('roles.buyers.cta') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text only card -->
|
||||||
|
<div class="col-span-12 md:col-span-4 p-8 rounded-3xl border-2 border-dashed border-base-300 flex flex-col justify-center items-center text-center h-[300px]">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
|
||||||
|
<Icon name="lucide:globe" size="32" class="text-base-content/40" />
|
||||||
|
</div>
|
||||||
|
<div class="text-4xl font-bold text-base-content mb-2">15+</div>
|
||||||
|
<div class="text-base-content/60">{{ $t('stats.countries', 'Стран присутствия') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partners -->
|
||||||
|
<div class="col-span-12 md:col-span-4 relative overflow-hidden rounded-3xl h-[300px] group cursor-pointer">
|
||||||
|
<img
|
||||||
|
src="/images/promo/partner.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-rose-900/95 via-rose-900/50 to-transparent" />
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-rose-500/30 backdrop-blur-sm flex items-center justify-center mb-3">
|
||||||
|
<Icon name="lucide:handshake" size="24" class="text-rose-200" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">{{ $t('roles.services.title') }}</h3>
|
||||||
|
<p class="text-white/60 text-sm">{{ $t('roles.services.description') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA section -->
|
||||||
|
<section class="container mx-auto px-4 mb-20">
|
||||||
|
<div class="rounded-3xl bg-gradient-to-r from-emerald-600 via-cyan-600 to-blue-600 p-12 md:p-16 text-center">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
{{ $t('cta.title', 'Готовы начать?') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-white/80 text-lg mb-8 max-w-xl mx-auto">
|
||||||
|
{{ $t('cta.description', 'Присоединяйтесь к сотням компаний, которые уже торгуют на Optovia') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<button class="btn btn-lg bg-white text-emerald-700 hover:bg-white/90 border-0">
|
||||||
|
{{ $t('cta.register', 'Зарегистрироваться') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-lg btn-outline border-white/30 text-white hover:bg-white/10 hover:border-white/50">
|
||||||
|
{{ $t('cta.demo', 'Запросить демо') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Dark Footer -->
|
||||||
|
<footer class="bg-slate-900 text-white pt-16 pb-8">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- Main footer grid -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 mb-12">
|
||||||
|
<!-- Logo & Description -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:leaf" size="24" class="text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="text-2xl font-bold">Optovia</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 text-sm mb-4">
|
||||||
|
{{ $t('footer.description', 'Глобальная B2B платформа для торговли сырьём и сельхозпродукцией. Соединяем производителей и покупателей по всему миру.') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
|
||||||
|
<Icon name="lucide:linkedin" size="18" class="text-slate-400" />
|
||||||
|
</a>
|
||||||
|
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
|
||||||
|
<Icon name="lucide:twitter" size="18" class="text-slate-400" />
|
||||||
|
</a>
|
||||||
|
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
|
||||||
|
<Icon name="lucide:instagram" size="18" class="text-slate-400" />
|
||||||
|
</a>
|
||||||
|
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
|
||||||
|
<Icon name="lucide:youtube" size="18" class="text-slate-400" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Europe -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:globe" size="16" class="text-blue-400" />
|
||||||
|
{{ $t('footer.europe', 'Европа') }}
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇩🇪 {{ $t('footer.germany', 'Германия') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇫🇷 {{ $t('footer.france', 'Франция') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇳🇱 {{ $t('footer.netherlands', 'Нидерланды') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇵🇱 {{ $t('footer.poland', 'Польша') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇪🇸 {{ $t('footer.spain', 'Испания') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CIS -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:map" size="16" class="text-emerald-400" />
|
||||||
|
{{ $t('footer.cis', 'СНГ') }}
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇷🇺 {{ $t('footer.russia', 'Россия') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇰🇿 {{ $t('footer.kazakhstan', 'Казахстан') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇺🇿 {{ $t('footer.uzbekistan', 'Узбекистан') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇧🇾 {{ $t('footer.belarus', 'Беларусь') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇦🇿 {{ $t('footer.azerbaijan', 'Азербайджан') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asia & Middle East -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:sunrise" size="16" class="text-amber-400" />
|
||||||
|
{{ $t('footer.asia', 'Азия') }}
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇨🇳 {{ $t('footer.china', 'Китай') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇮🇳 {{ $t('footer.india', 'Индия') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇹🇷 {{ $t('footer.turkey', 'Турция') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇦🇪 {{ $t('footer.uae', 'ОАЭ') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇸🇦 {{ $t('footer.saudi', 'Саудовская Аравия') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Americas & Africa -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:globe-2" size="16" class="text-rose-400" />
|
||||||
|
{{ $t('footer.americas', 'Америка и Африка') }}
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇺🇸 {{ $t('footer.usa', 'США') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇧🇷 {{ $t('footer.brazil', 'Бразилия') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇦🇷 {{ $t('footer.argentina', 'Аргентина') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇿🇦 {{ $t('footer.southafrica', 'ЮАР') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">🇪🇬 {{ $t('footer.egypt', 'Египет') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offices section -->
|
||||||
|
<div class="border-t border-slate-800 pt-8 mb-8">
|
||||||
|
<h4 class="font-semibold text-white mb-6 text-center">{{ $t('footer.offices', 'Наши офисы') }}</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- HQ -->
|
||||||
|
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:building" size="16" class="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">{{ $t('footer.hq', 'Штаб-квартира') }}</div>
|
||||||
|
<div class="text-xs text-slate-500">Dubai, UAE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400">Business Bay, Churchill Towers</p>
|
||||||
|
<p class="text-xs text-emerald-400 mt-2">+971 4 XXX XXXX</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Europe Office -->
|
||||||
|
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:building-2" size="16" class="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">{{ $t('footer.europeOffice', 'Европа') }}</div>
|
||||||
|
<div class="text-xs text-slate-500">Amsterdam, NL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400">Zuidas Business District</p>
|
||||||
|
<p class="text-xs text-blue-400 mt-2">+31 20 XXX XXXX</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CIS Office -->
|
||||||
|
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:landmark" size="16" class="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold">{{ $t('footer.cisOffice', 'СНГ') }}</div>
|
||||||
|
<div class="text-xs text-slate-500">Moscow, RU</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400">Moscow City, Federation Tower</p>
|
||||||
|
<p class="text-xs text-violet-400 mt-2">+7 495 XXX XXXX</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick links -->
|
||||||
|
<div class="border-t border-slate-800 pt-8 mb-8">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.products', 'Продукты') }}</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.grains', 'Зерновые') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.oilseeds', 'Масличные') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.sugar', 'Сахар') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.fertilizers', 'Удобрения') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.services', 'Сервисы') }}</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.logistics', 'Логистика') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.insurance', 'Страхование') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.financing', 'Финансирование') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.inspection', 'Инспекция') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.company', 'Компания') }}</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.about', 'О нас') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.careers', 'Карьера') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.press', 'Пресса') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.contact', 'Контакты') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.legal', 'Юридическое') }}</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-slate-400">
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.terms', 'Условия') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.privacy', 'Конфиденциальность') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.cookies', 'Cookies') }}</li>
|
||||||
|
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.compliance', 'Комплаенс') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar -->
|
||||||
|
<div class="border-t border-slate-800 pt-6 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
© 2024 Optovia. {{ $t('footer.rights', 'Все права защищены.') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6 text-sm text-slate-400">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:shield-check" size="16" class="text-emerald-500" />
|
||||||
|
<span>{{ $t('footer.secure', 'Безопасные сделки') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:lock" size="16" class="text-blue-500" />
|
||||||
|
<span>SSL</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:badge-check" size="16" class="text-violet-500" />
|
||||||
|
<span>{{ $t('footer.verified', 'Верификация') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
349
app/pages/kyc/[uuid].vue
Normal file
349
app/pages/kyc/[uuid].vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-200">
|
||||||
|
<div class="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<!-- Back button -->
|
||||||
|
<NuxtLink :to="backUrl" class="btn btn-ghost btn-sm mb-4">
|
||||||
|
<Icon name="lucide:arrow-left" size="16" />
|
||||||
|
{{ $t('common.back') }}
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Mock KYC Profile (demo mode) -->
|
||||||
|
<div v-if="isDemo" class="flex flex-col gap-6">
|
||||||
|
<!-- Header Card -->
|
||||||
|
<Card padding="lg">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center">
|
||||||
|
<Icon name="lucide:building-2" size="32" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="bold" size="xl">ООО "АГРОТОРГ ПЛЮС"</Text>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="badge badge-success">Действующая</span>
|
||||||
|
<span class="badge badge-outline badge-sm">ООО</span>
|
||||||
|
<span class="text-sm text-base-content/60">с 2015 года</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="lucide:shield-check" size="20" class="text-success" />
|
||||||
|
<span class="text-sm font-medium text-success">Верифицирован</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-base-content/50">Обновлено: {{ new Date().toLocaleDateString('ru-RU') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Реквизиты -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:file-text" size="20" />
|
||||||
|
Реквизиты
|
||||||
|
</Text>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="sm">ИНН</Text>
|
||||||
|
<Text weight="medium" class="font-mono">7707456789</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="sm">КПП</Text>
|
||||||
|
<Text weight="medium" class="font-mono">770701001</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="sm">ОГРН</Text>
|
||||||
|
<Text weight="medium" class="font-mono">1157746123456</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text tone="muted" size="sm">ОКПО</Text>
|
||||||
|
<Text weight="medium" class="font-mono">12345678</Text>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Text tone="muted" size="sm">Дата регистрации</Text>
|
||||||
|
<Text weight="medium">15 марта 2015 г.</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Руководство -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:user-cog" size="20" />
|
||||||
|
Руководство
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-primary text-primary-content">
|
||||||
|
<span>ПС</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="medium">Петров Сергей Александрович</Text>
|
||||||
|
<Text tone="muted" size="sm">Генеральный директор</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Учредители -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:users" size="20" />
|
||||||
|
Учредители
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-secondary text-secondary-content">
|
||||||
|
<span>ПС</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="medium">Петров Сергей Александрович</Text>
|
||||||
|
<Text tone="muted" size="sm">Физическое лицо</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary">60%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-secondary text-secondary-content">
|
||||||
|
<span>ИА</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text weight="medium">Иванова Анна Петровна</Text>
|
||||||
|
<Text tone="muted" size="sm">Физическое лицо</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary">40%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-base-200">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<Text tone="muted" size="sm">Уставный капитал</Text>
|
||||||
|
<Text weight="semibold">500 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Контакты -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:contact" size="20" />
|
||||||
|
Контакты
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon name="lucide:map-pin" size="18" class="text-base-content/50" />
|
||||||
|
<Text size="sm">123456, г. Москва, ул. Складская, д. 15, оф. 301</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon name="lucide:phone" size="18" class="text-base-content/50" />
|
||||||
|
<Text size="sm">+7 (495) 123-45-67</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon name="lucide:mail" size="18" class="text-base-content/50" />
|
||||||
|
<Text size="sm">info@agrotorg-plus.ru</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon name="lucide:globe" size="18" class="text-base-content/50" />
|
||||||
|
<Text size="sm">www.agrotorg-plus.ru</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Финансы -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:bar-chart-3" size="20" />
|
||||||
|
Финансовые показатели (2024)
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<Text tone="muted" size="sm">Выручка</Text>
|
||||||
|
<Text weight="semibold" class="text-success">↑ 15%</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="bold" size="lg">245 800 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<Text tone="muted" size="sm">Чистая прибыль</Text>
|
||||||
|
<Text weight="semibold" class="text-success">↑ 23%</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="bold" size="lg">18 450 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between mb-1">
|
||||||
|
<Text tone="muted" size="sm">Активы</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="bold" size="lg">89 200 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
<div class="pt-3 border-t border-base-200">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<Text tone="muted" size="sm">Сотрудников</Text>
|
||||||
|
<Text weight="medium">47 человек</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Арбитраж -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:scale" size="20" />
|
||||||
|
Арбитражные дела
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-warning badge-sm">Истец</span>
|
||||||
|
<Text size="sm">3 дела</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="medium" size="sm">1 250 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-error badge-sm">Ответчик</span>
|
||||||
|
<Text size="sm">1 дело</Text>
|
||||||
|
</div>
|
||||||
|
<Text weight="medium" size="sm">320 000 ₽</Text>
|
||||||
|
</div>
|
||||||
|
<div class="pt-3 border-t border-base-200">
|
||||||
|
<Text tone="muted" size="xs">Завершенных: 2 выигранных, 1 урегулировано</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Виды деятельности -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:briefcase" size="20" />
|
||||||
|
Виды деятельности (ОКВЭД)
|
||||||
|
</Text>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
|
||||||
|
<span class="badge badge-primary badge-sm mt-0.5">Основной</span>
|
||||||
|
<div>
|
||||||
|
<Text weight="medium" size="sm">46.21 - Торговля оптовая зерном, необработанным табаком, семенами и кормами для сельскохозяйственных животных</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
|
||||||
|
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
|
||||||
|
<Text size="sm">46.11 - Деятельность агентов по оптовой торговле сельскохозяйственным сырьем</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
|
||||||
|
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
|
||||||
|
<Text size="sm">52.10 - Деятельность по складированию и хранению</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
|
||||||
|
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
|
||||||
|
<Text size="sm">49.41 - Деятельность автомобильного грузового транспорта</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- История изменений -->
|
||||||
|
<Card padding="md">
|
||||||
|
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:history" size="20" />
|
||||||
|
История изменений
|
||||||
|
</Text>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-base-300" />
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex gap-4 relative">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center z-10">
|
||||||
|
<Icon name="lucide:check" size="16" class="text-primary-content" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pb-4">
|
||||||
|
<Text weight="medium" size="sm">Смена юридического адреса</Text>
|
||||||
|
<Text tone="muted" size="xs">12 января 2024</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 relative">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
|
||||||
|
<Icon name="lucide:user-plus" size="16" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pb-4">
|
||||||
|
<Text weight="medium" size="sm">Изменение состава учредителей</Text>
|
||||||
|
<Text tone="muted" size="xs">5 августа 2023</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 relative">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
|
||||||
|
<Icon name="lucide:banknote" size="16" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pb-4">
|
||||||
|
<Text weight="medium" size="sm">Увеличение уставного капитала</Text>
|
||||||
|
<Text tone="muted" size="xs">20 марта 2022</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 relative">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
|
||||||
|
<Icon name="lucide:building" size="16" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Text weight="medium" size="sm">Регистрация компании</Text>
|
||||||
|
<Text tone="muted" size="xs">15 марта 2015</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Sources Footer -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 text-xs text-base-content/50 px-2">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Icon name="lucide:database" size="14" />
|
||||||
|
Источники: ЕГРЮЛ, ФНС, Росстат, Арбитр
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>Данные актуальны на {{ new Date().toLocaleDateString('ru-RU') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo notice -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<Icon name="lucide:info" size="16" />
|
||||||
|
<span>{{ $t('kyc.demo.notice') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real KYC Profile Card -->
|
||||||
|
<KycProfileCard v-else :kyc-profile-uuid="uuid" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'topnav'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const uuid = computed(() => route.params.uuid as string)
|
||||||
|
const isDemo = computed(() => uuid.value === 'demo-kyc-profile')
|
||||||
|
|
||||||
|
// Back URL - try to go back to previous page or catalog
|
||||||
|
const backUrl = computed(() => {
|
||||||
|
return localePath('/catalog')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" gap="3">
|
<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') }}
|
{{ t('searchPage.cta.catalog') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">
|
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">
|
||||||
|
|||||||
@@ -78,6 +78,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocationStore } from '~/stores/location'
|
import { useLocationStore } from '~/stores/location'
|
||||||
|
import type { CatalogHubItem, CatalogNearestHubItem } from '~/composables/useCatalogHubs'
|
||||||
|
import type { TeamAddress } from '~/composables/graphql/team/teams-generated'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
@@ -107,20 +109,20 @@ const {
|
|||||||
} = useCatalogHubs()
|
} = useCatalogHubs()
|
||||||
|
|
||||||
// Selected/hovered hub for map
|
// Selected/hovered hub for map
|
||||||
const selectedHubId = ref<string>()
|
const selectedHubId = ref<string | undefined>()
|
||||||
const hoveredHubId = ref<string>()
|
const hoveredHubId = ref<string | undefined>()
|
||||||
|
|
||||||
await init()
|
await init()
|
||||||
|
|
||||||
// Load team addresses
|
// Load team addresses
|
||||||
const teamAddresses = ref<any[]>([])
|
const teamAddresses = ref<TeamAddress[]>([])
|
||||||
|
|
||||||
if (isAuthenticated.value) {
|
if (isAuthenticated.value) {
|
||||||
try {
|
try {
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||||
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
teamAddresses.value = data?.teamAddresses || []
|
teamAddresses.value = (data?.teamAddresses || []).filter((a): a is TeamAddress => a != null)
|
||||||
} catch {
|
} catch {
|
||||||
// Not critical
|
// Not critical
|
||||||
}
|
}
|
||||||
@@ -147,11 +149,12 @@ const goToRequestIfReady = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectHub = async (hub: any) => {
|
const selectHub = async (hub: CatalogHubItem | CatalogNearestHubItem) => {
|
||||||
|
if (!hub.uuid) return
|
||||||
selectedHubId.value = hub.uuid
|
selectedHubId.value = hub.uuid
|
||||||
|
|
||||||
if (isSearchMode.value) {
|
if (isSearchMode.value) {
|
||||||
searchStore.setLocation(hub.name)
|
searchStore.setLocation(hub.name ?? '')
|
||||||
searchStore.setLocationUuid(hub.uuid)
|
searchStore.setLocationUuid(hub.uuid)
|
||||||
if (goToRequestIfReady()) return
|
if (goToRequestIfReady()) return
|
||||||
router.back()
|
router.back()
|
||||||
@@ -159,7 +162,7 @@ const selectHub = async (hub: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await locationStore.select('hub', hub.uuid, hub.name, hub.latitude, hub.longitude)
|
const success = await locationStore.select('hub', hub.uuid, hub.name ?? '', hub.latitude ?? 0, hub.longitude ?? 0)
|
||||||
if (success) {
|
if (success) {
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,7 @@ const selectHub = async (hub: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAddress = async (addr: any) => {
|
const selectAddress = async (addr: TeamAddress) => {
|
||||||
if (isSearchMode.value) {
|
if (isSearchMode.value) {
|
||||||
searchStore.setLocation(addr.address || addr.name)
|
searchStore.setLocation(addr.address || addr.name)
|
||||||
searchStore.setLocationUuid(addr.uuid)
|
searchStore.setLocationUuid(addr.uuid)
|
||||||
@@ -178,7 +181,7 @@ const selectAddress = async (addr: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude, addr.longitude)
|
const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude ?? 0, addr.longitude ?? 0)
|
||||||
if (success) {
|
if (success) {
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
>
|
>
|
||||||
<template #cards>
|
<template #cards>
|
||||||
<HubCard
|
<HubCard
|
||||||
v-for="hub in items"
|
v-for="(hub, index) in items"
|
||||||
:key="hub.uuid"
|
:key="hub.uuid ?? index"
|
||||||
:hub="hub"
|
:hub="hub"
|
||||||
selectable
|
selectable
|
||||||
:is-selected="selectedItemId === hub.uuid"
|
:is-selected="selectedItemId === hub.uuid"
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocationStore } from '~/stores/location'
|
import { useLocationStore } from '~/stores/location'
|
||||||
|
import type { CatalogHubItem, CatalogNearestHubItem } from '~/composables/useCatalogHubs'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: false
|
layout: false
|
||||||
@@ -64,7 +65,8 @@ await init()
|
|||||||
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
|
||||||
const selectedItemId = ref<string | null>(null)
|
const selectedItemId = ref<string | null>(null)
|
||||||
|
|
||||||
const selectItem = async (item: any) => {
|
const selectItem = async (item: CatalogHubItem | CatalogNearestHubItem) => {
|
||||||
|
if (!item.uuid) return
|
||||||
selectedItemId.value = item.uuid
|
selectedItemId.value = item.uuid
|
||||||
|
|
||||||
if (item.latitude && item.longitude) {
|
if (item.latitude && item.longitude) {
|
||||||
@@ -73,7 +75,7 @@ const selectItem = async (item: any) => {
|
|||||||
|
|
||||||
// Selection logic
|
// Selection logic
|
||||||
if (isSearchMode.value) {
|
if (isSearchMode.value) {
|
||||||
searchStore.setLocation(item.name)
|
searchStore.setLocation(item.name ?? '')
|
||||||
searchStore.setLocationUuid(item.uuid)
|
searchStore.setLocationUuid(item.uuid)
|
||||||
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
|
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
@@ -92,7 +94,7 @@ const selectItem = async (item: any) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await locationStore.select('hub', item.uuid, item.name, item.latitude, item.longitude)
|
const success = await locationStore.select('hub', item.uuid, item.name ?? '', item.latitude ?? 0, item.longitude ?? 0)
|
||||||
if (success) router.push(localePath('/select-location'))
|
if (success) router.push(localePath('/select-location'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
358
app/pages/whitepaper.vue
Normal file
358
app/pages/whitepaper.vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pb-16">
|
||||||
|
<section class="container mx-auto px-4 mt-10">
|
||||||
|
<div class="rounded-3xl bg-base-200/70 border border-base-300/70 p-8 md:p-12 relative overflow-hidden">
|
||||||
|
<div class="absolute -top-24 -right-24 h-64 w-64 rounded-full bg-primary/20 blur-3xl" />
|
||||||
|
<div class="absolute -bottom-28 -left-20 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="text-xs uppercase tracking-[0.3em] text-base-content/50">White paper</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs"
|
||||||
|
:class="lang === 'ru' ? 'btn-primary' : 'btn-ghost'"
|
||||||
|
@click="lang = 'ru'"
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs"
|
||||||
|
:class="lang === 'en' ? 'btn-primary' : 'btn-ghost'"
|
||||||
|
@click="lang = 'en'"
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h1 class="text-3xl md:text-5xl font-bold text-base-content max-w-3xl">
|
||||||
|
{{ content.hero.title }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-5 text-lg text-base-content/70 max-w-2xl">
|
||||||
|
{{ content.hero.subtitle }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<button class="btn btn-primary">{{ content.hero.ctaPrimary }}</button>
|
||||||
|
<button class="btn btn-ghost border border-base-300">{{ content.hero.ctaSecondary }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[0].label }}</p>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[0].value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[1].label }}</p>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[1].value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[2].label }}</p>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[2].value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-4 mt-16">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{{ content.vision.title }}</h2>
|
||||||
|
<p class="text-base-content/70">{{ content.vision.body }}</p>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon name="lucide:sparkles" class="text-primary" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ content.vision.points[0].title }}</p>
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.vision.points[0].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon name="lucide:globe" class="text-primary" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ content.vision.points[1].title }}</p>
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.vision.points[1].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<Icon name="lucide:layers" class="text-primary" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ content.vision.points[2].title }}</p>
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.vision.points[2].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl">{{ content.mission.title }}</h2>
|
||||||
|
<p class="text-base-content/70">{{ content.mission.body }}</p>
|
||||||
|
<div class="mt-6 grid grid-cols-1 gap-3">
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[0].label }}</p>
|
||||||
|
<p class="text-lg font-semibold">{{ content.mission.cards[0].value }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[1].label }}</p>
|
||||||
|
<p class="text-lg font-semibold">{{ content.mission.cards[1].value }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[2].label }}</p>
|
||||||
|
<p class="text-lg font-semibold">{{ content.mission.cards[2].value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-4 mt-16">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-xl font-semibold">{{ content.principles[0].title }}</h3>
|
||||||
|
<p class="text-base-content/70">{{ content.principles[0].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-xl font-semibold">{{ content.principles[1].title }}</h3>
|
||||||
|
<p class="text-base-content/70">{{ content.principles[1].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-xl font-semibold">{{ content.principles[2].title }}</h3>
|
||||||
|
<p class="text-base-content/70">{{ content.principles[2].text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-4 mt-16">
|
||||||
|
<div class="rounded-3xl bg-base-100 border border-base-300/60 p-8 md:p-12">
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold">{{ content.trust.title }}</h2>
|
||||||
|
<p class="mt-4 text-base-content/70 max-w-2xl">{{ content.trust.body }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 w-full max-w-sm">
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.trust.items[0] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.trust.items[1] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-base-200/70 p-4">
|
||||||
|
<p class="text-sm text-base-content/60">{{ content.trust.items[2] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-4 mt-16">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-xl font-semibold">{{ content.team.title }}</h3>
|
||||||
|
<p class="text-base-content/60">{{ content.team.body }}</p>
|
||||||
|
<div class="mt-6 flex flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary/20 text-primary w-12 rounded-full">
|
||||||
|
<span>Р</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ content.team.members[0].name }}</p>
|
||||||
|
<p class="text-xs text-base-content/50">{{ content.team.members[0].role }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-secondary/20 text-secondary w-12 rounded-full">
|
||||||
|
<span>Д</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ content.team.members[1].name }}</p>
|
||||||
|
<p class="text-xs text-base-content/50">{{ content.team.members[1].role }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 border border-base-300/60">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-xl font-semibold">{{ content.roadmap.title }}</h3>
|
||||||
|
<ul class="mt-3 space-y-2 text-base-content/70">
|
||||||
|
<li v-for="item in content.roadmap.items" :key="item">• {{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-4 mt-16">
|
||||||
|
<div class="rounded-3xl bg-gradient-to-r from-primary/80 via-secondary/70 to-accent/80 text-primary-content p-10 md:p-14">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold">{{ content.cta.title }}</h2>
|
||||||
|
<p class="mt-2 text-primary-content/80">{{ content.cta.body }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn bg-base-100 text-base-content border-0">{{ content.cta.button }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const lang = ref<'ru' | 'en'>('ru')
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
ru: {
|
||||||
|
hero: {
|
||||||
|
title: 'Инфраструктура цифровых инструментов, которая стирает границы',
|
||||||
|
subtitle:
|
||||||
|
'Optovia строит единое пространство для участников рынка: чтобы процессы были прозрачными, доверенными и простыми, а команды могли работать быстрее и безопаснее.',
|
||||||
|
ctaPrimary: 'Запросить демо',
|
||||||
|
ctaSecondary: 'Открыть кабинет',
|
||||||
|
cards: [
|
||||||
|
{ label: 'Идея', value: 'Стираем границы и упрощаем рынок' },
|
||||||
|
{ label: 'Подход', value: 'Инфраструктура вместо точечных продуктов' },
|
||||||
|
{ label: 'Форма', value: 'B2B / API-first / экосистема' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vision: {
|
||||||
|
title: 'Видение',
|
||||||
|
body:
|
||||||
|
'Мы делаем инфраструктуру, которая позволяет компаниям быстрее доверять друг другу и прозрачнее работать с данными. Наша цель — убрать шум, убрать барьеры и дать рынку работающий цифровой фундамент.',
|
||||||
|
points: [
|
||||||
|
{ title: 'Стираем границы', text: 'Объединяем процессы и участников рынка в единый контур.' },
|
||||||
|
{ title: 'Делаем проще', text: 'Снижаем сложность и убираем ручные операции.' },
|
||||||
|
{ title: 'Строим фундамент', text: 'Даем основу, на которой бизнес может расти.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mission: {
|
||||||
|
title: 'Что мы строим',
|
||||||
|
body:
|
||||||
|
'Optovia — это инфраструктура для цифровых операций: от доверия и безопасности до мониторинга и прозрачности.',
|
||||||
|
cards: [
|
||||||
|
{ label: 'Единая экосистема', value: 'Команды, данные, процессы — в одной системе.' },
|
||||||
|
{ label: 'Прозрачность', value: 'Статусы и история действий в реальном времени.' },
|
||||||
|
{ label: 'Масштабируемость', value: 'Подходит для компаний любого размера.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
principles: [
|
||||||
|
{ title: 'Прозрачность', text: 'Каждый процесс наблюдаем и измерим.' },
|
||||||
|
{ title: 'Надежность', text: 'Стабильная инфраструктура и контроль рисков.' },
|
||||||
|
{ title: 'Скорость', text: 'Ускоряем рутину и даем бизнесу скорость решений.' },
|
||||||
|
],
|
||||||
|
trust: {
|
||||||
|
title: 'Доверие и безопасность',
|
||||||
|
body:
|
||||||
|
'Мы строим технологию, которая поддерживает надежность на всех уровнях — от доступа до мониторинга данных. Безопасность не отдельный слой, а часть ДНК платформы.',
|
||||||
|
items: [
|
||||||
|
'Токены и изоляция сервисов',
|
||||||
|
'Аудит действий и история событий',
|
||||||
|
'Контроль доступа и права команд',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
title: 'Команда',
|
||||||
|
body: 'Партнеры проекта, которые выстраивают стратегию и развитие продукта.',
|
||||||
|
members: [
|
||||||
|
{ name: 'Руслан', role: 'Партнер' },
|
||||||
|
{ name: 'Денис', role: 'Партнер' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
roadmap: {
|
||||||
|
title: 'Roadmap',
|
||||||
|
items: ['Private beta и пилоты', 'Усиление мониторинга и алертов', 'Масштабирование и новые вертикали'],
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
title: 'Хотите посмотреть систему в действии?',
|
||||||
|
body: 'Зайдите в демо-кабинет клиента и оцените платформу вживую.',
|
||||||
|
button: 'Перейти в демо',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
hero: {
|
||||||
|
title: 'Infrastructure for digital tools that removes borders',
|
||||||
|
subtitle:
|
||||||
|
'Optovia builds a shared space for market participants: transparent, trusted, and simple processes that let teams move faster and safer.',
|
||||||
|
ctaPrimary: 'Request demo',
|
||||||
|
ctaSecondary: 'Open demo',
|
||||||
|
cards: [
|
||||||
|
{ label: 'Idea', value: 'Remove borders and simplify the market' },
|
||||||
|
{ label: 'Approach', value: 'Infrastructure, not isolated products' },
|
||||||
|
{ label: 'Form', value: 'B2B / API-first / ecosystem' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vision: {
|
||||||
|
title: 'Vision',
|
||||||
|
body:
|
||||||
|
'We build infrastructure that helps companies trust each other faster and work with data more transparently. Our goal is to remove noise, remove barriers, and deliver a working digital foundation.',
|
||||||
|
points: [
|
||||||
|
{ title: 'Remove borders', text: 'Unite processes and participants into one flow.' },
|
||||||
|
{ title: 'Make it simple', text: 'Reduce complexity and manual actions.' },
|
||||||
|
{ title: 'Build the foundation', text: 'Give the market a reliable base to grow.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mission: {
|
||||||
|
title: 'What we build',
|
||||||
|
body:
|
||||||
|
'Optovia is infrastructure for digital operations: from trust and safety to monitoring and transparency.',
|
||||||
|
cards: [
|
||||||
|
{ label: 'Unified ecosystem', value: 'Teams, data, and processes in one system.' },
|
||||||
|
{ label: 'Transparency', value: 'Statuses and history in real time.' },
|
||||||
|
{ label: 'Scalability', value: 'Fits businesses of any size.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
principles: [
|
||||||
|
{ title: 'Transparency', text: 'Every process is observable and measurable.' },
|
||||||
|
{ title: 'Reliability', text: 'Stable infrastructure and risk control.' },
|
||||||
|
{ title: 'Speed', text: 'Faster operations and decision making.' },
|
||||||
|
],
|
||||||
|
trust: {
|
||||||
|
title: 'Trust & security',
|
||||||
|
body:
|
||||||
|
'Security is embedded in the platform DNA: access, monitoring, and auditability built-in from day one.',
|
||||||
|
items: ['Tokens and service isolation', 'Audit trails and event history', 'Access control and team permissions'],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
title: 'Team',
|
||||||
|
body: 'Partners shaping strategy and product development.',
|
||||||
|
members: [
|
||||||
|
{ name: 'Ruslan', role: 'Partner' },
|
||||||
|
{ name: 'Denis', role: 'Partner' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
roadmap: {
|
||||||
|
title: 'Roadmap',
|
||||||
|
items: ['Private beta and pilots', 'Monitoring and alerts expansion', 'Scaling and new verticals'],
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
title: 'Want to see the system in action?',
|
||||||
|
body: 'Enter the demo client cabinet and explore the platform live.',
|
||||||
|
button: 'Go to demo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const content = computed(() => copy[lang.value])
|
||||||
|
</script>
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
38
app/shims.d.ts
vendored
Normal file
38
app/shims.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Type declarations for modules without TypeScript support
|
||||||
|
|
||||||
|
declare module '@lottiefiles/dotlottie-vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
export const DotLottieVue: DefineComponent<{
|
||||||
|
src?: string
|
||||||
|
autoplay?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
class?: string
|
||||||
|
style?: Record<string, string | number>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue-chartjs' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
export const Line: DefineComponent
|
||||||
|
export const Bar: DefineComponent
|
||||||
|
export const Pie: DefineComponent
|
||||||
|
export const Doughnut: DefineComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'chart.js' {
|
||||||
|
export const CategoryScale: unknown
|
||||||
|
export const LinearScale: unknown
|
||||||
|
export const PointElement: unknown
|
||||||
|
export const LineElement: unknown
|
||||||
|
export const Title: unknown
|
||||||
|
export const Tooltip: unknown
|
||||||
|
export const Legend: unknown
|
||||||
|
export const Filler: unknown
|
||||||
|
export const Chart: {
|
||||||
|
register: (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
export type ChartData<T = unknown> = T
|
||||||
|
export type ChartOptions<T = unknown> = T
|
||||||
|
}
|
||||||
@@ -9,14 +9,22 @@ const plugins = [
|
|||||||
const pluginConfig = {
|
const pluginConfig = {
|
||||||
scalars: {
|
scalars: {
|
||||||
DateTime: 'string',
|
DateTime: 'string',
|
||||||
|
Date: 'string',
|
||||||
|
Decimal: 'string',
|
||||||
|
JSONString: 'Record<string, unknown>',
|
||||||
|
JSON: 'Record<string, unknown>',
|
||||||
|
UUID: 'string',
|
||||||
|
BigInt: 'string',
|
||||||
},
|
},
|
||||||
useTypeImports: true,
|
useTypeImports: true,
|
||||||
|
strictScalars: true,
|
||||||
// Add suffix to operation result types to avoid conflicts with schema types
|
// Add suffix to operation result types to avoid conflicts with schema types
|
||||||
operationResultSuffix: 'Result',
|
operationResultSuffix: 'Result',
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: CodegenConfig = {
|
const config: CodegenConfig = {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
|
allowPartialOutputs: true,
|
||||||
generates: {
|
generates: {
|
||||||
// Public operations (no token)
|
// Public operations (no token)
|
||||||
'./app/composables/graphql/public/exchange-generated.ts': {
|
'./app/composables/graphql/public/exchange-generated.ts': {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
query HubsList($limit: Int, $offset: Int, $country: String, $transportType: String) {
|
query HubsList($limit: Int, $offset: Int, $country: String, $transportType: String, $west: Float, $south: Float, $east: Float, $north: Float) {
|
||||||
hubsList(limit: $limit, offset: $offset, country: $country, transportType: $transportType) {
|
hubsList(limit: $limit, offset: $offset, country: $country, transportType: $transportType, west: $west, south: $south, east: $east, north: $north) {
|
||||||
uuid
|
uuid
|
||||||
name
|
name
|
||||||
latitude
|
latitude
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
query NearestHubs($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String, $limit: Int) {
|
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
|
uuid
|
||||||
name
|
name
|
||||||
latitude
|
latitude
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
query ProductsList($limit: Int, $offset: Int) {
|
query ProductsList($limit: Int, $offset: Int, $west: Float, $south: Float, $east: Float, $north: Float) {
|
||||||
productsList(limit: $limit, offset: $offset) {
|
productsList(limit: $limit, offset: $offset, west: $west, south: $south, east: $east, north: $north) {
|
||||||
uuid
|
uuid
|
||||||
name
|
name
|
||||||
offersCount
|
offersCount
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
query SuppliersList($limit: Int, $offset: Int, $country: String) {
|
query SuppliersList($limit: Int, $offset: Int, $country: String, $west: Float, $south: Float, $east: Float, $north: Float) {
|
||||||
suppliersList(limit: $limit, offset: $offset, country: $country) {
|
suppliersList(limit: $limit, offset: $offset, country: $country, west: $west, south: $south, east: $east, north: $north) {
|
||||||
uuid
|
uuid
|
||||||
name
|
name
|
||||||
latitude
|
latitude
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
|
"cabinet": "My Cabinet",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"catalog": "Catalog",
|
"catalog": "Catalog",
|
||||||
"orders": "My orders",
|
"orders": "My orders",
|
||||||
@@ -13,6 +14,10 @@
|
|||||||
"seller": "Seller",
|
"seller": "Seller",
|
||||||
"suppliers": "Suppliers",
|
"suppliers": "Suppliers",
|
||||||
"hubs": "Hubs",
|
"hubs": "Hubs",
|
||||||
"ai": "AI assistant"
|
"ai": "AI assistant",
|
||||||
|
"roles": {
|
||||||
|
"client": "I'm a client",
|
||||||
|
"seller": "I'm a seller"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,18 @@
|
|||||||
"suppliersNearby": "Suppliers nearby",
|
"suppliersNearby": "Suppliers nearby",
|
||||||
"noHubs": "No hubs found",
|
"noHubs": "No hubs found",
|
||||||
"noSuppliers": "No suppliers found",
|
"noSuppliers": "No suppliers found",
|
||||||
"viewSupplier": "View supplier"
|
"viewSupplier": "View supplier",
|
||||||
|
"supplier": "Supplier",
|
||||||
|
"kycTeaser": "Company Information",
|
||||||
|
"companyType": "Company Type",
|
||||||
|
"registrationYear": "Registration Year",
|
||||||
|
"status": "Status",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"sourcesCount": "Data Sources",
|
||||||
|
"viewFullKyc": "View full company profile",
|
||||||
|
"railHubs": "Rail hubs",
|
||||||
|
"seaHubs": "Sea hubs"
|
||||||
},
|
},
|
||||||
"modes": {
|
"modes": {
|
||||||
"explore": "Explore",
|
"explore": "Explore",
|
||||||
@@ -76,12 +87,23 @@
|
|||||||
"selectSupplier": "Select supplier",
|
"selectSupplier": "Select supplier",
|
||||||
"enterQty": "Quantity (t)",
|
"enterQty": "Quantity (t)",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"clear": "Clear"
|
"clear": "Clear",
|
||||||
|
"findOffers": "Find offers"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"title": "Explore the market",
|
"title": "Explore the market",
|
||||||
"subtitle": "Switch between offers, hubs, and suppliers"
|
"subtitle": "Switch between offers, hubs, and suppliers"
|
||||||
},
|
},
|
||||||
"offers": "offer | offers"
|
"offers": "offer | offers",
|
||||||
|
"list": "List",
|
||||||
|
"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,7 +3,14 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"quantity_with_unit": "{quantity} {unit}",
|
"quantity_with_unit": "{quantity} {unit}",
|
||||||
"default_unit": "t",
|
"default_unit": "t",
|
||||||
"country_unknown": "Not specified"
|
"unit_kg": "kg",
|
||||||
|
"distance_km": "{km} km",
|
||||||
|
"duration_label": "ETA",
|
||||||
|
"duration_days": "{days} d",
|
||||||
|
"country_unknown": "Not specified",
|
||||||
|
"supplier_unknown": "Supplier",
|
||||||
|
"origin_label": "From",
|
||||||
|
"origin_unknown": "Origin not specified"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Ready to Start Trading?",
|
"title": "Ready to Get Started?",
|
||||||
"description": "Join thousands of companies already using Optovia for their deals",
|
"description": "Join hundreds of companies already trading on Optovia",
|
||||||
"start_selling": "Start Selling",
|
"start_selling": "Start Selling",
|
||||||
"start_buying": "Start Buying"
|
"start_buying": "Start Buying",
|
||||||
|
"register": "Register",
|
||||||
|
"demo": "Request Demo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,68 @@
|
|||||||
{
|
{
|
||||||
"footer": {
|
"footer": {
|
||||||
|
"description": "Global B2B platform for raw materials and agricultural commodities trading. Connecting producers and buyers worldwide.",
|
||||||
"buyers": "For Buyers",
|
"buyers": "For Buyers",
|
||||||
"suppliers": "For Suppliers",
|
"suppliers": "For Suppliers",
|
||||||
"services": "For Service Companies",
|
"services": "For Service Companies",
|
||||||
"rights": "All rights reserved",
|
"rights": "All rights reserved.",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"terms": "Terms of Service",
|
"terms": "Terms",
|
||||||
"support": "Support"
|
"support": "Support",
|
||||||
|
"secure": "Secure Transactions",
|
||||||
|
"verified": "Verification",
|
||||||
|
|
||||||
|
"europe": "Europe",
|
||||||
|
"germany": "Germany",
|
||||||
|
"france": "France",
|
||||||
|
"netherlands": "Netherlands",
|
||||||
|
"poland": "Poland",
|
||||||
|
"spain": "Spain",
|
||||||
|
|
||||||
|
"cis": "CIS",
|
||||||
|
"russia": "Russia",
|
||||||
|
"kazakhstan": "Kazakhstan",
|
||||||
|
"uzbekistan": "Uzbekistan",
|
||||||
|
"belarus": "Belarus",
|
||||||
|
"azerbaijan": "Azerbaijan",
|
||||||
|
|
||||||
|
"asia": "Asia",
|
||||||
|
"china": "China",
|
||||||
|
"india": "India",
|
||||||
|
"turkey": "Turkey",
|
||||||
|
"uae": "UAE",
|
||||||
|
"saudi": "Saudi Arabia",
|
||||||
|
|
||||||
|
"americas": "Americas & Africa",
|
||||||
|
"usa": "USA",
|
||||||
|
"brazil": "Brazil",
|
||||||
|
"argentina": "Argentina",
|
||||||
|
"southafrica": "South Africa",
|
||||||
|
"egypt": "Egypt",
|
||||||
|
|
||||||
|
"offices": "Our Offices",
|
||||||
|
"hq": "Headquarters",
|
||||||
|
"europeOffice": "Europe",
|
||||||
|
"cisOffice": "CIS",
|
||||||
|
|
||||||
|
"products": "Products",
|
||||||
|
"grains": "Grains",
|
||||||
|
"oilseeds": "Oilseeds",
|
||||||
|
"sugar": "Sugar",
|
||||||
|
"fertilizers": "Fertilizers",
|
||||||
|
|
||||||
|
"logistics": "Logistics",
|
||||||
|
"insurance": "Insurance",
|
||||||
|
"financing": "Financing",
|
||||||
|
"inspection": "Inspection",
|
||||||
|
|
||||||
|
"company": "Company",
|
||||||
|
"about": "About Us",
|
||||||
|
"careers": "Careers",
|
||||||
|
"press": "Press",
|
||||||
|
"contact": "Contact",
|
||||||
|
|
||||||
|
"legal": "Legal",
|
||||||
|
"cookies": "Cookies",
|
||||||
|
"compliance": "Compliance"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"howto": {
|
"howto": {
|
||||||
"title": "How It Works",
|
"title": "How It Works",
|
||||||
|
"step": "Step",
|
||||||
"step1": {
|
"step1": {
|
||||||
"title": "Find Materials",
|
"title": "Find Materials",
|
||||||
"description": "Select the raw materials you need, specify quantity and delivery location"
|
"description": "Select the raw materials you need, specify quantity and delivery location"
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
"verification_status": "Verification Status",
|
"verification_status": "Verification Status",
|
||||||
"team_verification_description": "Complete team verification to create orders",
|
"team_verification_description": "Complete team verification to create orders",
|
||||||
"start_verification": "Start Verification",
|
"start_verification": "Start Verification",
|
||||||
"check_status_in_odoo": "Status is being reviewed by administrator"
|
"check_status_in_odoo": "Status is being reviewed by administrator",
|
||||||
|
"demo": {
|
||||||
|
"companyName": "Demo Company LLC",
|
||||||
|
"director": "Director",
|
||||||
|
"capital": "Authorized Capital",
|
||||||
|
"address": "Legal Address",
|
||||||
|
"activities": "Business Activities",
|
||||||
|
"sources": "Sources",
|
||||||
|
"updated": "Updated",
|
||||||
|
"notice": "This is demo data. Real company information will be available after connecting to the database."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Add address",
|
"add": "Add address",
|
||||||
|
"edit": "Edit",
|
||||||
"confirm_delete": "Delete this address?",
|
"confirm_delete": "Delete this address?",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleting": "Deleting..."
|
"deleting": "Deleting..."
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"location": "Location",
|
||||||
|
"map": "Map"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "New address",
|
"title": "New address",
|
||||||
"title_edit": "Edit address",
|
"title_edit": "Edit address",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"roles": {
|
"roles": {
|
||||||
"title": "Who Our Platform Is For",
|
"title": "Who Our Platform Is For",
|
||||||
|
"subtitle": "Platform for all agricultural commodity market participants",
|
||||||
"producers": {
|
"producers": {
|
||||||
"title": "Producers",
|
"title": "Producers",
|
||||||
"description": "Sell raw materials directly to buyers through our platform",
|
"description": "Sell raw materials directly to buyers through our platform",
|
||||||
"benefit1": "Sales bulletin board",
|
"benefit1": "Direct access to buyers",
|
||||||
"benefit2": "Auction tender participation",
|
"benefit2": "Transparent pricing",
|
||||||
"benefit3": "Access to financing",
|
"benefit3": "Access to financing",
|
||||||
"benefit4": "Logistics solutions",
|
"benefit4": "Logistics solutions",
|
||||||
"cta": "Start Selling"
|
"cta": "Start Selling"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"title": "Optovia in Numbers",
|
"title": "Optovia in Numbers",
|
||||||
"suppliers": "Suppliers",
|
"suppliers": "Suppliers",
|
||||||
|
"suppliersDesc": "Verified producers from Russia, Kazakhstan and other CIS countries",
|
||||||
"transactions": "Transaction Volume",
|
"transactions": "Transaction Volume",
|
||||||
"service_companies": "Service Companies",
|
"service_companies": "Service Companies",
|
||||||
"support": "Support"
|
"support": "Support",
|
||||||
|
"countries": "Countries of Presence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"title": "Client Testimonials"
|
"title": "Client Testimonials"
|
||||||
|
},
|
||||||
|
"testimonial": {
|
||||||
|
"quote": "Optovia helped us find reliable suppliers in just days. It used to take months.",
|
||||||
|
"author": "Alexey Petrov",
|
||||||
|
"role": "Procurement Director, AgroHolding"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
|
"cabinet": "Мой кабинет",
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"catalog": "Каталог",
|
"catalog": "Каталог",
|
||||||
"orders": "Мои заказы",
|
"orders": "Мои заказы",
|
||||||
@@ -13,6 +14,10 @@
|
|||||||
"seller": "Продавец",
|
"seller": "Продавец",
|
||||||
"suppliers": "Поставщики",
|
"suppliers": "Поставщики",
|
||||||
"hubs": "Хабы",
|
"hubs": "Хабы",
|
||||||
"ai": "AI ассистент"
|
"ai": "AI ассистент",
|
||||||
|
"roles": {
|
||||||
|
"client": "Я клиент",
|
||||||
|
"seller": "Я продавец"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user