Compare commits
88 Commits
fa0465fabb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39712613ae | ||
|
|
e1e6993f35 | ||
|
|
7b4eaeeb92 | ||
|
|
351125b51d | ||
|
|
7033df0fbc | ||
|
|
008f41d891 | ||
|
|
84deb2d1bc | ||
|
|
54aac790ee | ||
|
|
d3183bf6ad | ||
|
|
670e9b7fd1 | ||
|
|
a74e75049c | ||
|
|
ebe72907a4 | ||
|
|
b7f452cdd1 | ||
|
|
803dad5c8f | ||
|
|
71d35476c2 | ||
|
|
8cfe67809c | ||
|
|
ab3fee82f4 | ||
|
|
a962d04ed1 | ||
|
|
10b72a19e4 | ||
|
|
5a8ef3bea9 | ||
|
|
b5a292093b | ||
|
|
1a6287d13e | ||
|
|
fcc22b4051 | ||
|
|
3380e407f3 | ||
|
|
c940369c81 | ||
|
|
530c97b912 | ||
|
|
2761e61f01 | ||
|
|
2188676d25 | ||
|
|
4be7cade98 | ||
|
|
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 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Build Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: gitea.dsrptlab.com
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and Push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: gitea.dsrptlab.com/optovia/webapp/webapp:latest
|
|
||||||
build-args: |
|
|
||||||
INFISICAL_API_URL=${{ secrets.INFISICAL_API_URL }}
|
|
||||||
INFISICAL_CLIENT_ID=${{ secrets.INFISICAL_CLIENT_ID }}
|
|
||||||
INFISICAL_CLIENT_SECRET=${{ secrets.INFISICAL_CLIENT_SECRET }}
|
|
||||||
INFISICAL_PROJECT_ID=${{ secrets.INFISICAL_PROJECT_ID }}
|
|
||||||
INFISICAL_ENV=prod
|
|
||||||
|
|
||||||
- name: Deploy to Dokploy
|
|
||||||
run: curl -X POST "https://dokploy.optovia.ru/api/deploy/0_iNAXPDx28BLZIddGTzB"
|
|
||||||
35
Dockerfile
35
Dockerfile
@@ -1,36 +1,27 @@
|
|||||||
FROM node:22-slim AS build
|
FROM node:22-slim AS base
|
||||||
|
|
||||||
ENV PNPM_HOME=/pnpm
|
ENV PNPM_HOME=/pnpm
|
||||||
ENV PATH=$PNPM_HOME:$PATH
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile --config.confirmModulesPurge=false
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
ENV NODE_OPTIONS=--max-old-space-size=2048
|
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||||
ENV NUXT_SOURCEMAP=false
|
ENV NUXT_SOURCEMAP=false
|
||||||
ENV NUXT_MINIFY=false
|
ENV NUXT_MINIFY=false
|
||||||
ENV SENTRY_ENABLED=false
|
ENV SENTRY_ENABLED=false
|
||||||
ENV NUXT_TELEMETRY_DISABLED=1
|
ENV NUXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
ARG INFISICAL_API_URL
|
|
||||||
ARG INFISICAL_CLIENT_ID
|
|
||||||
ARG INFISICAL_CLIENT_SECRET
|
|
||||||
ARG INFISICAL_PROJECT_ID
|
|
||||||
ARG INFISICAL_ENV
|
|
||||||
|
|
||||||
ENV INFISICAL_API_URL=$INFISICAL_API_URL \
|
|
||||||
INFISICAL_CLIENT_ID=$INFISICAL_CLIENT_ID \
|
|
||||||
INFISICAL_CLIENT_SECRET=$INFISICAL_CLIENT_SECRET \
|
|
||||||
INFISICAL_PROJECT_ID=$INFISICAL_PROJECT_ID \
|
|
||||||
INFISICAL_ENV=$INFISICAL_ENV
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
|
RUN pnpm run build && pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
FROM node:22-slim
|
FROM node:22-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
33
app/app.vue
33
app/app.vue
@@ -1,14 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<NuxtLoadingIndicator :height="4" :duration="3000" color="#2f2418" />
|
||||||
|
|
||||||
|
<Transition name="route-loader-fade">
|
||||||
|
<div
|
||||||
|
v-if="routeLoading"
|
||||||
|
class="fixed inset-0 z-[999] flex items-center justify-center bg-[#f5efe7]/65 backdrop-blur-sm"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 rounded-full border border-[#ded2bf] bg-white/92 px-5 py-3 shadow-[0_18px_40px_rgba(47,36,24,0.12)]">
|
||||||
|
<span class="loading loading-spinner loading-md text-[#2f2418]" />
|
||||||
|
<span class="text-sm font-medium text-base-content">{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const routeLoading = useState<boolean>('route-loading', () => false)
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
'data-theme': 'cupcake',
|
'data-theme': 'silk',
|
||||||
},
|
},
|
||||||
script: []
|
script: []
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.route-loader-fade-enter-active,
|
||||||
|
.route-loader-fade-leave-active {
|
||||||
|
transition: opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-loader-fade-enter-from,
|
||||||
|
.route-loader-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,50 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
|
||||||
name: "cupcake";
|
|
||||||
default: true;
|
|
||||||
prefersdark: false;
|
|
||||||
color-scheme: "light";
|
|
||||||
--color-base-100: oklch(97.788% 0.004 56.375);
|
|
||||||
--color-base-200: oklch(93.982% 0.007 61.449);
|
|
||||||
--color-base-300: oklch(91.586% 0.006 53.44);
|
|
||||||
--color-base-content: oklch(23.574% 0.066 313.189);
|
|
||||||
--color-primary: oklch(85% 0.138 181.071);
|
|
||||||
--color-primary-content: oklch(43% 0.078 188.216);
|
|
||||||
--color-secondary: oklch(89% 0.061 343.231);
|
|
||||||
--color-secondary-content: oklch(45% 0.187 3.815);
|
|
||||||
--color-accent: oklch(90% 0.076 70.697);
|
|
||||||
--color-accent-content: oklch(47% 0.157 37.304);
|
|
||||||
--color-neutral: oklch(27% 0.006 286.033);
|
|
||||||
--color-neutral-content: oklch(92% 0.004 286.32);
|
|
||||||
--color-info: oklch(68% 0.169 237.323);
|
|
||||||
--color-info-content: oklch(29% 0.066 243.157);
|
|
||||||
--color-success: oklch(69% 0.17 162.48);
|
|
||||||
--color-success-content: oklch(26% 0.051 172.552);
|
|
||||||
--color-warning: oklch(79% 0.184 86.047);
|
|
||||||
--color-warning-content: oklch(28% 0.066 53.813);
|
|
||||||
--color-error: oklch(64% 0.246 16.439);
|
|
||||||
--color-error-content: oklch(27% 0.105 12.094);
|
|
||||||
--radius-selector: 2rem;
|
|
||||||
--radius-field: 2rem;
|
|
||||||
--radius-box: 2rem;
|
|
||||||
--size-selector: 0.3125rem;
|
|
||||||
--size-field: 0.3125rem;
|
|
||||||
--border: 0.5px;
|
|
||||||
--depth: 1;
|
|
||||||
--noise: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "silk";
|
name: "silk";
|
||||||
default: false;
|
default: true;
|
||||||
prefersdark: false;
|
prefersdark: false;
|
||||||
color-scheme: "light";
|
color-scheme: "light";
|
||||||
--color-base-100: oklch(97% 0.0035 67.78);
|
--color-base-100: oklch(97% 0.0035 67.78);
|
||||||
--color-base-200: oklch(95% 0.0081 61.42);
|
--color-base-200: oklch(95% 0.0081 61.42);
|
||||||
--color-base-300: oklch(90% 0.0081 61.42);
|
--color-base-300: oklch(90% 0.0081 61.42);
|
||||||
--color-base-content: oklch(40% 0.0081 61.42);
|
--color-base-content: oklch(24% 0.02 256);
|
||||||
--color-primary: oklch(23.27% 0.0249 284.3);
|
--color-primary: oklch(23.27% 0.0249 284.3);
|
||||||
--color-primary-content: oklch(94.22% 0.2505 117.44);
|
--color-primary-content: oklch(94.22% 0.2505 117.44);
|
||||||
--color-secondary: oklch(23.27% 0.0249 284.3);
|
--color-secondary: oklch(23.27% 0.0249 284.3);
|
||||||
@@ -62,46 +28,50 @@
|
|||||||
--color-error: oklch(75.1% 0.1814 22.37);
|
--color-error: oklch(75.1% 0.1814 22.37);
|
||||||
--color-error-content: oklch(35.1% 0.1814 22.37);
|
--color-error-content: oklch(35.1% 0.1814 22.37);
|
||||||
--radius-selector: 2rem;
|
--radius-selector: 2rem;
|
||||||
--radius-field: 1rem;
|
--radius-field: 2rem;
|
||||||
--radius-box: 1rem;
|
--radius-box: 2rem;
|
||||||
--size-selector: 0.28125rem;
|
--size-selector: 0.3125rem;
|
||||||
--size-field: 0.28125rem;
|
--size-field: 0.3125rem;
|
||||||
--border: 0.5px;
|
--border: 0.5px;
|
||||||
--depth: 0;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
:root {
|
||||||
name: "night";
|
color-scheme: light;
|
||||||
default: false;
|
}
|
||||||
prefersdark: true;
|
|
||||||
color-scheme: "dark";
|
html {
|
||||||
--color-base-100: oklch(20.768% 0.039 265.754);
|
scroll-behavior: smooth;
|
||||||
--color-base-200: oklch(19.314% 0.037 265.754);
|
color-scheme: light;
|
||||||
--color-base-300: oklch(17.86% 0.034 265.754);
|
background-color: #f7f5f1;
|
||||||
--color-base-content: oklch(84.153% 0.007 265.754);
|
}
|
||||||
--color-primary: oklch(75.351% 0.138 232.661);
|
|
||||||
--color-primary-content: oklch(15.07% 0.027 232.661);
|
body {
|
||||||
--color-secondary: oklch(68.011% 0.158 276.934);
|
min-height: 100vh;
|
||||||
--color-secondary-content: oklch(13.602% 0.031 276.934);
|
color-scheme: light;
|
||||||
--color-accent: oklch(72.36% 0.176 350.048);
|
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||||
--color-accent-content: oklch(14.472% 0.035 350.048);
|
color: oklch(24% 0.02 256);
|
||||||
--color-neutral: oklch(27.949% 0.036 260.03);
|
background:
|
||||||
--color-neutral-content: oklch(85.589% 0.007 260.03);
|
radial-gradient(circle at 10% 0%, #fdf8ea 0%, rgba(253, 248, 234, 0) 40%),
|
||||||
--color-info: oklch(68.455% 0.148 237.251);
|
radial-gradient(circle at 90% 100%, #e9f2ff 0%, rgba(233, 242, 255, 0) 35%),
|
||||||
--color-info-content: oklch(0% 0 0);
|
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
|
||||||
--color-success: oklch(78.452% 0.132 181.911);
|
}
|
||||||
--color-success-content: oklch(15.69% 0.026 181.911);
|
|
||||||
--color-warning: oklch(83.242% 0.139 82.95);
|
.glass-underlay {
|
||||||
--color-warning-content: oklch(16.648% 0.027 82.95);
|
background: #ece3d3;
|
||||||
--color-error: oklch(71.785% 0.17 13.118);
|
border: 1px solid #d7ccb7;
|
||||||
--color-error-content: oklch(14.357% 0.034 13.118);
|
box-shadow: 0 14px 30px rgba(24, 20, 12, 0.1);
|
||||||
--radius-selector: 1rem;
|
}
|
||||||
--radius-field: 1rem;
|
|
||||||
--radius-box: 1rem;
|
.glass-capsule {
|
||||||
--size-selector: 0.25rem;
|
background: #e9decb;
|
||||||
--size-field: 0.25rem;
|
border: 1px solid #d5c7b0;
|
||||||
--border: 0.5px;
|
box-shadow: 0 8px 22px rgba(24, 20, 12, 0.1);
|
||||||
--depth: 0;
|
}
|
||||||
--noise: 0;
|
|
||||||
|
.glass-chip {
|
||||||
|
background: #f2eadb;
|
||||||
|
border: 1px solid #dbcdb8;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
82
app/components/AppFooter.vue
Normal file
82
app/components/AppFooter.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
managerStage?: boolean
|
||||||
|
}>(), {
|
||||||
|
managerStage: false,
|
||||||
|
})
|
||||||
|
const { toLocalized } = useLocalizedNavigation()
|
||||||
|
|
||||||
|
const footerLinks = [
|
||||||
|
{ label: 'Home', to: '/' },
|
||||||
|
{ label: 'Orders', to: '/clientarea/orders' },
|
||||||
|
{ label: 'Profile', to: '/clientarea/profile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ label: 'Instagram', href: 'https://instagram.com', icon: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 16a4 4 0 110-8 4 4 0 010 8z' },
|
||||||
|
{ label: 'YouTube', href: 'https://youtube.com', icon: 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' },
|
||||||
|
{ label: 'Telegram', href: 'https://t.me', icon: 'M11.944 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer
|
||||||
|
class="-mt-px overflow-hidden border-t-0 text-white"
|
||||||
|
:class="props.managerStage ? 'bg-transparent' : 'bg-[#10223b] [background-image:radial-gradient(circle_at_82%_18%,rgba(244,89,69,0.45),rgba(244,89,69,0)_34%),linear-gradient(130deg,#10223b_0%,#193450_100%)]'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto max-w-[1280px] px-3 pb-10 md:px-4 md:pb-12"
|
||||||
|
:class="props.managerStage ? 'pt-0 md:pt-0' : 'pt-5 md:pt-6'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid gap-4 rounded-[26px] border p-5 md:p-6"
|
||||||
|
:class="props.managerStage
|
||||||
|
? 'border-white/12 bg-white/4 [backdrop-filter:blur(10px)]'
|
||||||
|
: 'border-white/20 bg-[rgba(11,24,42,0.26)] [backdrop-filter:blur(6px)]'"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="m-0 text-[clamp(1.6rem,3.4vw,2.6rem)] font-black leading-[1.08]">Ready for your next shipment?</h2>
|
||||||
|
<p class="mt-3 max-w-[720px] leading-6 text-white/85">
|
||||||
|
Get live offers, compare terms, and launch procurement directly in Optovia.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2.5">
|
||||||
|
<a href="tel:+74957402842" class="inline-flex items-center rounded-full border border-white/25 bg-white/10 px-3.5 py-2 text-[0.92rem] font-bold text-white transition-colors hover:bg-white/20">+7 (495) 740-28-42</a>
|
||||||
|
<a href="mailto:sales@optovia.ru" class="inline-flex items-center rounded-full border border-white/25 bg-white/10 px-3.5 py-2 text-[0.92rem] font-bold text-white transition-colors hover:bg-white/20">sales@optovia.ru</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap gap-2" aria-label="Footer navigation">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="link in footerLinks"
|
||||||
|
:key="link.to"
|
||||||
|
:to="toLocalized(link.to)"
|
||||||
|
class="rounded-full border border-white/25 px-3 py-2 text-[0.88rem] text-white/90 transition-colors hover:bg-white/15 hover:text-white"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2.5" aria-label="Social networks">
|
||||||
|
<a
|
||||||
|
v-for="s in socialLinks"
|
||||||
|
:key="s.label"
|
||||||
|
:href="s.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="s.label"
|
||||||
|
class="inline-flex h-[2.2rem] w-[2.2rem] items-center justify-center rounded-full border border-white/30 bg-white/10 text-white/95 transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-4 w-4"><path :d="s.icon" /></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 px-1 text-[0.78rem] text-white/70">
|
||||||
|
<p>© {{ currentYear }} Optovia. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
799
app/components/AppHeader.vue
Normal file
799
app/components/AppHeader.vue
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
isAssistantOpen?: boolean
|
||||||
|
profileLabel?: string
|
||||||
|
isAuthenticated?: boolean
|
||||||
|
showLogistics?: boolean
|
||||||
|
hideSearchCapsule?: boolean
|
||||||
|
}>(), {
|
||||||
|
isAssistantOpen: false,
|
||||||
|
profileLabel: '',
|
||||||
|
isAuthenticated: false,
|
||||||
|
showLogistics: false,
|
||||||
|
hideSearchCapsule: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:isAssistantOpen': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const auth = useAuth()
|
||||||
|
const { basePath, isBasePathActive, navigateToLocalized, toLocalized } = useLocalizedNavigation()
|
||||||
|
const { draft: calcDraft, hydrateFromQuery, buildQuery: buildCalcQuery } = useCalcSearchDraft()
|
||||||
|
const isLandingPage = computed(() => basePath.value === '/')
|
||||||
|
const isAuthPage = computed(() =>
|
||||||
|
basePath.value === '/auth'
|
||||||
|
|| basePath.value === '/sign-in'
|
||||||
|
|| basePath.value === '/sign-out'
|
||||||
|
|| basePath.value === '/callback'
|
||||||
|
)
|
||||||
|
const isCalcPage = computed(() => basePath.value.startsWith('/catalog'))
|
||||||
|
const isManagerStagePage = computed(() => basePath.value.startsWith('/manager'))
|
||||||
|
const isDarkHeaderScene = computed(() => isLandingPage.value || isManagerStagePage.value)
|
||||||
|
const isAuthenticated = computed(() => props.isAuthenticated || auth.isAuthenticated.value)
|
||||||
|
const profileLabel = computed(() => props.profileLabel || (auth.user.value?.id ?? t('ui.profile')))
|
||||||
|
const showLogistics = computed(() => props.showLogistics || Boolean((auth.user.value as any)?.isAdmin))
|
||||||
|
const landingSearchScrollY = ref(0)
|
||||||
|
const landingSearchTopStart = ref(450)
|
||||||
|
const landingSearchTopStop = ref(16)
|
||||||
|
const LANDING_SEARCH_TOP_FALLBACK_START = 450
|
||||||
|
const LANDING_SEARCH_TOP_STOP = 16
|
||||||
|
const LANDING_SEARCH_BOTTOM_GAP = 30
|
||||||
|
|
||||||
|
const logisticsSearch = reactive({
|
||||||
|
destination: '',
|
||||||
|
product: '',
|
||||||
|
quantity: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncSearchFromRoute() {
|
||||||
|
hydrateFromQuery(route.query)
|
||||||
|
logisticsSearch.destination = typeof route.query.hubName === 'string'
|
||||||
|
? route.query.hubName
|
||||||
|
: typeof route.query.to === 'string'
|
||||||
|
? route.query.to
|
||||||
|
: isCalcPage.value
|
||||||
|
? calcDraft.value.to
|
||||||
|
: ''
|
||||||
|
logisticsSearch.product = typeof route.query.productName === 'string'
|
||||||
|
? route.query.productName
|
||||||
|
: typeof route.query.from === 'string'
|
||||||
|
? route.query.from
|
||||||
|
: isCalcPage.value
|
||||||
|
? calcDraft.value.from
|
||||||
|
: ''
|
||||||
|
logisticsSearch.quantity = typeof route.query.qty === 'string'
|
||||||
|
? route.query.qty
|
||||||
|
: typeof route.query.quantity === 'string'
|
||||||
|
? route.query.quantity
|
||||||
|
: typeof route.query.cargo === 'string'
|
||||||
|
? route.query.cargo
|
||||||
|
: isCalcPage.value
|
||||||
|
? calcDraft.value.cargo
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCountryIso(value: string, fallback: string) {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (!normalized) return fallback
|
||||||
|
if (/(china|китай|guangzhou|shenzhen|yiwu|ningbo|beijing|shanghai|гуанчжоу|шанхай)/.test(normalized)) return 'CN'
|
||||||
|
if (/(russia|россия|moscow|москва|kazan|казань|saint petersburg|novosibirsk|екатеринбург)/.test(normalized)) return 'RU'
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoToFlag(iso: string) {
|
||||||
|
const normalized = iso.trim().toUpperCase()
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalized)) return '🏳️'
|
||||||
|
return String.fromCodePoint(...[...normalized].map(char => 127397 + char.charCodeAt(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationIso = computed(() => inferCountryIso(logisticsSearch.destination, 'RU'))
|
||||||
|
const destinationFlag = computed(() => isoToFlag(destinationIso.value))
|
||||||
|
const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value)
|
||||||
|
// Fullscreen menu
|
||||||
|
const isMenuOpen = ref(false)
|
||||||
|
const menuLinks = computed(() => {
|
||||||
|
const links: Array<{ label: string; to: string; icon: string }> = [
|
||||||
|
{ label: 'Optovia', to: '/', icon: 'map' },
|
||||||
|
{ label: t('ui.calculate'), to: '/catalog', icon: 'map' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
links.push({ label: t('ui.profile'), to: '/clientarea/profile', icon: 'referral' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
isMenuOpen.value = false
|
||||||
|
syncSearchFromRoute()
|
||||||
|
nextTick(() => {
|
||||||
|
syncLandingSearchScroll()
|
||||||
|
syncLandingSearchAnchor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function menuIconBg(icon: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
orders: 'from-amber-400 to-orange-500',
|
||||||
|
apps: 'from-pink-500 to-rose-600',
|
||||||
|
logistics: 'from-cyan-500 to-blue-600',
|
||||||
|
map: 'from-emerald-400 to-teal-600',
|
||||||
|
consultation: 'from-sky-400 to-blue-600',
|
||||||
|
referral: 'from-fuchsia-500 to-pink-600',
|
||||||
|
}
|
||||||
|
return map[icon] || 'from-gray-400 to-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncSearchFromRoute()
|
||||||
|
syncLandingSearchScroll()
|
||||||
|
syncLandingSearchAnchor()
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.addEventListener('scroll', syncLandingSearchScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', syncLandingSearchAnchor)
|
||||||
|
requestAnimationFrame(syncLandingSearchAnchor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.removeEventListener('scroll', syncLandingSearchScroll)
|
||||||
|
window.removeEventListener('resize', syncLandingSearchAnchor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncLandingSearchScroll() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
landingSearchScrollY.value = Math.max(0, window.scrollY || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLandingSearchAnchor() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const headerShell = document.querySelector<HTMLElement>('[data-header-shell]')
|
||||||
|
const headerPill = document.querySelector<HTMLElement>('[data-header-pill]')
|
||||||
|
|
||||||
|
if (headerShell && headerPill) {
|
||||||
|
landingSearchTopStop.value = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(headerPill.getBoundingClientRect().top - headerShell.getBoundingClientRect().top),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
landingSearchTopStop.value = LANDING_SEARCH_TOP_STOP
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLandingPage.value) {
|
||||||
|
landingSearchTopStart.value = LANDING_SEARCH_TOP_FALLBACK_START
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = document.querySelector<HTMLElement>('[data-landing-search-anchor]')
|
||||||
|
if (!anchor) {
|
||||||
|
landingSearchTopStart.value = LANDING_SEARCH_TOP_FALLBACK_START
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorBottom = anchor.getBoundingClientRect().bottom + window.scrollY
|
||||||
|
landingSearchTopStart.value = Math.max(
|
||||||
|
landingSearchTopStop.value,
|
||||||
|
Math.round(anchorBottom + LANDING_SEARCH_BOTTOM_GAP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const landingSearchTop = computed(() => {
|
||||||
|
if (!isLandingPage.value) return landingSearchTopStop.value
|
||||||
|
return Math.max(
|
||||||
|
landingSearchTopStop.value,
|
||||||
|
landingSearchTopStart.value - landingSearchScrollY.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchCapsuleWidthClass = 'w-full max-w-[1120px]'
|
||||||
|
const searchCapsuleBaseClass = `pill-glass h-16 min-w-0 rounded-full px-2.5 py-2.5 ${searchCapsuleWidthClass}`
|
||||||
|
|
||||||
|
const searchCapsuleClass = computed(() => {
|
||||||
|
if (isLandingPage.value) {
|
||||||
|
return `${searchCapsuleBaseClass} pointer-events-auto shadow-2xl`
|
||||||
|
}
|
||||||
|
return `${searchCapsuleBaseClass} pointer-events-auto`
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchCapsuleWrapperClass = computed(() => {
|
||||||
|
if (isLandingPage.value) {
|
||||||
|
return 'pointer-events-none absolute inset-x-0 z-10 hidden justify-center lg:flex'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'pointer-events-none absolute inset-x-0 top-1/2 z-10 hidden -translate-y-1/2 justify-center lg:flex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchCapsuleWrapperStyle = computed(() => {
|
||||||
|
if (!isLandingPage.value) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${landingSearchTop.value}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerBackdropClass = computed(() => {
|
||||||
|
if (isManagerStagePage.value) {
|
||||||
|
return 'header-glass-backdrop--manager'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLandingPage.value) {
|
||||||
|
return 'header-glass-backdrop--landing'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'header-glass-backdrop--default'
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildHeaderSearchQuery(destination: string, product: string, quantity: string) {
|
||||||
|
const currentQuery = route.query || {}
|
||||||
|
const patch = {
|
||||||
|
from: product,
|
||||||
|
to: destination,
|
||||||
|
cargo: quantity,
|
||||||
|
}
|
||||||
|
const semanticQuery = {
|
||||||
|
...(product ? { productName: product } : {}),
|
||||||
|
...(destination ? { hubName: destination } : {}),
|
||||||
|
...(quantity ? { qty: quantity, quantity } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCalcPage.value) {
|
||||||
|
return {
|
||||||
|
...currentQuery,
|
||||||
|
...buildCalcQuery(patch),
|
||||||
|
...semanticQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentQuery,
|
||||||
|
...(product ? { from: product } : {}),
|
||||||
|
...(destination ? { to: destination } : {}),
|
||||||
|
...(quantity ? { cargo: quantity } : {}),
|
||||||
|
...semanticQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitHeaderSearch() {
|
||||||
|
const destination = logisticsSearch.destination.trim()
|
||||||
|
const product = logisticsSearch.product.trim()
|
||||||
|
const quantity = logisticsSearch.quantity.trim()
|
||||||
|
await navigateToLocalized({
|
||||||
|
path: '/catalog',
|
||||||
|
query: buildHeaderSearchQuery(destination, product, quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepRoute = 'destination' | 'product' | 'quantity'
|
||||||
|
|
||||||
|
async function openStep(step: StepRoute) {
|
||||||
|
const destination = logisticsSearch.destination.trim()
|
||||||
|
const product = logisticsSearch.product.trim()
|
||||||
|
const quantity = logisticsSearch.quantity.trim()
|
||||||
|
const stepPath = step === 'destination'
|
||||||
|
? '/catalog/destination'
|
||||||
|
: step === 'product'
|
||||||
|
? '/catalog/product'
|
||||||
|
: '/catalog/quantity'
|
||||||
|
await navigateToLocalized({
|
||||||
|
path: stepPath,
|
||||||
|
query: buildHeaderSearchQuery(destination, product, quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToSignIn() {
|
||||||
|
await auth.signIn()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="header-glass fixed inset-x-0 top-0 z-50 border-0"
|
||||||
|
>
|
||||||
|
<div class="header-glass-backdrop" :class="headerBackdropClass" aria-hidden="true" />
|
||||||
|
<div class="relative z-10 mx-auto max-w-[2200px] px-4 py-4" data-header-shell>
|
||||||
|
<div class="grid items-center gap-2 lg:grid-cols-[auto_1fr_auto]">
|
||||||
|
<!-- Left pill: GL + hamburger (desktop only) -->
|
||||||
|
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex" data-header-pill>
|
||||||
|
<NuxtLink :to="toLocalized('/')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">Optovia</NuxtLink>
|
||||||
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
||||||
|
<AppLocaleCurrencySelector />
|
||||||
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
||||||
|
<button
|
||||||
|
class="btn h-11 w-11 min-h-0 min-w-0 rounded-full border-0 bg-transparent p-0 shadow-none text-base-content/70"
|
||||||
|
:aria-label="$t('ui.menu')"
|
||||||
|
@click="isMenuOpen = !isMenuOpen"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden lg:block" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div v-if="!props.hideSearchCapsule" class="lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary h-10 min-h-0 w-full rounded-full text-sm font-semibold"
|
||||||
|
@click="openStep('destination')"
|
||||||
|
>
|
||||||
|
{{ $t('ui.calculate') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right pill: profile (desktop only) -->
|
||||||
|
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex">
|
||||||
|
<NuxtLink :to="toLocalized('/clientarea/orders')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">
|
||||||
|
{{ $t('ui.my_orders') }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
||||||
|
<NuxtLink v-if="isAuthenticated" :to="toLocalized('/clientarea/profile')" class="btn h-11 min-h-0 gap-2 rounded-full border-0 bg-transparent pl-2 pr-4 shadow-none">
|
||||||
|
<UserAvatar :seed="profileLabel" :label="profileLabel" :size="36" />
|
||||||
|
{{ profileLabel }}
|
||||||
|
</NuxtLink>
|
||||||
|
<button v-else class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none" @click="goToSignIn">{{ $t('ui.log_in') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!props.hideSearchCapsule" :class="searchCapsuleWrapperClass" :style="searchCapsuleWrapperStyle">
|
||||||
|
<div :class="searchCapsuleClass">
|
||||||
|
<form class="flex min-w-0 flex-wrap items-center gap-2 rounded-full" @submit.prevent="submitHeaderSearch">
|
||||||
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
||||||
|
<span class="shrink-0 text-base leading-none">{{ destinationFlag }}</span>
|
||||||
|
<input
|
||||||
|
:value="logisticsSearch.destination"
|
||||||
|
type="text"
|
||||||
|
class="w-full cursor-pointer"
|
||||||
|
:placeholder="$t('ui.to')"
|
||||||
|
readonly
|
||||||
|
@focus.prevent="openStep('destination')"
|
||||||
|
@click.prevent="openStep('destination')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||||
|
<path d="M12 22V12" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
:value="logisticsSearch.product"
|
||||||
|
type="text"
|
||||||
|
class="w-full cursor-pointer"
|
||||||
|
:placeholder="$t('ui.product')"
|
||||||
|
readonly
|
||||||
|
@focus.prevent="openStep('product')"
|
||||||
|
@click.prevent="openStep('product')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a2 2 0 1 1 0-4h4a2 2 0 1 1 0 4h-4Zm0 0v2m4-2v2" />
|
||||||
|
<path d="M6 5h12M6 19h12" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
:value="logisticsSearch.quantity"
|
||||||
|
type="text"
|
||||||
|
class="w-full cursor-pointer"
|
||||||
|
:placeholder="$t('ui.quantity')"
|
||||||
|
readonly
|
||||||
|
@focus.prevent="openStep('quantity')"
|
||||||
|
@click.prevent="openStep('quantity')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">{{ $t('ui.find') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav v-if="showAdminDock" class="admin-dock-shell" :aria-label="$t('ui.manager_navigation')">
|
||||||
|
<div class="admin-dock-glass">
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/manager/orders')"
|
||||||
|
class="admin-dock-item"
|
||||||
|
:class="isBasePathActive('/manager/orders') ? 'admin-dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.orders')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7h11v10H3z" />
|
||||||
|
<path d="M14 10h4l3 3v4h-7z" />
|
||||||
|
<circle cx="7.5" cy="18" r="1.5" />
|
||||||
|
<circle cx="18.5" cy="18" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="admin-dock-label">{{ $t('ui.orders') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/manager/quotations')"
|
||||||
|
class="admin-dock-item"
|
||||||
|
:class="isBasePathActive('/manager/quotations') || isBasePathActive('/manager/tariffs') ? 'admin-dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.quotations')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7 3h8l4 4v14H5V3z" />
|
||||||
|
<path d="M15 3v5h5" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
<path d="M8 16h8" />
|
||||||
|
</svg>
|
||||||
|
<span class="admin-dock-label">{{ $t('ui.quotations') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/manager')"
|
||||||
|
class="admin-dock-item"
|
||||||
|
:class="isBasePathActive('/manager') ? 'admin-dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.hubs')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="6" cy="17" r="2" />
|
||||||
|
<circle cx="18" cy="7" r="2" />
|
||||||
|
<circle cx="18" cy="17" r="2" />
|
||||||
|
<path d="M7.5 15.5 16.5 8.5" />
|
||||||
|
<path d="M8 17h8" />
|
||||||
|
</svg>
|
||||||
|
<span class="admin-dock-label">{{ $t('ui.hubs') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/manager')"
|
||||||
|
class="admin-dock-item"
|
||||||
|
:class="isBasePathActive('/manager') ? 'admin-dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.users')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9.5" cy="7" r="3" />
|
||||||
|
<path d="M21 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a3 3 0 0 1 0 5.74" />
|
||||||
|
</svg>
|
||||||
|
<span class="admin-dock-label">{{ $t('ui.users') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile dock bar (bottom) -->
|
||||||
|
<nav v-else-if="!isAuthPage" class="dock-glass fixed inset-x-0 bottom-0 z-50 flex items-center justify-around px-2 py-2 lg:hidden">
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/')"
|
||||||
|
class="dock-item"
|
||||||
|
:class="isBasePathActive('/', true) ? 'dock-item--active' : ''"
|
||||||
|
aria-label="Optovia"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
<span class="dock-label">Optovia</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="dock-item"
|
||||||
|
:class="isMenuOpen ? 'dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.menu')"
|
||||||
|
@click="isMenuOpen = !isMenuOpen"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="dock-label">{{ $t('ui.menu') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="showLogistics"
|
||||||
|
:to="toLocalized('/manager')"
|
||||||
|
class="dock-item"
|
||||||
|
:class="isBasePathActive('/manager') ? 'dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.manager')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7h11v10H3z" />
|
||||||
|
<path d="M14 10h4l3 3v4h-7z" />
|
||||||
|
<circle cx="7.5" cy="18" r="1.5" />
|
||||||
|
<circle cx="18.5" cy="18" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="dock-label">{{ $t('ui.manager') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:to="toLocalized('/clientarea/orders')"
|
||||||
|
class="dock-item"
|
||||||
|
:class="isBasePathActive('/clientarea/orders') ? 'dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.orders')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7h11v10H3z" />
|
||||||
|
<path d="M14 10h4l3 3v4h-7z" />
|
||||||
|
<circle cx="7.5" cy="18" r="1.5" />
|
||||||
|
<circle cx="18.5" cy="18" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="dock-label">{{ $t('ui.orders') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
:to="toLocalized('/clientarea/profile')"
|
||||||
|
class="dock-item"
|
||||||
|
:class="isBasePathActive('/clientarea/profile') || isBasePathActive('/clientarea/team') ? 'dock-item--active' : ''"
|
||||||
|
:aria-label="$t('ui.profile')"
|
||||||
|
>
|
||||||
|
<UserAvatar :seed="profileLabel" :label="profileLabel" :size="24" />
|
||||||
|
<span class="dock-label">{{ $t('ui.profile') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="dock-item"
|
||||||
|
:aria-label="$t('ui.login')"
|
||||||
|
@click="goToSignIn"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span class="dock-label">{{ $t('ui.log_in') }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Fullscreen nav menu -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="menu-overlay">
|
||||||
|
<div
|
||||||
|
v-if="isMenuOpen"
|
||||||
|
class="fixed inset-0 z-[100] flex flex-col overflow-y-auto bg-[#0b0b0d]"
|
||||||
|
>
|
||||||
|
<div class="flex justify-end px-5 pt-5">
|
||||||
|
<button
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
|
@click="isMenuOpen = false"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-0 px-6 py-8">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(link, i) in menuLinks"
|
||||||
|
:key="link.to"
|
||||||
|
:to="toLocalized(link.to)"
|
||||||
|
class="menu-item group flex items-center gap-4 rounded-2xl px-5 py-5 transition-all duration-200 hover:bg-white/8 hover:pl-7"
|
||||||
|
:class="isBasePathActive(link.to) ? 'bg-white/8' : ''"
|
||||||
|
:style="{ animationDelay: `${i * 60}ms` }"
|
||||||
|
@click="isMenuOpen = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg transition-transform duration-200 group-hover:scale-110"
|
||||||
|
:class="menuIconBg(link.icon)"
|
||||||
|
>
|
||||||
|
<svg v-if="link.icon === 'orders'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="link.icon === 'logistics'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7h11v10H3z" />
|
||||||
|
<path d="M14 10h4l3 3v4h-7z" />
|
||||||
|
<circle cx="7.5" cy="18" r="1.5" />
|
||||||
|
<circle cx="18.5" cy="18" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="link.icon === 'apps'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="13.5" cy="6.5" r="2" /><circle cx="17.5" cy="10.5" r="2" /><circle cx="8.5" cy="7.5" r="2" /><circle cx="6.5" cy="12" r="2" />
|
||||||
|
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.5-.7 1.5-1.5 0-.4-.1-.7-.4-1-.3-.3-.4-.7-.4-1.1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-5.5-4.5-9.9-10-9.9Z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="link.icon === 'map'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /><circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="link.icon === 'referral'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-bold text-white md:text-xl">{{ link.label }}</span>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="ml-auto h-5 w-5 shrink-0 text-white/0 transition-all duration-200 group-hover:translate-x-1 group-hover:text-white/50" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header-glass {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-glass-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
height: 220%;
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 28%, rgba(0,0,0,0.35) 52%, rgba(0,0,0,0.08) 74%, transparent 100%);
|
||||||
|
mask-image: linear-gradient(to bottom, black 0%, black 28%, rgba(0,0,0,0.35) 52%, rgba(0,0,0,0.08) 74%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-glass-backdrop--default {
|
||||||
|
background: linear-gradient(180deg, rgba(243, 238, 230, 0.92) 0%, rgba(243, 238, 230, 0.72) 38%, rgba(243, 238, 230, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-glass-backdrop--landing {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 26, 47, 0.78) 0%, rgba(11, 26, 47, 0.38) 36%, rgba(11, 26, 47, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-glass-backdrop--manager {
|
||||||
|
background: linear-gradient(180deg, rgba(8, 17, 29, 0.84) 0%, rgba(8, 17, 29, 0.48) 36%, rgba(8, 17, 29, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-glass {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(243, 238, 230, 0.94);
|
||||||
|
border: 0;
|
||||||
|
box-shadow: 0 14px 34px rgba(62, 47, 26, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-glass::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-arch {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
padding-right: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-arch span {
|
||||||
|
color: rgba(55, 65, 81, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-arch input::placeholder {
|
||||||
|
color: rgba(107, 114, 128, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill {
|
||||||
|
transition: left 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
width 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-overlay-enter-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.menu-overlay-enter-active .menu-item {
|
||||||
|
animation: menu-item-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.menu-overlay-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.menu-overlay-leave-active .menu-item {
|
||||||
|
animation: menu-item-out 0.2s ease both;
|
||||||
|
}
|
||||||
|
.menu-overlay-enter-from,
|
||||||
|
.menu-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menu-item-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menu-item-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-glass {
|
||||||
|
background: #e8ddca;
|
||||||
|
border-top: 1px solid #d5c7b0;
|
||||||
|
box-shadow: 0 -4px 14px rgba(60, 47, 29, 0.12);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-shell {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 1rem calc(env(safe-area-inset-bottom, 0px) + 1rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-glass {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 4rem;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: min(100%, calc(100vw - 2rem));
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(219, 208, 191, 0.78);
|
||||||
|
background: rgba(243, 238, 230, 0.9);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(180%);
|
||||||
|
backdrop-filter: blur(18px) saturate(180%);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 44px rgba(52, 40, 24, 0.2),
|
||||||
|
0 6px 16px rgba(52, 40, 24, 0.1);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-item {
|
||||||
|
display: flex;
|
||||||
|
height: 3rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.9rem;
|
||||||
|
color: rgba(52, 40, 24, 0.72);
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease,
|
||||||
|
transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.64);
|
||||||
|
color: rgba(33, 25, 15, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-item--active {
|
||||||
|
background: #2f2416;
|
||||||
|
color: #fff8ef;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dock-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: rgba(30, 23, 14, 0.65);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-item--active {
|
||||||
|
color: oklch(var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
app/components/AppLocaleCurrencySelector.vue
Normal file
107
app/components/AppLocaleCurrencySelector.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { AppLocale } from '~/composables/useLocaleCurrency'
|
||||||
|
|
||||||
|
const {
|
||||||
|
locale,
|
||||||
|
currency,
|
||||||
|
localeOptions,
|
||||||
|
currencyOptions,
|
||||||
|
languageCode,
|
||||||
|
currencyCode,
|
||||||
|
ratesProviderUrl,
|
||||||
|
setLocale,
|
||||||
|
setCurrency,
|
||||||
|
t,
|
||||||
|
} = useLocaleCurrency()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
|
|
||||||
|
function closeSelector() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeLocale(code: AppLocale) {
|
||||||
|
const nextPath = switchLocalePath(code)
|
||||||
|
await setLocale(code)
|
||||||
|
if (nextPath) {
|
||||||
|
await navigateTo(nextPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn h-11 min-h-0 gap-2 rounded-full border-0 bg-transparent px-3 text-xs font-black uppercase tracking-[0.08em] shadow-none transition hover:bg-white/70"
|
||||||
|
:aria-label="t('settings.open')"
|
||||||
|
:aria-expanded="String(isOpen)"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 0 20" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 0 0 20" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ languageCode }} · {{ currencyCode }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute left-0 top-[calc(100%+0.7rem)] z-[110] w-[292px] rounded-[28px] bg-[#f3eee6] p-3 text-[#2f2418] shadow-[0_24px_60px_rgba(35,30,25,0.22)]"
|
||||||
|
>
|
||||||
|
<div class="rounded-[22px] bg-white p-3">
|
||||||
|
<p class="px-2 text-[11px] font-black uppercase tracking-[0.16em] text-[#8a7761]">{{ t('settings.language') }}</p>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="item in localeOptions"
|
||||||
|
:key="item.code"
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl px-3 py-2 text-left text-sm font-bold transition"
|
||||||
|
:class="locale === item.code ? 'bg-[#2f2418] text-white' : 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#eee6dc]'"
|
||||||
|
@click="changeLocale(item.code)"
|
||||||
|
>
|
||||||
|
<span class="block">{{ item.nativeLabel }}</span>
|
||||||
|
<span class="mt-0.5 block text-[11px] opacity-65">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 rounded-[22px] bg-white p-3">
|
||||||
|
<p class="px-2 text-[11px] font-black uppercase tracking-[0.16em] text-[#8a7761]">{{ t('settings.currency') }}</p>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="item in currencyOptions"
|
||||||
|
:key="item.code"
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl px-3 py-2 text-left text-sm font-bold transition"
|
||||||
|
:class="currency === item.code ? 'bg-[#2f2418] text-white' : 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#eee6dc]'"
|
||||||
|
@click="setCurrency(item.code)"
|
||||||
|
>
|
||||||
|
<span class="block">{{ item.code }}</span>
|
||||||
|
<span class="mt-0.5 block text-[11px] opacity-65">{{ item.symbol }} · {{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 h-10 w-full rounded-full bg-[#2f2418] text-sm font-black text-white transition hover:bg-[#493823]"
|
||||||
|
@click="closeSelector"
|
||||||
|
>
|
||||||
|
{{ t('settings.apply') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
:href="ratesProviderUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-2 block px-2 text-center text-[10px] font-bold uppercase tracking-[0.12em] text-[#8a7761] transition hover:text-[#2f2418]"
|
||||||
|
>
|
||||||
|
{{ t('settings.ratesBy') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -24,9 +24,11 @@
|
|||||||
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
||||||
:product-name="productName"
|
:product-name="productName"
|
||||||
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
|
: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)"
|
||||||
/>
|
/>
|
||||||
@@ -83,7 +85,7 @@ interface RoutePathType {
|
|||||||
stages?: (RouteStage | null)[]
|
stages?: (RouteStage | null)[]
|
||||||
}
|
}
|
||||||
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
||||||
import type { OfferWithRouteType, RouteStageType } from '~/composables/graphql/public/geo-generated'
|
import type { OfferWithRoute, RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
@@ -154,7 +156,7 @@ const fetchOffersByHub = async () => {
|
|||||||
|
|
||||||
// Offers already include routes from backend
|
// Offers already include routes from backend
|
||||||
const offersWithRoutes = offers
|
const offersWithRoutes = offers
|
||||||
.filter((offer): offer is NonNullable<OfferWithRouteType> => offer !== null)
|
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
|
||||||
.map((offer) => ({
|
.map((offer) => ({
|
||||||
sourceUuid: offer.uuid,
|
sourceUuid: offer.uuid,
|
||||||
sourceName: offer.productName,
|
sourceName: offer.productName,
|
||||||
@@ -205,10 +207,12 @@ 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
|
return route.stages
|
||||||
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
|
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||||
.map((stage) => ({
|
.map((stage) => ({
|
||||||
transportType: stage.transportType,
|
transportType: stage.transportType,
|
||||||
distanceKm: stage.distanceKm
|
distanceKm: stage.distanceKm,
|
||||||
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="bg-base-200 text-base-content/80 py-6">
|
<footer class="px-3 pb-8 pt-2 text-white md:px-4">
|
||||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-[1440px] rounded-[28px] border border-white/18 bg-[#10223b] px-6 py-6 shadow-[0_22px_54px_rgba(16,34,59,0.24)] [background-image:radial-gradient(circle_at_82%_18%,rgba(244,89,69,0.34),rgba(244,89,69,0)_34%),linear-gradient(130deg,#10223b_0%,#193450_100%)]">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
<p class="text-sm font-medium text-white/82">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
:location-name="offer.locationName || offer.locationCountry"
|
:location-name="offer.locationName || offer.locationCountry"
|
||||||
:product-name="offer.productName"
|
:product-name="offer.productName"
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
:currency="offer.currency"
|
:currency="offer.currency"
|
||||||
:unit="offer.unit"
|
:unit="offer.unit"
|
||||||
:stages="[]"
|
:stages="[]"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,123 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { profileAvatarSeedFromValue, profileAvatarUrl } from '~/utils/profileAvatars'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
avatarSeed?: string | null
|
||||||
|
seed?: string | null
|
||||||
|
label?: string | null
|
||||||
|
size?: number
|
||||||
|
}>(), {
|
||||||
|
avatarSeed: '',
|
||||||
|
seed: '',
|
||||||
|
label: '',
|
||||||
|
size: 48,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedSeed = computed(() => {
|
||||||
|
const source = props.avatarSeed || props.seed || props.label || 'person'
|
||||||
|
return profileAvatarSeedFromValue(source)
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarSrc = computed(() => profileAvatarUrl(normalizedSeed.value))
|
||||||
|
const avatarStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center space-y-4">
|
<div
|
||||||
<!-- Avatar -->
|
class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[34%] border border-white/80 bg-white shadow-[0_12px_28px_rgba(47,36,24,0.16)]"
|
||||||
<div class="relative">
|
:style="avatarStyle"
|
||||||
<div
|
:title="label || undefined"
|
||||||
class="w-24 h-24 rounded-full overflow-hidden border-4 border-base-300 shadow-lg"
|
role="img"
|
||||||
:class="{ 'animate-pulse bg-base-200': loading }"
|
:aria-label="label ? `Аватар: ${label}` : 'Аватар'"
|
||||||
>
|
>
|
||||||
<div
|
<img :src="avatarSrc" :alt="label || 'Аватар'" class="h-full w-full object-cover" loading="lazy">
|
||||||
v-if="!loading && avatarSvg"
|
|
||||||
v-html="avatarSvg"
|
|
||||||
class="w-full h-full"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else-if="!loading"
|
|
||||||
class="w-full h-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-primary-content text-2xl font-bold"
|
|
||||||
>
|
|
||||||
{{ initials }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Change avatar button -->
|
|
||||||
<button
|
|
||||||
@click="regenerateAvatar"
|
|
||||||
:disabled="loading"
|
|
||||||
class="absolute -bottom-1 -right-1 w-8 h-8 bg-primary hover:bg-primary/80 disabled:bg-base-300 text-primary-content rounded-full flex items-center justify-center shadow-lg transition-colors"
|
|
||||||
:title="$t('profile.regenerate_avatar')"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User name -->
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="font-semibold text-base-content">{{ displayName }}</p>
|
|
||||||
<p class="text-sm text-base-content/60" v-if="userId">ID: {{ userId }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
userId: String,
|
|
||||||
firstName: String,
|
|
||||||
lastName: String,
|
|
||||||
avatarId: String,
|
|
||||||
editable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['avatar-changed'])
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const avatarSvg = ref('')
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const displayName = computed(() => {
|
|
||||||
const first = props.firstName || ''
|
|
||||||
const last = props.lastName || ''
|
|
||||||
return `${first} ${last}`.trim() || 'User'
|
|
||||||
})
|
|
||||||
|
|
||||||
const initials = computed(() => {
|
|
||||||
const first = props.firstName?.charAt(0) || ''
|
|
||||||
const last = props.lastName?.charAt(0) || ''
|
|
||||||
return (first + last).toUpperCase() || '?'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Generate avatar via DiceBear API
|
|
||||||
const generateAvatar = async (seed) => {
|
|
||||||
if (!seed) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
// Use DiceBear API to generate SVG avatar
|
|
||||||
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
avatarSvg.value = await response.text()
|
|
||||||
} else {
|
|
||||||
console.error('Failed to generate avatar:', response.status)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating avatar:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new random avatar ID
|
|
||||||
const regenerateAvatar = async () => {
|
|
||||||
if (!props.editable || loading.value) return
|
|
||||||
|
|
||||||
const newAvatarId = Math.random().toString(36).substring(2, 15)
|
|
||||||
|
|
||||||
// Update avatar locally first
|
|
||||||
await generateAvatar(newAvatarId)
|
|
||||||
|
|
||||||
// Notify parent about avatar change
|
|
||||||
emit('avatar-changed', newAvatarId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch avatarId changes
|
|
||||||
watch(() => props.avatarId, (newAvatarId) => {
|
|
||||||
if (newAvatarId) {
|
|
||||||
generateAvatar(newAvatarId)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// If no avatarId, generate deterministic one based on userId
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!props.avatarId && props.userId) {
|
|
||||||
// Build deterministic ID from userId
|
|
||||||
const fallbackSeed = props.userId
|
|
||||||
await generateAvatar(fallbackSeed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|||||||
159
app/components/ai/AiChatSidebar.vue
Normal file
159
app/components/ai/AiChatSidebar.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="fixed top-0 left-0 bottom-0 z-50 overflow-hidden transition-[width] duration-300"
|
||||||
|
:style="{ width: open ? width : '0px' }"
|
||||||
|
aria-label="AI assistant"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full flex flex-col bg-base-100/80 backdrop-blur-xl border-r border-white/10 shadow-xl transition-opacity duration-200"
|
||||||
|
:class="open ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:bot" size="16" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold text-base-content">{{ $t('aiAssistants.view.agentName') }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:x" size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(message, idx) in chat"
|
||||||
|
:key="idx"
|
||||||
|
class="flex"
|
||||||
|
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[90%] rounded-2xl px-3 py-2 shadow-sm"
|
||||||
|
:class="message.role === 'user' ? 'bg-primary text-primary-content' : 'bg-base-100 text-base-content border border-base-300'"
|
||||||
|
>
|
||||||
|
<Text weight="semibold" class="mb-1">
|
||||||
|
{{ message.role === 'user' ? $t('aiAssistants.view.you') : $t('aiAssistants.view.agentName') }}
|
||||||
|
</Text>
|
||||||
|
<Text :tone="message.role === 'user' ? undefined : 'muted'">
|
||||||
|
{{ message.content }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isStreaming" class="text-sm text-base-content/60">
|
||||||
|
{{ $t('aiAssistants.view.typing') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-base-300 bg-base-100/70 p-3">
|
||||||
|
<form class="flex items-end gap-2" @submit.prevent="handleSend">
|
||||||
|
<div class="flex-1">
|
||||||
|
<Textarea
|
||||||
|
v-model="input"
|
||||||
|
:placeholder="$t('aiAssistants.view.placeholder')"
|
||||||
|
rows="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button type="submit" size="sm" :loading="isSending" :disabled="!input.trim()">
|
||||||
|
{{ $t('aiAssistants.view.send') }}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="ghost" @click="resetChat" :disabled="isSending">
|
||||||
|
{{ $t('aiAssistants.view.reset') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="text-xs text-error text-center mt-2" v-if="error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
width: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
|
const agentUrl = computed(() => runtimeConfig.public.langAgentUrl || '')
|
||||||
|
const chatContainer = ref<HTMLElement | null>(null)
|
||||||
|
const chat = ref<{ role: 'user' | 'assistant', content: string }[]>([
|
||||||
|
{ role: 'assistant', content: t('aiAssistants.view.welcome') }
|
||||||
|
])
|
||||||
|
const input = ref('')
|
||||||
|
const isSending = ref(false)
|
||||||
|
const isStreaming = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (chatContainer.value) {
|
||||||
|
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.value.trim()) return
|
||||||
|
error.value = ''
|
||||||
|
const userMessage = input.value.trim()
|
||||||
|
chat.value.push({ role: 'user', content: userMessage })
|
||||||
|
input.value = ''
|
||||||
|
isSending.value = true
|
||||||
|
isStreaming.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
input: {
|
||||||
|
messages: chat.value.map((m) => ({
|
||||||
|
type: m.role === 'assistant' ? 'ai' : 'human',
|
||||||
|
content: m.content
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await $fetch(`${agentUrl.value}/invoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputMessages = (response as any)?.output?.messages || []
|
||||||
|
const last = outputMessages[outputMessages.length - 1]
|
||||||
|
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
|
||||||
|
chat.value.push({ role: 'assistant', content })
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('Agent error', e)
|
||||||
|
error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
|
||||||
|
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
|
||||||
|
scrollToBottom()
|
||||||
|
} finally {
|
||||||
|
isSending.value = false
|
||||||
|
isStreaming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetChat = () => {
|
||||||
|
chat.value = [{ role: 'assistant', content: t('aiAssistants.view.welcome') }]
|
||||||
|
input.value = ''
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) scrollToBottom()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,84 +2,84 @@
|
|||||||
<Transition name="address-slide">
|
<Transition name="address-slide">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen && addressUuid"
|
v-if="isOpen && addressUuid"
|
||||||
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
|
||||||
style="height: 70vh"
|
style="height: 72vh"
|
||||||
>
|
>
|
||||||
<!-- Backdrop (clickable to close) -->
|
<!-- Backdrop (clickable to close) -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 -top-[30vh] bg-black/30"
|
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sheet content -->
|
<!-- Sheet content -->
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-base-300 bg-base-100 shadow-[0_-24px_70px_rgba(15,23,42,0.3)]">
|
||||||
<!-- Header with drag handle and close -->
|
<!-- Header with drag handle and close -->
|
||||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100">
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between px-6 pb-4">
|
<div class="flex items-center justify-between px-6 pb-4">
|
||||||
<template v-if="address">
|
<template v-if="address">
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div class="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center flex-shrink-0 text-2xl">
|
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success/20 text-2xl">
|
||||||
{{ isoToEmoji(address.countryCode) }}
|
{{ isoToEmoji(address.countryCode) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-bold text-white truncate">{{ address.name }}</div>
|
<div class="truncate text-xl font-black text-base-content">{{ address.name }}</div>
|
||||||
<div class="text-sm text-white/60 truncate">{{ address.address }}</div>
|
<div class="truncate text-sm text-base-content/60">{{ address.address }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center gap-3 flex-1">
|
<div class="flex items-center gap-3 flex-1">
|
||||||
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
|
||||||
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @click="emit('close')">
|
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||||
<Icon name="lucide:x" size="20" />
|
<Icon name="lucide:x" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div v-if="address" class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4 space-y-4">
|
<div v-if="address" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||||
<!-- Location info -->
|
<!-- Location info -->
|
||||||
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:map-pin" size="18" />
|
<Icon name="lucide:map-pin" size="18" />
|
||||||
{{ t('profileAddresses.detail.location') }}
|
<span class="text-lg font-black">{{ t('profileAddresses.detail.location') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex items-start gap-2 text-white/80">
|
<div class="flex items-start gap-2 text-base-content/80">
|
||||||
<Icon name="lucide:navigation" size="14" class="text-white/50 mt-0.5 flex-shrink-0" />
|
<Icon name="lucide:navigation" size="14" class="mt-0.5 flex-shrink-0 text-base-content/50" />
|
||||||
<span>{{ address.address }}</span>
|
<span>{{ address.address }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="address.latitude && address.longitude" class="flex items-center gap-2 text-white/60">
|
<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-white/50" />
|
<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>
|
<span class="font-mono text-xs">{{ address.latitude.toFixed(6) }}, {{ address.longitude.toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map preview -->
|
<!-- Map preview -->
|
||||||
<div v-if="address.latitude && address.longitude" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="address.latitude && address.longitude" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:map" size="18" />
|
<Icon name="lucide:map" size="18" />
|
||||||
{{ t('profileAddresses.detail.map') }}
|
<span class="text-lg font-black">{{ t('profileAddresses.detail.map') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-48 rounded-lg overflow-hidden">
|
<div class="h-48 overflow-hidden rounded-xl">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<MapboxMap
|
<MapboxMap
|
||||||
:map-id="'address-preview-' + addressUuid"
|
:map-id="'address-preview-' + addressUuid"
|
||||||
style="width: 100%; height: 100%"
|
style="width: 100%; height: 100%"
|
||||||
:options="{
|
:options="{
|
||||||
style: 'mapbox://styles/mapbox/dark-v11',
|
style: 'mapbox://styles/mapbox/light-v11',
|
||||||
center: [address.longitude, address.latitude],
|
center: [address.longitude, address.latitude],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
interactive: false
|
interactive: false
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
|
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
|
||||||
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
<button class="btn btn-sm w-full btn-outline">
|
||||||
<Icon name="lucide:pencil" size="14" class="mr-2" />
|
<Icon name="lucide:pencil" size="14" class="mr-2" />
|
||||||
{{ t('profileAddresses.actions.edit') }}
|
{{ t('profileAddresses.actions.edit') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-else class="px-6 py-4 space-y-4">
|
<div v-else class="px-6 py-4 space-y-4">
|
||||||
<div class="h-24 bg-white/5 rounded-xl animate-pulse" />
|
<div class="h-24 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,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 } 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 | null
|
uuid?: string | null
|
||||||
@@ -43,8 +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', ClusterPointType[]>>
|
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPoint[]>>
|
||||||
useServerClustering?: boolean
|
useServerClustering?: boolean
|
||||||
hoveredItemId?: string | null
|
hoveredItemId?: string | null
|
||||||
hoveredItem?: HoveredItem | null
|
hoveredItem?: HoveredItem | null
|
||||||
@@ -53,6 +53,7 @@ const props = withDefaults(defineProps<{
|
|||||||
initialCenter?: [number, number]
|
initialCenter?: [number, number]
|
||||||
initialZoom?: number
|
initialZoom?: number
|
||||||
infoLoading?: boolean
|
infoLoading?: boolean
|
||||||
|
fitPaddingLeft?: number
|
||||||
relatedPoints?: Array<{
|
relatedPoints?: Array<{
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
@@ -67,6 +68,7 @@ const props = withDefaults(defineProps<{
|
|||||||
initialZoom: 2,
|
initialZoom: 2,
|
||||||
useServerClustering: false,
|
useServerClustering: false,
|
||||||
infoLoading: false,
|
infoLoading: false,
|
||||||
|
fitPaddingLeft: 0,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
clusteredPoints: () => [],
|
clusteredPoints: () => [],
|
||||||
clusteredPointsByType: undefined,
|
clusteredPointsByType: undefined,
|
||||||
@@ -88,6 +90,16 @@ const usesTypedClusters = computed(() => {
|
|||||||
return !!typed && Object.keys(typed).length > 0
|
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 = {
|
||||||
@@ -226,7 +238,7 @@ const serverClusteredGeoJson = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const serverClusteredGeoJsonByType = computed(() => {
|
const serverClusteredGeoJsonByType = computed(() => {
|
||||||
const build = (points: ClusterPointType[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
|
const build = (points: ClusterPoint[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: (points || []).filter(Boolean).map(point => ({
|
features: (points || []).filter(Boolean).map(point => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
@@ -536,7 +548,7 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
bounds.extend([item.longitude, item.latitude])
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -962,7 +974,7 @@ watch(() => props.infoLoading, (loading, wasLoading) => {
|
|||||||
bounds.extend([p.longitude, p.latitude])
|
bounds.extend([p.longitude, p.latitude])
|
||||||
})
|
})
|
||||||
if (!bounds.isEmpty()) {
|
if (!bounds.isEmpty()) {
|
||||||
mapRef.value.fitBounds(bounds, { padding: 80, maxZoom: 12 })
|
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1013,7 +1025,7 @@ watch(() => props.clusteredPoints, (points) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!bounds.isEmpty()) {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1045,7 @@ watch(() => props.clusteredPointsByType, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
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
|
||||||
}
|
}
|
||||||
}, { deep: true, immediate: true })
|
}, { deep: true, immediate: true })
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
:location-name="offer.locationName"
|
:location-name="offer.locationName"
|
||||||
:product-name="offer.productName || offer.title || undefined"
|
:product-name="offer.productName || offer.title || undefined"
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
:currency="offer.currency"
|
:currency="offer.currency"
|
||||||
:unit="offer.unit"
|
:unit="offer.unit"
|
||||||
:stages="[]"
|
:stages="[]"
|
||||||
@@ -91,6 +92,7 @@ interface Offer {
|
|||||||
status?: string | null
|
status?: string | null
|
||||||
latitude?: number | null
|
latitude?: number | null
|
||||||
longitude?: number | null
|
longitude?: number | null
|
||||||
|
quantity?: number | string | null
|
||||||
pricePerUnit?: number | string | null
|
pricePerUnit?: number | string | null
|
||||||
currency?: string | null
|
currency?: string | null
|
||||||
unit?: string | null
|
unit?: string | null
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
:location-name="offer.locationName"
|
:location-name="offer.locationName"
|
||||||
:product-name="offer.title || undefined"
|
:product-name="offer.title || undefined"
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
:currency="offer.currency"
|
:currency="offer.currency"
|
||||||
:unit="offer.unit"
|
:unit="offer.unit"
|
||||||
:stages="[]"
|
:stages="[]"
|
||||||
@@ -56,6 +57,7 @@ interface Offer {
|
|||||||
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
|
pricePerUnit?: number | string | null
|
||||||
currency?: string | null
|
currency?: string | null
|
||||||
unit?: string | null
|
unit?: string | null
|
||||||
|
|||||||
@@ -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="distanceLabel" class="badge badge-neutral badge-dash text-xs">
|
|
||||||
{{ distanceLabel }}
|
|
||||||
</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,6 +59,8 @@ 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
|
||||||
distanceKm?: number | null
|
distanceKm?: number | null
|
||||||
transportTypes?: (string | null)[] | null
|
transportTypes?: (string | null)[] | null
|
||||||
@@ -54,6 +68,7 @@ interface Hub {
|
|||||||
|
|
||||||
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
|
||||||
@@ -88,4 +103,27 @@ const distanceLabel = computed(() => {
|
|||||||
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
|
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
|
||||||
return ''
|
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,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header with close button -->
|
<!-- Header with close button -->
|
||||||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
<div class="flex-shrink-0 p-4 border-b border-base-300">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -10,11 +10,22 @@
|
|||||||
>
|
>
|
||||||
<Icon :name="entityIcon" size="14" class="text-white" />
|
<Icon :name="entityIcon" size="14" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
|
<h3 class="font-semibold text-base text-base-content">{{ entityName }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="(entityType === 'hub' || entityType === 'supplier') && entity?.uuid"
|
||||||
|
class="rounded-full bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||||
|
<Icon name="lucide:x" size="16" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
|
||||||
<Icon name="lucide:x" size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
<span class="loading loading-spinner loading-md text-white" />
|
<span class="loading loading-spinner loading-md text-base-content" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
@@ -30,13 +41,13 @@
|
|||||||
<!-- Entity Info Header (text, not card) -->
|
<!-- Entity Info Header (text, not card) -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<!-- Location for hub/supplier -->
|
<!-- Location for hub/supplier -->
|
||||||
<p v-if="entityLocation" class="text-sm text-white/70 flex items-center gap-1">
|
<p v-if="entityLocation" class="text-sm text-base-content/70 flex items-center gap-1">
|
||||||
<Icon name="lucide:map-pin" size="14" />
|
<Icon name="lucide:map-pin" size="14" />
|
||||||
{{ entityLocation }}
|
{{ entityLocation }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Price for offer -->
|
<!-- Price for offer -->
|
||||||
<p v-if="entityType === 'offer' && entity?.pricePerUnit" class="text-sm text-white/70 flex items-center gap-1">
|
<p v-if="entityType === 'offer' && entity?.pricePerUnit" class="text-sm text-base-content/70 flex items-center gap-1">
|
||||||
<Icon name="lucide:tag" size="14" />
|
<Icon name="lucide:tag" size="14" />
|
||||||
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
|
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -54,8 +65,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KYC Teaser Section (for supplier) -->
|
<!-- KYC Teaser Section (for supplier) -->
|
||||||
<section v-if="entityType === 'supplier' && kycTeaser" class="bg-white/5 rounded-lg p-3">
|
<section v-if="entityType === 'supplier' && kycTeaser" class="bg-base-200/50 rounded-lg p-3">
|
||||||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
<h3 class="text-sm font-semibold text-base-content/80 mb-2 flex items-center gap-2">
|
||||||
<Icon name="lucide:shield-check" size="16" />
|
<Icon name="lucide:shield-check" size="16" />
|
||||||
{{ $t('catalog.info.kycTeaser') }}
|
{{ $t('catalog.info.kycTeaser') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -63,19 +74,19 @@
|
|||||||
<div class="flex flex-col gap-2 text-sm">
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
<!-- Company Type -->
|
<!-- Company Type -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-white/60">{{ $t('catalog.info.companyType') }}</span>
|
<span class="text-base-content/60">{{ $t('catalog.info.companyType') }}</span>
|
||||||
<span class="text-white">{{ kycTeaser.companyType }}</span>
|
<span class="text-base-content">{{ kycTeaser.companyType }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Registration Year -->
|
<!-- Registration Year -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-white/60">{{ $t('catalog.info.registrationYear') }}</span>
|
<span class="text-base-content/60">{{ $t('catalog.info.registrationYear') }}</span>
|
||||||
<span class="text-white">{{ kycTeaser.registrationYear }}</span>
|
<span class="text-base-content">{{ kycTeaser.registrationYear }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-white/60">{{ $t('catalog.info.status') }}</span>
|
<span class="text-base-content/60">{{ $t('catalog.info.status') }}</span>
|
||||||
<span :class="kycTeaser.isActive ? 'text-success' : 'text-error'">
|
<span :class="kycTeaser.isActive ? 'text-success' : 'text-error'">
|
||||||
{{ kycTeaser.isActive ? $t('catalog.info.active') : $t('catalog.info.inactive') }}
|
{{ kycTeaser.isActive ? $t('catalog.info.active') : $t('catalog.info.inactive') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -83,8 +94,8 @@
|
|||||||
|
|
||||||
<!-- Sources Count -->
|
<!-- Sources Count -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-white/60">{{ $t('catalog.info.sourcesCount') }}</span>
|
<span class="text-base-content/60">{{ $t('catalog.info.sourcesCount') }}</span>
|
||||||
<span class="text-white">{{ kycTeaser.sourcesCount }}</span>
|
<span class="text-base-content">{{ kycTeaser.sourcesCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,145 +111,196 @@
|
|||||||
|
|
||||||
<!-- Products Section (for hub/supplier) - hide when product selected -->
|
<!-- Products Section (for hub/supplier) - hide when product selected -->
|
||||||
<section v-if="(entityType === 'hub' || entityType === 'supplier') && !selectedProduct">
|
<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-base-content/80 mb-2 flex items-center gap-2">
|
||||||
<Icon name="lucide:package" size="16" />
|
<Icon name="lucide:package" size="16" />
|
||||||
{{ productsSectionTitle }}
|
{{ productsSectionTitle }}
|
||||||
<span v-if="loadingProducts" class="loading loading-spinner loading-xs" />
|
<span v-if="loadingProducts" class="loading loading-spinner loading-xs" />
|
||||||
<span v-else-if="relatedProducts.length > 0" class="text-white/50">({{ relatedProducts.length }})</span>
|
<span v-else-if="relatedProducts.length > 0" class="text-base-content/50">({{ relatedProducts.length }})</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="!loadingProducts && relatedProducts.length === 0" class="text-white/50 text-sm py-2">
|
<div v-if="!loadingProducts && relatedProducts.length === 0" class="text-base-content/50 text-sm py-2">
|
||||||
{{ $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, index) in relatedProducts"
|
v-for="(product, index) in relatedProducts"
|
||||||
:key="product.uuid ?? index"
|
: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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Offers Section (after product selected) -->
|
<!-- Offers Section (after product selected) -->
|
||||||
<section v-if="(entityType === 'hub' || entityType === 'supplier') && selectedProduct">
|
<section v-if="(entityType === 'hub' || entityType === 'supplier') && selectedProduct">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
<h3 class="text-sm font-semibold text-base-content/80 flex items-center gap-2">
|
||||||
<Icon name="lucide:shopping-bag" size="16" />
|
<Icon name="lucide:shopping-bag" size="16" />
|
||||||
{{ $t('catalog.headers.offers') }}
|
{{ $t('catalog.headers.offers') }}
|
||||||
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
|
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
|
||||||
<span v-else-if="offersWithPrice.length > 0" class="text-white/50">({{ offersWithPrice.length }})</span>
|
<span v-else-if="offersWithPrice.length > 0" class="text-base-content/50">({{ offersWithPrice.length }})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
|
<button
|
||||||
<Icon name="lucide:x" size="14" />
|
class="flex items-center gap-2 px-2 py-1 rounded-full border border-base-300/70 bg-base-200/70 text-xs text-base-content/80 hover:bg-base-200 transition-colors"
|
||||||
{{ $t('common.cancel') }}
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-white/50 text-sm py-2">
|
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-base-content/50 text-sm py-2">
|
||||||
{{ $t('catalog.empty.noOffers') }}
|
{{ $t('catalog.empty.noOffers') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
||||||
<OfferResultCard
|
<OfferResultCard
|
||||||
v-for="(offer, index) in offersWithPrice"
|
v-for="(offer, index) in offersWithPrice"
|
||||||
:key="offer.uuid ?? index"
|
:key="offer.uuid ?? index"
|
||||||
:supplier-name="offer.supplierName"
|
:supplier-name="getOfferSupplierName(offer)"
|
||||||
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
|
:location-name="offer.country || ''"
|
||||||
:product-name="offer.productName"
|
:product-name="offer.productName"
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
:currency="offer.currency"
|
:currency="offer.currency"
|
||||||
:unit="offer.unit"
|
:unit="offer.unit"
|
||||||
:stages="getOfferStages(offer)"
|
:stages="getOfferStages(offer)"
|
||||||
|
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||||
@select="onOfferSelect(offer)"
|
@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-base-content/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') }}
|
||||||
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
|
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
|
||||||
<span v-else-if="relatedSuppliers.length > 0" class="text-white/50">({{ relatedSuppliers.length }})</span>
|
<span v-else-if="relatedSuppliers.length > 0" class="text-base-content/50">({{ relatedSuppliers.length }})</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="!loadingSuppliers && relatedSuppliers.length === 0" class="text-white/50 text-sm py-2">
|
<div v-if="!loadingSuppliers && relatedSuppliers.length === 0" class="text-base-content/50 text-sm py-2">
|
||||||
{{ $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, index) in relatedSuppliers"
|
v-for="(supplier, index) in relatedSuppliers"
|
||||||
:key="supplier.uuid ?? index"
|
: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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Hubs Section (for supplier/offer) -->
|
<!-- Hubs Section (for supplier/offer) -->
|
||||||
<section v-if="entityType === 'supplier' || entityType === 'offer'">
|
<section v-if="entityType === 'supplier' || entityType === 'offer'">
|
||||||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
<h3 class="text-sm font-semibold text-base-content/80 mb-2 flex items-center gap-2">
|
||||||
<Icon name="lucide:warehouse" size="16" />
|
<Icon name="lucide:warehouse" size="16" />
|
||||||
{{ $t('catalog.info.nearestHubs') }}
|
{{ $t('catalog.info.nearestHubs') }}
|
||||||
<span v-if="loadingHubs" class="loading loading-spinner loading-xs" />
|
<span v-if="loadingHubs" class="loading loading-spinner loading-xs" />
|
||||||
<span v-else-if="relatedHubs.length > 0" class="text-white/50">({{ relatedHubs.length }})</span>
|
<span v-else-if="relatedHubs.length > 0" class="text-base-content/50">({{ relatedHubs.length }})</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
|
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-base-content/50 text-sm py-2">
|
||||||
{{ $t('catalog.info.noHubs') }}
|
{{ $t('catalog.info.noHubs') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loadingHubs" class="space-y-4">
|
<div v-else-if="!loadingHubs" class="space-y-4">
|
||||||
<template v-if="railHubs.length">
|
<template v-if="railHubs.length">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Card padding="small" class="border border-white/10 bg-white/5">
|
<Card padding="small" class="border border-base-300 bg-base-200/50">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-lg bg-base-200/70 flex items-center justify-center">
|
||||||
<Icon name="lucide:train-front" size="16" class="text-white/80" />
|
<Icon name="lucide:train-front" size="16" class="text-base-content/80" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-white/80">{{ $t('catalog.info.railHubs') }}</div>
|
<div class="text-sm text-base-content/80">{{ $t('catalog.info.railHubs') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<HubCard
|
<div
|
||||||
v-for="(hub, index) in railHubs"
|
v-for="(hub, index) in railHubs"
|
||||||
:key="hub.uuid ?? index"
|
:key="hub.uuid ?? index"
|
||||||
:hub="hub"
|
class="relative group"
|
||||||
selectable
|
>
|
||||||
@select="onHubSelect(hub)"
|
<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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="seaHubs.length">
|
<template v-if="seaHubs.length">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Card padding="small" class="border border-white/10 bg-white/5">
|
<Card padding="small" class="border border-base-300 bg-base-200/50">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-lg bg-base-200/70 flex items-center justify-center">
|
||||||
<Icon name="lucide:ship" size="16" class="text-white/80" />
|
<Icon name="lucide:ship" size="16" class="text-base-content/80" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-white/80">{{ $t('catalog.info.seaHubs') }}</div>
|
<div class="text-sm text-base-content/80">{{ $t('catalog.info.seaHubs') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<HubCard
|
<div
|
||||||
v-for="(hub, index) in seaHubs"
|
v-for="(hub, index) in seaHubs"
|
||||||
:key="hub.uuid ?? index"
|
:key="hub.uuid ?? index"
|
||||||
:hub="hub"
|
class="relative group"
|
||||||
selectable
|
>
|
||||||
@select="onHubSelect(hub)"
|
<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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -253,7 +315,7 @@ import type {
|
|||||||
InfoSupplierItem,
|
InfoSupplierItem,
|
||||||
InfoOfferItem
|
InfoOfferItem
|
||||||
} from '~/composables/useCatalogInfo'
|
} from '~/composables/useCatalogInfo'
|
||||||
import type { RouteStageType } from '~/composables/graphql/public/geo-generated'
|
import type { RouteStage } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entityType: InfoEntityType
|
entityType: InfoEntityType
|
||||||
@@ -274,12 +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 }]
|
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
|
||||||
'update:current-tab': [tab: string]
|
'update:current-tab': [tab: string]
|
||||||
'open-kyc': [uuid: string | undefined]
|
'open-kyc': [uuid: string | undefined]
|
||||||
|
'pin': [type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -294,6 +356,33 @@ const offersWithPrice = computed(() =>
|
|||||||
relatedOffers.value.filter(o => o?.pricePerUnit != null)
|
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(() => {
|
||||||
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
|
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
|
||||||
@@ -306,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'
|
||||||
@@ -397,10 +493,12 @@ const getOfferStages = (offer: InfoOfferItem) => {
|
|||||||
const route = offer.routes?.[0]
|
const route = offer.routes?.[0]
|
||||||
if (!route?.stages) return []
|
if (!route?.stages) return []
|
||||||
return route.stages
|
return route.stages
|
||||||
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
|
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
|
||||||
.map(stage => ({
|
.map(stage => ({
|
||||||
transportType: stage.transportType,
|
transportType: stage.transportType,
|
||||||
distanceKm: stage.distanceKm
|
distanceKm: stage.distanceKm,
|
||||||
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sheet content -->
|
<!-- Sheet content -->
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="relative flex-1 bg-neutral rounded-t-2xl border-t border-neutral/70 shadow-2xl overflow-hidden">
|
||||||
<!-- Header with drag handle and close -->
|
<!-- Header with drag handle and close -->
|
||||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
<div class="sticky top-0 z-10 bg-neutral border-b border-white/10">
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header: белое стекло (negative margins to expand beyond parent padding) -->
|
<!-- Header: solid panel (negative margins to expand beyond parent padding) -->
|
||||||
<div class="sticky top-0 z-10 -mx-4 -mt-4 px-4 pt-4 pb-3 rounded-t-xl bg-white/90 backdrop-blur-md border-b border-white/20">
|
<div class="sticky top-0 z-10 -mx-4 -mt-4 px-4 pt-4 pb-3 rounded-t-xl bg-base-100 border-b border-base-300">
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,77 @@
|
|||||||
<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">{{ supplierDisplay }}</Text>
|
<div class="flex items-center gap-2">
|
||||||
<Text tone="muted" size="sm">
|
<div class="w-6 h-6 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
|
||||||
{{ t('catalogOfferCard.labels.origin_label') }}: {{ originDisplay }}
|
<Icon name="lucide:factory" size="14" class="text-white" />
|
||||||
</Text>
|
</div>
|
||||||
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
|
<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
|
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<{
|
||||||
@@ -56,17 +85,32 @@ const supplierDisplay = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const originDisplay = computed(() => {
|
const originDisplay = computed(() => {
|
||||||
return props.locationName || t('catalogOfferCard.labels.origin_unknown')
|
const fromStage = props.stages?.find(stage => stage?.fromName)?.fromName
|
||||||
|
return props.locationName || fromStage || t('catalogOfferCard.labels.origin_unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
const priceDisplay = computed(() => {
|
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 '$'
|
||||||
@@ -80,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>
|
||||||
|
|||||||
@@ -2,40 +2,40 @@
|
|||||||
<Transition name="order-slide">
|
<Transition name="order-slide">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen && orderUuid"
|
v-if="isOpen && orderUuid"
|
||||||
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
|
||||||
style="height: 70vh"
|
style="height: 72vh"
|
||||||
>
|
>
|
||||||
<!-- Backdrop (clickable to close) -->
|
<!-- Backdrop (clickable to close) -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 -top-[30vh] bg-black/30"
|
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sheet content -->
|
<!-- Sheet content -->
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-base-300 bg-base-100 shadow-[0_-24px_70px_rgba(15,23,42,0.3)]">
|
||||||
<!-- Header with drag handle and close -->
|
<!-- Header with drag handle and close -->
|
||||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100">
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between px-6 pb-4">
|
<div class="flex items-center justify-between px-6 pb-4">
|
||||||
<template v-if="hasOrderError">
|
<template v-if="hasOrderError">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-semibold text-white">{{ t('common.error') }}</div>
|
<div class="font-black text-base-content">{{ t('common.error') }}</div>
|
||||||
<div class="text-sm text-white/50">{{ orderError }}</div>
|
<div class="text-sm text-base-content/60">{{ orderError }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="!isLoadingOrder && order">
|
<template v-else-if="!isLoadingOrder && order">
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
<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-indigo-400" />
|
<Icon name="lucide:package" size="24" class="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-bold text-white truncate">{{ orderTitle }}</div>
|
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
<div class="mt-0.5 flex items-center gap-2">
|
||||||
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
|
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
|
||||||
<span v-if="order.status" class="badge badge-outline badge-sm text-white/60">{{ order.status }}</span>
|
<span v-if="order.status" class="badge badge-outline badge-sm">{{ order.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,15 +43,15 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center gap-3 flex-1">
|
<div class="flex items-center gap-3 flex-1">
|
||||||
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
|
||||||
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @click="emit('close')">
|
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||||
<Icon name="lucide:x" size="20" />
|
<Icon name="lucide:x" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,26 +59,26 @@
|
|||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-if="hasOrderError" class="px-6 py-8 text-center">
|
<div v-if="hasOrderError" class="px-6 py-8 text-center">
|
||||||
<div class="text-white/70 mb-4">{{ orderError }}</div>
|
<div class="mb-4 text-base-content/70">{{ orderError }}</div>
|
||||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
<button class="btn btn-sm btn-outline" @click="loadOrder">
|
||||||
{{ t('ordersDetail.errors.retry') }}
|
{{ t('ordersDetail.errors.retry') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
<!-- Scrollable content -->
|
||||||
<div v-else-if="order" class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4 space-y-4">
|
<div v-else-if="order" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||||
<!-- Order meta -->
|
<!-- Order meta -->
|
||||||
<div class="flex flex-wrap gap-2 text-sm">
|
<div class="flex flex-wrap gap-2 text-sm">
|
||||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="px-3 py-1 bg-white/10 rounded-full text-white/70">
|
<span v-for="(meta, idx) in orderMeta" :key="idx" class="rounded-full border border-base-300 bg-base-200 px-3 py-1 text-base-content/70">
|
||||||
{{ meta }}
|
{{ meta }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Route stages -->
|
<!-- Route stages -->
|
||||||
<div v-if="orderStageItems.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:route" size="18" />
|
<Icon name="lucide:route" size="18" />
|
||||||
{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
@@ -87,15 +87,15 @@
|
|||||||
class="flex gap-3"
|
class="flex gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="w-3 h-3 rounded-full bg-indigo-500" />
|
<div class="h-3 w-3 rounded-full bg-primary" />
|
||||||
<div v-if="idx < orderStageItems.length - 1" class="w-0.5 flex-1 bg-white/20 my-1" />
|
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 pb-3">
|
<div class="flex-1 pb-3">
|
||||||
<div class="text-sm text-white font-medium">{{ stage.from }}</div>
|
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
|
||||||
<div v-if="stage.to && stage.to !== stage.from" class="text-xs text-white/50 mt-0.5">
|
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
|
||||||
→ {{ stage.to }}
|
→ {{ stage.to }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="stage.meta?.length" class="text-xs text-white/40 mt-1">
|
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
|
||||||
{{ stage.meta.join(' · ') }}
|
{{ stage.meta.join(' · ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,10 +104,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<div v-if="order.stages?.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:calendar" size="18" />
|
<Icon name="lucide:calendar" size="18" />
|
||||||
{{ t('ordersDetail.sections.timeline.title') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<GanttTimeline
|
<GanttTimeline
|
||||||
:stages="order.stages"
|
:stages="order.stages"
|
||||||
@@ -117,10 +117,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map preview (small) -->
|
<!-- Map preview (small) -->
|
||||||
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:map" size="18" />
|
<Icon name="lucide:map" size="18" />
|
||||||
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||||
</div>
|
</div>
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-else class="px-6 py-4 space-y-4">
|
<div v-else class="px-6 py-4 space-y-4">
|
||||||
<div class="h-20 bg-white/5 rounded-xl animate-pulse" />
|
<div class="h-20 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
<div class="h-32 bg-white/5 rounded-xl animate-pulse" />
|
<div class="h-32 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
<div class="flex-shrink-0 p-4 border-b border-base-300">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-base text-white">{{ $t('catalog.headers.offers') }}</h3>
|
<h3 class="font-semibold text-base text-base-content">{{ $t('catalog.headers.offers') }}</h3>
|
||||||
<span class="badge badge-neutral">{{ offers.length }}</span>
|
<span class="badge badge-neutral">{{ totalOffers }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content (scrollable) -->
|
<!-- Content (scrollable) -->
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
<span class="loading loading-spinner loading-md text-white" />
|
<span class="loading loading-spinner loading-md text-base-content" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="offersWithPrice.length === 0" class="text-center py-8 text-white/60">
|
<div v-else-if="offersWithPrice.length === 0" class="text-center py-8 text-base-content/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.noOffers') }}</p>
|
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-3">
|
||||||
<div
|
<div
|
||||||
v-for="offer in offersWithPrice"
|
v-for="group in offerGroups"
|
||||||
:key="offer.uuid"
|
:key="group.id"
|
||||||
class="cursor-pointer"
|
class="flex flex-col gap-0"
|
||||||
@click="emit('select-offer', offer)"
|
>
|
||||||
>
|
<div
|
||||||
<OfferResultCard
|
v-if="group.offers.length > 1"
|
||||||
:supplier-name="offer.supplierName"
|
class="rounded-2xl overflow-hidden border border-base-200/60 divide-y divide-base-200/60"
|
||||||
:location-name="offer.locationName || offer.locationCountry"
|
>
|
||||||
:product-name="offer.productName"
|
<div
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
v-for="offer in group.offers"
|
||||||
:currency="offer.currency"
|
:key="offer.uuid"
|
||||||
:unit="offer.unit"
|
class="cursor-pointer"
|
||||||
:stages="[]"
|
@click="emit('select-offer', offer)"
|
||||||
/>
|
>
|
||||||
</div>
|
<OfferResultCard
|
||||||
|
grouped
|
||||||
|
:supplier-name="offer.supplierName"
|
||||||
|
:location-name="offer.country || ''"
|
||||||
|
:product-name="offer.productName"
|
||||||
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
|
:stages="getOfferStages(offer)"
|
||||||
|
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-for="offer in group.offers"
|
||||||
|
:key="offer.uuid"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="emit('select-offer', offer)"
|
||||||
|
>
|
||||||
|
<OfferResultCard
|
||||||
|
:supplier-name="offer.supplierName"
|
||||||
|
:location-name="offer.country || ''"
|
||||||
|
:product-name="offer.productName"
|
||||||
|
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||||||
|
:quantity="offer.quantity"
|
||||||
|
:currency="offer.currency"
|
||||||
|
:unit="offer.unit"
|
||||||
|
:stages="getOfferStages(offer)"
|
||||||
|
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,13 +81,27 @@ interface Offer {
|
|||||||
productName?: string | null
|
productName?: string | null
|
||||||
productUuid?: string | null
|
productUuid?: string | null
|
||||||
supplierName?: string | null
|
supplierName?: string | null
|
||||||
|
supplierUuid?: string | null
|
||||||
quantity?: number | string | null
|
quantity?: number | string | null
|
||||||
unit?: string | null
|
unit?: string | null
|
||||||
pricePerUnit?: number | string | null
|
pricePerUnit?: number | string | null
|
||||||
currency?: string | null
|
currency?: string | null
|
||||||
locationName?: string | null
|
country?: string | null
|
||||||
locationCountry?: string | null
|
countryCode?: string | null
|
||||||
locationCountryCode?: string | null
|
routes?: Array<{
|
||||||
|
totalTimeSeconds?: number | null
|
||||||
|
stages?: Array<{
|
||||||
|
transportType?: string | null
|
||||||
|
distanceKm?: number | null
|
||||||
|
travelTimeSeconds?: number | null
|
||||||
|
fromName?: string | null
|
||||||
|
} | null> | null
|
||||||
|
} | null> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferGroup {
|
||||||
|
id: string
|
||||||
|
offers: Offer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -63,9 +111,38 @@ const emit = defineEmits<{
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
loading: boolean
|
loading: boolean
|
||||||
offers: Offer[]
|
offers: Offer[]
|
||||||
|
calculations?: OfferGroup[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const offersWithPrice = computed(() =>
|
const offersWithPrice = computed(() =>
|
||||||
(props.offers || []).filter(o => o?.pricePerUnit != null)
|
(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>
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
<div class="flex-shrink-0 p-4 border-b border-base-300">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h3 class="font-semibold text-base text-white">{{ title }}</h3>
|
<h3 class="font-semibold text-base text-base-content">{{ title }}</h3>
|
||||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
<button class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||||
<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) -->
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
<span class="loading loading-spinner loading-md text-white" />
|
<span class="loading loading-spinner loading-md text-base-content" />
|
||||||
</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-base-content/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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</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,16 +87,24 @@
|
|||||||
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 bg-base-100 border border-base-300 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-base-content" />
|
||||||
|
</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"
|
||||||
>
|
>
|
||||||
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
|
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-base-content/60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute inset-0 overflow-hidden bg-slate-900">
|
<div
|
||||||
<!-- Lottie animation -->
|
class="absolute inset-0 overflow-hidden bg-slate-900"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<DotLottieVue
|
<DotLottieVue
|
||||||
src="/animations/supply-chain.lottie"
|
src="/animations/supply-chain.lottie"
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
:layout="{ fit: 'cover', align: [0.5, 0.5] }"
|
:layout="{ fit: 'cover', align: [0.5, 0.5] }"
|
||||||
class="absolute top-0 left-0 w-full"
|
class="absolute inset-0 h-full w-full"
|
||||||
:style="{
|
|
||||||
height: '100vh',
|
|
||||||
opacity: 1 - collapseProgress * 0.7,
|
|
||||||
transform: `scale(${1 + collapseProgress * 0.1})`
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<!-- Overlay for text readability - only when hero starts collapsing -->
|
<div class="absolute inset-0 bg-gradient-to-b from-slate-900/45 via-slate-900/35 to-slate-900/65" />
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
collapseProgress: number
|
height?: number
|
||||||
}>()
|
fill?: boolean
|
||||||
|
}>(), {
|
||||||
|
height: 860,
|
||||||
|
fill: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
if (props.fill) return undefined
|
||||||
|
return { height: `${props.height ?? 860}px` }
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
46
app/components/manager/ManagerListLoadMore.vue
Normal file
46
app/components/manager/ManagerListLoadMore.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
shown: number
|
||||||
|
total?: number | null
|
||||||
|
canLoadMore?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
pageSize?: number
|
||||||
|
itemLabel?: string
|
||||||
|
}>(), {
|
||||||
|
total: null,
|
||||||
|
canLoadMore: false,
|
||||||
|
loading: false,
|
||||||
|
pageSize: 12,
|
||||||
|
itemLabel: 'элементов',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
loadMore: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const summaryText = computed(() => {
|
||||||
|
if (typeof props.total === 'number') {
|
||||||
|
return `Показано ${props.shown} из ${props.total} ${props.itemLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Загружено ${props.shown} ${props.itemLabel}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonText = computed(() => props.loading ? 'Загружаем...' : `Загрузить ещё ${props.pageSize}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center gap-3 rounded-[28px] bg-white/82 px-5 py-5 text-center text-sm text-[#6f6353] shadow-none">
|
||||||
|
<p>{{ summaryText }}</p>
|
||||||
|
<button
|
||||||
|
v-if="canLoadMore"
|
||||||
|
type="button"
|
||||||
|
class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('loadMore')"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||||
|
{{ buttonText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,89 +1,107 @@
|
|||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="shadow-lg"
|
class="relative"
|
||||||
:class="headerClasses"
|
|
||||||
: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">
|
:style="rowStyle"
|
||||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
>
|
||||||
<span class="font-bold text-xl" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
|
<!-- Left: Logo + AI button + Nav links (top aligned) -->
|
||||||
</NuxtLink>
|
<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' && !isClientArea
|
<button
|
||||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
:class="showActiveMode && isExploreModeActive && !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' && !isClientArea
|
|
||||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
|
||||||
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
|
||||||
@click="$emit('set-catalog-mode', 'quote')"
|
|
||||||
>
|
|
||||||
{{ $t('catalog.modes.quote') }}
|
|
||||||
</button>
|
|
||||||
<!-- Role switcher: Я клиент + dropdown -->
|
|
||||||
<div v-if="loggedIn" class="flex items-center">
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
|
:to="localePath('/catalog/product')"
|
||||||
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
|
||||||
:class="isClientArea
|
:class="isQuoteModeActive
|
||||||
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
? (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')"
|
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||||
>
|
>
|
||||||
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
|
{{ $t('catalog.modes.quote') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<!-- Role switcher: Я клиент + dropdown -->
|
||||||
<!-- Dropdown для переключения роли (если есть обе роли) -->
|
<div v-if="loggedIn" class="flex items-center">
|
||||||
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
|
<NuxtLink
|
||||||
<button
|
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
|
||||||
tabindex="0"
|
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
|
||||||
class="p-1 ml-0.5 transition-colors"
|
:class="isClientArea
|
||||||
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
|
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
|
||||||
|
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:chevron-down" size="14" />
|
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
|
||||||
</button>
|
</NuxtLink>
|
||||||
<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>
|
<!-- Dropdown для переключения роли (если есть обе роли) -->
|
||||||
<a
|
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
|
||||||
:class="{ active: currentRole === 'BUYER' }"
|
<button
|
||||||
@click="$emit('switch-role', 'BUYER')"
|
tabindex="0"
|
||||||
>
|
class="p-1 ml-0.5 transition-colors"
|
||||||
{{ $t('cabinetNav.roles.client') }}
|
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
|
||||||
</a>
|
>
|
||||||
</li>
|
<Icon name="lucide:chevron-down" size="14" />
|
||||||
<li>
|
</button>
|
||||||
<a
|
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
|
||||||
:class="{ active: currentRole === 'SELLER' }"
|
<li>
|
||||||
@click="$emit('switch-role', 'SELLER')"
|
<a
|
||||||
>
|
:class="{ active: currentRole === 'BUYER' }"
|
||||||
{{ $t('cabinetNav.roles.seller') }}
|
@click="$emit('switch-role', 'BUYER')"
|
||||||
</a>
|
>
|
||||||
</li>
|
{{ $t('cabinetNav.roles.client') }}
|
||||||
</ul>
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
:class="{ active: currentRole === 'SELLER' }"
|
||||||
|
@click="$emit('switch-role', 'SELLER')"
|
||||||
|
>
|
||||||
|
{{ $t('cabinetNav.roles.seller') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Search input OR Client Area tabs (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
|
||||||
<!-- Hero slot for home page title -->
|
class="relative mx-auto flex flex-1 flex-col items-center justify-center gap-2 max-w-2xl"
|
||||||
<slot name="hero" />
|
>
|
||||||
|
|
||||||
<!-- Client Area tabs -->
|
<!-- Client Area tabs -->
|
||||||
<template v-if="isClientArea">
|
<template v-if="isClientArea">
|
||||||
<div class="flex items-center gap-1 rounded-full border border-white/20 bg-white/80 backdrop-blur-md shadow-lg p-1">
|
<div class="flex items-center gap-1 rounded-full pill-glass p-1">
|
||||||
<!-- BUYER tabs -->
|
<!-- BUYER tabs -->
|
||||||
<template v-if="currentRole !== 'SELLER'">
|
<template v-if="currentRole !== 'SELLER'">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -115,47 +133,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
|
<!-- Quote mode: Step-based capsule navigation (like logistics) -->
|
||||||
<template v-else-if="catalogMode === 'quote'">
|
<template v-else-if="isQuoteModeActive">
|
||||||
<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 h-12 items-center overflow-hidden rounded-full pill-glass"
|
||||||
|
:class="searchCapsuleClass"
|
||||||
|
:style="searchCapsuleStyle"
|
||||||
|
>
|
||||||
<!-- 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 h-full min-w-0 flex-1 items-center px-4 text-left text-sm font-medium transition-colors hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.product') }}</div>
|
<span class="truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</span>
|
||||||
<div class="font-medium truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</div>
|
</NuxtLink>
|
||||||
</button>
|
<div class="h-6 w-px self-center bg-base-300/40" />
|
||||||
<!-- 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 h-full min-w-0 flex-1 items-center px-4 text-left text-sm font-medium transition-colors hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.hub') }}</div>
|
<span class="truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</span>
|
||||||
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
|
</NuxtLink>
|
||||||
</button>
|
<div class="h-6 w-px self-center bg-base-300/40" />
|
||||||
<!-- Quantity segment (inline input) -->
|
<!-- Quantity segment -->
|
||||||
<div class="flex-1 px-4 py-2 min-w-0">
|
<NuxtLink
|
||||||
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div>
|
:to="localePath('/catalog/quantity')"
|
||||||
<div class="flex items-center gap-1">
|
class="flex h-full min-w-0 flex-1 items-center px-4 text-left text-sm font-medium transition-colors hover:bg-white/10"
|
||||||
<input
|
>
|
||||||
v-model="localQuantity"
|
<span class="truncate text-base-content">{{ quantityLabel }}</span>
|
||||||
type="number"
|
</NuxtLink>
|
||||||
min="0"
|
<!-- Search button -->
|
||||||
step="0.1"
|
|
||||||
placeholder="—"
|
|
||||||
class="w-16 font-medium bg-transparent outline-none text-base-content [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
@blur="$emit('update-quantity', localQuantity)"
|
|
||||||
@keyup.enter="$emit('update-quantity', localQuantity)"
|
|
||||||
/>
|
|
||||||
<span v-if="localQuantity" class="text-base-content/60 text-sm">{{ $t('units.t') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Search button inside -->
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-circle m-1"
|
class="btn btn-primary btn-circle btn-sm m-1"
|
||||||
:disabled="!canSearch"
|
@click="navigateToSearch"
|
||||||
@click="$emit('search')"
|
|
||||||
>
|
>
|
||||||
<Icon name="lucide:search" size="18" />
|
<Icon name="lucide:search" size="18" />
|
||||||
</button>
|
</button>
|
||||||
@@ -166,7 +177,9 @@
|
|||||||
<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 px-5 py-3 rounded-full pill-glass focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
|
||||||
|
:class="searchCapsuleClass"
|
||||||
|
:style="searchCapsuleStyle"
|
||||||
@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" />
|
||||||
@@ -210,52 +223,47 @@
|
|||||||
</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="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:bot" size="18" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Globe (language/currency) dropdown -->
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<button
|
|
||||||
tabindex="0"
|
|
||||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
|
|
||||||
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:globe" size="18" />
|
|
||||||
</button>
|
|
||||||
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
|
|
||||||
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
|
|
||||||
<div class="flex gap-2 mb-4">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="loc in locales"
|
|
||||||
:key="loc.code"
|
|
||||||
:to="switchLocalePath(loc.code)"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
>
|
|
||||||
{{ loc.code.toUpperCase() }}
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
|
|
||||||
<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"
|
||||||
@@ -287,10 +295,11 @@
|
|||||||
</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
|
||||||
@@ -340,9 +349,10 @@
|
|||||||
{{ $t('auth.login') }}
|
{{ $t('auth.login') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -359,7 +369,7 @@ const props = withDefaults(defineProps<{
|
|||||||
userAvatarSvg?: string
|
userAvatarSvg?: string
|
||||||
userName?: string
|
userName?: string
|
||||||
userInitials?: string
|
userInitials?: string
|
||||||
theme?: 'cupcake' | 'night'
|
theme?: 'silk' | 'night'
|
||||||
userData?: {
|
userData?: {
|
||||||
id?: string
|
id?: string
|
||||||
activeTeam?: { name?: string; teamType?: string }
|
activeTeam?: { name?: string; teamType?: string }
|
||||||
@@ -390,13 +400,25 @@ const props = withDefaults(defineProps<{
|
|||||||
isHomePage?: boolean
|
isHomePage?: boolean
|
||||||
// Client area flag - shows cabinet tabs instead of search
|
// Client area flag - shows cabinet tabs instead of search
|
||||||
isClientArea?: boolean
|
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
|
||||||
|
// Home scroll position for floating center capsule
|
||||||
|
heroScrollY?: number
|
||||||
|
// Initial hero height for stable landing capsule start position
|
||||||
|
heroBaseHeight?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
height: 100
|
height: 100,
|
||||||
|
collapseProgress: 1,
|
||||||
|
heroScrollY: 0,
|
||||||
|
heroBaseHeight: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits([
|
defineEmits([
|
||||||
|
'toggle-chat',
|
||||||
'toggle-theme',
|
'toggle-theme',
|
||||||
'sign-out',
|
'sign-out',
|
||||||
'sign-in',
|
'sign-in',
|
||||||
@@ -419,6 +441,30 @@ 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')
|
||||||
|
})
|
||||||
|
const isHomeQuoteLayout = computed(() => props.isHomePage && !props.isClientArea)
|
||||||
|
const isQuoteModeActive = computed(() =>
|
||||||
|
isHomeQuoteLayout.value || isQuoteStepPage.value || props.catalogMode === 'quote'
|
||||||
|
)
|
||||||
|
const isExploreModeActive = computed(() =>
|
||||||
|
!isQuoteModeActive.value && props.catalogMode === 'explore'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Navigate to search results (quote mode step flow)
|
||||||
|
const navigateToSearch = () => {
|
||||||
|
router.push(localePath('/catalog/product'))
|
||||||
|
}
|
||||||
|
|
||||||
// Check if client area tab is active
|
// Check if client area tab is active
|
||||||
const isClientAreaTabActive = (path: string) => {
|
const isClientAreaTabActive = (path: string) => {
|
||||||
@@ -478,21 +524,40 @@ const getTokenIcon = (type: string) => {
|
|||||||
return icons[type] || 'lucide:tag'
|
return icons[type] || 'lucide:tag'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header background classes
|
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
|
||||||
const headerClasses = computed(() => {
|
const LANDING_CAPSULE_TOP_START = 500
|
||||||
if (props.isCollapsed) {
|
const LANDING_CAPSULE_TOP_STOP = 0
|
||||||
// Glass style when collapsed
|
|
||||||
return 'bg-black/30 backdrop-blur-md border-b border-white/10'
|
const rowStyle = computed(() => ({ height: `${props.height}px` }))
|
||||||
}
|
|
||||||
if (props.isHomePage) {
|
const isFloatingHomeCapsule = computed(() => isHeroLayout.value)
|
||||||
// Transparent on home page (animation visible behind)
|
|
||||||
return 'bg-transparent'
|
const landingCapsuleTopStart = computed(() => {
|
||||||
}
|
if (!isFloatingHomeCapsule.value) return LANDING_CAPSULE_TOP_STOP
|
||||||
// White on other pages
|
return LANDING_CAPSULE_TOP_START
|
||||||
return 'bg-base-100 border-b border-base-300'
|
})
|
||||||
|
|
||||||
|
const landingCapsuleTop = computed(() => {
|
||||||
|
if (!isFloatingHomeCapsule.value) return LANDING_CAPSULE_TOP_STOP
|
||||||
|
const y = Math.max(0, props.heroScrollY || 0)
|
||||||
|
return Math.max(LANDING_CAPSULE_TOP_STOP, landingCapsuleTopStart.value - y)
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchCapsuleClass = computed(() => {
|
||||||
|
if (!isFloatingHomeCapsule.value) return 'w-full'
|
||||||
|
return 'w-full lg:fixed lg:left-1/2 lg:z-[55] lg:w-[min(1120px,calc(100vw-1.5rem))] lg:-translate-x-1/2'
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchCapsuleStyle = computed(() => {
|
||||||
|
if (!isFloatingHomeCapsule.value) return undefined
|
||||||
|
return { top: `${landingCapsuleTop.value}px` }
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantityLabel = computed(() => {
|
||||||
|
if (!props.quantity) return t('catalog.quote.enterQty', 'Введите количество')
|
||||||
|
return `${props.quantity} ${t('units.t')}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use white text on dark backgrounds (collapsed or home page with animation)
|
// Use white text on dark backgrounds (collapsed or home page with animation)
|
||||||
const useWhiteText = computed(() => props.isCollapsed || props.isHomePage)
|
const useWhiteText = computed(() => props.isCollapsed || props.isHomePage)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav v-if="items.length > 0" class="bg-base-100 shadow-sm">
|
<nav v-if="items.length > 0" class="mx-auto mt-2 w-full max-w-[2200px] px-3 md:px-4">
|
||||||
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
|
<div class="flex items-center gap-1 overflow-x-auto rounded-[24px] border border-[#e2d8ca] bg-[#efe6d8]/92 px-3 py-2 shadow-[0_14px_34px_rgba(47,36,24,0.08)] backdrop-blur">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="localePath(item.path)"
|
:to="localePath(item.path)"
|
||||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap text-base-content/70 hover:text-base-content hover:bg-base-200"
|
class="rounded-full px-4 py-2 text-sm font-bold whitespace-nowrap transition-colors text-[#5f4b33] hover:bg-[#f8f3ec]"
|
||||||
:class="{ 'text-primary bg-primary/10': isActive(item.path) }"
|
:class="{ 'bg-[#2f2418] text-white shadow-[0_10px_24px_rgba(47,36,24,0.16)]': isActive(item.path) }"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -49,4 +49,3 @@ const isActive = (path: string) => {
|
|||||||
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
|
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
type CalendarCheckpoint = {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
plannedDate?: string | null
|
||||||
|
actualDate?: string | null
|
||||||
|
completed: boolean
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarOrder = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
quotationId?: string | null
|
||||||
|
totalAmount: number
|
||||||
|
currency: string
|
||||||
|
createdAt: string
|
||||||
|
pickupDate?: string | null
|
||||||
|
fromAddress: {
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
toAddress: {
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
currentCheckpoint?: {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
plannedDate?: string | null
|
||||||
|
completed: boolean
|
||||||
|
current: boolean
|
||||||
|
} | null
|
||||||
|
checkpoints: CalendarCheckpoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orders: CalendarOrder[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [orderId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||||
|
|
||||||
|
function startOfMonthUtc(value: Date) {
|
||||||
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonthsUtc(value: Date, diff: number) {
|
||||||
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + diff, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateKey(value: Date) {
|
||||||
|
return value.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnlyDate(value?: string | null) {
|
||||||
|
if (!value) return null
|
||||||
|
const normalized = String(value).trim()
|
||||||
|
if (!normalized) return null
|
||||||
|
const date = new Date(`${normalized}T00:00:00.000Z`)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderAnchorCheckpoint(order: CalendarOrder) {
|
||||||
|
const plannedCurrent = order.checkpoints.find(checkpoint => checkpoint.current && checkpoint.plannedDate)
|
||||||
|
if (plannedCurrent) return plannedCurrent
|
||||||
|
|
||||||
|
const firstPending = order.checkpoints.find(checkpoint => !checkpoint.completed && checkpoint.plannedDate)
|
||||||
|
if (firstPending) return firstPending
|
||||||
|
|
||||||
|
const lastPlanned = [...order.checkpoints].reverse().find(checkpoint => checkpoint.plannedDate)
|
||||||
|
if (lastPlanned) return lastPlanned
|
||||||
|
|
||||||
|
return order.currentCheckpoint || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderCalendarDate(order: CalendarOrder) {
|
||||||
|
const checkpointDate = orderAnchorCheckpoint(order)?.plannedDate
|
||||||
|
if (checkpointDate) return checkpointDate
|
||||||
|
if (order.pickupDate) return order.pickupDate
|
||||||
|
return order.createdAt.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderCalendarLabel(order: CalendarOrder) {
|
||||||
|
const checkpoint = orderAnchorCheckpoint(order)
|
||||||
|
if (checkpoint?.name) return checkpoint.name
|
||||||
|
return 'Дата уточняется'
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderPersonLabel(order: CalendarOrder) {
|
||||||
|
if (order.quotationId) {
|
||||||
|
return `Клиент ${order.quotationId.slice(-6).toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Клиент ${order.id.slice(-6).toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderAvatarSeed(order: CalendarOrder) {
|
||||||
|
return order.quotationId || order.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMonth = computed(() => {
|
||||||
|
const firstOrderDate = props.orders
|
||||||
|
.map(order => toDateOnlyDate(orderCalendarDate(order)))
|
||||||
|
.find(Boolean)
|
||||||
|
|
||||||
|
return startOfMonthUtc(firstOrderDate || new Date())
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleMonth = ref(startOfMonthUtc(initialMonth.value))
|
||||||
|
|
||||||
|
watch(initialMonth, (nextValue) => {
|
||||||
|
visibleMonth.value = startOfMonthUtc(nextValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabel = computed(() => {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}).format(visibleMonth.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarOrdersByDay = computed(() => {
|
||||||
|
return props.orders.reduce<Record<string, CalendarOrder[]>>((acc, order) => {
|
||||||
|
const dateKey = orderCalendarDate(order)
|
||||||
|
if (!dateKey) return acc
|
||||||
|
if (!acc[dateKey]) acc[dateKey] = []
|
||||||
|
acc[dateKey].push(order)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthCells = computed(() => {
|
||||||
|
const firstDay = visibleMonth.value
|
||||||
|
const firstWeekday = (firstDay.getUTCDay() + 6) % 7
|
||||||
|
const gridStart = new Date(firstDay.getTime())
|
||||||
|
gridStart.setUTCDate(gridStart.getUTCDate() - firstWeekday)
|
||||||
|
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(gridStart.getTime())
|
||||||
|
date.setUTCDate(gridStart.getUTCDate() + index)
|
||||||
|
const dateKey = formatDateKey(date)
|
||||||
|
return {
|
||||||
|
key: dateKey,
|
||||||
|
date,
|
||||||
|
dateKey,
|
||||||
|
inCurrentMonth: date.getUTCMonth() === visibleMonth.value.getUTCMonth(),
|
||||||
|
isToday: dateKey === formatDateKey(new Date()),
|
||||||
|
orders: calendarOrdersByDay.value[dateKey] || [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function previousMonth() {
|
||||||
|
visibleMonth.value = addMonthsUtc(visibleMonth.value, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
visibleMonth.value = addMonthsUtc(visibleMonth.value, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrder(orderId: string) {
|
||||||
|
emit('select', orderId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-[28px] bg-white p-4 md:p-5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders calendar</p>
|
||||||
|
<p class="mt-1 text-lg font-black capitalize text-[#2f2418]">{{ monthLabel }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-flex items-center rounded-full bg-[#f6f1ea] p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
|
||||||
|
aria-label="Previous month"
|
||||||
|
@click="previousMonth"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
|
||||||
|
aria-label="Next month"
|
||||||
|
@click="nextMonth"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-7 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="weekday in weekdayLabels"
|
||||||
|
:key="weekday"
|
||||||
|
class="px-1 text-center text-[11px] font-bold uppercase tracking-[0.12em] text-[#8c7b67]"
|
||||||
|
>
|
||||||
|
{{ weekday }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="cell in monthCells"
|
||||||
|
:key="cell.key"
|
||||||
|
class="flex min-h-[132px] flex-col rounded-[22px] border border-[#e6ddd1] bg-[#fbf8f4] p-2.5"
|
||||||
|
:class="[
|
||||||
|
cell.inCurrentMonth ? 'opacity-100' : 'opacity-45',
|
||||||
|
cell.isToday ? 'ring-2 ring-[#8bc7f2]/70' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-black text-[#2f2418]">{{ cell.date.getUTCDate() }}</span>
|
||||||
|
<span v-if="cell.orders.length" class="rounded-full bg-white px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.1em] text-[#5f4b33]">
|
||||||
|
{{ cell.orders.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="order in cell.orders.slice(0, 3)"
|
||||||
|
:key="order.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-[16px] bg-white px-2.5 py-2 text-left transition hover:shadow-[0_12px_30px_rgba(38,29,18,0.14)]"
|
||||||
|
@click="openOrder(order.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UserAvatar
|
||||||
|
:seed="orderAvatarSeed(order)"
|
||||||
|
:label="orderPersonLabel(order)"
|
||||||
|
:size="26"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-[11px] font-black text-[#2f2418]">
|
||||||
|
{{ orderPersonLabel(order) }}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs font-black text-[#2f2418]">
|
||||||
|
{{ order.fromAddress.city }} • {{ order.toAddress.city }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 truncate text-[11px] text-[#7c6d5d]">
|
||||||
|
{{ orderCalendarLabel(order) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="cell.orders.length > 3"
|
||||||
|
class="rounded-[16px] bg-white/70 px-2.5 py-2 text-[11px] font-semibold text-[#7c6d5d]"
|
||||||
|
>
|
||||||
|
Ещё {{ cell.orders.length - 3 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -18,7 +8,7 @@
|
|||||||
:map-id="mapId"
|
:map-id="mapId"
|
||||||
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
|
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
|
||||||
:clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])"
|
:clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])"
|
||||||
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
|
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
|
||||||
:use-server-clustering="useServerClustering && !isInfoMode"
|
:use-server-clustering="useServerClustering && !isInfoMode"
|
||||||
:point-color="activePointColor"
|
:point-color="activePointColor"
|
||||||
:entity-type="activeEntityType"
|
:entity-type="activeEntityType"
|
||||||
@@ -26,6 +16,7 @@
|
|||||||
:hovered-item="hoveredItem"
|
:hovered-item="hoveredItem"
|
||||||
:related-points="relatedPoints"
|
:related-points="relatedPoints"
|
||||||
:info-loading="infoLoading"
|
:info-loading="infoLoading"
|
||||||
|
:fit-padding-left="fitPaddingLeft"
|
||||||
@select-item="onMapSelect"
|
@select-item="onMapSelect"
|
||||||
@bounds-change="onBoundsChange"
|
@bounds-change="onBoundsChange"
|
||||||
/>
|
/>
|
||||||
@@ -34,27 +25,33 @@
|
|||||||
|
|
||||||
<!-- 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 map-chip 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 bottom-4 left-4 top-[108px] z-20 hidden w-14 lg:flex flex-col items-center justify-between rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-2 py-3 text-[#2f2418] shadow-[0_20px_40px_rgba(38,29,18,0.18)] transition-colors hover:bg-[#eee6dc]"
|
||||||
@click="openPanel"
|
@click="openPanel"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:menu" size="16" />
|
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-white text-[#5f4b33]">
|
||||||
<span>{{ $t('catalog.list') }}</span>
|
<Icon name="lucide:panel-left-open" size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="-rotate-90 whitespace-nowrap text-[11px] font-bold uppercase tracking-[0.14em] text-[#6f6353]">
|
||||||
|
{{ $t('catalog.list') }}
|
||||||
|
</span>
|
||||||
|
<span class="h-10 w-10" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 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-[108px] z-20 hidden lg:flex items-center gap-2 rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-3 py-1.5 cursor-pointer text-[#2f2418] text-sm shadow-[0_12px_26px_rgba(38,29,18,0.12)] transition-colors hover:bg-[#eee6dc]"
|
||||||
|
:style="boundsFilterStyle"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -69,10 +66,11 @@
|
|||||||
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
|
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
|
||||||
<div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
|
<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 rounded-full border border-[#d5c7b0] bg-[#f3eee6] p-1 shadow-[0_12px_26px_rgba(38,29,18,0.12)]">
|
||||||
<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-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -81,8 +79,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-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -91,8 +90,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-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -107,10 +107,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 max-w-[calc(100vw-2rem)] hidden lg:block"
|
class="absolute top-[108px] left-4 bottom-4 z-30 max-w-[calc(100vw-1rem)] hidden lg:block"
|
||||||
:class="panelWidth"
|
: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="h-full flex flex-col overflow-hidden rounded-[28px] border-0 bg-[#f3eee6] text-[#2f2418] shadow-[0_28px_70px_rgba(38,29,18,0.18)]">
|
||||||
<slot name="panel" />
|
<slot name="panel" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
<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 rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-3 py-2 text-[#2f2418] text-sm font-medium shadow-[0_10px_24px_rgba(38,29,18,0.12)]"
|
||||||
@click="openPanel"
|
@click="openPanel"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:menu" size="16" />
|
<Icon name="lucide:menu" size="16" />
|
||||||
@@ -130,10 +130,11 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
|
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
|
||||||
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 rounded-full border border-[#d5c7b0] bg-[#f3eee6] p-1 shadow-[0_10px_24px_rgba(38,29,18,0.12)]">
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
v-if="showOffersToggle"
|
||||||
:class="mapViewMode === 'offers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
|
:class="mapViewMode === 'offers' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -141,8 +142,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
v-if="showHubsToggle"
|
||||||
:class="mapViewMode === 'hubs' ? 'bg-white/20' : 'hover:bg-white/10'"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
|
:class="mapViewMode === 'hubs' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -150,8 +152,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
v-if="showSuppliersToggle"
|
||||||
:class="mapViewMode === 'suppliers' ? 'bg-white/20' : 'hover:bg-white/10'"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
|
:class="mapViewMode === 'suppliers' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
|
||||||
@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">
|
||||||
@@ -165,14 +168,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="rounded-t-3xl border-t border-[#d5c7b0] bg-[#f3eee6] text-[#2f2418] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 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)]">
|
||||||
@@ -192,6 +195,39 @@ 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 = 0
|
||||||
|
const rightInset = 16
|
||||||
|
return leftInset + panelWidthPx.value + rightInset
|
||||||
|
})
|
||||||
|
|
||||||
|
const boundsFilterStyle = computed(() => {
|
||||||
|
if (!isDesktop.value || panelWidthPx.value === 0) return { left: '1rem' }
|
||||||
|
return { left: `${panelWidthPx.value + 16}px` }
|
||||||
|
})
|
||||||
|
|
||||||
// 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'
|
||||||
@@ -255,8 +291,15 @@ const props = withDefaults(defineProps<{
|
|||||||
showPanel?: boolean
|
showPanel?: boolean
|
||||||
filterByBounds?: boolean
|
filterByBounds?: boolean
|
||||||
infoLoading?: boolean
|
infoLoading?: boolean
|
||||||
|
forceInfoMode?: boolean
|
||||||
panelWidth?: string
|
panelWidth?: string
|
||||||
hideViewToggle?: boolean
|
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
|
||||||
@@ -275,8 +318,15 @@ const props = withDefaults(defineProps<{
|
|||||||
showPanel: false,
|
showPanel: false,
|
||||||
filterByBounds: false,
|
filterByBounds: false,
|
||||||
infoLoading: false,
|
infoLoading: false,
|
||||||
|
forceInfoMode: false,
|
||||||
panelWidth: 'w-96',
|
panelWidth: 'w-96',
|
||||||
hideViewToggle: false,
|
hideViewToggle: false,
|
||||||
|
showOffersToggle: true,
|
||||||
|
showHubsToggle: true,
|
||||||
|
showSuppliersToggle: true,
|
||||||
|
clusterProductUuid: undefined,
|
||||||
|
clusterHubUuid: undefined,
|
||||||
|
clusterSupplierUuid: undefined,
|
||||||
relatedPoints: () => []
|
relatedPoints: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,8 +339,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
|
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
|
||||||
|
|
||||||
|
const clusterProductUuid = computed(() => props.clusterProductUuid ?? undefined)
|
||||||
|
const clusterHubUuid = computed(() => props.clusterHubUuid ?? undefined)
|
||||||
|
const clusterSupplierUuid = computed(() => props.clusterSupplierUuid ?? undefined)
|
||||||
|
|
||||||
// Server-side clustering (single-type mode)
|
// Server-side clustering (single-type mode)
|
||||||
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
|
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(
|
||||||
|
undefined,
|
||||||
|
activeClusterNodeType,
|
||||||
|
)
|
||||||
|
|
||||||
// Server-side clustering (typed mode)
|
// Server-side clustering (typed mode)
|
||||||
const offerClusters = useClusteredNodes(undefined, ref('offer'))
|
const offerClusters = useClusteredNodes(undefined, ref('offer'))
|
||||||
@@ -339,6 +396,7 @@ const fetchActiveClusters = async () => {
|
|||||||
// Refetch clusters when view mode changes
|
// Refetch clusters when view mode changes
|
||||||
watch(mapViewMode, async () => {
|
watch(mapViewMode, async () => {
|
||||||
if (!props.useServerClustering) return
|
if (!props.useServerClustering) return
|
||||||
|
if (isInfoMode.value) return
|
||||||
if (useTypedClusters.value) {
|
if (useTypedClusters.value) {
|
||||||
clearInactiveClusters(activeClusterType.value)
|
clearInactiveClusters(activeClusterType.value)
|
||||||
if (currentBounds.value) {
|
if (currentBounds.value) {
|
||||||
@@ -354,6 +412,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)
|
||||||
|
|
||||||
@@ -364,7 +433,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(() => {
|
||||||
|
|||||||
86
app/components/shell/MapSidePanel.vue
Normal file
86
app/components/shell/MapSidePanel.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
title?: string
|
||||||
|
initialCollapsed?: boolean
|
||||||
|
collapsible?: boolean
|
||||||
|
positionClass?: string
|
||||||
|
topOffsetClass?: string
|
||||||
|
bottomOffsetClass?: string
|
||||||
|
widthClass?: string
|
||||||
|
}>(), {
|
||||||
|
title: '',
|
||||||
|
initialCollapsed: false,
|
||||||
|
collapsible: true,
|
||||||
|
positionClass: 'left-4',
|
||||||
|
topOffsetClass: 'top-[96px]',
|
||||||
|
bottomOffsetClass: 'bottom-4',
|
||||||
|
widthClass: 'w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]',
|
||||||
|
})
|
||||||
|
|
||||||
|
const collapsed = ref(props.initialCollapsed)
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pointer-events-none fixed z-40" :class="[positionClass, topOffsetClass, bottomOffsetClass]">
|
||||||
|
<section
|
||||||
|
class="pointer-events-auto flex h-full flex-col overflow-hidden rounded-[28px] border-0 bg-[#f3eee6] text-[#2f2418] shadow-[0_28px_70px_rgba(38,29,18,0.18)]"
|
||||||
|
:class="collapsible && collapsed ? 'w-16 rounded-full' : widthClass"
|
||||||
|
style="transition: width 260ms ease;"
|
||||||
|
>
|
||||||
|
<template v-if="collapsible && collapsed">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-full cursor-pointer flex-col items-center justify-between px-3 py-3 text-left transition hover:bg-[#eee6dc]"
|
||||||
|
:aria-expanded="String(!collapsed)"
|
||||||
|
:aria-label="$t('ui.expand_panel')"
|
||||||
|
@click="toggleCollapsed"
|
||||||
|
>
|
||||||
|
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-white text-[#5f4b33] transition">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p v-if="title" class="origin-center whitespace-nowrap -rotate-90 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6f6353]">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="h-10 w-10" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header v-if="title || $slots.header" class="flex shrink-0 items-start justify-between gap-3 border-b border-[#ded3c2] bg-[#f3eee6] px-5 py-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<slot name="header">
|
||||||
|
<h1 v-if="title" class="truncate text-xl font-black text-[#2f2418] md:text-2xl">{{ title }}</h1>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="collapsible"
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white text-[#5f4b33] transition hover:bg-[#fbf8f4]"
|
||||||
|
:aria-label="$t('ui.collapse_panel')"
|
||||||
|
@click="toggleCollapsed"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto p-4 md:p-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="$slots.footer" class="shrink-0 border-t border-[#ded3c2] bg-[#f3eee6] px-4 py-4 md:px-5 md:py-5">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -17,22 +17,22 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const variantMap: Record<string, string> = {
|
const variantMap: Record<string, string> = {
|
||||||
default: 'badge-neutral',
|
default: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||||
success: 'badge-success',
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
warning: 'badge-warning',
|
warning: 'bg-amber-100 text-amber-700',
|
||||||
error: 'badge-error',
|
error: 'bg-rose-100 text-rose-700',
|
||||||
muted: 'badge-ghost',
|
muted: 'bg-[#efe7da] text-[#8a7761]',
|
||||||
primary: 'badge-primary',
|
primary: 'bg-[#2f2418] text-white',
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap: Record<string, string> = {
|
const sizeMap: Record<string, string> = {
|
||||||
xs: 'badge-xs',
|
xs: 'px-2 py-1 text-[10px]',
|
||||||
sm: 'badge-sm',
|
sm: 'px-3 py-1 text-xs',
|
||||||
md: 'badge-md',
|
md: 'px-3.5 py-1.5 text-sm',
|
||||||
}
|
}
|
||||||
|
|
||||||
const badgeClass = computed(() => {
|
const badgeClass = computed(() => {
|
||||||
const base = 'badge'
|
const base = 'inline-flex items-center rounded-full font-semibold'
|
||||||
const variantClass = variantMap[props.variant] || variantMap.default
|
const variantClass = variantMap[props.variant] || variantMap.default
|
||||||
const sizeClass = sizeMap[props.size] || sizeMap.sm
|
const sizeClass = sizeMap[props.size] || sizeMap.sm
|
||||||
return [base, variantClass, sizeClass].join(' ')
|
return [base, variantClass, sizeClass].join(' ')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<component
|
<component
|
||||||
:is="componentTag"
|
:is="componentTag"
|
||||||
:type="componentType"
|
:type="componentType"
|
||||||
:class="['btn', variantClass, fullWidth ? 'w-full' : '']"
|
:class="[baseClass, variantClass, fullWidth ? 'w-full' : '']"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -36,10 +36,11 @@ const componentTag = computed(() => {
|
|||||||
return props.as || 'button'
|
return props.as || 'button'
|
||||||
})
|
})
|
||||||
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
|
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
|
||||||
|
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-full border-0 px-5 py-3 text-sm font-bold transition duration-200'
|
||||||
|
|
||||||
const variantClass = computed(() => {
|
const variantClass = computed(() => {
|
||||||
if (props.variant === 'outline') return 'btn-outline btn-primary'
|
if (props.variant === 'outline') return 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6] hover:bg-[#f6f1ea]'
|
||||||
if (props.variant === 'ghost') return 'btn-ghost'
|
if (props.variant === 'ghost') return 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#ece2d3]'
|
||||||
return 'btn-primary'
|
return 'bg-[#2f2418] text-white shadow-[0_12px_28px_rgba(47,36,24,0.16)] hover:bg-[#493824]'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,18 +27,18 @@ const paddingMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toneMap: Record<string, string> = {
|
const toneMap: Record<string, string> = {
|
||||||
default: 'bg-base-100',
|
default: 'bg-white',
|
||||||
muted: 'bg-base-200',
|
muted: 'bg-[#fbf8f4]',
|
||||||
primary: 'bg-primary/10',
|
primary: 'bg-[#f6f1ea]',
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardClass = computed(() => {
|
const cardClass = computed(() => {
|
||||||
const paddingClass = paddingMap[props.padding] || paddingMap.medium
|
const paddingClass = paddingMap[props.padding] || paddingMap.medium
|
||||||
const toneClass = toneMap[props.tone] || toneMap.default
|
const toneClass = toneMap[props.tone] || toneMap.default
|
||||||
const interactiveClass = props.interactive
|
const interactiveClass = props.interactive
|
||||||
? 'cursor-pointer hover:shadow-lg transition-shadow duration-200'
|
? 'cursor-pointer transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]'
|
||||||
: ''
|
: ''
|
||||||
const baseClass = 'card'
|
const baseClass = 'rounded-[28px] border border-[#eadfce] text-[#2f2418] shadow-none'
|
||||||
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
|
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<label v-if="label" class="w-full space-y-1">
|
<label v-if="label" class="w-full space-y-2">
|
||||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||||
<input
|
<input
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="space-y-1">
|
<div class="space-y-2">
|
||||||
<h1 class="text-2xl lg:text-3xl font-bold text-base-content">{{ title }}</h1>
|
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Workspace</p>
|
||||||
<p v-if="description" class="text-base-content/70">{{ description }}</p>
|
<h1 class="text-2xl font-black text-[#2f2418] lg:text-3xl">{{ title }}</h1>
|
||||||
|
<p v-if="description" class="max-w-[720px] text-sm leading-6 text-[#6f6353]">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.actions || actions?.length" class="flex items-center gap-2 flex-shrink-0">
|
<div v-if="$slots.actions || actions?.length" class="flex items-center gap-2 flex-shrink-0">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<component
|
<component
|
||||||
:is="to ? NuxtLink : 'button'"
|
:is="to ? NuxtLink : 'button'"
|
||||||
:to="to"
|
:to="to"
|
||||||
class="btn btn-sm btn-ghost gap-2"
|
class="inline-flex items-center gap-2 rounded-full bg-[#f6f1ea] px-4 py-2 text-sm font-bold text-[#5f4b33] transition hover:bg-[#ece2d3]"
|
||||||
@click="!to && $emit('click')"
|
@click="!to && $emit('click')"
|
||||||
>
|
>
|
||||||
<Icon v-if="icon" :name="icon" size="16" />
|
<Icon v-if="icon" :name="icon" size="16" />
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const variantMap: Record<string, string> = {
|
const variantMap: Record<string, string> = {
|
||||||
neutral: 'badge-neutral',
|
neutral: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||||
primary: 'badge-primary',
|
primary: 'bg-[#2f2418] text-white',
|
||||||
outline: 'badge-outline',
|
outline: 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6]',
|
||||||
inverse: 'badge-ghost bg-white/10 text-white border border-white/40',
|
inverse: 'bg-white/10 text-white border border-white/40',
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneMap: Record<string, string> = {
|
const toneMap: Record<string, string> = {
|
||||||
default: '',
|
default: '',
|
||||||
success: 'badge-success',
|
success: 'bg-emerald-100 text-emerald-700',
|
||||||
warning: 'badge-warning',
|
warning: 'bg-amber-100 text-amber-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const pillClass = computed(() => {
|
const pillClass = computed(() => {
|
||||||
const base = ['badge', props.size === 'sm' ? 'badge-sm' : 'badge-md']
|
const base = ['inline-flex items-center rounded-full font-semibold', props.size === 'sm' ? 'px-3 py-1 text-xs' : 'px-3.5 py-1.5 text-sm']
|
||||||
const variantClass = variantMap[props.variant] || variantMap.neutral
|
const variantClass = variantMap[props.variant] || variantMap.neutral
|
||||||
const toneClass = toneMap[props.tone] || ''
|
const toneClass = toneMap[props.tone] || ''
|
||||||
return [base, variantClass, toneClass].flat().filter(Boolean).join(' ')
|
return [base, variantClass, toneClass].flat().filter(Boolean).join(' ')
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const variantMap: Record<string, string> = {
|
const variantMap: Record<string, string> = {
|
||||||
default: 'bg-base-200 text-base-content',
|
default: 'rounded-[28px] bg-white px-6 text-[#2f2418] shadow-none',
|
||||||
hero: 'bg-primary text-primary-content rounded-box overflow-hidden px-6',
|
hero: 'overflow-hidden rounded-[34px] bg-[#10223b] px-6 text-white shadow-[0_22px_54px_rgba(16,34,59,0.24)]',
|
||||||
plain: '',
|
plain: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<select v-bind="$attrs" class="select select-bordered w-full">
|
<select v-bind="$attrs" class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none">
|
||||||
<slot />
|
<slot />
|
||||||
</select>
|
</select>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<label v-if="label" class="w-full space-y-1">
|
<label v-if="label" class="w-full space-y-2">
|
||||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:class="fieldClass"
|
:class="fieldClass"
|
||||||
@@ -39,6 +39,6 @@ const onInput = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fieldClass = computed(() =>
|
const fieldClass = computed(() =>
|
||||||
['textarea textarea-bordered w-full min-h-[120px]', props.mono ? 'font-mono' : ''].join(' ')
|
['w-full min-h-[120px] rounded-[24px] border-0 bg-[#f6f1ea] px-5 py-4 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]', props.mono ? 'font-mono' : ''].join(' ')
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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: string; output: string; }
|
|
||||||
DateTime: { input: string; output: string; }
|
|
||||||
Decimal: { input: string; output: string; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
export type GetLocationOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||||
|
|
||||||
export type GetOfferQueryVariables = Exact<{
|
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: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
|
export type GetOfferQueryResult = { __typename?: 'Query', getOffer?: { __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
|
||||||
|
|
||||||
export type GetOffersQueryVariables = Exact<{
|
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: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
export type GetOffersQueryResult = { __typename?: 'Query', getOffersCount?: number | null, getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||||
|
|
||||||
export type GetProductQueryVariables = Exact<{
|
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: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
export type GetProductOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||||
|
|
||||||
export type GetProductsQueryVariables = Exact<{ [key: string]: never; }>;
|
export type 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: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
export type GetSupplierOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
|
||||||
|
|
||||||
export type GetSupplierProfileQueryVariables = Exact<{
|
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: Record<string, unknown>; output: Record<string, unknown>; }
|
JSON: { input: Record<string, unknown>; output: Record<string, unknown>; }
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cluster or individual point for map display. */
|
export type 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,14 +164,12 @@ 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']>;
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
@@ -227,25 +182,21 @@ export type QueryHubsListArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** 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']>;
|
||||||
lon: Scalars['Float']['input'];
|
lon: Scalars['Float']['input'];
|
||||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
radius?: InputMaybe<Scalars['Float']['input']>;
|
radius?: InputMaybe<Scalars['Float']['input']>;
|
||||||
sourceUuid?: InputMaybe<Scalars['String']['input']>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** 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']>;
|
||||||
@@ -253,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'];
|
||||||
@@ -264,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']>;
|
||||||
@@ -274,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']>;
|
||||||
@@ -288,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']>;
|
||||||
@@ -302,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']>;
|
||||||
@@ -313,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']>;
|
||||||
@@ -328,26 +271,22 @@ 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']>;
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -358,14 +297,12 @@ export type QueryProductsListArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/** 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'];
|
||||||
@@ -374,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'];
|
||||||
@@ -382,13 +318,11 @@ 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']>;
|
east?: InputMaybe<Scalars['Float']['input']>;
|
||||||
@@ -399,17 +333,21 @@ export type QuerySuppliersListArgs = {
|
|||||||
west?: 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']>;
|
||||||
@@ -423,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']>;
|
||||||
@@ -449,7 +378,7 @@ export type GetAutoRouteQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||||
|
|
||||||
export type GetClusteredNodesQueryVariables = Exact<{
|
export type GetClusteredNodesQueryVariables = Exact<{
|
||||||
west: Scalars['Float']['input'];
|
west: Scalars['Float']['input'];
|
||||||
@@ -462,19 +391,19 @@ export type GetClusteredNodesQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes?: Array<{ __typename?: 'ClusterPointType', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null } | null> | null };
|
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes: Array<{ __typename?: 'ClusterPoint', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null }> };
|
||||||
|
|
||||||
export type GetHubCountriesQueryVariables = Exact<{ [key: string]: never; }>;
|
export type 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'];
|
||||||
@@ -484,7 +413,7 @@ export type GetRailRouteQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
|
||||||
|
|
||||||
export type HubsListQueryVariables = Exact<{
|
export type HubsListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -498,19 +427,18 @@ export type HubsListQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type HubsListQueryResult = { __typename?: 'Query', hubsList?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null> | null };
|
export type HubsListQueryResult = { __typename?: 'Query', hubsList: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
|
||||||
|
|
||||||
export type NearestHubsQueryVariables = Exact<{
|
export type NearestHubsQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
lon: Scalars['Float']['input'];
|
lon: Scalars['Float']['input'];
|
||||||
radius?: InputMaybe<Scalars['Float']['input']>;
|
radius?: InputMaybe<Scalars['Float']['input']>;
|
||||||
productUuid?: InputMaybe<Scalars['String']['input']>;
|
productUuid?: InputMaybe<Scalars['String']['input']>;
|
||||||
sourceUuid?: InputMaybe<Scalars['String']['input']>;
|
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null, distanceKm?: number | null } | null> | null };
|
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
|
||||||
|
|
||||||
export type NearestOffersQueryVariables = Exact<{
|
export type NearestOffersQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -522,7 +450,7 @@ export type NearestOffersQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers?: Array<{ __typename?: 'OfferWithRouteType', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePathType', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStageType', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null } | null> | null };
|
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers: Array<{ __typename?: 'OfferWithRoute', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePath', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStage', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null }> };
|
||||||
|
|
||||||
export type NearestSuppliersQueryVariables = Exact<{
|
export type NearestSuppliersQueryVariables = Exact<{
|
||||||
lat: Scalars['Float']['input'];
|
lat: Scalars['Float']['input'];
|
||||||
@@ -533,7 +461,7 @@ export type NearestSuppliersQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers?: Array<{ __typename?: 'SupplierType', uuid?: string | null } | null> | null };
|
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers: Array<{ __typename?: 'Supplier', uuid?: string | null }> };
|
||||||
|
|
||||||
export type ProductsListQueryVariables = Exact<{
|
export type ProductsListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -545,7 +473,7 @@ export type ProductsListQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type ProductsListQueryResult = { __typename?: 'Query', productsList?: Array<{ __typename?: 'ProductType', uuid?: string | null, name?: string | null, offersCount?: number | null } | null> | null };
|
export type ProductsListQueryResult = { __typename?: 'Query', productsList: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, offersCount?: number | null }> };
|
||||||
|
|
||||||
export type SuppliersListQueryVariables = Exact<{
|
export type SuppliersListQueryVariables = Exact<{
|
||||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||||
@@ -558,7 +486,7 @@ export type SuppliersListQueryVariables = Exact<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList?: Array<{ __typename?: 'SupplierType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null } | null> | null };
|
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList: Array<{ __typename?: 'Supplier', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null }> };
|
||||||
|
|
||||||
|
|
||||||
export const GetAutoRouteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAutoRoute"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoRoute"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"geometry"}}]}}]}}]} as unknown as DocumentNode<GetAutoRouteQueryResult, GetAutoRouteQueryVariables>;
|
export const GetAutoRouteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAutoRoute"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoRoute"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"geometry"}}]}}]}}]} as unknown as DocumentNode<GetAutoRouteQueryResult, GetAutoRouteQueryVariables>;
|
||||||
@@ -567,7 +495,7 @@ export const GetHubCountriesDocument = {"kind":"Document","definitions":[{"kind"
|
|||||||
export const GetNodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetNode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"uuid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"uuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"uuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<GetNodeQueryResult, GetNodeQueryVariables>;
|
export const 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"}}},{"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 HubsListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"HubsList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"country"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"transportType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"west"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"south"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"east"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"north"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hubsList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"country"},"value":{"kind":"Variable","name":{"kind":"Name","value":"country"}}},{"kind":"Argument","name":{"kind":"Name","value":"transportType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"transportType"}}},{"kind":"Argument","name":{"kind":"Name","value":"west"},"value":{"kind":"Variable","name":{"kind":"Name","value":"west"}}},{"kind":"Argument","name":{"kind":"Name","value":"south"},"value":{"kind":"Variable","name":{"kind":"Name","value":"south"}}},{"kind":"Argument","name":{"kind":"Name","value":"east"},"value":{"kind":"Variable","name":{"kind":"Name","value":"east"}}},{"kind":"Argument","name":{"kind":"Name","value":"north"},"value":{"kind":"Variable","name":{"kind":"Name","value":"north"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<HubsListQueryResult, HubsListQueryVariables>;
|
||||||
export const NearestHubsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestHubs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestHubs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}}]}}]}}]} as unknown as DocumentNode<NearestHubsQueryResult, NearestHubsQueryVariables>;
|
export const NearestHubsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestHubs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestHubs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"transportTypes"}}]}}]}}]} as unknown as DocumentNode<NearestHubsQueryResult, NearestHubsQueryVariables>;
|
||||||
export const NearestOffersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NearestOffers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"radius"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"hubUuid"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nearestOffers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lat"}}},{"kind":"Argument","name":{"kind":"Name","value":"lon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lon"}}},{"kind":"Argument","name":{"kind":"Name","value":"radius"},"value":{"kind":"Variable","name":{"kind":"Name","value":"radius"}}},{"kind":"Argument","name":{"kind":"Name","value":"productUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"productUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"hubUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"hubUuid"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"supplierUuid"}},{"kind":"Field","name":{"kind":"Name","value":"supplierName"}},{"kind":"Field","name":{"kind":"Name","value":"latitude"}},{"kind":"Field","name":{"kind":"Name","value":"longitude"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"countryCode"}},{"kind":"Field","name":{"kind":"Name","value":"pricePerUnit"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"routes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalDistanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"totalTimeSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"stages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fromUuid"}},{"kind":"Field","name":{"kind":"Name","value":"fromName"}},{"kind":"Field","name":{"kind":"Name","value":"fromLat"}},{"kind":"Field","name":{"kind":"Name","value":"fromLon"}},{"kind":"Field","name":{"kind":"Name","value":"toUuid"}},{"kind":"Field","name":{"kind":"Name","value":"toName"}},{"kind":"Field","name":{"kind":"Name","value":"toLat"}},{"kind":"Field","name":{"kind":"Name","value":"toLon"}},{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"travelTimeSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"transportType"}}]}}]}}]}}]}}]} as unknown as DocumentNode<NearestOffersQueryResult, NearestOffersQueryVariables>;
|
export const 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"}}},{"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 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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export const useActiveTeam = () => {
|
export const useActiveTeam = () => {
|
||||||
const activeTeamId = useState<string | null>('activeTeamId', () => null)
|
const activeTeamId = useState<string | null>('activeTeamId', () => null)
|
||||||
const activeLogtoOrgId = useState<string | null>('activeLogtoOrgId', () => null)
|
const activeLogtoOrgId = useState<string | null>('activeLogtoOrgId', () => null)
|
||||||
|
const logtoOrgState = useState<string | null>('logto-org-id', () => null)
|
||||||
|
|
||||||
const setActiveTeam = (teamId: string | null, logtoOrgId?: string | null) => {
|
const setActiveTeam = (teamId: string | null, logtoOrgId?: string | null) => {
|
||||||
activeTeamId.value = teamId
|
activeTeamId.value = teamId
|
||||||
activeLogtoOrgId.value = logtoOrgId ?? null
|
const nextOrgId = logtoOrgId ?? null
|
||||||
|
activeLogtoOrgId.value = nextOrgId
|
||||||
|
logtoOrgState.value = nextOrgId
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activeTeamId, activeLogtoOrgId, setActiveTeam }
|
return { activeTeamId, activeLogtoOrgId, setActiveTeam }
|
||||||
|
|||||||
@@ -7,15 +7,16 @@
|
|||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const { getToken, initTokens, idToken } = useLogtoTokens()
|
const { getToken, initTokens, idToken } = useLogtoTokens()
|
||||||
const me = useState<{ id?: string | null } | null>('me', () => null)
|
const me = useState<{ id?: string | null } | null>('me', () => null)
|
||||||
const isAuthenticated = computed(() => !!me.value?.id)
|
const logtoUser = useState<Record<string, unknown> | null>('logto-user', () => null)
|
||||||
|
const isAuthenticated = computed(() => !!(me.value?.id || logtoUser.value))
|
||||||
const loggedIn = isAuthenticated
|
const loggedIn = isAuthenticated
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get access token for a resource.
|
* Get access token for a resource.
|
||||||
* Tokens are synced from SSR via useState, auto-refreshes if expired.
|
* Tokens are synced from SSR via useState, auto-refreshes if expired.
|
||||||
*/
|
*/
|
||||||
const getAccessToken = async (resource: string, _organizationId?: string): Promise<string> => {
|
const getAccessToken = async (resource: string, organizationId?: string): Promise<string> => {
|
||||||
return getToken(resource as Parameters<typeof getToken>[0])
|
return getToken(resource as Parameters<typeof getToken>[0], organizationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOrganizationToken = getAccessToken
|
const getOrganizationToken = getAccessToken
|
||||||
@@ -48,12 +49,20 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signIn = async (_redirectUri?: string) => {
|
const signIn = async (_redirectUri?: string) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.location.assign('/sign-in')
|
||||||
|
return
|
||||||
|
}
|
||||||
await navigateTo('/sign-in', { external: true })
|
await navigateTo('/sign-in', { external: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = signIn
|
const login = signIn
|
||||||
|
|
||||||
const signOut = async (_logoutRedirectUri?: string) => {
|
const signOut = async (_logoutRedirectUri?: string) => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.location.assign('/sign-out')
|
||||||
|
return
|
||||||
|
}
|
||||||
await navigateTo('/sign-out', { external: true })
|
await navigateTo('/sign-out', { external: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
app/composables/useCalcSearchDraft.ts
Normal file
86
app/composables/useCalcSearchDraft.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
type CalcSearchDraft = {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
cargo: string
|
||||||
|
fromLat: number | null
|
||||||
|
fromLng: number | null
|
||||||
|
toLat: number | null
|
||||||
|
toLng: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalcSearchDraftPatch = Partial<CalcSearchDraft>
|
||||||
|
|
||||||
|
const initialCalcSearchDraft = (): CalcSearchDraft => ({
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
cargo: '',
|
||||||
|
fromLat: null,
|
||||||
|
fromLng: null,
|
||||||
|
toLat: null,
|
||||||
|
toLng: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
function stringParam(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberParam(value: unknown) {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCalcSearchDraft() {
|
||||||
|
const draft = useState<CalcSearchDraft>('calc-search-draft', initialCalcSearchDraft)
|
||||||
|
|
||||||
|
function hydrateFromQuery(query: Record<string, unknown>) {
|
||||||
|
const nextDraft = { ...draft.value }
|
||||||
|
const from = stringParam(query.from)
|
||||||
|
const to = stringParam(query.to)
|
||||||
|
const cargo = stringParam(query.cargo)
|
||||||
|
const fromLat = numberParam(query.fromLat)
|
||||||
|
const fromLng = numberParam(query.fromLng)
|
||||||
|
const toLat = numberParam(query.toLat)
|
||||||
|
const toLng = numberParam(query.toLng)
|
||||||
|
|
||||||
|
if (from) nextDraft.from = from
|
||||||
|
if (to) nextDraft.to = to
|
||||||
|
if (cargo) nextDraft.cargo = cargo
|
||||||
|
if (fromLat !== null) nextDraft.fromLat = fromLat
|
||||||
|
if (fromLng !== null) nextDraft.fromLng = fromLng
|
||||||
|
if (toLat !== null) nextDraft.toLat = toLat
|
||||||
|
if (toLng !== null) nextDraft.toLng = toLng
|
||||||
|
|
||||||
|
draft.value = nextDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchDraft(patch: CalcSearchDraftPatch) {
|
||||||
|
draft.value = {
|
||||||
|
...draft.value,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(patch: CalcSearchDraftPatch = {}) {
|
||||||
|
const nextDraft = {
|
||||||
|
...draft.value,
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(nextDraft.from.trim() ? { from: nextDraft.from.trim() } : {}),
|
||||||
|
...(nextDraft.to.trim() ? { to: nextDraft.to.trim() } : {}),
|
||||||
|
...(nextDraft.cargo.trim() ? { cargo: nextDraft.cargo.trim() } : {}),
|
||||||
|
...(nextDraft.fromLat !== null ? { fromLat: String(nextDraft.fromLat) } : {}),
|
||||||
|
...(nextDraft.fromLng !== null ? { fromLng: String(nextDraft.fromLng) } : {}),
|
||||||
|
...(nextDraft.toLat !== null ? { toLat: String(nextDraft.toLat) } : {}),
|
||||||
|
...(nextDraft.toLng !== null ? { toLng: String(nextDraft.toLng) } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
hydrateFromQuery,
|
||||||
|
patchDraft,
|
||||||
|
buildQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated'
|
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
|
// Type from codegen - exported for use in pages
|
||||||
export type CatalogHubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
|
export type CatalogHubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
|
||||||
@@ -61,16 +61,15 @@ 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',
|
||||||
@@ -147,9 +146,7 @@ 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) => {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import type {
|
|||||||
} from '~/composables/graphql/public/exchange-generated'
|
} 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
|
// Types from codegen
|
||||||
@@ -125,7 +126,8 @@ export function useCatalogInfo() {
|
|||||||
{
|
{
|
||||||
lat: coords.lat,
|
lat: coords.lat,
|
||||||
lon: coords.lon,
|
lon: coords.lon,
|
||||||
radius: 500
|
hubUuid: uuid,
|
||||||
|
limit: 500
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
@@ -224,21 +226,16 @@ 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, InfoProductItem>()
|
||||||
offersData?.nearestOffers?.forEach(offer => {
|
offersData?.getOffers?.forEach(offer => {
|
||||||
if (!offer || !offer.productUuid || !offer.productName) return
|
if (!offer?.productUuid || !offer.productName) return
|
||||||
const existing = productsMap.get(offer.productUuid)
|
const existing = productsMap.get(offer.productUuid)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.offersCount = (existing.offersCount || 0) + 1
|
existing.offersCount = (existing.offersCount || 0) + 1
|
||||||
@@ -261,7 +258,6 @@ 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,
|
sourceUuid: entity.value.uuid,
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
@@ -312,7 +308,6 @@ export function useCatalogInfo() {
|
|||||||
{
|
{
|
||||||
lat: coords.lat,
|
lat: coords.lat,
|
||||||
lon: coords.lon,
|
lon: coords.lon,
|
||||||
radius: 1000,
|
|
||||||
sourceUuid: entity.value?.uuid ?? null,
|
sourceUuid: entity.value?.uuid ?? null,
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
@@ -372,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',
|
||||||
@@ -438,7 +432,6 @@ export function useCatalogInfo() {
|
|||||||
{
|
{
|
||||||
lat: supplier.latitude,
|
lat: supplier.latitude,
|
||||||
lon: supplier.longitude,
|
lon: supplier.longitude,
|
||||||
radius: 1000,
|
|
||||||
sourceUuid: supplier.uuid,
|
sourceUuid: supplier.uuid,
|
||||||
limit: 1
|
limit: 1
|
||||||
},
|
},
|
||||||
@@ -462,14 +455,17 @@ export function useCatalogInfo() {
|
|||||||
lon: supplier.longitude,
|
lon: supplier.longitude,
|
||||||
productUuid,
|
productUuid,
|
||||||
...(hubUuid ? { hubUuid } : {}),
|
...(hubUuid ? { hubUuid } : {}),
|
||||||
radius: 500,
|
|
||||||
limit: 12
|
limit: 12
|
||||||
},
|
},
|
||||||
'public',
|
'public',
|
||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
|
|
||||||
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
|
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => {
|
||||||
|
if (!o) return false
|
||||||
|
if (!supplier.uuid) return true
|
||||||
|
return o.supplierUuid === supplier.uuid
|
||||||
|
})
|
||||||
isLoadingOffers.value = false
|
isLoadingOffers.value = false
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingOffers.value = false
|
isLoadingOffers.value = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
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 from codegen
|
||||||
@@ -43,46 +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, AggregatedProduct>()
|
|
||||||
offersData?.nearestOffers?.forEach((offer) => {
|
|
||||||
if (!offer?.productUuid) return
|
|
||||||
if (!productsMap.has(offer.productUuid)) {
|
|
||||||
productsMap.set(offer.productUuid, {
|
|
||||||
uuid: offer.productUuid,
|
|
||||||
name: offer.productName,
|
|
||||||
offersCount: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
productsMap.get(offer.productUuid)!.offersCount++
|
|
||||||
})
|
|
||||||
items.value = Array.from(productsMap.values()) as ProductItem[]
|
|
||||||
}
|
|
||||||
} 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(
|
||||||
@@ -97,13 +77,14 @@ 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'
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export function useCatalogSearch() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Filter by bounds checkbox state from URL
|
// Filter by bounds checkbox state from URL
|
||||||
const filterByBounds = computed(() => route.query.bounds !== undefined)
|
// Use explicit flag so bounds don't auto-enable filtering.
|
||||||
|
const filterByBounds = computed(() => route.query.boundsFilter === '1')
|
||||||
|
|
||||||
// Get label for a filter (from cache or fallback to ID)
|
// 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 => {
|
||||||
@@ -228,18 +229,18 @@ export function useCatalogSearch() {
|
|||||||
|
|
||||||
const cancelSelect = () => {
|
const cancelSelect = () => {
|
||||||
updateQuery({
|
updateQuery({
|
||||||
select: null,
|
select: null
|
||||||
view: lastViewMode.value === 'offers' ? null : lastViewMode.value
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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: lastViewMode.value === 'offers' ? null : lastViewMode.value
|
view: forcedView
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ export function useCatalogSearch() {
|
|||||||
const setBoundsInUrl = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
const setBoundsInUrl = (bounds: { west: number; south: number; east: number; north: number } | null) => {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
const boundsStr = `${bounds.west.toFixed(4)},${bounds.south.toFixed(4)},${bounds.east.toFixed(4)},${bounds.north.toFixed(4)}`
|
const boundsStr = `${bounds.west.toFixed(4)},${bounds.south.toFixed(4)},${bounds.east.toFixed(4)},${bounds.north.toFixed(4)}`
|
||||||
updateQuery({ bounds: boundsStr })
|
updateQuery({ bounds: boundsStr, boundsFilter: '1' })
|
||||||
} else {
|
} else {
|
||||||
updateQuery({ bounds: null })
|
updateQuery({ bounds: null })
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,12 @@ export function useCatalogSearch() {
|
|||||||
|
|
||||||
// Clear bounds from URL
|
// Clear bounds from URL
|
||||||
const clearBoundsFromUrl = () => {
|
const clearBoundsFromUrl = () => {
|
||||||
updateQuery({ bounds: null })
|
updateQuery({ bounds: null, boundsFilter: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly enable/disable bounds filter flag in URL
|
||||||
|
const setBoundsFilterEnabled = (enabled: boolean) => {
|
||||||
|
updateQuery({ boundsFilter: enabled ? '1' : null })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openInfo = (type: InfoEntityType, uuid: string) => {
|
const openInfo = (type: InfoEntityType, uuid: string) => {
|
||||||
@@ -308,17 +314,13 @@ export function useCatalogSearch() {
|
|||||||
})
|
})
|
||||||
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
|
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
|
||||||
const setMapViewMode = (mode: MapViewMode) => {
|
const setMapViewMode = (mode: MapViewMode) => {
|
||||||
if (selectMode.value) {
|
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
|
||||||
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
|
: mode === 'suppliers' ? 'supplier'
|
||||||
: mode === 'suppliers' ? 'supplier'
|
: 'product'
|
||||||
: 'product'
|
updateQuery({
|
||||||
updateQuery({
|
view: mode === 'offers' ? null : mode,
|
||||||
view: mode === 'offers' ? null : mode,
|
select: newSelectMode
|
||||||
select: newSelectMode
|
})
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateQuery({ view: mode === 'offers' ? null : mode })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawer state for list view
|
// Drawer state for list view
|
||||||
@@ -368,7 +370,29 @@ export function useCatalogSearch() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setCatalogMode = (newMode: CatalogMode) => {
|
const setCatalogMode = (newMode: CatalogMode) => {
|
||||||
updateQuery({ mode: newMode })
|
const catalogPath = localePath('/catalog')
|
||||||
|
const isCatalogRoute = route.path.startsWith(catalogPath)
|
||||||
|
|
||||||
|
if (newMode === 'explore') {
|
||||||
|
// Always make Explore a clean navigation to map page without intermediate select state.
|
||||||
|
if (!isCatalogRoute) {
|
||||||
|
router.push({ path: catalogPath })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQuery({
|
||||||
|
mode: null,
|
||||||
|
qty: null,
|
||||||
|
select: null,
|
||||||
|
info: null,
|
||||||
|
infoTab: null,
|
||||||
|
infoProduct: null
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quote mode is a dedicated flow that starts from product step.
|
||||||
|
router.push({ path: localePath('/catalog/product') })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can search for offers (product + hub or product + supplier required)
|
// Can search for offers (product + hub or product + supplier required)
|
||||||
@@ -422,6 +446,7 @@ export function useCatalogSearch() {
|
|||||||
setQuantity,
|
setQuantity,
|
||||||
setBoundsInUrl,
|
setBoundsInUrl,
|
||||||
clearBoundsFromUrl,
|
clearBoundsFromUrl,
|
||||||
|
setBoundsFilterEnabled,
|
||||||
openInfo,
|
openInfo,
|
||||||
closeInfo,
|
closeInfo,
|
||||||
setInfoTab,
|
setInfoTab,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated'
|
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
|
// Types from codegen
|
||||||
type SupplierItem = NonNullable<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
|
type SupplierItem = NonNullable<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
|
||||||
@@ -28,15 +28,13 @@ 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
|
||||||
},
|
},
|
||||||
@@ -100,9 +98,7 @@ 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) => {
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const CLIENT_MAP: Record<string, string> = {
|
|||||||
export const useGraphQL = () => {
|
export const useGraphQL = () => {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const { activeLogtoOrgId } = useActiveTeam()
|
const { activeLogtoOrgId } = useActiveTeam()
|
||||||
|
const { refreshTokens } = useLogtoTokens()
|
||||||
|
|
||||||
const getClientId = (endpoint: Endpoint, api: Api): string => {
|
const getClientId = (endpoint: Endpoint, api: Api): string => {
|
||||||
return CLIENT_MAP[`${endpoint}:${api}`] || 'default'
|
return CLIENT_MAP[`${endpoint}:${api}`] || 'default'
|
||||||
@@ -77,20 +78,37 @@ export const useGraphQL = () => {
|
|||||||
): Promise<TResult> => {
|
): Promise<TResult> => {
|
||||||
const clientId = getClientId(endpoint, api)
|
const clientId = getClientId(endpoint, api)
|
||||||
const { client } = useApolloClient(clientId)
|
const { client } = useApolloClient(clientId)
|
||||||
const context = await getAuthContext(endpoint, api)
|
const executeOnce = async () => {
|
||||||
|
const context = await getAuthContext(endpoint, api)
|
||||||
|
const result = await client.query({
|
||||||
|
query: document,
|
||||||
|
variables,
|
||||||
|
context,
|
||||||
|
fetchPolicy: 'network-only'
|
||||||
|
})
|
||||||
|
|
||||||
const result = await client.query({
|
if (result.errors?.length) {
|
||||||
query: document,
|
throw new Error(result.errors[0]?.message || 'GraphQL error')
|
||||||
variables,
|
}
|
||||||
context,
|
|
||||||
fetchPolicy: 'network-only'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.errors?.length) {
|
return result.data as TResult
|
||||||
throw new Error(result.errors[0]?.message || 'GraphQL error')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data as TResult
|
try {
|
||||||
|
return await executeOnce()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
const isAuthContextFailure = message.includes('Invalid Compact JWS')
|
||||||
|
|| message.includes('Context creation failed')
|
||||||
|
|| message.includes('Received status code 500')
|
||||||
|
|
||||||
|
if (endpoint === 'team' && isAuthContextFailure) {
|
||||||
|
await refreshTokens()
|
||||||
|
return await executeOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutate = async <TResult, TVariables extends Record<string, unknown>>(
|
const mutate = async <TResult, TVariables extends Record<string, unknown>>(
|
||||||
@@ -101,19 +119,36 @@ export const useGraphQL = () => {
|
|||||||
): Promise<TResult> => {
|
): Promise<TResult> => {
|
||||||
const clientId = getClientId(endpoint, api)
|
const clientId = getClientId(endpoint, api)
|
||||||
const { client } = useApolloClient(clientId)
|
const { client } = useApolloClient(clientId)
|
||||||
const context = await getAuthContext(endpoint, api)
|
const mutateOnce = async () => {
|
||||||
|
const context = await getAuthContext(endpoint, api)
|
||||||
|
const result = await client.mutate({
|
||||||
|
mutation: document,
|
||||||
|
variables,
|
||||||
|
context
|
||||||
|
})
|
||||||
|
|
||||||
const result = await client.mutate({
|
if (result.errors?.length) {
|
||||||
mutation: document,
|
throw new Error(result.errors[0]?.message || 'GraphQL error')
|
||||||
variables,
|
}
|
||||||
context
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.errors?.length) {
|
return result.data as TResult
|
||||||
throw new Error(result.errors[0]?.message || 'GraphQL error')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data as TResult
|
try {
|
||||||
|
return await mutateOnce()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
const isAuthContextFailure = message.includes('Invalid Compact JWS')
|
||||||
|
|| message.includes('Context creation failed')
|
||||||
|
|| message.includes('Received status code 500')
|
||||||
|
|
||||||
|
if (endpoint === 'team' && isAuthContextFailure) {
|
||||||
|
await refreshTokens()
|
||||||
|
return await mutateOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { execute, mutate }
|
return { execute, mutate }
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ export const useHeroScroll = () => {
|
|||||||
const scrollY = ref(0)
|
const scrollY = ref(0)
|
||||||
|
|
||||||
// Hero height = viewport height minus some space
|
// Hero height = viewport height minus some space
|
||||||
const heroBaseHeight = ref(0)
|
const heroBaseHeight = ref(860)
|
||||||
const collapsedHeight = 100 // Fixed header height when collapsed
|
const collapsedHeight = 100 // Fixed header height when collapsed
|
||||||
|
|
||||||
// Calculate hero height based on viewport
|
// Calculate hero height based on viewport
|
||||||
const updateHeroHeight = () => {
|
const updateHeroHeight = () => {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
heroBaseHeight.value = window.innerHeight - 80
|
heroBaseHeight.value = Math.max(640, window.innerHeight - 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
188
app/composables/useLocaleCurrency.ts
Normal file
188
app/composables/useLocaleCurrency.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
export type AppLocale = 'ru' | 'en'
|
||||||
|
export type AppCurrency = 'USD' | 'RUB' | 'CNY' | 'EUR' | 'AED'
|
||||||
|
|
||||||
|
export type CurrencyRatesResponse = {
|
||||||
|
baseCurrency: 'USD'
|
||||||
|
rates: Record<AppCurrency, number>
|
||||||
|
updatedAt: string
|
||||||
|
nextUpdateAt: string
|
||||||
|
provider: string
|
||||||
|
documentation: string
|
||||||
|
termsOfUse: string
|
||||||
|
attributionUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocaleOption = {
|
||||||
|
code: AppLocale
|
||||||
|
label: string
|
||||||
|
nativeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrencyOption = {
|
||||||
|
code: AppCurrency
|
||||||
|
label: string
|
||||||
|
symbol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranslationKey =
|
||||||
|
| 'settings.language'
|
||||||
|
| 'settings.currency'
|
||||||
|
| 'settings.open'
|
||||||
|
| 'settings.apply'
|
||||||
|
| 'settings.ratesProvider'
|
||||||
|
| 'settings.ratesBy'
|
||||||
|
|
||||||
|
const LOCALE_MAP: Record<AppLocale, string> = {
|
||||||
|
ru: 'ru-RU',
|
||||||
|
en: 'en-US',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOLS: Record<AppCurrency, string> = {
|
||||||
|
USD: '$',
|
||||||
|
RUB: '₽',
|
||||||
|
CNY: '¥',
|
||||||
|
EUR: '€',
|
||||||
|
AED: 'د.إ',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALES: AppLocale[] = ['ru', 'en']
|
||||||
|
const CURRENCIES: AppCurrency[] = ['USD', 'RUB', 'CNY', 'EUR', 'AED']
|
||||||
|
|
||||||
|
const localeCodes = new Set<AppLocale>(LOCALES)
|
||||||
|
const currencyCodes = new Set<AppCurrency>(CURRENCIES)
|
||||||
|
|
||||||
|
function normalizeLocale(value: unknown): AppLocale {
|
||||||
|
return localeCodes.has(value as AppLocale) ? value as AppLocale : 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCurrency(value: unknown): AppCurrency {
|
||||||
|
const normalized = String(value || '').trim().toUpperCase()
|
||||||
|
|
||||||
|
if (!currencyCodes.has(normalized as AppCurrency)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Unsupported currency: ${normalized || 'empty'}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized as AppCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrency(value: unknown): AppCurrency {
|
||||||
|
const normalized = String(value || '').trim().toUpperCase()
|
||||||
|
return currencyCodes.has(normalized as AppCurrency) ? normalized as AppCurrency : 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocaleCurrency() {
|
||||||
|
const { locale: i18nLocale, setLocale: setI18nLocale, t: i18nT } = useI18n()
|
||||||
|
const currencyCookie = useCookie<AppCurrency>('ex_currency', {
|
||||||
|
default: () => 'USD',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
const currencyRates = useState<CurrencyRatesResponse | null>('currency-rates', () => null)
|
||||||
|
|
||||||
|
const locale = computed<AppLocale>({
|
||||||
|
get: () => normalizeLocale(i18nLocale.value),
|
||||||
|
set: (value) => {
|
||||||
|
i18nLocale.value = normalizeLocale(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const currency = computed<AppCurrency>({
|
||||||
|
get: () => normalizeCurrency(currencyCookie.value),
|
||||||
|
set: (value) => {
|
||||||
|
currencyCookie.value = normalizeCurrency(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const intlLocale = computed(() => LOCALE_MAP[locale.value])
|
||||||
|
const localeOptions = computed<LocaleOption[]>(() => LOCALES.map(code => ({
|
||||||
|
code,
|
||||||
|
label: i18nT(`settings.locales.${code}.label`),
|
||||||
|
nativeLabel: i18nT(`settings.locales.${code}.nativeLabel`),
|
||||||
|
})))
|
||||||
|
const currencyOptions = computed<CurrencyOption[]>(() => CURRENCIES.map(code => ({
|
||||||
|
code,
|
||||||
|
label: i18nT(`settings.currencies.${code}`),
|
||||||
|
symbol: CURRENCY_SYMBOLS[code],
|
||||||
|
})))
|
||||||
|
const languageCode = computed(() => locale.value.toUpperCase())
|
||||||
|
const currencyCode = computed(() => currency.value)
|
||||||
|
const ratesProviderUrl = computed(() => currencyRates.value?.attributionUrl || 'https://www.exchangerate-api.com')
|
||||||
|
|
||||||
|
async function setLocale(value: AppLocale) {
|
||||||
|
const normalizedLocale = normalizeLocale(value)
|
||||||
|
locale.value = normalizedLocale
|
||||||
|
await setI18nLocale(normalizedLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrency(value: AppCurrency) {
|
||||||
|
currency.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key: TranslationKey) {
|
||||||
|
return i18nT(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRates() {
|
||||||
|
if (!currencyRates.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Currency rates are not loaded',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return currencyRates.value.rates
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMoney(value: number, sourceCurrency: AppCurrency = 'USD', targetCurrency: AppCurrency = currency.value) {
|
||||||
|
const rates = getRates()
|
||||||
|
const amountInUsd = value / rates[sourceCurrency]
|
||||||
|
return amountInUsd * rates[targetCurrency]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: number, sourceCurrency: string = 'USD') {
|
||||||
|
const normalizedSourceCurrency = assertCurrency(sourceCurrency)
|
||||||
|
const convertedValue = convertMoney(value, normalizedSourceCurrency, currency.value)
|
||||||
|
|
||||||
|
return new Intl.NumberFormat(intlLocale.value, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.value,
|
||||||
|
maximumFractionDigits: currency.value === 'RUB' ? 0 : 0,
|
||||||
|
}).format(convertedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date | string, options: Intl.DateTimeFormatOptions = {}) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
return new Intl.DateTimeFormat(intlLocale.value, options).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: Date | string) {
|
||||||
|
return formatDate(value, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
currency,
|
||||||
|
intlLocale,
|
||||||
|
localeOptions,
|
||||||
|
currencyOptions,
|
||||||
|
languageCode,
|
||||||
|
currencyCode,
|
||||||
|
setLocale,
|
||||||
|
setCurrency,
|
||||||
|
convertMoney,
|
||||||
|
formatMoney,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
currencyRates,
|
||||||
|
ratesProviderUrl,
|
||||||
|
t,
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/composables/useLocalizedNavigation.ts
Normal file
70
app/composables/useLocalizedNavigation.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
type LocalizedRouteTarget = string | {
|
||||||
|
path?: string
|
||||||
|
name?: string
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
query?: Record<string, unknown>
|
||||||
|
hash?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// This composable is used in route middleware, where `useI18n()` is not available.
|
||||||
|
const LOCALE_CODES = ['ru', 'en']
|
||||||
|
|
||||||
|
function normalizePath(path: string) {
|
||||||
|
return path || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripLocalePrefix(path: string, localeCodes: string[]) {
|
||||||
|
const normalizedPath = normalizePath(path)
|
||||||
|
const localePattern = localeCodes.map(code => code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||||
|
|
||||||
|
if (!localePattern) return normalizedPath
|
||||||
|
|
||||||
|
const match = normalizedPath.match(new RegExp(`^/(${localePattern})(?=/|$)`))
|
||||||
|
if (!match) return normalizedPath
|
||||||
|
|
||||||
|
const nextPath = normalizedPath.slice(match[0].length)
|
||||||
|
return nextPath || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocalizedNavigation() {
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const basePath = computed(() => stripLocalePrefix(route.path, LOCALE_CODES))
|
||||||
|
|
||||||
|
function toLocalized(to: LocalizedRouteTarget) {
|
||||||
|
if (typeof to === 'string') {
|
||||||
|
return to.startsWith('/') ? localePath(to) : to
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path) {
|
||||||
|
return {
|
||||||
|
...to,
|
||||||
|
path: to.path.startsWith('/') ? localePath(to.path) : to.path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBasePathActive(target: string, exact = false) {
|
||||||
|
const normalizedTarget = normalizePath(target)
|
||||||
|
|
||||||
|
if (exact) {
|
||||||
|
return basePath.value === normalizedTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePath.value === normalizedTarget || basePath.value.startsWith(`${normalizedTarget}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToLocalized(to: LocalizedRouteTarget, options?: Parameters<typeof navigateTo>[1]) {
|
||||||
|
return navigateTo(toLocalized(to), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
basePath,
|
||||||
|
isBasePathActive,
|
||||||
|
localePath,
|
||||||
|
navigateToLocalized,
|
||||||
|
toLocalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,14 +27,19 @@ const EXPIRY_BUFFER_MS = 60 * 1000
|
|||||||
*/
|
*/
|
||||||
export const useLogtoTokens = () => {
|
export const useLogtoTokens = () => {
|
||||||
const tokens = useState<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({}))
|
const tokens = useState<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({}))
|
||||||
|
const tokensOrgId = useState<string | null>('logto-tokens-org-id', () => null)
|
||||||
const idToken = useState<string | null>('logto-id-token', () => null)
|
const idToken = useState<string | null>('logto-id-token', () => null)
|
||||||
|
const activeOrgId = useState<string | null>('activeLogtoOrgId', () => null)
|
||||||
|
const orgState = useState<string | null>('logto-org-id', () => null)
|
||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false)
|
||||||
let refreshPromise: Promise<void> | null = null
|
let refreshPromise: Promise<void> | null = null
|
||||||
|
let refreshPromiseOrgId: string | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get organization ID from Logto user (first organization)
|
* Get organization ID from Logto user (first organization)
|
||||||
*/
|
*/
|
||||||
const getOrganizationId = (): string | undefined => {
|
const resolveOrganizationId = (preferred?: string | null): string | undefined => {
|
||||||
|
if (preferred) return preferred
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
const context = nuxtApp.ssrContext?.event.context as {
|
const context = nuxtApp.ssrContext?.event.context as {
|
||||||
@@ -43,8 +48,7 @@ export const useLogtoTokens = () => {
|
|||||||
} | undefined
|
} | undefined
|
||||||
return context?.logtoOrgId || context?.logtoUser?.organizations?.[0]
|
return context?.logtoOrgId || context?.logtoUser?.organizations?.[0]
|
||||||
}
|
}
|
||||||
const orgId = useState<string | null>('logto-org-id', () => null)
|
return activeOrgId.value || orgState.value || undefined
|
||||||
return orgId.value || undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +58,7 @@ export const useLogtoTokens = () => {
|
|||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
const client = nuxtApp.ssrContext?.event.context.logtoClient
|
const client = nuxtApp.ssrContext?.event.context.logtoClient
|
||||||
const organizationId = getOrganizationId()
|
const organizationId = resolveOrganizationId()
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
const results: Partial<Record<ResourceKey, TokenInfo>> = {}
|
const results: Partial<Record<ResourceKey, TokenInfo>> = {}
|
||||||
@@ -78,6 +82,7 @@ export const useLogtoTokens = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
tokens.value = results
|
tokens.value = results
|
||||||
|
tokensOrgId.value = organizationId || null
|
||||||
|
|
||||||
// Also fetch ID token for SSR
|
// Also fetch ID token for SSR
|
||||||
try {
|
try {
|
||||||
@@ -124,24 +129,36 @@ export const useLogtoTokens = () => {
|
|||||||
/**
|
/**
|
||||||
* Refresh all tokens from server
|
* Refresh all tokens from server
|
||||||
*/
|
*/
|
||||||
const refreshTokens = async (): Promise<void> => {
|
const refreshTokens = async (organizationId?: string | null): Promise<void> => {
|
||||||
|
const resolvedOrgId = resolveOrganizationId(organizationId) || null
|
||||||
|
|
||||||
// Deduplicate concurrent refresh calls
|
// Deduplicate concurrent refresh calls
|
||||||
if (refreshPromise) {
|
if (refreshPromise) {
|
||||||
return refreshPromise
|
if (refreshPromiseOrgId === resolvedOrgId) {
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
await refreshPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
|
refreshPromiseOrgId = resolvedOrgId
|
||||||
|
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<RefreshResponse>('/api/auth/refresh', {
|
const response = await $fetch<RefreshResponse>('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
body: resolvedOrgId ? { organizationId: resolvedOrgId } : undefined
|
||||||
})
|
})
|
||||||
tokens.value = response.tokens
|
tokens.value = response.tokens
|
||||||
|
tokensOrgId.value = resolvedOrgId
|
||||||
|
if (resolvedOrgId) {
|
||||||
|
orgState.value = resolvedOrgId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
refreshPromise = null
|
refreshPromise = null
|
||||||
|
refreshPromiseOrgId = null
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -152,19 +169,21 @@ export const useLogtoTokens = () => {
|
|||||||
* Get access token for a resource URL.
|
* Get access token for a resource URL.
|
||||||
* Auto-refreshes if token is expired.
|
* Auto-refreshes if token is expired.
|
||||||
*/
|
*/
|
||||||
const getToken = async (resourceUrl: ResourceUrl): Promise<string> => {
|
const getToken = async (resourceUrl: ResourceUrl, organizationId?: string | null): Promise<string> => {
|
||||||
// Find resource key by URL
|
// Find resource key by URL
|
||||||
const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl)
|
const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new Error(`Unknown resource: ${resourceUrl}`)
|
throw new Error(`Unknown resource: ${resourceUrl}`)
|
||||||
}
|
}
|
||||||
const key = entry[0] as ResourceKey
|
const key = entry[0] as ResourceKey
|
||||||
|
const resolvedOrgId = resolveOrganizationId(organizationId) || null
|
||||||
|
|
||||||
const tokenInfo = tokens.value[key]
|
const tokenInfo = tokens.value[key]
|
||||||
|
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
|
||||||
|
|
||||||
// If expired, refresh all tokens
|
// If expired (or cached for another org), refresh all tokens
|
||||||
if (isTokenExpired(tokenInfo)) {
|
if (isOrgMismatch || isTokenExpired(tokenInfo)) {
|
||||||
await refreshTokens()
|
await refreshTokens(resolvedOrgId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedToken = tokens.value[key]
|
const refreshedToken = tokens.value[key]
|
||||||
@@ -178,11 +197,13 @@ export const useLogtoTokens = () => {
|
|||||||
/**
|
/**
|
||||||
* Get token by resource key (teams, exchange, orders, kyc)
|
* Get token by resource key (teams, exchange, orders, kyc)
|
||||||
*/
|
*/
|
||||||
const getTokenByKey = async (key: ResourceKey): Promise<string> => {
|
const getTokenByKey = async (key: ResourceKey, organizationId?: string | null): Promise<string> => {
|
||||||
const tokenInfo = tokens.value[key]
|
const tokenInfo = tokens.value[key]
|
||||||
|
const resolvedOrgId = resolveOrganizationId(organizationId) || null
|
||||||
|
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
|
||||||
|
|
||||||
if (isTokenExpired(tokenInfo)) {
|
if (isOrgMismatch || isTokenExpired(tokenInfo)) {
|
||||||
await refreshTokens()
|
await refreshTokens(resolvedOrgId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedToken = tokens.value[key]
|
const refreshedToken = tokens.value[key]
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
export const useOrdersRestAPI = () => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
const getTeamOrders = async (teamUuid) => {
|
|
||||||
try {
|
|
||||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/orders/api/v1/orders/team/${teamUuid}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching team orders:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOrderByUuid = async (orderUuid) => {
|
|
||||||
try {
|
|
||||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/orders/api/v1/orders/${orderUuid}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching order:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCompanies = async () => {
|
|
||||||
try {
|
|
||||||
const response = await $fetch(`${config.public.odooApiUrl}/fastapi/companies/api/v1/companies`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching companies:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getTeamOrders,
|
|
||||||
getOrderByUuid,
|
|
||||||
getCompanies
|
|
||||||
}
|
|
||||||
}
|
|
||||||
88
app/layouts/manager.vue
Normal file
88
app/layouts/manager.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { signOut } = useAuth()
|
||||||
|
|
||||||
|
const userData = useState<{
|
||||||
|
activeTeam?: { name?: string | null }
|
||||||
|
firstName?: string | null
|
||||||
|
} | null>('me', () => null)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Orders', path: '/manager/orders', icon: 'lucide:package' },
|
||||||
|
{ label: 'Quotations', path: '/manager/quotations', icon: 'lucide:file-text' },
|
||||||
|
{ label: 'Tariffs', path: '/manager/tariffs', icon: 'lucide:waypoints' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
const localized = localePath(path)
|
||||||
|
return route.path === localized || route.path.startsWith(`${localized}/`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="manager-logistics-shell text-[#2f2418]">
|
||||||
|
<div class="sticky top-0 z-50 px-3 pt-3 md:px-4">
|
||||||
|
<div class="mx-auto max-w-[1440px]">
|
||||||
|
<header class="rounded-[30px] border border-[#e1d7c7] bg-[#efe6d8]/95 px-4 py-4 shadow-[0_18px_40px_rgba(47,36,24,0.08)] backdrop-blur">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/')"
|
||||||
|
class="flex h-12 min-w-[88px] items-center justify-center rounded-full bg-[#2f2418] px-5 text-sm font-black uppercase tracking-[0.2em] text-white"
|
||||||
|
>
|
||||||
|
Optovia
|
||||||
|
</NuxtLink>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-[#8a7761]">Logistics manager</p>
|
||||||
|
<h1 class="text-xl font-black leading-tight text-[#2f2418] md:text-2xl">Control tower</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap items-center gap-2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="localePath(item.path)"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold transition"
|
||||||
|
:class="isActive(item.path) ? 'bg-[#2f2418] text-white shadow-[0_10px_24px_rgba(47,36,24,0.16)]' : 'bg-white text-[#5f4b33] hover:bg-[#f8f3ec]'"
|
||||||
|
>
|
||||||
|
<Icon :name="item.icon" size="16" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="rounded-full bg-white px-4 py-2 text-sm font-semibold text-[#5f4b33]">
|
||||||
|
{{ userData?.activeTeam?.name || userData?.firstName || 'Active workspace' }}
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/orders')"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-bold text-[#5f4b33] transition hover:bg-[#f8f3ec]"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:arrow-left" size="16" />
|
||||||
|
<span>Client area</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full bg-[#2f2418] px-4 py-2 text-sm font-bold text-white transition hover:bg-[#493824]"
|
||||||
|
@click="signOut()"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:log-out" size="16" />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="px-3 pb-5 pt-3 md:px-4 md:pb-6">
|
||||||
|
<div class="mx-auto max-w-[1440px]">
|
||||||
|
<section class="rounded-[34px] bg-[#f3eee6] px-4 py-4 shadow-[0_24px_72px_rgba(3,8,20,0.18)] md:px-5 md:py-5 lg:px-6 lg:py-6">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,340 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const isHomePage = computed(() => route.path === localePath('/'))
|
||||||
|
const isCatalogSection = computed(() => route.path.startsWith(localePath('/catalog')))
|
||||||
|
const isClientArea = computed(() => route.path.startsWith(localePath('/clientarea')))
|
||||||
|
const isFullscreenMapPage = computed(() => {
|
||||||
|
const p = route.path
|
||||||
|
return (
|
||||||
|
p.startsWith(localePath('/catalog/')) ||
|
||||||
|
p === localePath('/catalog') ||
|
||||||
|
p === localePath('/clientarea/orders') ||
|
||||||
|
p === localePath('/select-location/map') ||
|
||||||
|
p === localePath('/clientarea/orders/map')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentClass = computed(() => {
|
||||||
|
if (isCatalogSection.value || isHomePage.value) {
|
||||||
|
return 'flex-1 flex flex-col min-h-0'
|
||||||
|
}
|
||||||
|
return 'flex-1 flex flex-col min-h-0 px-3 lg:px-6'
|
||||||
|
})
|
||||||
|
|
||||||
|
const mainStyle = computed(() => {
|
||||||
|
if (isCatalogSection.value || isHomePage.value) return { paddingTop: '0' }
|
||||||
|
if (isClientArea.value) return { paddingTop: '116px', paddingBottom: '96px' }
|
||||||
|
return { paddingTop: '132px', paddingBottom: '96px' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col bg-base-300">
|
<div class="min-h-screen flex flex-col bg-base-100 text-base-content">
|
||||||
<!-- Fixed Header Container -->
|
<AppHeader />
|
||||||
<div class="fixed top-0 left-0 right-0 z-40" :style="headerContainerStyle">
|
|
||||||
<!-- Animated background for home page -->
|
|
||||||
<HeroBackground v-if="isHomePage" :collapse-progress="collapseProgress" />
|
|
||||||
|
|
||||||
<!-- MainNavigation - dynamic height on home page -->
|
<main :class="contentClass" :style="mainStyle">
|
||||||
<MainNavigation
|
|
||||||
class="relative z-10"
|
|
||||||
:height="isHomePage ? heroHeight : 100"
|
|
||||||
:session-checked="sessionChecked"
|
|
||||||
:logged-in="isLoggedIn"
|
|
||||||
:user-avatar-svg="userAvatarSvg"
|
|
||||||
:user-name="userName"
|
|
||||||
:user-initials="userInitials"
|
|
||||||
:theme="theme"
|
|
||||||
:user-data="userData"
|
|
||||||
:is-seller="isSeller"
|
|
||||||
:has-multiple-roles="hasMultipleRoles"
|
|
||||||
:current-role="currentRole"
|
|
||||||
:active-tokens="activeTokens"
|
|
||||||
:available-chips="availableChips"
|
|
||||||
:select-mode="selectMode"
|
|
||||||
:search-query="searchQuery"
|
|
||||||
:catalog-mode="catalogMode"
|
|
||||||
:product-label="productLabel ?? undefined"
|
|
||||||
:hub-label="hubLabel ?? undefined"
|
|
||||||
:quantity="quantity"
|
|
||||||
:can-search="canSearch"
|
|
||||||
:show-mode-toggle="true"
|
|
||||||
:show-active-mode="isCatalogSection"
|
|
||||||
:is-collapsed="isHomePage ? heroIsCollapsed : (isCatalogSection || isClientArea)"
|
|
||||||
:is-home-page="isHomePage"
|
|
||||||
:is-client-area="isClientArea"
|
|
||||||
@toggle-theme="toggleTheme"
|
|
||||||
@set-catalog-mode="setCatalogMode"
|
|
||||||
@sign-out="onClickSignOut"
|
|
||||||
@sign-in="signIn()"
|
|
||||||
@switch-team="switchToTeam"
|
|
||||||
@switch-role="switchToRole"
|
|
||||||
@start-select="startSelect"
|
|
||||||
@cancel-select="cancelSelect"
|
|
||||||
@edit-token="editFilter"
|
|
||||||
@remove-token="removeFilter"
|
|
||||||
@update:search-query="searchQuery = $event"
|
|
||||||
@update-quantity="setQuantity"
|
|
||||||
@search="onSearch"
|
|
||||||
>
|
|
||||||
<!-- Hero content for home page -->
|
|
||||||
<template v-if="isHomePage && collapseProgress < 1" #hero>
|
|
||||||
<h1
|
|
||||||
class="text-3xl lg:text-4xl font-bold text-white mb-4"
|
|
||||||
:style="{ opacity: 1 - collapseProgress }"
|
|
||||||
>
|
|
||||||
{{ $t('hero.tagline', 'Make trade easy') }}
|
|
||||||
</h1>
|
|
||||||
</template>
|
|
||||||
</MainNavigation>
|
|
||||||
|
|
||||||
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home/non-clientarea sections -->
|
|
||||||
<SubNavigation
|
|
||||||
v-if="!isHomePage && !isCatalogSection && !isClientArea"
|
|
||||||
:section="currentSection"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page content - padding-top compensates for fixed header -->
|
|
||||||
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AppFooter v-if="!isFullscreenMapPage" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
|
||||||
const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
|
|
||||||
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const localePath = useLocalePath()
|
|
||||||
const { locale, locales } = useI18n()
|
|
||||||
const switchLocalePath = useSwitchLocalePath()
|
|
||||||
|
|
||||||
// Catalog search state
|
|
||||||
const {
|
|
||||||
selectMode,
|
|
||||||
searchQuery,
|
|
||||||
activeTokens,
|
|
||||||
availableChips,
|
|
||||||
startSelect,
|
|
||||||
cancelSelect,
|
|
||||||
removeFilter,
|
|
||||||
editFilter,
|
|
||||||
setQuantity,
|
|
||||||
catalogMode,
|
|
||||||
setCatalogMode,
|
|
||||||
productLabel,
|
|
||||||
hubLabel,
|
|
||||||
quantity,
|
|
||||||
canSearch
|
|
||||||
} = useCatalogSearch()
|
|
||||||
|
|
||||||
// Collapsible header for catalog pages
|
|
||||||
const { headerOffset, isCollapsed } = useScrollCollapse(100)
|
|
||||||
|
|
||||||
// Hero scroll for home page
|
|
||||||
const {
|
|
||||||
heroHeight,
|
|
||||||
heroBaseHeight,
|
|
||||||
collapseProgress,
|
|
||||||
isCollapsed: heroIsCollapsed,
|
|
||||||
collapsedHeight
|
|
||||||
} = useHeroScroll()
|
|
||||||
|
|
||||||
// Theme state
|
|
||||||
const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake')
|
|
||||||
|
|
||||||
// User data state (shared across layouts)
|
|
||||||
interface SelectedLocation {
|
|
||||||
type: string
|
|
||||||
uuid: string
|
|
||||||
name: string
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = useState<{
|
|
||||||
id?: string
|
|
||||||
firstName?: string
|
|
||||||
lastName?: string
|
|
||||||
avatarId?: string
|
|
||||||
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: SelectedLocation | null }
|
|
||||||
activeTeamId?: string
|
|
||||||
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string; teamType?: string }>
|
|
||||||
} | null>('me', () => null)
|
|
||||||
|
|
||||||
const sessionChecked = ref(false)
|
|
||||||
const userAvatarSvg = useState('user-avatar-svg', () => '')
|
|
||||||
const lastAvatarSeed = useState('user-avatar-seed', () => '')
|
|
||||||
|
|
||||||
const isSeller = computed(() => {
|
|
||||||
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 userName = computed(() => {
|
|
||||||
return userData.value?.firstName || 'User'
|
|
||||||
})
|
|
||||||
|
|
||||||
const userInitials = computed(() => {
|
|
||||||
const first = userData.value?.firstName?.charAt(0) || ''
|
|
||||||
const last = userData.value?.lastName?.charAt(0) || ''
|
|
||||||
if (first || last) return (first + last).toUpperCase()
|
|
||||||
return '?'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Determine current section from route
|
|
||||||
const currentSection = computed(() => {
|
|
||||||
const path = route.path
|
|
||||||
if (path.startsWith('/catalog') || path === '/') return 'catalog'
|
|
||||||
if (path.includes('/clientarea/offers')) return 'seller'
|
|
||||||
if (path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')) return 'orders'
|
|
||||||
if (path.includes('/clientarea')) return 'settings'
|
|
||||||
return 'catalog'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Home page detection
|
|
||||||
const isHomePage = computed(() => {
|
|
||||||
return route.path === '/' || route.path === '/en' || route.path === '/ru'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Catalog section detection (unified search, no SubNav needed)
|
|
||||||
const isCatalogSection = computed(() => {
|
|
||||||
return route.path.startsWith('/catalog') ||
|
|
||||||
route.path.startsWith('/en/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
|
|
||||||
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value && !isClientArea.value)
|
|
||||||
const canCollapse = computed(() => hasSubNav.value)
|
|
||||||
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
|
|
||||||
|
|
||||||
// Header container style - transform for SubNav pages
|
|
||||||
const headerContainerStyle = computed(() => {
|
|
||||||
if (hasSubNav.value) {
|
|
||||||
// SubNav pages: slide up on scroll
|
|
||||||
return { transform: `translateY(${headerOffset.value}px)` }
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// Main content padding-top to compensate for fixed header
|
|
||||||
const mainStyle = computed(() => {
|
|
||||||
if (isCatalogSection.value) return { paddingTop: '0' }
|
|
||||||
if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` }
|
|
||||||
if (isClientArea.value) return { paddingTop: '116px' } // Header only, no SubNav
|
|
||||||
return { paddingTop: '154px' }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Provide collapsed state to child components (CatalogPage needs it for map positioning)
|
|
||||||
provide('headerCollapsed', isHeaderCollapsed)
|
|
||||||
|
|
||||||
// Avatar generation
|
|
||||||
const generateUserAvatar = async (seed: string) => {
|
|
||||||
if (!seed) return
|
|
||||||
try {
|
|
||||||
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
|
||||||
if (response.ok) {
|
|
||||||
userAvatarSvg.value = await response.text()
|
|
||||||
lastAvatarSeed.value = seed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating avatar:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { setActiveTeam } = useActiveTeam()
|
|
||||||
const { mutate } = useGraphQL()
|
|
||||||
const locationStore = useLocationStore()
|
|
||||||
|
|
||||||
const syncUserUi = async () => {
|
|
||||||
if (!userData.value) {
|
|
||||||
locationStore.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userData.value.activeTeamId && userData.value.activeTeam?.logtoOrgId) {
|
|
||||||
setActiveTeam(userData.value.activeTeamId, userData.value.activeTeam.logtoOrgId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seed = userData.value.avatarId || userData.value.id || userData.value.firstName || 'default'
|
|
||||||
|
|
||||||
if (!userAvatarSvg.value || lastAvatarSeed.value !== seed) {
|
|
||||||
userAvatarSvg.value = ''
|
|
||||||
await generateUserAvatar(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
locationStore.setFromUserData(userData.value.activeTeam?.selectedLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(userData, () => {
|
|
||||||
void syncUserUi()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Check session
|
|
||||||
await fetchSession().catch(() => {})
|
|
||||||
sessionChecked.value = true
|
|
||||||
|
|
||||||
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string; teamType?: string }) => {
|
|
||||||
if (!team?.id) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { SwitchTeamDocument } = await import('~/composables/graphql/user/teams-generated')
|
|
||||||
const result = await mutate(SwitchTeamDocument, { teamId: team.id }, 'user', 'teams')
|
|
||||||
|
|
||||||
if (result.switchTeam?.user && userData.value) {
|
|
||||||
userData.value.activeTeam = team as typeof userData.value.activeTeam
|
|
||||||
userData.value.activeTeamId = team.id
|
|
||||||
if (team.logtoOrgId) {
|
|
||||||
setActiveTeam(team.id, team.logtoOrgId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to switch team:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = () => {
|
|
||||||
signOut(siteUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyTheme = (value: 'cupcake' | 'night') => {
|
|
||||||
if (import.meta.client) {
|
|
||||||
document.documentElement.setAttribute('data-theme', value)
|
|
||||||
localStorage.setItem('theme', value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const stored = import.meta.client ? localStorage.getItem('theme') : null
|
|
||||||
if (stored === 'night' || stored === 'cupcake') {
|
|
||||||
theme.value = stored as 'cupcake' | 'night'
|
|
||||||
}
|
|
||||||
applyTheme(theme.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(theme, (value) => applyTheme(value))
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
theme.value = theme.value === 'night' ? 'cupcake' : 'night'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search handler for Quote mode - triggers search via shared state
|
|
||||||
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
|
|
||||||
const onSearch = () => {
|
|
||||||
// Navigate to catalog page if not there
|
|
||||||
if (!route.path.includes('/catalog')) {
|
|
||||||
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote' } })
|
|
||||||
}
|
|
||||||
// Trigger search by incrementing the counter (page watches this)
|
|
||||||
searchTrigger.value++
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const basePath = stripLocalePrefix(to.path, ['ru', 'en'])
|
||||||
|
|
||||||
// Skip auth routes handled by @logto/nuxt
|
// Skip auth routes handled by @logto/nuxt
|
||||||
if (to.path === '/sign-in' || to.path === '/sign-out' || to.path === '/callback') {
|
if (basePath === '/sign-in' || basePath === '/sign-out' || basePath === '/callback') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip public auth paths
|
// Skip public auth paths
|
||||||
if (to.path.startsWith('/auth/')) {
|
if (basePath.startsWith('/auth/')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { loggedIn } = useAuth()
|
const { loggedIn } = useAuth()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const logtoUser = useState<Record<string, unknown> | null>('logto-user', () => null)
|
||||||
|
|
||||||
if (!loggedIn.value) {
|
if (!loggedIn.value && !logtoUser.value) {
|
||||||
return navigateTo('/sign-in')
|
return navigateTo(localePath('/sign-in'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
154
app/pages/catalog/destination.vue
Normal file
154
app/pages/catalog/destination.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<CatalogMap
|
||||||
|
map-id="step-hub-map"
|
||||||
|
:items="hubMapItems"
|
||||||
|
:use-server-clustering="false"
|
||||||
|
point-color="#22c55e"
|
||||||
|
entity-type="hub"
|
||||||
|
:fit-padding-left="460"
|
||||||
|
@select-item="onMapSelect"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<MapSidePanel
|
||||||
|
:title="t('catalog.steps.selectDestination')"
|
||||||
|
:initial-collapsed="false"
|
||||||
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 2 }) }}</p>
|
||||||
|
|
||||||
|
<div v-if="productName" class="inline-flex items-center gap-2 rounded-full border border-[#ded3c2] bg-white px-3 py-1.5 text-xs font-medium text-[#6f6353]">
|
||||||
|
<Icon name="lucide:package" size="14" />
|
||||||
|
<span class="truncate">{{ productName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="input w-full rounded-full bg-white border-[#dccfbf] flex items-center gap-2">
|
||||||
|
<Icon name="lucide:search" size="16" class="text-[#8a7761]" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('catalog.search.searchHubs')"
|
||||||
|
class="grow bg-transparent"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<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="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
|
<Icon name="lucide:warehouse" size="30" class="mx-auto mb-2" />
|
||||||
|
<p>{{ $t('catalog.empty.noHubs') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="hub in filteredHubs"
|
||||||
|
v-else
|
||||||
|
:key="hub.uuid"
|
||||||
|
class="flex items-center gap-4 rounded-2xl border border-[#e4d9ca] bg-white p-4 text-left transition-colors hover:bg-[#f8f3ec]"
|
||||||
|
@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="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-base font-bold text-[#2f2418]">{{ hub.name || hub.uuid }}</span>
|
||||||
|
<span v-if="hub.country" class="text-sm text-[#7a6d5d]">{{ hub.country }}</span>
|
||||||
|
</div>
|
||||||
|
<Icon name="lucide:chevron-right" size="18" class="text-[#8a7761]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm rounded-full border-[#d7c9b7] bg-white text-[#2f2418] hover:bg-[#f7f1e8]"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:arrow-left" size="14" />
|
||||||
|
{{ $t('common.back') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</MapSidePanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ layout: 'topnav' })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const productUuid = computed(() => route.query.product as string | undefined)
|
||||||
|
const productName = computed(() => route.query.productName as string | undefined)
|
||||||
|
|
||||||
|
const { items: hubs, isLoading, init: initHubs, setProductFilter } = useCatalogHubs()
|
||||||
|
|
||||||
|
const hubMapItems = computed(() =>
|
||||||
|
hubs.value
|
||||||
|
.filter(h => h.latitude != null && h.longitude != null && h.uuid)
|
||||||
|
.map(h => ({
|
||||||
|
uuid: h.uuid!,
|
||||||
|
name: h.name || '',
|
||||||
|
latitude: Number(h.latitude),
|
||||||
|
longitude: Number(h.longitude),
|
||||||
|
country: h.country || undefined,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMapSelect = (uuid: string) => {
|
||||||
|
const hub = hubs.value.find(h => h.uuid === uuid)
|
||||||
|
if (hub) selectHub(hub)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/product'),
|
||||||
|
query: route.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (productUuid.value) {
|
||||||
|
setProductFilter(productUuid.value)
|
||||||
|
}
|
||||||
|
initHubs()
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: t('catalog.steps.selectDestination')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetNodeDocument, NearestOffersDocument, type OfferWithRouteType, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
|
import { GetNodeDocument, NearestOffersDocument, type OfferWithRoute, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
type Hub = NonNullable<GetNodeQueryResult['node']>
|
type Hub = NonNullable<GetNodeQueryResult['node']>
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,29 @@
|
|||||||
<CatalogPage
|
<CatalogPage
|
||||||
ref="catalogPageRef"
|
ref="catalogPageRef"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
:use-server-clustering="true"
|
:use-server-clustering="useServerClustering"
|
||||||
:use-typed-clusters="true"
|
:use-typed-clusters="useServerClustering"
|
||||||
:cluster-node-type="clusterNodeType"
|
:cluster-node-type="clusterNodeType"
|
||||||
|
panel-width="w-[32rem]"
|
||||||
map-id="unified-catalog-map"
|
map-id="unified-catalog-map"
|
||||||
:point-color="mapPointColor"
|
:point-color="mapPointColor"
|
||||||
:items="currentSelectionItems"
|
:items="mapItems"
|
||||||
:hovered-id="hoveredItemId ?? undefined"
|
:hovered-id="hoveredItemId ?? undefined"
|
||||||
:show-panel="showPanel && !kycSheetUuid"
|
:show-panel="showPanel && !kycSheetUuid"
|
||||||
:filter-by-bounds="filterByBounds"
|
:filter-by-bounds="filterByBounds"
|
||||||
:related-points="relatedPoints"
|
:related-points="relatedPoints"
|
||||||
:info-loading="isInfoLoading"
|
:info-loading="mapInfoLoading"
|
||||||
|
:force-info-mode="forceInfoMode"
|
||||||
|
:hide-view-toggle="hideViewToggle"
|
||||||
|
:show-offers-toggle="showOffersToggle"
|
||||||
|
:show-hubs-toggle="showHubsToggle"
|
||||||
|
:show-suppliers-toggle="showSuppliersToggle"
|
||||||
|
:cluster-product-uuid="clusterProductUuid"
|
||||||
|
:cluster-hub-uuid="clusterHubUuid"
|
||||||
|
:cluster-supplier-uuid="clusterSupplierUuid"
|
||||||
@select="onMapSelect"
|
@select="onMapSelect"
|
||||||
@bounds-change="onBoundsChange"
|
@bounds-change="onBoundsChange"
|
||||||
@update:filter-by-bounds="$event ? setBoundsInUrl(currentMapBounds) : clearBoundsFromUrl()"
|
@update:filter-by-bounds="onToggleBoundsFilter"
|
||||||
>
|
>
|
||||||
<!-- Panel slot - shows selection list OR info OR quote results -->
|
<!-- Panel slot - shows selection list OR info OR quote results -->
|
||||||
<template #panel>
|
<template #panel>
|
||||||
@@ -31,6 +40,7 @@
|
|||||||
:loading-more="selectionLoadingMore"
|
:loading-more="selectionLoadingMore"
|
||||||
:has-more="selectionHasMore && !filterByBounds"
|
:has-more="selectionHasMore && !filterByBounds"
|
||||||
@select="onSelectItem"
|
@select="onSelectItem"
|
||||||
|
@pin="onPinItem"
|
||||||
@close="onClosePanel"
|
@close="onClosePanel"
|
||||||
@load-more="onLoadMore"
|
@load-more="onLoadMore"
|
||||||
@hover="onHoverItem"
|
@hover="onHoverItem"
|
||||||
@@ -53,11 +63,11 @@
|
|||||||
: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"
|
@select-offer="onSelectOffer"
|
||||||
@open-kyc="onOpenKyc"
|
@open-kyc="onOpenKyc"
|
||||||
|
@pin="onPinItem"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Quote results: show offers after search -->
|
<!-- Quote results: show offers after search -->
|
||||||
@@ -65,6 +75,7 @@
|
|||||||
v-else-if="showQuoteResults"
|
v-else-if="showQuoteResults"
|
||||||
:loading="offersLoading"
|
:loading="offersLoading"
|
||||||
:offers="offers"
|
:offers="offers"
|
||||||
|
:calculations="quoteCalculations"
|
||||||
@select-offer="onSelectOffer"
|
@select-offer="onSelectOffer"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,11 +91,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GetOffersDocument, GetOfferDocument, type GetOffersQueryVariables, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
|
import { GetOffersDocument, type GetOffersQueryVariables } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
|
||||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||||
|
|
||||||
// Offer type from search results
|
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
|
||||||
type OfferResult = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
@@ -126,6 +137,7 @@ const toMapItems = <T extends { uuid?: string | null; name?: string | null; lati
|
|||||||
|
|
||||||
// Current selection items for hover highlighting on map
|
// Current selection items for hover highlighting on map
|
||||||
const currentSelectionItems = computed((): MapItemWithCoords[] => {
|
const currentSelectionItems = computed((): MapItemWithCoords[] => {
|
||||||
|
if (showQuoteResults.value) return []
|
||||||
if (selectMode.value === 'product') return [] // Products don't have coordinates
|
if (selectMode.value === 'product') return [] // Products don't have coordinates
|
||||||
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
|
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
|
||||||
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
|
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
|
||||||
@@ -162,7 +174,8 @@ const {
|
|||||||
urlBounds,
|
urlBounds,
|
||||||
filterByBounds,
|
filterByBounds,
|
||||||
setBoundsInUrl,
|
setBoundsInUrl,
|
||||||
clearBoundsFromUrl
|
clearBoundsFromUrl,
|
||||||
|
setBoundsFilterEnabled
|
||||||
} = useCatalogSearch()
|
} = useCatalogSearch()
|
||||||
|
|
||||||
// Info panel composable
|
// Info panel composable
|
||||||
@@ -242,7 +255,20 @@ const getSelectionBounds = () => {
|
|||||||
return { west: bounds.west, south: bounds.south, east: bounds.east, north: bounds.north }
|
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 = () => {
|
const applySelectionBounds = () => {
|
||||||
|
if (!filterByBounds.value) return
|
||||||
if (!selectionBoundsBackup.value) {
|
if (!selectionBoundsBackup.value) {
|
||||||
selectionBoundsBackup.value = {
|
selectionBoundsBackup.value = {
|
||||||
hadBounds: !!urlBounds.value,
|
hadBounds: !!urlBounds.value,
|
||||||
@@ -304,6 +330,16 @@ 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
|
||||||
// Only watch URL bounds - currentMapBounds changes too often (every map move)
|
// Only watch URL bounds - currentMapBounds changes too often (every map move)
|
||||||
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
|
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
|
||||||
@@ -335,7 +371,7 @@ watch(infoProduct, async (productUuid) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Related points for Info mode (shown on map) - show current entity + 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<{
|
||||||
@@ -389,8 +425,56 @@ 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<OfferResult[]>([])
|
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)
|
||||||
|
|
||||||
@@ -404,13 +488,99 @@ 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)
|
// Info loading state for map fitBounds (true while any info data is still loading)
|
||||||
const isInfoLoading = computed(() =>
|
const isInfoLoading = computed(() =>
|
||||||
infoLoading.value || isLoadingProducts.value || isLoadingHubs.value || isLoadingSuppliers.value || isLoadingOffers.value
|
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(() => {
|
||||||
return selectMode.value !== null || infoId.value !== null || showQuoteResults.value
|
return selectMode.value !== null || infoId.value !== null || showQuoteResults.value
|
||||||
@@ -440,7 +610,7 @@ interface MapSelectItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle map item selection
|
// Handle map item selection
|
||||||
const onMapSelect = async (item: MapSelectItem) => {
|
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
|
||||||
@@ -449,39 +619,7 @@ const onMapSelect = async (item: MapSelectItem) => {
|
|||||||
|
|
||||||
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
|
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
|
||||||
|
|
||||||
// If in selection mode, use map click to fill the selector
|
// Default behavior - open Info directly
|
||||||
if (selectMode.value) {
|
|
||||||
// For hubs selection - click on hub fills hub selector
|
|
||||||
if (selectMode.value === 'hub' && (itemType === 'hub' || mapViewMode.value === 'hubs')) {
|
|
||||||
selectItem('hub', itemId, itemName)
|
|
||||||
showQuoteResults.value = false
|
|
||||||
offers.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For supplier selection - click on supplier fills supplier selector
|
|
||||||
if (selectMode.value === 'supplier' && (itemType === 'supplier' || mapViewMode.value === 'suppliers')) {
|
|
||||||
selectItem('supplier', itemId, itemName)
|
|
||||||
showQuoteResults.value = false
|
|
||||||
offers.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For product selection viewing offers - fetch offer to get productUuid
|
|
||||||
if (selectMode.value === 'product' && (itemType === 'offer' || mapViewMode.value === 'offers')) {
|
|
||||||
// Fetch offer details to get productUuid (not available in cluster data)
|
|
||||||
const data = await execute(GetOfferDocument, { uuid: itemId }, 'public', 'exchange')
|
|
||||||
const offer = data?.getOffer
|
|
||||||
if (offer?.productUuid) {
|
|
||||||
selectItem('product', offer.productUuid, offer.productName || itemName)
|
|
||||||
showQuoteResults.value = false
|
|
||||||
offers.value = []
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Default behavior - open Info directly
|
|
||||||
let infoType: 'hub' | 'supplier' | 'offer'
|
let infoType: 'hub' | 'supplier' | 'offer'
|
||||||
if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
|
if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
|
||||||
else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
|
else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
|
||||||
@@ -491,11 +629,25 @@ const onMapSelect = async (item: MapSelectItem) => {
|
|||||||
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: { uuid?: string | null; name?: string | null }) => {
|
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)
|
||||||
@@ -509,30 +661,6 @@ const onInfoClose = () => {
|
|||||||
clearInfo()
|
clearInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onInfoAddToFilter = () => {
|
|
||||||
if (!infoId.value || !entity.value) return
|
|
||||||
const { type, uuid } = infoId.value
|
|
||||||
|
|
||||||
// For offers, add the product AND hub to filter
|
|
||||||
if (type === 'offer') {
|
|
||||||
if (entity.value.productUuid) {
|
|
||||||
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
|
|
||||||
selectItem('product', entity.value.productUuid, productName)
|
|
||||||
}
|
|
||||||
// Also add hub (location) to filter if available
|
|
||||||
if (entity.value.locationUuid) {
|
|
||||||
const hubName = entity.value.locationName || entity.value.locationUuid.slice(0, 8) + '...'
|
|
||||||
selectItem('hub', entity.value.locationUuid, hubName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For hubs and suppliers, add directly
|
|
||||||
const name = entity.value.name || uuid.slice(0, 8) + '...'
|
|
||||||
selectItem(type, uuid, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeInfo()
|
|
||||||
clearInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
|
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
|
||||||
openInfo(type, uuid)
|
openInfo(type, uuid)
|
||||||
@@ -563,26 +691,82 @@ const onSearch = async () => {
|
|||||||
|
|
||||||
offersLoading.value = true
|
offersLoading.value = true
|
||||||
showQuoteResults.value = true
|
showQuoteResults.value = true
|
||||||
|
searchHubPoint.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const vars: GetOffersQueryVariables = {}
|
// 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 || []).filter((o): o is OfferResult => o !== null)
|
if (supplierId.value) {
|
||||||
|
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Update labels from response
|
offers.value = nearest
|
||||||
const first = offers.value[0]
|
quoteCalculations.value = buildCalculationsFromOffers(nearest)
|
||||||
if (first) {
|
|
||||||
if (productId.value && first.productName) {
|
const first = offers.value[0]
|
||||||
setLabel('product', productId.value, first.productName)
|
if (first?.productName) {
|
||||||
|
setLabel('product', productId.value, first.productName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offers.value = []
|
||||||
|
quoteCalculations.value = []
|
||||||
}
|
}
|
||||||
if (hubId.value && first.locationName) {
|
} else {
|
||||||
setLabel('hub', hubId.value, first.locationName)
|
searchHubPoint.value = null
|
||||||
|
const vars: GetOffersQueryVariables = {}
|
||||||
|
if (productId.value) vars.productUuid = productId.value
|
||||||
|
if (supplierId.value) vars.teamUuid = supplierId.value
|
||||||
|
if (hubId.value) vars.locationUuid = hubId.value
|
||||||
|
|
||||||
|
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
|
||||||
|
const exchangeOffers = (data?.getOffers || []).filter((o): o is NonNullable<typeof o> => o !== null)
|
||||||
|
offers.value = exchangeOffers.map((offer) => ({
|
||||||
|
uuid: offer.uuid,
|
||||||
|
productUuid: offer.productUuid,
|
||||||
|
productName: offer.productName,
|
||||||
|
teamUuid: offer.teamUuid,
|
||||||
|
quantity: offer.quantity,
|
||||||
|
unit: offer.unit,
|
||||||
|
pricePerUnit: offer.pricePerUnit,
|
||||||
|
currency: offer.currency,
|
||||||
|
locationName: offer.locationName,
|
||||||
|
locationCountry: offer.locationCountry
|
||||||
|
}))
|
||||||
|
quoteCalculations.value = buildCalculationsFromOffers(offers.value)
|
||||||
|
|
||||||
|
// Update labels from response
|
||||||
|
const first = offers.value[0]
|
||||||
|
if (first) {
|
||||||
|
if (productId.value && first.productName) {
|
||||||
|
setLabel('product', productId.value, first.productName)
|
||||||
|
}
|
||||||
|
if (hubId.value && first.locationName) {
|
||||||
|
setLabel('hub', hubId.value, first.locationName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Note: teamName not included in GetOffers query, supplier label cannot be updated from offer
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
offersLoading.value = false
|
offersLoading.value = false
|
||||||
@@ -591,8 +775,9 @@ const onSearch = async () => {
|
|||||||
|
|
||||||
// Select offer - navigate to detail page
|
// Select offer - navigate to detail page
|
||||||
const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
|
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}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
app/pages/catalog/product.vue
Normal file
114
app/pages/catalog/product.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<CatalogMap
|
||||||
|
map-id="step-product-map"
|
||||||
|
:items="[]"
|
||||||
|
:clustered-points="clusteredNodes"
|
||||||
|
:use-server-clustering="true"
|
||||||
|
point-color="#f97316"
|
||||||
|
entity-type="offer"
|
||||||
|
:fit-padding-left="460"
|
||||||
|
@bounds-change="onBoundsChange"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<MapSidePanel
|
||||||
|
:title="t('catalog.steps.selectProduct')"
|
||||||
|
:initial-collapsed="false"
|
||||||
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 1 }) }}</p>
|
||||||
|
|
||||||
|
<label class="input w-full rounded-full bg-white border-[#dccfbf] flex items-center gap-2">
|
||||||
|
<Icon name="lucide:search" size="16" class="text-[#8a7761]" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('catalog.search.searchProducts')"
|
||||||
|
class="grow bg-transparent"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<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="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
|
<Icon name="lucide:package-x" size="30" class="mx-auto mb-2" />
|
||||||
|
<p>{{ $t('catalog.empty.noProducts') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="product in filteredProducts"
|
||||||
|
v-else
|
||||||
|
:key="product.uuid"
|
||||||
|
class="flex items-center gap-4 rounded-2xl border border-[#e4d9ca] bg-white p-4 text-left transition-colors hover:bg-[#f8f3ec]"
|
||||||
|
@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="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-base font-bold text-[#2f2418]">{{ product.name || product.uuid }}</span>
|
||||||
|
<span v-if="product.offersCount" class="text-sm text-[#7a6d5d]">{{ product.offersCount }} {{ $t('catalog.offers') }}</span>
|
||||||
|
</div>
|
||||||
|
<Icon name="lucide:chevron-right" size="18" class="text-[#8a7761]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MapSidePanel>
|
||||||
|
</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 searchQuery = ref('')
|
||||||
|
|
||||||
|
const { items: products, isLoading, init: initProducts } = useCatalogProducts()
|
||||||
|
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, ref('offer'))
|
||||||
|
|
||||||
|
const onBoundsChange = (bounds: MapBounds) => {
|
||||||
|
fetchClusters(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
151
app/pages/catalog/quantity.vue
Normal file
151
app/pages/catalog/quantity.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<CatalogMap
|
||||||
|
map-id="step-quantity-map"
|
||||||
|
:items="mapPoints"
|
||||||
|
:use-server-clustering="false"
|
||||||
|
point-color="#22c55e"
|
||||||
|
entity-type="hub"
|
||||||
|
:related-points="relatedPoints"
|
||||||
|
:info-loading="false"
|
||||||
|
:fit-padding-left="460"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<MapSidePanel
|
||||||
|
:title="t('catalog.steps.setQuantity')"
|
||||||
|
:initial-collapsed="false"
|
||||||
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 3 }) }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div v-if="productName" class="inline-flex items-center gap-2 rounded-full border border-[#ded3c2] bg-white px-3 py-1.5 text-xs font-medium text-[#6f6353]">
|
||||||
|
<Icon name="lucide:package" size="14" />
|
||||||
|
<span class="truncate">{{ productName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hubName" class="inline-flex items-center gap-2 rounded-full border border-[#ded3c2] bg-white px-3 py-1.5 text-xs font-medium text-[#6f6353]">
|
||||||
|
<Icon name="lucide:warehouse" size="14" />
|
||||||
|
<span class="truncate">{{ hubName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-semibold text-[#4a3b2a]">{{ $t('catalog.filters.quantity') }}</label>
|
||||||
|
<label class="input w-full rounded-2xl bg-white border-[#dccfbf] 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-[#8a7761] text-sm">{{ $t('units.t') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn w-full rounded-full border-0 bg-[#10223b] text-white hover:bg-[#1b3552]"
|
||||||
|
:disabled="!canSearch"
|
||||||
|
@click="goSearch"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:search" size="18" />
|
||||||
|
{{ $t('catalog.quote.findOffers') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm rounded-full border-[#d7c9b7] bg-white text-[#2f2418] hover:bg-[#f7f1e8]"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:arrow-left" size="14" />
|
||||||
|
{{ $t('common.back') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</MapSidePanel>
|
||||||
|
</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 qty = ref(typeof route.query.qty === 'string' ? route.query.qty : '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 && Number(qty.value) > 0))
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/destination'),
|
||||||
|
query: route.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadHubPoint()
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: t('catalog.steps.setQuantity')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
226
app/pages/catalog/results.vue
Normal file
226
app/pages/catalog/results.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<CatalogMap
|
||||||
|
map-id="step-results-map"
|
||||||
|
:items="mapItems"
|
||||||
|
:use-server-clustering="false"
|
||||||
|
point-color="#f97316"
|
||||||
|
entity-type="offer"
|
||||||
|
:related-points="relatedPoints"
|
||||||
|
:info-loading="offersLoading"
|
||||||
|
:fit-padding-left="460"
|
||||||
|
@select-item="onMapSelect"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<MapSidePanel
|
||||||
|
:title="t('catalog.headers.offers')"
|
||||||
|
:initial-collapsed="false"
|
||||||
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.steps.results') }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span v-if="productName" class="inline-flex items-center gap-1.5 rounded-full border border-[#ded3c2] bg-white px-3 py-1 text-xs font-medium text-[#6f6353]">
|
||||||
|
<Icon name="lucide:package" size="12" />
|
||||||
|
{{ productName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hubName" class="inline-flex items-center gap-1.5 rounded-full border border-[#ded3c2] bg-white px-3 py-1 text-xs font-medium text-[#6f6353]">
|
||||||
|
<Icon name="lucide:warehouse" size="12" />
|
||||||
|
{{ hubName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="qty" class="inline-flex items-center gap-1.5 rounded-full border border-[#ded3c2] bg-white px-3 py-1 text-xs font-medium text-[#6f6353]">
|
||||||
|
{{ qty }} {{ $t('units.t') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!offersLoading" class="badge badge-neutral ml-auto">{{ offers.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-3">
|
||||||
|
<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="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
|
<Icon name="lucide:search-x" size="30" class="mx-auto mb-2" />
|
||||||
|
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
v-for="offer in offers"
|
||||||
|
:key="offer.uuid"
|
||||||
|
class="text-left"
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn btn-sm rounded-full border-[#d7c9b7] bg-white text-[#2f2418] hover:bg-[#f7f1e8]" @click="goBack">
|
||||||
|
<Icon name="lucide:refresh-cw" size="14" />
|
||||||
|
{{ $t('catalog.steps.newSearch') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MapSidePanel>
|
||||||
|
</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 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)
|
||||||
|
|
||||||
|
const offers = ref<NearestOffer[]>([])
|
||||||
|
const offersLoading = ref(false)
|
||||||
|
|
||||||
|
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
||||||
|
|
||||||
|
const mapItems = computed(() =>
|
||||||
|
offers.value
|
||||||
|
.filter(o => o.uuid && o.latitude != null && o.longitude != null)
|
||||||
|
.map(o => ({
|
||||||
|
uuid: o.uuid,
|
||||||
|
name: o.productName || '',
|
||||||
|
latitude: Number(o.latitude),
|
||||||
|
longitude: Number(o.longitude),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const relatedPoints = computed(() => {
|
||||||
|
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number; type: 'hub' | '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.uuid && 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 selectedProductUuid = offer.productUuid
|
||||||
|
if (offer.uuid && selectedProductUuid) {
|
||||||
|
router.push(localePath(`/catalog/offers/${selectedProductUuid}?offer=${offer.uuid}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferStages = (offer: NearestOffer) => {
|
||||||
|
const firstRoute = offer.routes?.[0]
|
||||||
|
if (!firstRoute?.stages) return []
|
||||||
|
|
||||||
|
return firstRoute.stages
|
||||||
|
.filter((stage): stage is NonNullable<typeof stage> => stage !== null)
|
||||||
|
.map(stage => ({
|
||||||
|
transportType: stage.transportType,
|
||||||
|
distanceKm: stage.distanceKm,
|
||||||
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/product'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchOffers = async () => {
|
||||||
|
if (!productUuid.value || !hubUuid.value) return
|
||||||
|
|
||||||
|
offersLoading.value = true
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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((offer): offer is NearestOffer => offer !== null)
|
||||||
|
} finally {
|
||||||
|
offersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
searchOffers()
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: t('catalog.steps.results')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -21,16 +21,21 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
|
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div ref="searchBoxContainer" class="w-full" />
|
<div
|
||||||
<input
|
v-if="hasMapboxToken"
|
||||||
v-if="addressData.address"
|
ref="searchBoxContainer"
|
||||||
type="hidden"
|
class="w-full"
|
||||||
:value="addressData.address"
|
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model="addressData.address"
|
||||||
|
:placeholder="t('profileAddresses.form.address.placeholder')"
|
||||||
|
/>
|
||||||
|
<input v-if="addressData.address && hasMapboxToken" type="hidden" :value="addressData.address" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mapbox map for selecting coordinates -->
|
<!-- Mapbox map for selecting coordinates -->
|
||||||
<div class="form-control">
|
<div v-if="hasMapboxToken" class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
|
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -61,9 +66,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="alert alert-warning text-sm">
|
||||||
|
Mapbox is not configured. Enter address manually.
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack direction="row" gap="3">
|
<Stack direction="row" gap="3">
|
||||||
<Button @click="updateAddress" :disabled="isSaving || !addressData.latitude">
|
<Button @click="updateAddress" :disabled="isSaving || (hasMapboxToken && !addressData.latitude)">
|
||||||
{{ isSaving ? t('profileAddresses.form.updating') : t('profileAddresses.form.update') }}
|
{{ isSaving ? t('profileAddresses.form.updating') : t('profileAddresses.form.update') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
|
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
|
||||||
@@ -108,6 +116,8 @@ const { execute, mutate } = useGraphQL()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const mapboxAccessToken = computed(() => String(config.public.mapboxAccessToken || '').trim())
|
||||||
|
const hasMapboxToken = computed(() => mapboxAccessToken.value.length > 0)
|
||||||
|
|
||||||
const uuid = computed(() => route.params.uuid as string)
|
const uuid = computed(() => route.params.uuid as string)
|
||||||
|
|
||||||
@@ -161,10 +171,13 @@ const onMapCreated = (map: MapboxMapType) => {
|
|||||||
|
|
||||||
// Reverse geocode: get address by coordinates (local language)
|
// Reverse geocode: get address by coordinates (local language)
|
||||||
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
|
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
|
||||||
|
if (!hasMapboxToken.value) {
|
||||||
|
return { address: null, countryCode: null }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = config.public.mapboxAccessToken
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}`
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxAccessToken.value}`
|
||||||
)
|
)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const feature = data.features?.[0]
|
const feature = data.features?.[0]
|
||||||
@@ -203,12 +216,12 @@ const onMapClick = async (event: MapMouseEvent) => {
|
|||||||
|
|
||||||
// Initialize Mapbox SearchBox
|
// Initialize Mapbox SearchBox
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!searchBoxContainer.value) return
|
if (!hasMapboxToken.value || !searchBoxContainer.value) return
|
||||||
|
|
||||||
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
|
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
|
||||||
|
|
||||||
const searchBox = new MapboxSearchBox()
|
const searchBox = new MapboxSearchBox()
|
||||||
searchBox.accessToken = config.public.mapboxAccessToken as string
|
searchBox.accessToken = mapboxAccessToken.value
|
||||||
searchBox.options = {
|
searchBox.options = {
|
||||||
// Without language: uses local country language
|
// Without language: uses local country language
|
||||||
}
|
}
|
||||||
@@ -248,7 +261,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const updateAddress = async () => {
|
const updateAddress = async () => {
|
||||||
if (!addressData.value || !addressData.value.name || !addressData.value.address || !addressData.value.latitude) return
|
if (!addressData.value || !addressData.value.name || !addressData.value.address || (hasMapboxToken.value && !addressData.value.latitude)) return
|
||||||
|
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
>
|
>
|
||||||
<template #panel>
|
<template #panel>
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
<div class="p-4 border-b border-base-300 flex-shrink-0">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
|
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('common.search')"
|
:placeholder="t('common.search')"
|
||||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
class="input input-sm w-full bg-base-200 border-base-300 text-base-content placeholder:text-base-content/50"
|
||||||
/>
|
/>
|
||||||
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add button -->
|
<!-- Add button -->
|
||||||
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
<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">
|
<button class="btn btn-sm w-full bg-base-200 border-base-300 text-base-content hover:bg-base-200">
|
||||||
<Icon name="lucide:plus" size="14" class="mr-1" />
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
{{ t('profileAddresses.actions.add') }}
|
{{ t('profileAddresses.actions.add') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in displayItems"
|
v-for="item in displayItems"
|
||||||
:key="item.uuid"
|
:key="item.uuid"
|
||||||
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
class="bg-base-200 rounded-lg p-3 hover:bg-base-200 transition-colors cursor-pointer"
|
||||||
:class="{ 'ring-2 ring-emerald-500': selectedAddressId === item.uuid }"
|
:class="{ 'ring-2 ring-emerald-500': selectedAddressId === item.uuid }"
|
||||||
@click="selectedAddressId = item.uuid"
|
@click="selectedAddressId = item.uuid"
|
||||||
@mouseenter="hoveredAddressId = item.uuid"
|
@mouseenter="hoveredAddressId = item.uuid"
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<span class="text-xl">{{ isoToEmoji(item.countryCode) }}</span>
|
<span class="text-xl">{{ isoToEmoji(item.countryCode) }}</span>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-semibold text-sm truncate">{{ item.name }}</div>
|
<div class="font-semibold text-sm truncate">{{ item.name }}</div>
|
||||||
<div class="text-xs text-white/60 line-clamp-2">{{ item.address }}</div>
|
<div class="text-xs text-base-content/60 line-clamp-2">{{ item.address }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,9 +65,9 @@
|
|||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<div class="text-3xl mb-2">📍</div>
|
<div class="text-3xl mb-2">📍</div>
|
||||||
<div class="font-semibold text-sm mb-1">{{ t('profileAddresses.empty.title') }}</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>
|
<div class="text-xs text-base-content/60 mb-3">{{ t('profileAddresses.empty.description') }}</div>
|
||||||
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
||||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
<button class="btn btn-sm bg-base-200 border-base-300 text-base-content hover:bg-base-200">
|
||||||
<Icon name="lucide:plus" size="14" class="mr-1" />
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
{{ t('profileAddresses.empty.cta') }}
|
{{ t('profileAddresses.empty.cta') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
<div class="p-3 border-t border-base-300 flex-shrink-0">
|
||||||
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
|
<span class="text-xs text-base-content/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CatalogPage>
|
</CatalogPage>
|
||||||
|
|||||||
@@ -14,16 +14,21 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
|
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div ref="searchBoxContainer" class="w-full" />
|
<div
|
||||||
<input
|
v-if="hasMapboxToken"
|
||||||
v-if="newAddress.address"
|
ref="searchBoxContainer"
|
||||||
type="hidden"
|
class="w-full"
|
||||||
:value="newAddress.address"
|
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model="newAddress.address"
|
||||||
|
:placeholder="t('profileAddresses.form.address.placeholder')"
|
||||||
|
/>
|
||||||
|
<input v-if="newAddress.address && hasMapboxToken" type="hidden" :value="newAddress.address" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mapbox map for selecting coordinates -->
|
<!-- Mapbox map for selecting coordinates -->
|
||||||
<div class="form-control">
|
<div v-if="hasMapboxToken" class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
|
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -54,9 +59,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="alert alert-warning text-sm">
|
||||||
|
Mapbox is not configured. Enter address manually.
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack direction="row" gap="3">
|
<Stack direction="row" gap="3">
|
||||||
<Button @click="createAddress" :disabled="isCreating || !newAddress.latitude">
|
<Button @click="createAddress" :disabled="isCreating || (hasMapboxToken && !newAddress.latitude)">
|
||||||
{{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }}
|
{{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
|
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
|
||||||
@@ -84,6 +92,8 @@ const { mutate } = useGraphQL()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const mapboxAccessToken = computed(() => String(config.public.mapboxAccessToken || '').trim())
|
||||||
|
const hasMapboxToken = computed(() => mapboxAccessToken.value.length > 0)
|
||||||
|
|
||||||
const isCreating = ref(false)
|
const isCreating = ref(false)
|
||||||
const searchBoxContainer = ref<HTMLElement | null>(null)
|
const searchBoxContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -104,10 +114,13 @@ const onMapCreated = (map: MapboxMapType) => {
|
|||||||
|
|
||||||
// Reverse geocode: get address by coordinates (local language)
|
// Reverse geocode: get address by coordinates (local language)
|
||||||
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
|
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
|
||||||
|
if (!hasMapboxToken.value) {
|
||||||
|
return { address: null, countryCode: null }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = config.public.mapboxAccessToken
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}`
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxAccessToken.value}`
|
||||||
)
|
)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const feature = data.features?.[0]
|
const feature = data.features?.[0]
|
||||||
@@ -144,12 +157,12 @@ const onMapClick = async (event: MapMouseEvent) => {
|
|||||||
|
|
||||||
// Initialize Mapbox SearchBox
|
// Initialize Mapbox SearchBox
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!searchBoxContainer.value) return
|
if (!hasMapboxToken.value || !searchBoxContainer.value) return
|
||||||
|
|
||||||
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
|
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
|
||||||
|
|
||||||
const searchBox = new MapboxSearchBox()
|
const searchBox = new MapboxSearchBox()
|
||||||
searchBox.accessToken = config.public.mapboxAccessToken as string
|
searchBox.accessToken = mapboxAccessToken.value
|
||||||
searchBox.options = {
|
searchBox.options = {
|
||||||
// Without language: uses local country language
|
// Without language: uses local country language
|
||||||
}
|
}
|
||||||
@@ -182,7 +195,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createAddress = async () => {
|
const createAddress = async () => {
|
||||||
if (!newAddress.name || !newAddress.address || !newAddress.latitude) return
|
if (!newAddress.name || !newAddress.address || (hasMapboxToken.value && !newAddress.latitude)) return
|
||||||
|
|
||||||
isCreating.value = true
|
isCreating.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ definePageMeta({
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
await navigateTo(localePath('/'))
|
await navigateTo(localePath('/clientarea/orders'))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,27 +12,28 @@
|
|||||||
|
|
||||||
<!-- Bottom Sheet with slide-up animation -->
|
<!-- Bottom Sheet with slide-up animation -->
|
||||||
<Transition name="slide-up" appear>
|
<Transition name="slide-up" appear>
|
||||||
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
<div class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4" style="height: 72vh">
|
||||||
<!-- Glass sheet -->
|
<div class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent" />
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<!-- Sheet -->
|
||||||
|
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-base-300 bg-base-100 shadow-[0_-24px_70px_rgba(15,23,42,0.3)]">
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 pb-4 border-b border-white/10">
|
<div class="border-b border-base-300 bg-base-100 px-6 pb-4">
|
||||||
<!-- Back button -->
|
<!-- Back button -->
|
||||||
<NuxtLink :to="localePath('/clientarea/orders')" class="inline-flex items-center gap-1 text-white/60 hover:text-white text-sm mb-3">
|
<NuxtLink :to="localePath('/clientarea/orders')" class="mb-3 inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content">
|
||||||
<Icon name="lucide:arrow-left" size="16" />
|
<Icon name="lucide:arrow-left" size="16" />
|
||||||
{{ t('common.back') }}
|
{{ t('common.back') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<template v-if="hasOrderError">
|
<template v-if="hasOrderError">
|
||||||
<div class="bg-error/20 border border-error/30 rounded-lg p-4">
|
<div class="rounded-lg border border-error/30 bg-error/10 p-4">
|
||||||
<div class="font-semibold text-white mb-2">{{ t('common.error') }}</div>
|
<div class="mb-2 font-black text-base-content">{{ t('common.error') }}</div>
|
||||||
<div class="text-sm text-white/70 mb-3">{{ orderError }}</div>
|
<div class="mb-3 text-sm text-base-content/70">{{ orderError }}</div>
|
||||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
<button class="btn btn-sm btn-outline" @click="loadOrder">
|
||||||
{{ t('ordersDetail.errors.retry') }}
|
{{ t('ordersDetail.errors.retry') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,13 +41,13 @@
|
|||||||
|
|
||||||
<template v-else-if="!isLoadingOrder && order">
|
<template v-else-if="!isLoadingOrder && order">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center">
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15">
|
||||||
<Icon name="lucide:package" size="24" class="text-indigo-400" />
|
<Icon name="lucide:package" size="24" class="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-bold text-lg text-white truncate">{{ orderTitle }}</div>
|
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-white/50">
|
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-base-content/55">
|
||||||
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
|
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,18 +57,18 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="h-12 bg-white/10 rounded-xl w-48" />
|
<div class="h-12 w-48 rounded-xl bg-base-300/70" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
<!-- Scrollable content -->
|
||||||
<div v-if="!hasOrderError && order" class="overflow-y-auto h-[calc(70vh-140px)] px-6 py-4 space-y-4">
|
<div v-if="!hasOrderError && order" class="h-[calc(72vh-150px)] overflow-y-auto px-6 py-4 space-y-4">
|
||||||
<!-- Route stages -->
|
<!-- Route stages -->
|
||||||
<div v-if="orderStageItems.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:route" size="18" />
|
<Icon name="lucide:route" size="18" />
|
||||||
{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
@@ -76,15 +77,15 @@
|
|||||||
class="flex gap-3"
|
class="flex gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="w-3 h-3 rounded-full bg-indigo-500" />
|
<div class="h-3 w-3 rounded-full bg-primary" />
|
||||||
<div v-if="idx < orderStageItems.length - 1" class="w-0.5 flex-1 bg-white/20 my-1" />
|
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 pb-3">
|
<div class="flex-1 pb-3">
|
||||||
<div class="text-sm text-white font-medium">{{ stage.from }}</div>
|
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
|
||||||
<div v-if="stage.to && stage.to !== stage.from" class="text-xs text-white/50 mt-0.5">
|
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
|
||||||
→ {{ stage.to }}
|
→ {{ stage.to }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="stage.meta?.length" class="text-xs text-white/40 mt-1">
|
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
|
||||||
{{ stage.meta.join(' · ') }}
|
{{ stage.meta.join(' · ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,10 +94,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<div v-if="order.stages?.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:calendar" size="18" />
|
<Icon name="lucide:calendar" size="18" />
|
||||||
{{ t('ordersDetail.sections.timeline.title') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<GanttTimeline
|
<GanttTimeline
|
||||||
:stages="order.stages"
|
:stages="order.stages"
|
||||||
@@ -106,10 +107,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map preview (small) -->
|
<!-- Map preview (small) -->
|
||||||
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2 text-base-content">
|
||||||
<Icon name="lucide:map" size="18" />
|
<Icon name="lucide:map" size="18" />
|
||||||
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,111 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="fixed inset-0">
|
||||||
<CatalogPage
|
<ClientOnly>
|
||||||
:items="mapPoints"
|
<CatalogMap
|
||||||
:loading="isLoading"
|
map-id="orders-map"
|
||||||
:use-server-clustering="false"
|
:items="mapPoints"
|
||||||
map-id="orders-map"
|
:use-server-clustering="false"
|
||||||
point-color="#6366f1"
|
point-color="#6366f1"
|
||||||
:hovered-id="hoveredOrderId"
|
entity-type="offer"
|
||||||
:show-panel="!selectedOrderId"
|
:hovered-item-id="hoveredOrderId"
|
||||||
panel-width="w-96"
|
:fit-padding-left="460"
|
||||||
:hide-view-toggle="true"
|
@select-item="onMapSelect"
|
||||||
@select="onMapSelect"
|
/>
|
||||||
@update:hovered-id="hoveredOrderId = $event"
|
</ClientOnly>
|
||||||
>
|
|
||||||
<template #panel>
|
|
||||||
<!-- Panel header -->
|
|
||||||
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
|
|
||||||
<Icon name="lucide:package" size="16" class="text-indigo-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold text-sm">{{ t('cabinetNav.orders') }}</span>
|
|
||||||
<div class="text-xs text-white/50">{{ filteredItems.length }} {{ t('orders.total', 'total') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search -->
|
<MapSidePanel
|
||||||
<div class="relative mb-3">
|
:title="t('cabinetNav.orders')"
|
||||||
|
:initial-collapsed="false"
|
||||||
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('common.search')"
|
:placeholder="t('common.search')"
|
||||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
class="input input-sm w-full bg-white border-[#dccfbf] text-[#2f2418] placeholder:text-[#8a7761]"
|
||||||
/>
|
>
|
||||||
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-[#8a7761]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter dropdown -->
|
|
||||||
<div class="dropdown dropdown-end w-full">
|
<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">
|
<label tabindex="0" class="btn btn-sm w-full bg-white border-[#dccfbf] text-[#2f2418] hover:bg-[#f9f4ed] justify-between">
|
||||||
<span>{{ selectedFilterLabel }}</span>
|
<span>{{ selectedFilterLabel }}</span>
|
||||||
<Icon name="lucide:chevron-down" size="14" />
|
<Icon name="lucide:chevron-down" size="14" />
|
||||||
</label>
|
</label>
|
||||||
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-base-200 rounded-box w-full mt-2">
|
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-white rounded-box w-full mt-2">
|
||||||
<li v-for="filter in filters" :key="filter.id">
|
<li v-for="filter in filters" :key="filter.id">
|
||||||
<a
|
<a :class="{ active: selectedFilter === filter.id }" @click="selectedFilter = filter.id">{{ filter.label }}</a>
|
||||||
:class="{ 'active': selectedFilter === filter.id }"
|
|
||||||
@click="selectedFilter = filter.id"
|
|
||||||
>{{ filter.label }}</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders list -->
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
|
||||||
<template v-if="displayItems.length > 0">
|
<template v-if="displayItems.length > 0">
|
||||||
<div
|
<button
|
||||||
v-for="item in displayItems"
|
v-for="item in displayItems"
|
||||||
:key="item.uuid"
|
:key="item.uuid"
|
||||||
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
class="rounded-2xl border border-[#e4d9ca] bg-white p-3 text-left transition-colors hover:bg-[#f8f3ec]"
|
||||||
:class="{ 'ring-2 ring-indigo-500': selectedOrderId === item.uuid }"
|
:class="selectedOrderId === item.uuid ? 'ring-2 ring-[#2f2416]' : ''"
|
||||||
@click="selectedOrderId = item.uuid"
|
@click="selectedOrderId = item.uuid"
|
||||||
@mouseenter="hoveredOrderId = item.uuid"
|
@mouseenter="hoveredOrderId = item.uuid"
|
||||||
@mouseleave="hoveredOrderId = undefined"
|
@mouseleave="hoveredOrderId = undefined"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
<span class="font-semibold text-sm">#{{ item.name }}</span>
|
<span class="truncate font-semibold text-sm text-[#2f2418]">#{{ item.name }}</span>
|
||||||
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
||||||
{{ getStatusText(item.status) }}
|
{{ getStatusText(item.status) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-white/70 space-y-1">
|
|
||||||
|
<div class="space-y-1 text-xs text-[#6f6353]">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Icon name="lucide:map-pin" size="12" class="text-white/40" />
|
<Icon name="lucide:map-pin" size="12" />
|
||||||
<span class="truncate">{{ item.sourceLocationName }}</span>
|
<span class="truncate">{{ item.sourceLocationName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Icon name="lucide:navigation" size="12" class="text-white/40" />
|
<Icon name="lucide:navigation" size="12" />
|
||||||
<span class="truncate">{{ item.destinationLocationName }}</span>
|
<span class="truncate">{{ item.destinationLocationName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-white/50 mt-2">
|
|
||||||
{{ getOrderDate(item) }}
|
<div class="mt-2 text-xs text-[#95836d]">{{ getOrderDate(item) }}</div>
|
||||||
</div>
|
</button>
|
||||||
</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('orders.no_orders') }}</div>
|
|
||||||
<div class="text-xs text-white/60">{{ t('orders.no_orders_desc') }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
|
<div class="text-2xl">📦</div>
|
||||||
|
<div class="mt-2 text-sm font-semibold">{{ t('orders.no_orders') }}</div>
|
||||||
|
<div class="mt-1 text-xs">{{ t('orders.no_orders_desc') }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<template #footer>
|
||||||
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
<span class="text-xs text-[#7c6d5a]">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
||||||
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
</template>
|
||||||
</div>
|
</MapSidePanel>
|
||||||
</template>
|
|
||||||
</CatalogPage>
|
|
||||||
|
|
||||||
<!-- Order Detail Bottom Sheet -->
|
|
||||||
<OrderDetailBottomSheet
|
<OrderDetailBottomSheet
|
||||||
:is-open="!!selectedOrderId"
|
:is-open="!!selectedOrderId"
|
||||||
:order-uuid="selectedOrderId"
|
:order-uuid="selectedOrderId"
|
||||||
@@ -128,7 +109,6 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
filteredItems,
|
filteredItems,
|
||||||
isLoading,
|
|
||||||
filters,
|
filters,
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
init,
|
init,
|
||||||
@@ -140,13 +120,11 @@ const hoveredOrderId = ref<string>()
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedOrderId = ref<string | null>(null)
|
const selectedOrderId = ref<string | null>(null)
|
||||||
|
|
||||||
// Selected filter label
|
|
||||||
const selectedFilterLabel = computed(() => {
|
const selectedFilterLabel = computed(() => {
|
||||||
const filter = filters.value.find(f => f.id === selectedFilter.value)
|
const filter = filters.value.find(f => f.id === selectedFilter.value)
|
||||||
return filter?.label || t('ordersList.filters.status')
|
return filter?.label || t('ordersList.filters.status')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map points - source locations
|
|
||||||
const mapPoints = computed(() => {
|
const mapPoints = computed(() => {
|
||||||
return filteredItems.value
|
return filteredItems.value
|
||||||
.filter(order => order.uuid && order.sourceLatitude && order.sourceLongitude)
|
.filter(order => order.uuid && order.sourceLatitude && order.sourceLongitude)
|
||||||
@@ -158,7 +136,6 @@ const mapPoints = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Display items with search filter
|
|
||||||
const displayItems = computed(() => {
|
const displayItems = computed(() => {
|
||||||
let items = filteredItems.value.filter(order => order.uuid)
|
let items = filteredItems.value.filter(order => order.uuid)
|
||||||
|
|
||||||
@@ -174,9 +151,9 @@ const displayItems = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const onMapSelect = (item: { uuid?: string | null }) => {
|
const onMapSelect = (uuid: string) => {
|
||||||
if (item.uuid) {
|
if (uuid) {
|
||||||
selectedOrderId.value = item.uuid
|
selectedOrderId.value = uuid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<!-- Bottom Sheet -->
|
<!-- Bottom Sheet -->
|
||||||
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||||
<!-- Glass sheet -->
|
<!-- Glass sheet -->
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="relative flex-1 bg-neutral rounded-t-2xl border-t border-neutral/70 shadow-2xl overflow-hidden">
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<!-- Bottom Sheet -->
|
<!-- Bottom Sheet -->
|
||||||
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||||
<!-- Glass sheet -->
|
<!-- Glass sheet -->
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="relative flex-1 bg-neutral rounded-t-2xl border-t border-neutral/70 shadow-2xl overflow-hidden">
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div class="flex justify-center py-2">
|
<div class="flex justify-center py-2">
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
|||||||
1050
app/pages/index.vue
1050
app/pages/index.vue
File diff suppressed because it is too large
Load Diff
9
app/pages/manager/index.vue
Normal file
9
app/pages/manager/index.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'manager',
|
||||||
|
middleware: ['auth-oidc'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
await navigateTo(localePath('/manager/orders'), { redirectCode: 302 })
|
||||||
|
</script>
|
||||||
243
app/pages/manager/orders/[id].vue
Normal file
243
app/pages/manager/orders/[id].vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
GetOrderDocument,
|
||||||
|
type GetOrderQueryResult,
|
||||||
|
type GetOrderQueryVariables,
|
||||||
|
} from '~/composables/graphql/team/orders-generated'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'manager',
|
||||||
|
middleware: ['auth-oidc'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type OrderRecord = NonNullable<GetOrderQueryResult['getOrder']>
|
||||||
|
type OrderStage = NonNullable<NonNullable<OrderRecord['stages']>[number]>
|
||||||
|
type OrderLine = NonNullable<NonNullable<OrderRecord['orderLines']>[number]>
|
||||||
|
type StageTrip = NonNullable<NonNullable<OrderStage['trips']>[number]>
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const orderId = computed(() => String(route.params.id || '').trim())
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
} = await useAsyncData(
|
||||||
|
() => `manager-order-${orderId.value}`,
|
||||||
|
async () => {
|
||||||
|
const response = await execute(GetOrderDocument, {
|
||||||
|
orderUuid: orderId.value,
|
||||||
|
} as GetOrderQueryVariables, 'team', 'orders')
|
||||||
|
return response.getOrder
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const order = computed(() => data.value || null)
|
||||||
|
const stages = computed(() => ((order.value?.stages || []).filter((stage): stage is OrderStage => stage !== null)))
|
||||||
|
const lines = computed(() => ((order.value?.orderLines || []).filter((line): line is OrderLine => line !== null)))
|
||||||
|
const trips = computed(() => stages.value.flatMap(stage => (stage.trips || []).filter((trip): trip is StageTrip => trip !== null)))
|
||||||
|
|
||||||
|
function formatMoney(value?: number | null, currency?: string | null) {
|
||||||
|
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return 'Дата уточняется'
|
||||||
|
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel() {
|
||||||
|
return `${order.value?.sourceLocationName || 'Откуда'} - ${order.value?.destinationLocationName || 'Куда'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status?: string | null) {
|
||||||
|
const normalized = String(status || '').toLowerCase()
|
||||||
|
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
|
||||||
|
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
|
||||||
|
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
|
||||||
|
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripDateLabel(trip: StageTrip) {
|
||||||
|
return trip.actualLoadingDate || trip.realLoadingDate || trip.plannedLoadingDate || trip.plannedUnloadingDate
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<NuxtLink :to="localePath('/manager/orders')" class="inline-flex items-center gap-2 text-sm font-bold text-[#8a7761] transition hover:text-[#2f2418]">
|
||||||
|
<Icon name="lucide:arrow-left" size="16" />
|
||||||
|
<span>Назад к списку</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="mt-4 text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Order detail</p>
|
||||||
|
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">{{ routeLabel() }}</h2>
|
||||||
|
<p class="mt-2 text-sm text-[#6f6353]">Заказ {{ order?.name || order?.uuid || orderId }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order" class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(order.status).className">
|
||||||
|
{{ statusMeta(order.status).label }}
|
||||||
|
</span>
|
||||||
|
<div class="rounded-full bg-white px-4 py-2 text-sm font-bold text-[#2f2418]">
|
||||||
|
{{ formatMoney(order.totalAmount, order.currency) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="pending" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||||
|
<p class="text-sm opacity-70">Загружаем заказ…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="error || !order" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||||
|
<p class="text-sm font-medium">Не удалось открыть заказ</p>
|
||||||
|
<p v-if="error" class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="mt-6 grid gap-3 lg:grid-cols-4">
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Created</p>
|
||||||
|
<p class="mt-3 text-lg font-black text-[#2f2418]">{{ formatDate(order.createdAt) }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Stages</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ stages.length }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Trips</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ trips.length }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo lines</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ lines.length }}</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,0.9fr)]">
|
||||||
|
<article class="rounded-[28px] bg-white p-6">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo manifest</p>
|
||||||
|
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Order lines</h3>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||||
|
{{ lines.length }} items
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 overflow-hidden rounded-[24px] border border-[#eadfce]">
|
||||||
|
<div class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 bg-[#fbf8f4] px-4 py-3 text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">
|
||||||
|
<span>Product</span>
|
||||||
|
<span>Qty</span>
|
||||||
|
<span>Subtotal</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="lines.length">
|
||||||
|
<div
|
||||||
|
v-for="line in lines"
|
||||||
|
:key="line.uuid"
|
||||||
|
class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 border-t border-[#f1e7da] px-4 py-4 text-sm text-[#4f4130]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-[#2f2418]">{{ line.productName || 'Cargo item' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-[#8a7761]">{{ line.uuid }}</p>
|
||||||
|
</div>
|
||||||
|
<span>{{ line.quantity || 0 }} {{ line.unit || '' }}</span>
|
||||||
|
<span>{{ formatMoney(line.subtotal, order.currency) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="px-4 py-6 text-sm text-[#6f6353]">
|
||||||
|
Состав груза ещё не заполнен.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="rounded-[28px] bg-white p-6">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Manager note</p>
|
||||||
|
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Summary</h3>
|
||||||
|
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
|
||||||
|
{{ order.notes || 'Комментарий пока не добавлен. Этот экран уже перенесён в manager-паттерн logistics и теперь опирается на Optovia GraphQL.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-3 rounded-[24px] bg-[#fbf8f4] p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-[#8a7761]">Source</span>
|
||||||
|
<span class="font-bold text-[#2f2418]">{{ order.sourceLocationName || 'Не указано' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-[#8a7761]">Destination</span>
|
||||||
|
<span class="font-bold text-[#2f2418]">{{ order.destinationLocationName || 'Не указано' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-[#8a7761]">Status</span>
|
||||||
|
<span class="font-bold text-[#2f2418]">{{ statusMeta(order.status).label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-[28px] bg-white p-6">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Route flow</p>
|
||||||
|
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Stages and trips</h3>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||||
|
{{ stages.length }} stages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="stage in stages"
|
||||||
|
:key="stage.uuid"
|
||||||
|
class="rounded-[24px] border border-[#eadfce] bg-[#fbf8f4] p-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ stage.stageType || 'Stage' }}</p>
|
||||||
|
<h4 class="mt-1 text-xl font-black text-[#2f2418]">{{ stage.name || 'Этап маршрута' }}</h4>
|
||||||
|
<p class="mt-2 text-sm text-[#5f4b33]">
|
||||||
|
{{ stage.sourceLocationName || 'Источник' }} → {{ stage.destinationLocationName || stage.locationName || 'Точка назначения' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-if="stage.transportType" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
|
||||||
|
{{ stage.transportType }}
|
||||||
|
</span>
|
||||||
|
<span v-if="stage.selectedCompany?.name" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
|
||||||
|
{{ stage.selectedCompany.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="(stage.trips || []).length" class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="trip in stage.trips"
|
||||||
|
:key="trip?.uuid"
|
||||||
|
class="rounded-[20px] bg-white px-4 py-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-bold text-[#2f2418]">{{ trip?.name || 'Trip' }}</p>
|
||||||
|
<p class="mt-1 text-xs text-[#8a7761]">{{ trip?.company?.name || trip?.company?.country || 'Carrier pending' }}</p>
|
||||||
|
<p class="mt-3 text-sm text-[#5f4b33]">{{ formatDate(tripDateLabel(trip || {} as StageTrip)) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
310
app/pages/manager/orders/index.vue
Normal file
310
app/pages/manager/orders/index.vue
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
GetTeamOrdersDocument,
|
||||||
|
type GetTeamOrdersQueryResult,
|
||||||
|
type GetTeamOrdersQueryVariables,
|
||||||
|
} from '~/composables/graphql/team/orders-generated'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'manager',
|
||||||
|
middleware: ['auth-oidc'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
|
||||||
|
type TeamStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
|
||||||
|
type OrdersViewMode = 'list' | 'calendar'
|
||||||
|
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const search = ref('')
|
||||||
|
const visibleLimit = ref(12)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useAsyncData('manager-orders-optovia', async () => {
|
||||||
|
const response = await execute(GetTeamOrdersDocument, {} as GetTeamOrdersQueryVariables, 'team', 'orders')
|
||||||
|
return response.getTeamOrders || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const orders = computed(() => (Array.isArray(data.value) ? data.value.filter((item): item is TeamOrder => item !== null) : []))
|
||||||
|
const viewMode = computed<OrdersViewMode>(() => route.query.view === 'calendar' ? 'calendar' : 'list')
|
||||||
|
const filteredOrders = computed(() => {
|
||||||
|
const query = search.value.trim().toLowerCase()
|
||||||
|
if (!query) return orders.value
|
||||||
|
|
||||||
|
return orders.value.filter((item) => {
|
||||||
|
const haystack = [
|
||||||
|
item.uuid,
|
||||||
|
item.name,
|
||||||
|
item.status,
|
||||||
|
item.sourceLocationName,
|
||||||
|
item.destinationLocationName,
|
||||||
|
...(item.orderLines || []).map(line => line?.productName || ''),
|
||||||
|
...(item.stages || []).map(stage => stage?.name || ''),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const visibleOrders = computed(() => filteredOrders.value.slice(0, visibleLimit.value))
|
||||||
|
const canLoadMoreOrders = computed(() => visibleOrders.value.length < filteredOrders.value.length)
|
||||||
|
const totalTurnover = computed(() => orders.value.reduce((sum, item) => sum + Number(item.totalAmount || 0), 0))
|
||||||
|
const inTransitCount = computed(() => orders.value.filter(item => ['processing', 'in_transit'].includes(String(item.status || '').toLowerCase())).length)
|
||||||
|
const deliveredCount = computed(() => orders.value.filter(item => ['delivered', 'completed'].includes(String(item.status || '').toLowerCase())).length)
|
||||||
|
|
||||||
|
const orderViewTabs = computed<Array<{ key: OrdersViewMode, label: string, active: boolean }>>(() => ([
|
||||||
|
{ key: 'list', label: 'Списком', active: viewMode.value === 'list' },
|
||||||
|
{ key: 'calendar', label: 'Календарь', active: viewMode.value === 'calendar' },
|
||||||
|
]))
|
||||||
|
|
||||||
|
function formatMoney(value?: number | null, currency?: string | null) {
|
||||||
|
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return ''
|
||||||
|
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel(item: TeamOrder) {
|
||||||
|
return `${item.sourceLocationName || 'Откуда'} - ${item.destinationLocationName || 'Куда'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargoLabel(item: TeamOrder) {
|
||||||
|
const lines = (item.orderLines || [])
|
||||||
|
.filter((line): line is NonNullable<(typeof item.orderLines)[number]> => line !== null)
|
||||||
|
.map(line => `${line.productName || 'Cargo'}${line.quantity ? ` · ${line.quantity} ${line.unit || ''}` : ''}`)
|
||||||
|
return lines.slice(0, 2).join(' · ') || 'Состав груза уточняется'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status?: string | null) {
|
||||||
|
const normalized = String(status || '').toLowerCase()
|
||||||
|
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
|
||||||
|
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
|
||||||
|
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
|
||||||
|
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkpointLabel(item: TeamOrder) {
|
||||||
|
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
|
||||||
|
return stages[0]?.name || 'Этап уточняется'
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerLabel(item: TeamOrder) {
|
||||||
|
return `Клиент ${(item.uuid || 'order').slice(-6).toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageCount(item: TeamOrder) {
|
||||||
|
return (item.stages || []).filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCalendarOrders() {
|
||||||
|
return filteredOrders.value.map((item) => {
|
||||||
|
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
|
||||||
|
const checkpoints = stages.map((stage, index) => {
|
||||||
|
const firstTrip = (stage.trips || []).find(Boolean)
|
||||||
|
return {
|
||||||
|
code: stage.uuid || `stage-${index}`,
|
||||||
|
name: stage.name || 'Этап',
|
||||||
|
plannedDate: firstTrip?.plannedLoadingDate || firstTrip?.plannedUnloadingDate || null,
|
||||||
|
actualDate: firstTrip?.actualLoadingDate || firstTrip?.actualUnloadingDate || null,
|
||||||
|
completed: Boolean(firstTrip?.actualUnloadingDate || firstTrip?.realLoadingDate),
|
||||||
|
current: index === 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.uuid || '',
|
||||||
|
status: item.status || '',
|
||||||
|
totalAmount: Number(item.totalAmount || 0),
|
||||||
|
currency: item.currency || 'USD',
|
||||||
|
createdAt: item.createdAt || new Date().toISOString(),
|
||||||
|
pickupDate: checkpoints[0]?.plannedDate || null,
|
||||||
|
fromAddress: {
|
||||||
|
city: item.sourceLocationName || '',
|
||||||
|
country: '',
|
||||||
|
},
|
||||||
|
toAddress: {
|
||||||
|
city: item.destinationLocationName || '',
|
||||||
|
country: '',
|
||||||
|
},
|
||||||
|
currentCheckpoint: checkpoints[0] || null,
|
||||||
|
checkpoints,
|
||||||
|
}
|
||||||
|
}).filter(order => order.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOrder(orderId?: string | null) {
|
||||||
|
if (!orderId) return
|
||||||
|
await navigateTo(localePath(`/manager/orders/${orderId}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setViewMode(nextMode: OrdersViewMode) {
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
if (nextMode === 'calendar') {
|
||||||
|
nextQuery.view = 'calendar'
|
||||||
|
} else {
|
||||||
|
delete nextQuery.view
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateTo({
|
||||||
|
path: route.path,
|
||||||
|
query: nextQuery,
|
||||||
|
}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreOrders() {
|
||||||
|
visibleLimit.value += 12
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="grid gap-3 lg:grid-cols-4">
|
||||||
|
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ orders.length }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Активный пул заказов в microservice flow</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">In transit</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ inTransitCount }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Маршруты, которые сейчас в движении</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Delivered</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ deliveredCount }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Завершённые кейсы для менеджерского кабинета</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5 shadow-none">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Turnover</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ formatMoney(totalTurnover, orders[0]?.currency || 'USD') }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Сумма по заказам без фронтового Odoo-слоя</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Manager workspace</p>
|
||||||
|
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">Orders</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-3 lg:w-auto lg:min-w-[620px] lg:flex-row">
|
||||||
|
<label class="block flex-1">
|
||||||
|
<span class="sr-only">Search orders</span>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
|
||||||
|
placeholder="Поиск по маршруту, грузу или этапу"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="inline-flex w-fit items-center rounded-full bg-white p-1 shadow-[0_16px_38px_rgba(38,29,18,0.08)]">
|
||||||
|
<button
|
||||||
|
v-for="tab in orderViewTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 text-sm font-bold transition"
|
||||||
|
:class="tab.active ? 'bg-[#2f2418] text-white' : 'text-[#6a5947] hover:bg-[#f6f1ea]'"
|
||||||
|
@click="setViewMode(tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="pending && !orders.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||||
|
<p class="text-sm opacity-70">Загружаем заказы…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="error" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||||
|
<p class="text-sm font-medium">Не удалось загрузить manager orders</p>
|
||||||
|
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<OrdersCalendarPanel
|
||||||
|
v-else-if="viewMode === 'calendar'"
|
||||||
|
class="mt-6"
|
||||||
|
:orders="mapCalendarOrders()"
|
||||||
|
@select="openOrder"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-else class="mt-6 space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="item in visibleOrders"
|
||||||
|
:key="item.uuid"
|
||||||
|
class="cursor-pointer rounded-[28px] bg-white px-6 py-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||||
|
@click="openOrder(item.uuid)"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 md:grid-cols-[minmax(0,2.1fr)_minmax(0,1.15fr)_auto_auto] md:items-center">
|
||||||
|
<div class="flex min-w-0 items-start gap-4">
|
||||||
|
<UserAvatar
|
||||||
|
:seed="item.uuid"
|
||||||
|
:label="customerLabel(item)"
|
||||||
|
:size="52"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-bold text-[#8a7761]">{{ customerLabel(item) }}</p>
|
||||||
|
<p class="mt-1 truncate text-lg font-black leading-tight text-[#2f2418]">{{ routeLabel(item) }}</p>
|
||||||
|
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ cargoLabel(item) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 text-sm text-[#6f6353]">
|
||||||
|
<p class="truncate">{{ checkpointLabel(item) }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-[#998b78]">
|
||||||
|
{{ stageCount(item) }} этапов · {{ formatDate(item.createdAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:text-right">
|
||||||
|
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
|
||||||
|
{{ statusMeta(item.status).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="whitespace-nowrap text-right text-lg font-semibold text-[#2f2418]">
|
||||||
|
{{ formatMoney(item.totalAmount, item.currency) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-if="visibleOrders.length === 0"
|
||||||
|
class="rounded-[28px] bg-white p-8 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold text-[#2f2418]">Заказы не найдены</p>
|
||||||
|
<p class="mt-2 text-sm text-[#6f6353]">Попробуй изменить строку поиска или фильтр представления.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ManagerListLoadMore
|
||||||
|
v-if="!error && visibleOrders.length"
|
||||||
|
class="mt-5"
|
||||||
|
:shown="visibleOrders.length"
|
||||||
|
:total="filteredOrders.length"
|
||||||
|
:can-load-more="canLoadMoreOrders"
|
||||||
|
:loading="pending"
|
||||||
|
:page-size="12"
|
||||||
|
item-label="заказов"
|
||||||
|
@load-more="loadMoreOrders"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
166
app/pages/manager/quotations/index.vue
Normal file
166
app/pages/manager/quotations/index.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'manager',
|
||||||
|
middleware: ['auth-oidc'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type QuotationCard = {
|
||||||
|
id: string
|
||||||
|
client: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
mode: string
|
||||||
|
weight: string
|
||||||
|
status: 'draft' | 'pricing' | 'graph-ready'
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const quotations = useState<QuotationCard[]>('manager-quotation-cards', () => [
|
||||||
|
{
|
||||||
|
id: 'qt-001',
|
||||||
|
client: 'Client 42A7',
|
||||||
|
from: 'Guangzhou',
|
||||||
|
to: 'Moscow',
|
||||||
|
mode: 'Multimodal',
|
||||||
|
weight: '800 kg',
|
||||||
|
status: 'pricing',
|
||||||
|
note: 'Базовый сценарий для переноса quotation flow из logistics.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qt-002',
|
||||||
|
client: 'Client 90F2',
|
||||||
|
from: 'Shenzhen',
|
||||||
|
to: 'Novorossiysk',
|
||||||
|
mode: 'Sea + truck',
|
||||||
|
weight: '1 200 kg',
|
||||||
|
status: 'graph-ready',
|
||||||
|
note: 'Маршрут уже мыслится как graph-native corridor без Odoo-связки.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qt-003',
|
||||||
|
client: 'Client 18D1',
|
||||||
|
from: 'Yiwu',
|
||||||
|
to: 'Kazan',
|
||||||
|
mode: 'Rail',
|
||||||
|
weight: '2 400 kg',
|
||||||
|
status: 'draft',
|
||||||
|
note: 'Черновик manager workspace под tariffs и decision rules.',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredCards = computed(() => {
|
||||||
|
const query = search.value.trim().toLowerCase()
|
||||||
|
if (!query) return quotations.value
|
||||||
|
|
||||||
|
return quotations.value.filter((item) => {
|
||||||
|
return [item.client, item.from, item.to, item.mode, item.note, item.status]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function createNewQuotation() {
|
||||||
|
quotations.value = [
|
||||||
|
{
|
||||||
|
id: `qt-${String(quotations.value.length + 1).padStart(3, '0')}`,
|
||||||
|
client: `Client ${(Math.random().toString(16).slice(2, 6)).toUpperCase()}`,
|
||||||
|
from: 'Shanghai',
|
||||||
|
to: 'Saint Petersburg',
|
||||||
|
mode: 'Multimodal',
|
||||||
|
weight: '950 kg',
|
||||||
|
status: 'draft',
|
||||||
|
note: 'Новый manager draft. Дальше его нужно привязать к реальному quotation microservice.',
|
||||||
|
},
|
||||||
|
...quotations.value,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status: QuotationCard['status']) {
|
||||||
|
if (status === 'graph-ready') return { label: 'Graph ready', className: 'bg-emerald-100 text-emerald-700' }
|
||||||
|
if (status === 'pricing') return { label: 'Pricing', className: 'bg-amber-100 text-amber-700' }
|
||||||
|
return { label: 'Draft', className: 'bg-stone-200 text-stone-700' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="grid gap-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)]">
|
||||||
|
<article class="rounded-[28px] bg-white p-6">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Quotations workspace</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Exact logistics shell, Optovia logic next</h2>
|
||||||
|
<p class="mt-4 max-w-[760px] text-sm leading-6 text-[#5f4b33]">
|
||||||
|
Этот экран уже переведён в визуальный паттерн `logistic`: те же теплые поверхности, те же rounded cards, тот же manager rhythm.
|
||||||
|
Следующий шаг здесь очевидный: посадить quotation CRUD и tariff selection на ваши microservices и graph-модель.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="rounded-[28px] bg-[#2f2418] p-6 text-white shadow-[0_20px_44px_rgba(47,36,24,0.18)]">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.16em] text-white/60">Migration note</p>
|
||||||
|
<h3 class="mt-2 text-2xl font-black">No Odoo frontend path</h3>
|
||||||
|
<p class="mt-4 text-sm leading-6 text-white/78">
|
||||||
|
Quotation UI here is ready to stop depending on old Odoo-driven flows. Сейчас это staging surface, дальше сюда подключается новый graph-native backend contract.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<label class="block flex-1">
|
||||||
|
<span class="sr-only">Search quotations</span>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
|
||||||
|
placeholder="Поиск по клиенту, маршруту или статусу"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]" @click="createNewQuotation">
|
||||||
|
Создать quotation draft
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="item in filteredCards"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-[28px] bg-white px-6 py-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||||
|
>
|
||||||
|
<div class="grid gap-3 md:grid-cols-[minmax(0,2fr)_auto_minmax(0,1.3fr)_auto] md:items-center">
|
||||||
|
<div class="flex min-w-0 items-center gap-3 text-lg font-black leading-tight text-[#2f2418]">
|
||||||
|
<p class="truncate">{{ item.from }}</p>
|
||||||
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#efe7da] text-[#7a684f]">
|
||||||
|
<svg viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3.5 10h13" />
|
||||||
|
<path d="M12 5.5 16.5 10 12 14.5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<p class="truncate">{{ item.to }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="whitespace-nowrap text-sm font-medium text-[#6f6353]">
|
||||||
|
{{ item.weight }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold text-[#2f2418]">{{ item.client }}</p>
|
||||||
|
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ item.mode }} · {{ item.note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:text-right">
|
||||||
|
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
|
||||||
|
{{ statusMeta(item.status).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-if="filteredCards.length === 0"
|
||||||
|
class="rounded-[28px] bg-white p-8 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold text-[#2f2418]">Quotation cards не найдены</p>
|
||||||
|
<p class="mt-2 text-sm text-[#6f6353]">Измени строку поиска или добавь новый draft.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
165
app/pages/manager/tariffs/index.vue
Normal file
165
app/pages/manager/tariffs/index.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
GetHubCountriesDocument,
|
||||||
|
HubsListDocument,
|
||||||
|
type GetHubCountriesQueryVariables,
|
||||||
|
type HubsListQueryResult,
|
||||||
|
type HubsListQueryVariables,
|
||||||
|
} from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'manager',
|
||||||
|
middleware: ['auth-oidc'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type HubRecord = HubsListQueryResult['hubsList'][number]
|
||||||
|
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
const search = ref('')
|
||||||
|
const selectedCountry = ref('')
|
||||||
|
|
||||||
|
const [{ data: countriesData }, { data: hubsData, pending, error }] = await Promise.all([
|
||||||
|
useAsyncData('manager-tariff-countries', async () => {
|
||||||
|
const response = await execute(GetHubCountriesDocument, {} as GetHubCountriesQueryVariables, 'public', 'geo')
|
||||||
|
return response.hubCountries || []
|
||||||
|
}),
|
||||||
|
useAsyncData('manager-tariff-hubs', async () => {
|
||||||
|
const response = await execute(HubsListDocument, {
|
||||||
|
limit: 60,
|
||||||
|
offset: 0,
|
||||||
|
country: null,
|
||||||
|
transportType: null,
|
||||||
|
west: null,
|
||||||
|
south: null,
|
||||||
|
east: null,
|
||||||
|
north: null,
|
||||||
|
} as HubsListQueryVariables, 'public', 'geo')
|
||||||
|
return response.hubsList || []
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const countries = computed(() => (countriesData.value || []).filter(Boolean))
|
||||||
|
const hubs = computed(() => (Array.isArray(hubsData.value) ? hubsData.value.filter((item): item is HubRecord => item !== null) : []))
|
||||||
|
const filteredHubs = computed(() => {
|
||||||
|
const query = search.value.trim().toLowerCase()
|
||||||
|
|
||||||
|
return hubs.value.filter((hub) => {
|
||||||
|
if (selectedCountry.value && hub.country !== selectedCountry.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) return true
|
||||||
|
|
||||||
|
return [hub.name, hub.country, hub.countryCode, ...(hub.transportTypes || [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueTransportTypes = computed(() => {
|
||||||
|
return new Set(
|
||||||
|
hubs.value.flatMap(hub => (hub.transportTypes || []).filter((item): item is string => Boolean(item))),
|
||||||
|
).size
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="grid gap-3 lg:grid-cols-3">
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Tariff anchors</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ hubs.length }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Хабы из geo graph для будущего tariff workspace</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Countries</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ countries.length }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Страны, уже доступные для corridor pricing</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-[28px] bg-white p-5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Transport types</p>
|
||||||
|
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ uniqueTransportTypes }}</p>
|
||||||
|
<p class="mt-1 text-sm text-[#6f6353]">Основа для будущих tariff rules без Odoo</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-[28px] bg-white p-6">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Tariff topology</p>
|
||||||
|
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Graph-native corridor base</h2>
|
||||||
|
<p class="mt-4 max-w-[840px] text-sm leading-6 text-[#5f4b33]">
|
||||||
|
Здесь я не делаю вид, что тарифный backend уже существует. Но сам экран уже опирается на реальные geo microservices и показывает,
|
||||||
|
от каких hub nodes дальше строить tariff references, quotation routes и decision rules.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-3 lg:flex-row">
|
||||||
|
<label class="block flex-1">
|
||||||
|
<span class="sr-only">Search hubs</span>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
class="input h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none"
|
||||||
|
placeholder="Поиск по хабу, стране или типу транспорта"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select v-model="selectedCountry" class="select h-12 rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none">
|
||||||
|
<option value="">Все страны</option>
|
||||||
|
<option v-for="country in countries" :key="country" :value="country">
|
||||||
|
{{ country }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="pending && !hubs.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
|
||||||
|
<p class="text-sm opacity-70">Загружаем graph hubs…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="error" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
|
||||||
|
<p class="text-sm font-medium">Не удалось загрузить hub topology</p>
|
||||||
|
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<article
|
||||||
|
v-for="hub in filteredHubs"
|
||||||
|
:key="hub.uuid"
|
||||||
|
class="rounded-[28px] bg-white p-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ hub.countryCode || 'Hub' }}</p>
|
||||||
|
<h3 class="mt-1 text-xl font-black leading-tight text-[#2f2418]">{{ hub.name || 'Unnamed hub' }}</h3>
|
||||||
|
<p class="mt-2 text-sm text-[#6f6353]">{{ hub.country || 'Country pending' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
|
||||||
|
{{ (hub.transportTypes || []).length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="transportType in hub.transportTypes || []"
|
||||||
|
:key="transportType || 'transport'"
|
||||||
|
class="rounded-full bg-[#fbf8f4] px-3 py-1 text-xs font-semibold text-[#5f4b33]"
|
||||||
|
>
|
||||||
|
{{ transportType }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
|
||||||
|
Этот хаб можно использовать как anchor point для новой tariff reference модели внутри Optovia manager workspace.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-if="filteredHubs.length === 0"
|
||||||
|
class="rounded-[28px] bg-white p-8 text-center md:col-span-2 xl:col-span-3"
|
||||||
|
>
|
||||||
|
<p class="text-lg font-semibold text-[#2f2418]">Под фильтры ничего не попало</p>
|
||||||
|
<p class="mt-2 text-sm text-[#6f6353]">Сбрось поиск или выбери другую страну.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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">
|
||||||
|
|||||||
48
app/plugins/00-apollo-console-filter.client.ts
Normal file
48
app/plugins/00-apollo-console-filter.client.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const originalConsoleError = console.error
|
||||||
|
const originalConsoleWarn = console.warn
|
||||||
|
|
||||||
|
const shouldSuppressApolloNoise = (args: unknown[]) => {
|
||||||
|
const serializedArgs = args
|
||||||
|
.map((arg) => {
|
||||||
|
if (typeof arg === 'string') return arg
|
||||||
|
if (arg instanceof Error) return `${arg.message}\n${arg.stack || ''}`
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg)
|
||||||
|
} catch {
|
||||||
|
return String(arg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
serializedArgs.includes('connectToDevTools')
|
||||||
|
&& serializedArgs.includes('devtools.enabled')
|
||||||
|
)
|
||||||
|
|| (
|
||||||
|
serializedArgs.includes('go.apollo.dev/c/err')
|
||||||
|
&& (
|
||||||
|
serializedArgs.includes('"message":104')
|
||||||
|
|| serializedArgs.includes('%22message%22%3A104')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
if (shouldSuppressApolloNoise(args)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConsoleError(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
if (shouldSuppressApolloNoise(args)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConsoleWarn(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const baseUrl = String(config.public.chatwootBaseUrl || '').trim()
|
||||||
|
const websiteToken = String(config.public.chatwootWebsiteToken || '').trim()
|
||||||
|
|
||||||
|
if (!baseUrl || !websiteToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const loadChatwoot = () => {
|
const loadChatwoot = () => {
|
||||||
if (document.getElementById('chatwoot-sdk')) return
|
if (document.getElementById('chatwoot-sdk')) return
|
||||||
|
|
||||||
const baseUrl = 'https://chatwoot.optovia.ru'
|
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script')
|
||||||
script.id = 'chatwoot-sdk'
|
script.id = 'chatwoot-sdk'
|
||||||
script.src = `${baseUrl}/packs/js/sdk.js`
|
script.src = `${baseUrl}/packs/js/sdk.js`
|
||||||
@@ -10,7 +17,7 @@ export default defineNuxtPlugin(() => {
|
|||||||
script.defer = true
|
script.defer = true
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
window.chatwootSDK?.run({
|
window.chatwootSDK?.run({
|
||||||
websiteToken: 'bc668ge3hM5ZpPeUgGEV1ZU9',
|
websiteToken,
|
||||||
baseUrl
|
baseUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/plugins/route-loading.client.ts
Normal file
41
app/plugins/route-loading.client.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const routeLoading = useState<boolean>('route-loading', () => false)
|
||||||
|
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
if (hideTimer) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
routeLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
if (hideTimer) {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
// Small delay to prevent flicker on very fast routes.
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
routeLoading.value = false
|
||||||
|
hideTimer = null
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.$router.beforeEach((_to, _from, next) => {
|
||||||
|
show()
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.$router.afterEach(() => {
|
||||||
|
hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.$router.onError(() => {
|
||||||
|
hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxtApp.hook('page:finish', () => {
|
||||||
|
hide()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
23
app/utils/profileAvatars.ts
Normal file
23
app/utils/profileAvatars.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const DICEBEAR_STYLE = 'notionists'
|
||||||
|
const DICEBEAR_BACKGROUNDS = ['f6efe3', 'e7f4ee', 'eef2ff', 'fff1dc', 'fce7f3']
|
||||||
|
|
||||||
|
function hashProfileAvatarValue(value: string) {
|
||||||
|
return [...value].reduce((acc, char) => {
|
||||||
|
return (acc * 33 + char.charCodeAt(0)) >>> 0
|
||||||
|
}, 5381)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileAvatarSeedFromValue(value?: string | null) {
|
||||||
|
const normalized = String(value || 'profile').trim() || 'profile'
|
||||||
|
return `gl-${hashProfileAvatarValue(normalized).toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileAvatarUrl(seed?: string | null) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
seed: String(seed || 'profile').trim() || 'profile',
|
||||||
|
radius: '32',
|
||||||
|
backgroundColor: DICEBEAR_BACKGROUNDS.join(','),
|
||||||
|
})
|
||||||
|
|
||||||
|
return `https://api.dicebear.com/9.x/${DICEBEAR_STYLE}/svg?${params.toString()}`
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const pluginConfig = {
|
|||||||
|
|
||||||
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,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
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
"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",
|
||||||
@@ -95,6 +96,14 @@
|
|||||||
},
|
},
|
||||||
"offers": "offer | offers",
|
"offers": "offer | offers",
|
||||||
"list": "List",
|
"list": "List",
|
||||||
"applyFilter": "Apply filter"
|
"applyFilter": "Apply filter",
|
||||||
|
"step": "Step {n}",
|
||||||
|
"steps": {
|
||||||
|
"selectProduct": "What are you looking for?",
|
||||||
|
"selectDestination": "Where to deliver?",
|
||||||
|
"setQuantity": "How much do you need?",
|
||||||
|
"results": "Results",
|
||||||
|
"newSearch": "New search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"quantity_with_unit": "{quantity} {unit}",
|
"quantity_with_unit": "{quantity} {unit}",
|
||||||
"default_unit": "t",
|
"default_unit": "t",
|
||||||
|
"unit_kg": "kg",
|
||||||
|
"distance_km": "{km} km",
|
||||||
|
"duration_label": "ETA",
|
||||||
|
"duration_days": "{days} d",
|
||||||
"country_unknown": "Not specified",
|
"country_unknown": "Not specified",
|
||||||
"supplier_unknown": "Supplier",
|
"supplier_unknown": "Supplier",
|
||||||
"origin_label": "From",
|
"origin_label": "From",
|
||||||
|
|||||||
27
i18n/locales/en/settings.json
Normal file
27
i18n/locales/en/settings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"language": "Language",
|
||||||
|
"currency": "Currency",
|
||||||
|
"open": "Open settings",
|
||||||
|
"apply": "Apply",
|
||||||
|
"ratesProvider": "Rates provider",
|
||||||
|
"ratesBy": "Rates by ExchangeRate API",
|
||||||
|
"locales": {
|
||||||
|
"ru": {
|
||||||
|
"label": "Russian",
|
||||||
|
"nativeLabel": "Русский"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"label": "English",
|
||||||
|
"nativeLabel": "English"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"currencies": {
|
||||||
|
"USD": "US Dollar",
|
||||||
|
"RUB": "Russian Ruble",
|
||||||
|
"CNY": "Chinese Yuan",
|
||||||
|
"EUR": "Euro",
|
||||||
|
"AED": "UAE Dirham"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
i18n/locales/en/ui.json
Normal file
22
i18n/locales/en/ui.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"ui": {
|
||||||
|
"menu": "Menu",
|
||||||
|
"calculate": "Calculate",
|
||||||
|
"my_orders": "My orders",
|
||||||
|
"log_in": "Log in",
|
||||||
|
"login": "Log in",
|
||||||
|
"profile": "Profile",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"cargo": "Cargo",
|
||||||
|
"product": "What",
|
||||||
|
"quantity": "How much",
|
||||||
|
"find": "Find",
|
||||||
|
"manager_navigation": "Manager navigation",
|
||||||
|
"orders": "Orders",
|
||||||
|
"quotations": "Quotations",
|
||||||
|
"hubs": "Hubs",
|
||||||
|
"users": "Users",
|
||||||
|
"manager": "Manager"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,8 @@
|
|||||||
"selectSupplier": "Выберите поставщика",
|
"selectSupplier": "Выберите поставщика",
|
||||||
"enterQty": "Количество (т)",
|
"enterQty": "Количество (т)",
|
||||||
"search": "Найти",
|
"search": "Найти",
|
||||||
"clear": "Очистить"
|
"clear": "Очистить",
|
||||||
|
"findOffers": "Найти предложения"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"title": "Исследуйте рынок",
|
"title": "Исследуйте рынок",
|
||||||
@@ -95,6 +96,14 @@
|
|||||||
},
|
},
|
||||||
"offers": "предложение | предложения | предложений",
|
"offers": "предложение | предложения | предложений",
|
||||||
"list": "Список",
|
"list": "Список",
|
||||||
"applyFilter": "Применить фильтр"
|
"applyFilter": "Применить фильтр",
|
||||||
|
"step": "Шаг {n}",
|
||||||
|
"steps": {
|
||||||
|
"selectProduct": "Что ищете?",
|
||||||
|
"selectDestination": "Куда доставить?",
|
||||||
|
"setQuantity": "Сколько нужно?",
|
||||||
|
"results": "Результаты",
|
||||||
|
"newSearch": "Новый поиск"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"quantity_with_unit": "{quantity} {unit}",
|
"quantity_with_unit": "{quantity} {unit}",
|
||||||
"default_unit": "т",
|
"default_unit": "т",
|
||||||
|
"unit_kg": "кг",
|
||||||
|
"distance_km": "{km} км",
|
||||||
|
"duration_label": "Срок",
|
||||||
|
"duration_days": "{days} дн",
|
||||||
"country_unknown": "Не указана",
|
"country_unknown": "Не указана",
|
||||||
"supplier_unknown": "Поставщик",
|
"supplier_unknown": "Поставщик",
|
||||||
"origin_label": "Откуда",
|
"origin_label": "Откуда",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user