Compare commits
10 Commits
a74e75049c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39712613ae | ||
|
|
e1e6993f35 | ||
|
|
7b4eaeeb92 | ||
|
|
351125b51d | ||
|
|
7033df0fbc | ||
|
|
008f41d891 | ||
|
|
84deb2d1bc | ||
|
|
54aac790ee | ||
|
|
d3183bf6ad | ||
|
|
670e9b7fd1 |
@@ -19,7 +19,7 @@ ENV SENTRY_ENABLED=false
|
|||||||
ENV NUXT_TELEMETRY_DISABLED=1
|
ENV NUXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build && pnpm prune --prod
|
RUN pnpm run build && pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
FROM node:22-slim AS runtime
|
FROM node:22-slim AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -1,155 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
/* ── Three-tier glass system (Apple-style glassmorphism) ── */
|
|
||||||
|
|
||||||
/* Tier 1 — lightest underlay, large panels / sidebars */
|
|
||||||
.glass-underlay {
|
|
||||||
background: rgba(255, 255, 255, 0.34);
|
|
||||||
box-shadow:
|
|
||||||
0 16px 44px rgba(24, 20, 12, 0.11),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tier 2 — medium capsule, nav pills / search bar */
|
|
||||||
.glass-capsule {
|
|
||||||
background: rgba(255, 255, 255, 0.56);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgba(24, 20, 12, 0.1),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.56);
|
|
||||||
backdrop-filter: blur(22px);
|
|
||||||
-webkit-backdrop-filter: blur(22px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tier 3 — densest chip, small tags / badges */
|
|
||||||
.glass-chip {
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legacy aliases — keep backward compat during transition */
|
|
||||||
.glass-soft {
|
|
||||||
background: rgba(255, 255, 255, 0.34);
|
|
||||||
box-shadow:
|
|
||||||
0 16px 44px rgba(24, 20, 12, 0.11),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
-webkit-backdrop-filter: blur(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-bright {
|
|
||||||
background: rgba(255, 255, 255, 0.56);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgba(24, 20, 12, 0.1),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.56);
|
|
||||||
backdrop-filter: blur(22px);
|
|
||||||
-webkit-backdrop-filter: blur(22px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Map overlays must stay solid (no glass) */
|
|
||||||
.map-chip {
|
|
||||||
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
|
|
||||||
border: 1px solid color-mix(in oklab, var(--color-base-300) 80%, transparent);
|
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header glass: two-layer Apple-style glassmorphism ── */
|
|
||||||
|
|
||||||
.header-glass {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layer 1: frosted bar backdrop — fades to transparent at bottom */
|
|
||||||
.header-glass-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
height: 350%;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
|
||||||
backdrop-filter: blur(16px) saturate(180%);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layer 2: capsule pills — denser frosted glass with inner shine */
|
|
||||||
.pill-glass {
|
|
||||||
position: relative;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 32px rgba(31, 38, 135, 0.2),
|
|
||||||
inset 0 4px 20px rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inner shine highlight — liquid glass refraction */
|
|
||||||
.pill-glass::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
box-shadow:
|
|
||||||
inset -10px -8px 0 -11px rgba(255, 255, 255, 1),
|
|
||||||
inset 0 -9px 0 -8px rgba(255, 255, 255, 1);
|
|
||||||
opacity: 0.6;
|
|
||||||
filter: blur(1px) brightness(115%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "silk";
|
name: "silk";
|
||||||
default: false;
|
default: 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);
|
||||||
@@ -167,55 +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";
|
|
||||||
--color-base-100: oklch(20.768% 0.039 265.754);
|
|
||||||
--color-base-200: oklch(19.314% 0.037 265.754);
|
|
||||||
--color-base-300: oklch(17.86% 0.034 265.754);
|
|
||||||
--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);
|
|
||||||
--color-secondary: oklch(68.011% 0.158 276.934);
|
|
||||||
--color-secondary-content: oklch(13.602% 0.031 276.934);
|
|
||||||
--color-accent: oklch(72.36% 0.176 350.048);
|
|
||||||
--color-accent-content: oklch(14.472% 0.035 350.048);
|
|
||||||
--color-neutral: oklch(27.949% 0.036 260.03);
|
|
||||||
--color-neutral-content: oklch(85.589% 0.007 260.03);
|
|
||||||
--color-info: oklch(68.455% 0.148 237.251);
|
|
||||||
--color-info-content: oklch(0% 0 0);
|
|
||||||
--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);
|
|
||||||
--color-warning-content: oklch(16.648% 0.027 82.95);
|
|
||||||
--color-error: oklch(71.785% 0.17 13.118);
|
|
||||||
--color-error-content: oklch(14.357% 0.034 13.118);
|
|
||||||
--radius-selector: 1rem;
|
|
||||||
--radius-field: 1rem;
|
|
||||||
--radius-box: 1rem;
|
|
||||||
--size-selector: 0.25rem;
|
|
||||||
--size-field: 0.25rem;
|
|
||||||
--border: 0.5px;
|
|
||||||
--depth: 0;
|
|
||||||
--noise: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.manager-logistics-shell {
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
color-scheme: light;
|
||||||
|
background-color: #f7f5f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
color-scheme: light;
|
||||||
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
|
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
|
||||||
|
color: oklch(24% 0.02 256);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 0%, #fdf8ea 0%, rgba(253, 248, 234, 0) 40%),
|
radial-gradient(circle at 10% 0%, #fdf8ea 0%, rgba(253, 248, 234, 0) 40%),
|
||||||
radial-gradient(circle at 90% 100%, #e9f2ff 0%, rgba(233, 242, 255, 0) 35%),
|
radial-gradient(circle at 90% 100%, #e9f2ff 0%, rgba(233, 242, 255, 0) 35%),
|
||||||
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
|
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glass-underlay {
|
||||||
|
background: #ece3d3;
|
||||||
|
border: 1px solid #d7ccb7;
|
||||||
|
box-shadow: 0 14px 30px rgba(24, 20, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-capsule {
|
||||||
|
background: #e9decb;
|
||||||
|
border: 1px solid #d5c7b0;
|
||||||
|
box-shadow: 0 8px 22px rgba(24, 20, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -35,17 +35,22 @@
|
|||||||
<!-- 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 map-chip rounded-full px-3 py-1.5 text-base-content text-sm hover:bg-base-200 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] z-20 hidden lg:flex items-center gap-2 map-chip rounded-full px-3 py-1.5 cursor-pointer text-base-content text-sm hover:bg-base-200 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"
|
:style="boundsFilterStyle"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -61,11 +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 map-chip rounded-full p-1">
|
<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
|
||||||
v-if="showOffersToggle"
|
v-if="showOffersToggle"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||||
:class="mapViewMode === 'offers' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
: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">
|
||||||
@@ -76,7 +81,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="showHubsToggle"
|
v-if="showHubsToggle"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||||
:class="mapViewMode === 'hubs' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
: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">
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="showSuppliersToggle"
|
v-if="showSuppliersToggle"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
|
||||||
:class="mapViewMode === 'suppliers' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
|
: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">
|
||||||
@@ -102,10 +107,10 @@
|
|||||||
<Transition name="slide-left">
|
<Transition name="slide-left">
|
||||||
<div
|
<div
|
||||||
v-if="isPanelOpen"
|
v-if="isPanelOpen"
|
||||||
class="absolute top-[116px] left-0 bottom-0 z-30 max-w-[calc(100vw-1rem)] 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-base-100 text-base-content border border-base-300 border-l-0 rounded-none rounded-tr-[1.1rem] rounded-bl-[1.1rem] shadow-2xl h-full flex flex-col">
|
<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>
|
||||||
@@ -117,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 map-chip rounded-full px-3 py-2 text-base-content text-sm font-medium"
|
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" />
|
||||||
@@ -125,11 +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 map-chip rounded-full p-1">
|
<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
|
||||||
v-if="showOffersToggle"
|
v-if="showOffersToggle"
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
:class="mapViewMode === 'offers' ? 'bg-base-300' : 'hover:bg-base-200'"
|
: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">
|
||||||
@@ -139,7 +144,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="showHubsToggle"
|
v-if="showHubsToggle"
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
:class="mapViewMode === 'hubs' ? 'bg-base-300' : 'hover:bg-base-200'"
|
: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">
|
||||||
@@ -149,7 +154,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="showSuppliersToggle"
|
v-if="showSuppliersToggle"
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
:class="mapViewMode === 'suppliers' ? 'bg-base-300' : 'hover:bg-base-200'"
|
: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">
|
||||||
@@ -163,7 +168,7 @@
|
|||||||
<Transition name="slide-up">
|
<Transition name="slide-up">
|
||||||
<div
|
<div
|
||||||
v-if="isPanelOpen"
|
v-if="isPanelOpen"
|
||||||
class="bg-base-100 text-base-content rounded-t-3xl border-t border-base-300 shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 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
|
||||||
|
|||||||
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>
|
||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
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
@@ -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,347 +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 manager-logistics-shell">
|
<div class="min-h-screen flex flex-col bg-base-100 text-base-content">
|
||||||
<AiChatSidebar
|
<AppHeader />
|
||||||
:open="isChatOpen"
|
|
||||||
:width="chatWidth"
|
|
||||||
@close="isChatOpen = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col" :style="contentStyle">
|
<main :class="contentClass" :style="mainStyle">
|
||||||
<!-- Fixed Header Container -->
|
<slot />
|
||||||
<div class="header-glass fixed inset-x-0 top-0 z-50 border-0" :style="headerContainerStyle">
|
</main>
|
||||||
<div class="header-glass-backdrop" aria-hidden="true" />
|
|
||||||
|
|
||||||
<!-- MainNavigation -->
|
<AppFooter v-if="!isFullscreenMapPage" />
|
||||||
<MainNavigation
|
|
||||||
class="relative z-10"
|
|
||||||
:height="100"
|
|
||||||
:collapse-progress="isHomePage ? 0 : 1"
|
|
||||||
:hero-scroll-y="isHomePage ? heroScrollY : 0"
|
|
||||||
:hero-base-height="isHomePage ? heroBaseHeight : 0"
|
|
||||||
: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 ? false : (isCatalogSection || isClientArea)"
|
|
||||||
:is-home-page="isHomePage"
|
|
||||||
:is-client-area="isClientArea"
|
|
||||||
:chat-open="isChatOpen"
|
|
||||||
@toggle-theme="toggleTheme"
|
|
||||||
@toggle-chat="isChatOpen = !isChatOpen"
|
|
||||||
@set-catalog-mode="setCatalogMode"
|
|
||||||
@sign-out="onClickSignOut"
|
|
||||||
@sign-in="signIn()"
|
|
||||||
@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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 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 />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</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()
|
|
||||||
|
|
||||||
const isChatOpen = useState('ai-chat-open', () => false)
|
|
||||||
const chatWidth = computed(() => (isChatOpen.value ? 'clamp(240px, 15vw, 360px)' : '0px'))
|
|
||||||
const contentStyle = computed(() => ({
|
|
||||||
marginLeft: isChatOpen.value ? chatWidth.value : '0px',
|
|
||||||
width: isChatOpen.value ? `calc(100% - ${chatWidth.value})` : '100%',
|
|
||||||
transition: 'margin-left 250ms ease, width 250ms ease'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
scrollY: heroScrollY,
|
|
||||||
heroBaseHeight,
|
|
||||||
} = useHeroScroll()
|
|
||||||
|
|
||||||
// Theme state
|
|
||||||
const theme = useState<'silk' | 'night'>('theme', () => 'silk')
|
|
||||||
|
|
||||||
// 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: '0' }
|
|
||||||
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: 'silk' | '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 === 'silk') {
|
|
||||||
theme.value = stored as 'silk' | 'night'
|
|
||||||
}
|
|
||||||
applyTheme(theme.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(theme, (value) => applyTheme(value))
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
theme.value = theme.value === 'night' ? 'silk' : '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', select: 'product' } })
|
|
||||||
}
|
|
||||||
// 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'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,87 +1,83 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0">
|
||||||
<!-- Fullscreen Map -->
|
<ClientOnly>
|
||||||
<div class="absolute inset-0">
|
<CatalogMap
|
||||||
<ClientOnly>
|
map-id="step-hub-map"
|
||||||
<CatalogMap
|
:items="hubMapItems"
|
||||||
ref="mapRef"
|
:use-server-clustering="false"
|
||||||
map-id="step-hub-map"
|
point-color="#22c55e"
|
||||||
:items="hubMapItems"
|
entity-type="hub"
|
||||||
:use-server-clustering="false"
|
:fit-padding-left="460"
|
||||||
point-color="#22c55e"
|
@select-item="onMapSelect"
|
||||||
entity-type="hub"
|
/>
|
||||||
@select-item="onMapSelect"
|
</ClientOnly>
|
||||||
@bounds-change="onBoundsChange"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom sheet card -->
|
<MapSidePanel
|
||||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
:title="t('catalog.steps.selectDestination')"
|
||||||
<article
|
:initial-collapsed="false"
|
||||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
style="max-height: 60vh"
|
>
|
||||||
>
|
<div class="space-y-3">
|
||||||
<!-- Header -->
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 2 }) }}</p>
|
||||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-1">
|
<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]">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 2 }) }}</p>
|
<Icon name="lucide:package" size="14" />
|
||||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectDestination') }}</h2>
|
<span class="truncate">{{ productName }}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search input -->
|
|
||||||
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
|
|
||||||
<Icon name="lucide:search" size="16" class="text-base-content/40" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('catalog.search.searchHubs')"
|
|
||||||
class="grow bg-transparent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hub list -->
|
<label class="input w-full rounded-full bg-white border-[#dccfbf] flex items-center gap-2">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
<Icon name="lucide:search" size="16" class="text-[#8a7761]" />
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
<input
|
||||||
<span class="loading loading-spinner loading-md" />
|
v-model="searchQuery"
|
||||||
</div>
|
type="text"
|
||||||
|
:placeholder="$t('catalog.search.searchHubs')"
|
||||||
|
class="grow bg-transparent"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredHubs.length === 0" class="text-center py-8 text-base-content/50">
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
<Icon name="lucide:warehouse" size="32" class="mb-2" />
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
<p>{{ $t('catalog.empty.noHubs') }}</p>
|
<span class="loading loading-spinner loading-md" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
v-for="hub in filteredHubs"
|
|
||||||
:key="hub.uuid"
|
|
||||||
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
|
|
||||||
@click="selectHub(hub)"
|
|
||||||
>
|
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-green-400 to-green-600 shadow-lg">
|
|
||||||
<Icon name="lucide:warehouse" size="20" class="text-white" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<span class="text-base font-bold text-base-content block truncate">{{ hub.name || hub.uuid }}</span>
|
|
||||||
<span v-if="hub.country" class="text-sm text-base-content/50">{{ hub.country }}</span>
|
|
||||||
</div>
|
|
||||||
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
|
||||||
|
|
||||||
definePageMeta({ layout: 'topnav' })
|
definePageMeta({ layout: 'topnav' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -89,24 +85,18 @@ const router = useRouter()
|
|||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const mapRef = ref(null)
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Get product from query
|
|
||||||
const productUuid = computed(() => route.query.product as string | undefined)
|
const productUuid = computed(() => route.query.product as string | undefined)
|
||||||
// Load hubs (filtered by product if available)
|
const productName = computed(() => route.query.productName as string | undefined)
|
||||||
|
|
||||||
const { items: hubs, isLoading, init: initHubs, setProductFilter } = useCatalogHubs()
|
const { items: hubs, isLoading, init: initHubs, setProductFilter } = useCatalogHubs()
|
||||||
|
|
||||||
const onBoundsChange = (_bounds: MapBounds) => {
|
|
||||||
// No clustering needed — showing hub items directly
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hub items for map
|
|
||||||
const hubMapItems = computed(() =>
|
const hubMapItems = computed(() =>
|
||||||
hubs.value
|
hubs.value
|
||||||
.filter(h => h.latitude != null && h.longitude != null)
|
.filter(h => h.latitude != null && h.longitude != null && h.uuid)
|
||||||
.map(h => ({
|
.map(h => ({
|
||||||
uuid: h.uuid || '',
|
uuid: h.uuid!,
|
||||||
name: h.name || '',
|
name: h.name || '',
|
||||||
latitude: Number(h.latitude),
|
latitude: Number(h.latitude),
|
||||||
longitude: Number(h.longitude),
|
longitude: Number(h.longitude),
|
||||||
@@ -114,13 +104,11 @@ const hubMapItems = computed(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Map click → select hub
|
|
||||||
const onMapSelect = (uuid: string) => {
|
const onMapSelect = (uuid: string) => {
|
||||||
const hub = hubs.value.find(h => h.uuid === uuid)
|
const hub = hubs.value.find(h => h.uuid === uuid)
|
||||||
if (hub) selectHub(hub)
|
if (hub) selectHub(hub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter hubs by search
|
|
||||||
const filteredHubs = computed(() => {
|
const filteredHubs = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return hubs.value
|
if (!searchQuery.value.trim()) return hubs.value
|
||||||
const q = searchQuery.value.toLowerCase().trim()
|
const q = searchQuery.value.toLowerCase().trim()
|
||||||
@@ -130,13 +118,14 @@ const filteredHubs = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Select hub → navigate to quantity step
|
|
||||||
const selectHub = (hub: { uuid?: string | null; name?: string | null }) => {
|
const selectHub = (hub: { uuid?: string | null; name?: string | null }) => {
|
||||||
if (!hub.uuid) return
|
if (!hub.uuid) return
|
||||||
|
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
...route.query as Record<string, string>,
|
...route.query as Record<string, string>,
|
||||||
hub: hub.uuid,
|
hub: hub.uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hub.name) query.hubName = hub.name
|
if (hub.name) query.hubName = hub.name
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
@@ -145,7 +134,13 @@ const selectHub = (hub: { uuid?: string | null; name?: string | null }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
const goBack = () => {
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/product'),
|
||||||
|
query: route.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (productUuid.value) {
|
if (productUuid.value) {
|
||||||
setProductFilter(productUuid.value)
|
setProductFilter(productUuid.value)
|
||||||
|
|||||||
@@ -1,83 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0">
|
||||||
<!-- Fullscreen Map -->
|
<ClientOnly>
|
||||||
<div class="absolute inset-0">
|
<CatalogMap
|
||||||
<ClientOnly>
|
map-id="step-product-map"
|
||||||
<CatalogMap
|
:items="[]"
|
||||||
ref="mapRef"
|
:clustered-points="clusteredNodes"
|
||||||
map-id="step-product-map"
|
:use-server-clustering="true"
|
||||||
:items="[]"
|
point-color="#f97316"
|
||||||
:clustered-points="clusteredNodes"
|
entity-type="offer"
|
||||||
:use-server-clustering="true"
|
:fit-padding-left="460"
|
||||||
point-color="#f97316"
|
@bounds-change="onBoundsChange"
|
||||||
entity-type="offer"
|
/>
|
||||||
@select-item="onMapSelect"
|
</ClientOnly>
|
||||||
@bounds-change="onBoundsChange"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom sheet card -->
|
<MapSidePanel
|
||||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
:title="t('catalog.steps.selectProduct')"
|
||||||
<article
|
:initial-collapsed="false"
|
||||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
style="max-height: 60vh"
|
>
|
||||||
>
|
<div class="space-y-3">
|
||||||
<!-- Header -->
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 1 }) }}</p>
|
||||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
|
||||||
<!-- Drag handle -->
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-1">
|
<label class="input w-full rounded-full bg-white border-[#dccfbf] flex items-center gap-2">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 1 }) }}</p>
|
<Icon name="lucide:search" size="16" class="text-[#8a7761]" />
|
||||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectProduct') }}</h2>
|
<input
|
||||||
</div>
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('catalog.search.searchProducts')"
|
||||||
|
class="grow bg-transparent"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search input -->
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
<Icon name="lucide:search" size="16" class="text-base-content/40" />
|
<span class="loading loading-spinner loading-md" />
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('catalog.search.searchProducts')"
|
|
||||||
class="grow bg-transparent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product list -->
|
<div v-else-if="filteredProducts.length === 0" class="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
<Icon name="lucide:package-x" size="30" class="mx-auto mb-2" />
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
<p>{{ $t('catalog.empty.noProducts') }}</p>
|
||||||
<span class="loading loading-spinner loading-md" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="filteredProducts.length === 0" class="text-center py-8 text-base-content/50">
|
|
||||||
<Icon name="lucide:package-x" size="32" class="mb-2" />
|
|
||||||
<p>{{ $t('catalog.empty.noProducts') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
v-for="product in filteredProducts"
|
|
||||||
:key="product.uuid"
|
|
||||||
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
|
|
||||||
@click="selectProduct(product)"
|
|
||||||
>
|
|
||||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 shadow-lg">
|
|
||||||
<Icon name="lucide:package" size="20" class="text-white" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<span class="text-base font-bold text-base-content block truncate">{{ product.name || product.uuid }}</span>
|
|
||||||
<span v-if="product.offersCount" class="text-sm text-base-content/50">{{ product.offersCount }} {{ $t('catalog.offers') }}</span>
|
|
||||||
</div>
|
|
||||||
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -91,24 +73,15 @@ const router = useRouter()
|
|||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const mapRef = ref(null)
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Load products
|
|
||||||
const { items: products, isLoading, init: initProducts } = useCatalogProducts()
|
const { items: products, isLoading, init: initProducts } = useCatalogProducts()
|
||||||
|
|
||||||
// Clustering for map background
|
|
||||||
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, ref('offer'))
|
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, ref('offer'))
|
||||||
|
|
||||||
const onBoundsChange = (bounds: MapBounds) => {
|
const onBoundsChange = (bounds: MapBounds) => {
|
||||||
fetchClusters(bounds)
|
fetchClusters(bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMapSelect = (uuid: string) => {
|
|
||||||
// Map click — ignore for product step
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter products by search
|
|
||||||
const filteredProducts = computed(() => {
|
const filteredProducts = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return products.value
|
if (!searchQuery.value.trim()) return products.value
|
||||||
const q = searchQuery.value.toLowerCase().trim()
|
const q = searchQuery.value.toLowerCase().trim()
|
||||||
@@ -118,7 +91,6 @@ const filteredProducts = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Select product → navigate to hub step
|
|
||||||
const selectProduct = (product: { uuid: string; name?: string | null }) => {
|
const selectProduct = (product: { uuid: string; name?: string | null }) => {
|
||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
...route.query as Record<string, string>,
|
...route.query as Record<string, string>,
|
||||||
@@ -132,7 +104,6 @@ const selectProduct = (product: { uuid: string; name?: string | null }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initProducts()
|
initProducts()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,66 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0">
|
||||||
<!-- Fullscreen Map -->
|
<ClientOnly>
|
||||||
<div class="absolute inset-0">
|
<CatalogMap
|
||||||
<ClientOnly>
|
map-id="step-quantity-map"
|
||||||
<CatalogMap
|
:items="mapPoints"
|
||||||
ref="mapRef"
|
:use-server-clustering="false"
|
||||||
map-id="step-quantity-map"
|
point-color="#22c55e"
|
||||||
:items="mapPoints"
|
entity-type="hub"
|
||||||
:use-server-clustering="false"
|
:related-points="relatedPoints"
|
||||||
point-color="#22c55e"
|
:info-loading="false"
|
||||||
entity-type="hub"
|
:fit-padding-left="460"
|
||||||
:related-points="relatedPoints"
|
/>
|
||||||
:info-loading="false"
|
</ClientOnly>
|
||||||
@bounds-change="() => {}"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom sheet card -->
|
<MapSidePanel
|
||||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
:title="t('catalog.steps.setQuantity')"
|
||||||
<article
|
:initial-collapsed="false"
|
||||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
style="max-height: 60vh"
|
>
|
||||||
>
|
<div class="space-y-4">
|
||||||
<div class="shrink-0 p-5 md:px-7 md:pt-7">
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.step', { n: 3 }) }}</p>
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
<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>
|
||||||
|
<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]">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 3 }) }}</p>
|
<Icon name="lucide:warehouse" size="14" />
|
||||||
<h2 class="text-2xl font-black tracking-tight text-base-content mb-4">{{ $t('catalog.steps.setQuantity') }}</h2>
|
<span class="truncate">{{ hubName }}</span>
|
||||||
|
|
||||||
<!-- Quantity input -->
|
|
||||||
<div class="form-control w-full max-w-xs">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-bold">{{ $t('catalog.filters.quantity') }}</span>
|
|
||||||
</label>
|
|
||||||
<label class="input input-bordered rounded-xl flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="qty"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
placeholder="100"
|
|
||||||
class="grow bg-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
<span class="text-base-content/50 text-sm">{{ $t('units.t') }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search button -->
|
|
||||||
<button
|
|
||||||
class="btn btn-primary w-full mt-6 rounded-full text-base font-bold"
|
|
||||||
:disabled="!canSearch"
|
|
||||||
@click="goSearch"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:search" size="18" />
|
|
||||||
{{ $t('catalog.quote.findOffers') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,22 +81,23 @@ const localePath = useLocalePath()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
const mapRef = ref(null)
|
const qty = ref(typeof route.query.qty === 'string' ? route.query.qty : '100')
|
||||||
const qty = ref('100')
|
|
||||||
|
|
||||||
const productUuid = computed(() => route.query.product as string | undefined)
|
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 hubUuid = computed(() => route.query.hub as string | undefined)
|
||||||
const hubName = computed(() => route.query.hubName as string | undefined)
|
const hubName = computed(() => route.query.hubName as string | undefined)
|
||||||
|
|
||||||
const canSearch = computed(() => !!(productUuid.value && hubUuid.value))
|
const canSearch = computed(() => !!(productUuid.value && hubUuid.value && Number(qty.value) > 0))
|
||||||
|
|
||||||
// Load hub coordinates for map
|
|
||||||
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
||||||
|
|
||||||
const loadHubPoint = async () => {
|
const loadHubPoint = async () => {
|
||||||
if (!hubUuid.value) return
|
if (!hubUuid.value) return
|
||||||
|
|
||||||
const data = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
const data = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
||||||
const node = data?.node
|
const node = data?.node
|
||||||
|
|
||||||
if (node?.latitude != null && node?.longitude != null) {
|
if (node?.latitude != null && node?.longitude != null) {
|
||||||
hubPoint.value = {
|
hubPoint.value = {
|
||||||
uuid: node.uuid,
|
uuid: node.uuid,
|
||||||
@@ -118,6 +125,7 @@ const goSearch = () => {
|
|||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
...route.query as Record<string, string>,
|
...route.query as Record<string, string>,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qty.value) query.qty = qty.value
|
if (qty.value) query.qty = qty.value
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
@@ -126,6 +134,13 @@ const goSearch = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/destination'),
|
||||||
|
query: route.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadHubPoint()
|
loadHubPoint()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,106 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0">
|
||||||
<!-- Fullscreen Map -->
|
<ClientOnly>
|
||||||
<div class="absolute inset-0">
|
<CatalogMap
|
||||||
<ClientOnly>
|
map-id="step-results-map"
|
||||||
<CatalogMap
|
:items="mapItems"
|
||||||
ref="mapRef"
|
:use-server-clustering="false"
|
||||||
map-id="step-results-map"
|
point-color="#f97316"
|
||||||
:items="[]"
|
entity-type="offer"
|
||||||
:use-server-clustering="false"
|
:related-points="relatedPoints"
|
||||||
point-color="#f97316"
|
:info-loading="offersLoading"
|
||||||
entity-type="offer"
|
:fit-padding-left="460"
|
||||||
:related-points="relatedPoints"
|
@select-item="onMapSelect"
|
||||||
:info-loading="offersLoading"
|
/>
|
||||||
@select-item="onMapSelect"
|
</ClientOnly>
|
||||||
@bounds-change="() => {}"
|
|
||||||
/>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom sheet card -->
|
<MapSidePanel
|
||||||
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
|
:title="t('catalog.headers.offers')"
|
||||||
<article
|
:initial-collapsed="false"
|
||||||
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
|
width-class="w-[min(calc(100vw-1rem),440px)] md:w-[420px] xl:w-[460px]"
|
||||||
style="max-height: 60vh"
|
>
|
||||||
>
|
<div class="space-y-3">
|
||||||
<!-- Header -->
|
<p class="text-xs font-bold uppercase tracking-wider text-[#8a7761]">{{ $t('catalog.steps.results') }}</p>
|
||||||
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div class="w-10 h-1 bg-base-300 rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div>
|
<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]">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.steps.results') }}</p>
|
<Icon name="lucide:package" size="12" />
|
||||||
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.headers.offers') }}</h2>
|
{{ productName }}
|
||||||
</div>
|
</span>
|
||||||
<span v-if="!offersLoading" class="badge badge-neutral">{{ offers.length }}</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]">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- Selected filters summary -->
|
<div class="mt-4 flex flex-col gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
<div v-if="offersLoading" class="flex items-center justify-center py-8">
|
||||||
<span v-if="productName" class="badge badge-warning gap-1">
|
<span class="loading loading-spinner loading-md" />
|
||||||
<Icon name="lucide:package" size="12" />
|
|
||||||
{{ productName }}
|
|
||||||
</span>
|
|
||||||
<Icon name="lucide:arrow-right" size="14" class="text-base-content/30" />
|
|
||||||
<span v-if="hubName" class="badge badge-success gap-1">
|
|
||||||
<Icon name="lucide:warehouse" size="12" />
|
|
||||||
{{ hubName }}
|
|
||||||
</span>
|
|
||||||
<span v-if="qty" class="badge badge-info gap-1">
|
|
||||||
{{ qty }} {{ $t('units.t') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Offers list -->
|
<div v-else-if="offers.length === 0" class="rounded-2xl border border-[#e4d9ca] bg-white px-4 py-8 text-center text-[#7c6d5a]">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
|
<Icon name="lucide:search-x" size="30" class="mx-auto mb-2" />
|
||||||
<div v-if="offersLoading" class="flex items-center justify-center py-8">
|
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||||
<span class="loading loading-spinner loading-md" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="offers.length === 0" class="text-center py-8 text-base-content/50">
|
|
||||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
|
||||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
|
||||||
<button class="btn btn-ghost btn-sm mt-3" @click="goBack">
|
|
||||||
<Icon name="lucide:arrow-left" size="16" />
|
|
||||||
{{ $t('common.back') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-3">
|
|
||||||
<div
|
|
||||||
v-for="offer in offers"
|
|
||||||
:key="offer.uuid"
|
|
||||||
class="cursor-pointer"
|
|
||||||
@click="onSelectOffer(offer)"
|
|
||||||
>
|
|
||||||
<OfferResultCard
|
|
||||||
:supplier-name="offer.supplierName"
|
|
||||||
:location-name="offer.country || ''"
|
|
||||||
:product-name="offer.productName"
|
|
||||||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
|
||||||
:quantity="offer.quantity"
|
|
||||||
:currency="offer.currency"
|
|
||||||
:unit="offer.unit"
|
|
||||||
:stages="getOfferStages(offer)"
|
|
||||||
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New search button -->
|
<div v-else class="flex flex-col gap-3">
|
||||||
<div class="shrink-0 p-5 pt-0 md:px-7">
|
<button
|
||||||
<button class="btn btn-outline btn-sm w-full rounded-full" @click="goBack">
|
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" />
|
<Icon name="lucide:refresh-cw" size="14" />
|
||||||
{{ $t('catalog.steps.newSearch') }}
|
{{ $t('catalog.steps.newSearch') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</template>
|
||||||
</div>
|
</MapSidePanel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -117,24 +95,30 @@ const localePath = useLocalePath()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { execute } = useGraphQL()
|
const { execute } = useGraphQL()
|
||||||
|
|
||||||
const mapRef = ref(null)
|
|
||||||
|
|
||||||
const productUuid = computed(() => route.query.product as string | undefined)
|
const productUuid = computed(() => route.query.product as string | undefined)
|
||||||
const productName = computed(() => route.query.productName as string | undefined)
|
const productName = computed(() => route.query.productName as string | undefined)
|
||||||
const hubUuid = computed(() => route.query.hub as string | undefined)
|
const hubUuid = computed(() => route.query.hub as string | undefined)
|
||||||
const hubName = computed(() => route.query.hubName as string | undefined)
|
const hubName = computed(() => route.query.hubName as string | undefined)
|
||||||
const qty = computed(() => route.query.qty as string | undefined)
|
const qty = computed(() => route.query.qty as string | undefined)
|
||||||
|
|
||||||
// Offers data
|
|
||||||
const offers = ref<NearestOffer[]>([])
|
const offers = ref<NearestOffer[]>([])
|
||||||
const offersLoading = ref(false)
|
const offersLoading = ref(false)
|
||||||
|
|
||||||
// Hub point for map
|
|
||||||
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
|
||||||
|
|
||||||
// Related points for map (hub + offer locations)
|
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 relatedPoints = computed(() => {
|
||||||
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number; type: 'hub' | 'supplier' | 'offer' }> = []
|
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number; type: 'hub' | 'offer' }> = []
|
||||||
|
|
||||||
if (hubPoint.value) {
|
if (hubPoint.value) {
|
||||||
points.push({
|
points.push({
|
||||||
@@ -147,7 +131,7 @@ const relatedPoints = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
offers.value
|
offers.value
|
||||||
.filter(o => o.latitude != null && o.longitude != null)
|
.filter(o => o.uuid && o.latitude != null && o.longitude != null)
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
points.push({
|
points.push({
|
||||||
uuid: o.uuid,
|
uuid: o.uuid,
|
||||||
@@ -167,22 +151,23 @@ const onMapSelect = (uuid: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSelectOffer = (offer: NearestOffer) => {
|
const onSelectOffer = (offer: NearestOffer) => {
|
||||||
const productUuid = offer.productUuid
|
const selectedProductUuid = offer.productUuid
|
||||||
if (offer.uuid && productUuid) {
|
if (offer.uuid && selectedProductUuid) {
|
||||||
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
|
router.push(localePath(`/catalog/offers/${selectedProductUuid}?offer=${offer.uuid}`))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOfferStages = (offer: NearestOffer) => {
|
const getOfferStages = (offer: NearestOffer) => {
|
||||||
const r = offer.routes?.[0]
|
const firstRoute = offer.routes?.[0]
|
||||||
if (!r?.stages) return []
|
if (!firstRoute?.stages) return []
|
||||||
return r.stages
|
|
||||||
.filter((s): s is NonNullable<typeof s> => s !== null)
|
return firstRoute.stages
|
||||||
.map(s => ({
|
.filter((stage): stage is NonNullable<typeof stage> => stage !== null)
|
||||||
transportType: s.transportType,
|
.map(stage => ({
|
||||||
distanceKm: s.distanceKm,
|
transportType: stage.transportType,
|
||||||
travelTimeSeconds: s.travelTimeSeconds,
|
distanceKm: stage.distanceKm,
|
||||||
fromName: s.fromName,
|
travelTimeSeconds: stage.travelTimeSeconds,
|
||||||
|
fromName: stage.fromName,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,14 +177,14 @@ const goBack = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for offers
|
|
||||||
const searchOffers = async () => {
|
const searchOffers = async () => {
|
||||||
if (!productUuid.value || !hubUuid.value) return
|
if (!productUuid.value || !hubUuid.value) return
|
||||||
|
|
||||||
offersLoading.value = true
|
offersLoading.value = true
|
||||||
try {
|
try {
|
||||||
// Load hub coordinates
|
|
||||||
const hubData = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
const hubData = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
|
||||||
const hub = hubData?.node
|
const hub = hubData?.node
|
||||||
|
|
||||||
if (!hub?.latitude || !hub?.longitude) {
|
if (!hub?.latitude || !hub?.longitude) {
|
||||||
offers.value = []
|
offers.value = []
|
||||||
return
|
return
|
||||||
@@ -212,7 +197,6 @@ const searchOffers = async () => {
|
|||||||
longitude: Number(hub.longitude),
|
longitude: Number(hub.longitude),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search nearest offers
|
|
||||||
const data = await execute(
|
const data = await execute(
|
||||||
NearestOffersDocument,
|
NearestOffersDocument,
|
||||||
{
|
{
|
||||||
@@ -226,7 +210,7 @@ const searchOffers = async () => {
|
|||||||
'geo'
|
'geo'
|
||||||
)
|
)
|
||||||
|
|
||||||
offers.value = (data?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
|
offers.value = (data?.nearestOffers || []).filter((offer): offer is NearestOffer => offer !== null)
|
||||||
} finally {
|
} finally {
|
||||||
offersLoading.value = false
|
offersLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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-base-300 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-base-content/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-base-200 border-base-300 text-base-content placeholder:text-base-content/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-base-content/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-base-200 border-base-300 text-base-content hover:bg-base-200 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-base-200 rounded-lg p-3 hover:bg-base-200 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-base-content/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-base-content/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-base-content/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-base-content/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-base-content/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-base-300 flex-shrink-0">
|
<span class="text-xs text-[#7c6d5a]">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
||||||
<span class="text-xs text-base-content/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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1009
app/pages/index.vue
@@ -1,20 +1,48 @@
|
|||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const originalConsoleError = console.error
|
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[]) => {
|
console.error = (...args: unknown[]) => {
|
||||||
const hasApolloDevtoolsWarning = args.some((arg) => {
|
if (shouldSuppressApolloNoise(args)) {
|
||||||
if (typeof arg !== 'string') return false
|
|
||||||
|
|
||||||
return (
|
|
||||||
arg.includes('connectToDevTools') &&
|
|
||||||
arg.includes('devtools.enabled')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (hasApolloDevtoolsWarning) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
originalConsoleError(...args)
|
originalConsoleError(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
if (shouldSuppressApolloNoise(args)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
originalConsoleWarn(...args)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
i18n/locales/ru/settings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"language": "Язык",
|
||||||
|
"currency": "Валюта",
|
||||||
|
"open": "Открыть настройки",
|
||||||
|
"apply": "Применить",
|
||||||
|
"ratesProvider": "Поставщик курсов",
|
||||||
|
"ratesBy": "Курсы от ExchangeRate API",
|
||||||
|
"locales": {
|
||||||
|
"ru": {
|
||||||
|
"label": "Russian",
|
||||||
|
"nativeLabel": "Русский"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"label": "English",
|
||||||
|
"nativeLabel": "English"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"currencies": {
|
||||||
|
"USD": "Доллар США",
|
||||||
|
"RUB": "Российский рубль",
|
||||||
|
"CNY": "Китайский юань",
|
||||||
|
"EUR": "Евро",
|
||||||
|
"AED": "Дирхам ОАЭ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
i18n/locales/ru/ui.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"ui": {
|
||||||
|
"menu": "Меню",
|
||||||
|
"calculate": "Рассчитать",
|
||||||
|
"my_orders": "Мои заказы",
|
||||||
|
"log_in": "Войти",
|
||||||
|
"login": "Войти",
|
||||||
|
"profile": "Профиль",
|
||||||
|
"from": "Откуда",
|
||||||
|
"to": "Куда",
|
||||||
|
"cargo": "Груз",
|
||||||
|
"product": "Что",
|
||||||
|
"quantity": "Сколько",
|
||||||
|
"find": "Найти",
|
||||||
|
"manager_navigation": "Навигация менеджера",
|
||||||
|
"orders": "Заказы",
|
||||||
|
"quotations": "Котировки",
|
||||||
|
"hubs": "Хабы",
|
||||||
|
"users": "Пользователи",
|
||||||
|
"manager": "Менеджер"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ export default defineNuxtConfig({
|
|||||||
'ru/clientTeam.json',
|
'ru/clientTeam.json',
|
||||||
'ru/clientTeamSwitch.json',
|
'ru/clientTeamSwitch.json',
|
||||||
'ru/common.json',
|
'ru/common.json',
|
||||||
|
'ru/ui.json',
|
||||||
|
'ru/settings.json',
|
||||||
'ru/cta.json',
|
'ru/cta.json',
|
||||||
'ru/dashboard.json',
|
'ru/dashboard.json',
|
||||||
'ru/footer.json',
|
'ru/footer.json',
|
||||||
@@ -114,6 +116,8 @@ export default defineNuxtConfig({
|
|||||||
'en/clientTeam.json',
|
'en/clientTeam.json',
|
||||||
'en/clientTeamSwitch.json',
|
'en/clientTeamSwitch.json',
|
||||||
'en/common.json',
|
'en/common.json',
|
||||||
|
'en/ui.json',
|
||||||
|
'en/settings.json',
|
||||||
'en/cta.json',
|
'en/cta.json',
|
||||||
'en/dashboard.json',
|
'en/dashboard.json',
|
||||||
'en/footer.json',
|
'en/footer.json',
|
||||||
@@ -184,6 +188,20 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
routeRules: {
|
||||||
|
// Avoid stale HTML after deploys (old page -> missing _nuxt chunks -> white flash).
|
||||||
|
'/**': {
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'no-store'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Keep long-lived immutable cache for hashed static assets.
|
||||||
|
'/_nuxt/**': {
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'public, max-age=31536000, immutable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000
|
port: 3000
|
||||||
|
|||||||
6
public/trust-logos/absolutbank.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Absolut Bank</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#fff8ea"/>
|
||||||
|
<circle cx="34" cy="32" r="14" fill="#d84a2f"/>
|
||||||
|
<text x="58" y="37" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="18" font-weight="800">Absolut Bank</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
7
public/trust-logos/dellin.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Dellin</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#eef8f0"/>
|
||||||
|
<path d="M28 20h22v24H28z" fill="#2b8a3e"/>
|
||||||
|
<path d="M34 26h10v12H34z" fill="#ffffff"/>
|
||||||
|
<text x="62" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="800">Dellin</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
6
public/trust-logos/fesco.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">FESCO</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#eef5ff"/>
|
||||||
|
<path d="M24 39c13-17 29-17 42 0" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<text x="76" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="23" font-weight="900">FESCO</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 448 B |
6
public/trust-logos/gazprom.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Gazprom</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#eef7ff"/>
|
||||||
|
<path d="M36 18c8 10 2 15 2 22 0 5 4 8 9 8 8 0 13-6 13-14 0-9-7-16-15-22 2 10-8 14-9 6Z" fill="#2b7de9"/>
|
||||||
|
<text x="72" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">Gazprom</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
6
public/trust-logos/kalashnikov.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Kalashnikov</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#f5f0ea"/>
|
||||||
|
<path d="M30 20h24v8H42v16H30z" fill="#242424"/>
|
||||||
|
<text x="64" y="37" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="17" font-weight="900">Kalashnikov</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 404 B |
6
public/trust-logos/pek.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">PEK</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#fff2e6"/>
|
||||||
|
<rect x="26" y="20" width="34" height="24" rx="7" fill="#ef7d22"/>
|
||||||
|
<text x="74" y="39" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="27" font-weight="900">PEK</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 406 B |
7
public/trust-logos/russia1.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Russia 1</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#eef3ff"/>
|
||||||
|
<rect x="24" y="20" width="42" height="24" rx="7" fill="#2563eb"/>
|
||||||
|
<rect x="50" y="20" width="16" height="24" rx="5" fill="#dc2626"/>
|
||||||
|
<text x="78" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">Russia 1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
7
public/trust-logos/sberlog.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">SberLog</title>
|
||||||
|
<rect width="180" height="64" rx="18" fill="#edf9f2"/>
|
||||||
|
<circle cx="39" cy="32" r="15" fill="#21a038"/>
|
||||||
|
<path d="m31 31 6 6 13-15" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="66" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">SberLog</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 519 B |
@@ -1,4 +1,4 @@
|
|||||||
import { defineEventHandler, createError } from 'h3'
|
import { defineEventHandler, createError, readBody } from 'h3'
|
||||||
import type LogtoClient from '@logto/node'
|
import type LogtoClient from '@logto/node'
|
||||||
|
|
||||||
const RESOURCES = {
|
const RESOURCES = {
|
||||||
@@ -20,6 +20,10 @@ export interface RefreshResponse {
|
|||||||
tokens: Partial<Record<ResourceKey, TokenInfo>>
|
tokens: Partial<Record<ResourceKey, TokenInfo>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RefreshBody {
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
function decodeTokenExpiry(token: string): number {
|
function decodeTokenExpiry(token: string): number {
|
||||||
try {
|
try {
|
||||||
const payload = token.split('.')[1]
|
const payload = token.split('.')[1]
|
||||||
@@ -44,13 +48,20 @@ function decodeTokenExpiry(token: string): number {
|
|||||||
export default defineEventHandler(async (event): Promise<RefreshResponse> => {
|
export default defineEventHandler(async (event): Promise<RefreshResponse> => {
|
||||||
const client = event.context.logtoClient as LogtoClient | undefined
|
const client = event.context.logtoClient as LogtoClient | undefined
|
||||||
const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
|
const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
|
||||||
|
let body: RefreshBody = {}
|
||||||
|
try {
|
||||||
|
body = (await readBody<RefreshBody>(event)) || {}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
body = {}
|
||||||
|
}
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw createError({ statusCode: 401, message: 'Not authenticated' })
|
throw createError({ statusCode: 401, message: 'Not authenticated' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get first organization from Logto user
|
// Prefer explicit organizationId from client when switching active team.
|
||||||
const organizationId = logtoUser?.organizations?.[0]
|
const organizationId = body.organizationId || logtoUser?.organizations?.[0]
|
||||||
|
|
||||||
const tokens: Partial<Record<ResourceKey, TokenInfo>> = {}
|
const tokens: Partial<Record<ResourceKey, TokenInfo>> = {}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,28 @@ const RESOURCES = [
|
|||||||
'https://billing.optovia.ru'
|
'https://billing.optovia.ru'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const LOCALE_CODES = ['ru', 'en'] as const
|
||||||
|
|
||||||
|
function resolveLocalizedPath(pathname: string) {
|
||||||
|
const trimmed = pathname === '/' ? '/' : pathname.replace(/\/+$/, '') || '/'
|
||||||
|
const segments = trimmed.split('/').filter(Boolean)
|
||||||
|
const firstSegment = segments[0]
|
||||||
|
|
||||||
|
if (firstSegment && LOCALE_CODES.includes(firstSegment as (typeof LOCALE_CODES)[number])) {
|
||||||
|
const normalized = `/${segments.slice(1).join('/')}` || '/'
|
||||||
|
const normalizedPath = normalized === '//' ? '/' : normalized
|
||||||
|
return {
|
||||||
|
localePrefix: `/${firstSegment}`,
|
||||||
|
normalizedPath: normalizedPath || '/',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
localePrefix: '',
|
||||||
|
normalizedPath: trimmed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createSessionWrapper = (event: H3Event) => {
|
const createSessionWrapper = (event: H3Event) => {
|
||||||
const storage = useStorage('logto')
|
const storage = useStorage('logto')
|
||||||
let currentSessionId = ''
|
let currentSessionId = ''
|
||||||
@@ -60,16 +82,19 @@ const createSessionWrapper = (event: H3Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const endpoint = process.env.NUXT_LOGTO_ENDPOINT || 'https://auth.optovia.ru'
|
const endpoint = process.env.NUXT_LOGTO_ENDPOINT || process.env.LOGTO_ENDPOINT || 'https://auth.optovia.ru'
|
||||||
const appId = process.env.NUXT_LOGTO_APP_ID || ''
|
const appId = process.env.NUXT_LOGTO_APP_ID || process.env.LOGTO_APP_ID || process.env.LOGTO_CLIENT_ID || ''
|
||||||
const appSecret = process.env.NUXT_LOGTO_APP_SECRET || ''
|
const appSecret = process.env.NUXT_LOGTO_APP_SECRET || process.env.LOGTO_APP_SECRET || process.env.LOGTO_CLIENT_SECRET || ''
|
||||||
|
const url = getRequestURL(event)
|
||||||
|
const { localePrefix, normalizedPath } = resolveLocalizedPath(url.pathname)
|
||||||
|
|
||||||
if (!appId || !appSecret) {
|
if (!appId || !appSecret) {
|
||||||
|
if (normalizedPath === '/sign-in' || normalizedPath === '/sign-out' || normalizedPath === '/callback') {
|
||||||
|
await sendRedirect(event, localePrefix || '/', 302)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getRequestURL(event)
|
|
||||||
|
|
||||||
if (getCookie(event, LEGACY_COOKIE_NAME)) {
|
if (getCookie(event, LEGACY_COOKIE_NAME)) {
|
||||||
setCookie(event, LEGACY_COOKIE_NAME, '', { path: '/', maxAge: 0 })
|
setCookie(event, LEGACY_COOKIE_NAME, '', { path: '/', maxAge: 0 })
|
||||||
}
|
}
|
||||||
@@ -113,21 +138,24 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (url.pathname === '/sign-in') {
|
if (normalizedPath === '/sign-in') {
|
||||||
|
const callbackPath = `${localePrefix}/callback`
|
||||||
await logto.signIn({
|
await logto.signIn({
|
||||||
redirectUri: new URL('/callback', url).href
|
redirectUri: new URL(callbackPath || '/callback', url).href
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/sign-out') {
|
if (normalizedPath === '/sign-out') {
|
||||||
await logto.signOut(new URL('/', url).href)
|
const homePath = localePrefix || '/'
|
||||||
|
await logto.signOut(new URL(homePath, url).href)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/callback') {
|
if (normalizedPath === '/callback') {
|
||||||
await logto.handleSignInCallback(url.href)
|
await logto.handleSignInCallback(url.href)
|
||||||
await sendRedirect(event, '/', 302)
|
const clientareaPath = `${localePrefix}/clientarea`
|
||||||
|
await sendRedirect(event, clientareaPath || '/clientarea', 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
const client = event.context.logtoClient as LogtoClient | undefined
|
const client = event.context.logtoClient as LogtoClient | undefined
|
||||||
if (!client) return
|
if (!client) return
|
||||||
|
|
||||||
let idToken: string | null = null
|
const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
|
||||||
|
const organizationId = event.context.logtoOrgId || logtoUser?.organizations?.[0]
|
||||||
|
|
||||||
|
let token: string | null = null
|
||||||
try {
|
try {
|
||||||
idToken = await client.getIdToken()
|
token = await client.getIdToken()
|
||||||
} catch {
|
} catch {
|
||||||
return
|
try {
|
||||||
|
token = await client.getAccessToken('https://teams.optovia.ru', organizationId)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idToken) return
|
if (!token) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { GetMeDocument, GetMeProfileDocument } = await import('~/composables/graphql/user/teams-generated')
|
const { GetMeDocument, GetMeProfileDocument } = await import('~/composables/graphql/user/teams-generated')
|
||||||
@@ -48,12 +55,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const [meResponse, profileResponse] = await Promise.all([
|
const [meResponse, profileResponse] = await Promise.all([
|
||||||
$fetch<{ data?: { me?: MePayload } }>(endpoint, {
|
$fetch<{ data?: { me?: MePayload } }>(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${idToken}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: { query: print(GetMeDocument) }
|
body: { query: print(GetMeDocument) }
|
||||||
}),
|
}),
|
||||||
$fetch<{ data?: { me?: MePayload } }>(endpoint, {
|
$fetch<{ data?: { me?: MePayload } }>(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${idToken}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: { query: print(GetMeProfileDocument) }
|
body: { query: print(GetMeProfileDocument) }
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|||||||