Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:10:35 +07:00
commit 3db50d9637
371 changed files with 43223 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

46
.storybook/main.ts Normal file
View File

@@ -0,0 +1,46 @@
import path from 'node:path'
import type { StorybookConfig } from '@storybook/vue3-vite'
import vue from '@vitejs/plugin-vue'
const config: StorybookConfig = {
stories: ['../app/components/**/*.stories.@(js|ts)'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: {
name: '@storybook/vue3-vite',
options: {}
},
core: {
disableTelemetry: true
},
docs: {
autodocs: false
},
viteFinal: async (baseConfig) => {
const projectRoot = path.resolve(__dirname, '..')
baseConfig.resolve = baseConfig.resolve || {}
baseConfig.resolve.alias = {
...baseConfig.resolve.alias,
'~': path.resolve(__dirname, '../app'),
'@': path.resolve(__dirname, '../app'),
'@graphql-typed-document-node/core': path.resolve(__dirname, './shims/graphql-typed-document-node-core.ts')
}
baseConfig.plugins = baseConfig.plugins || []
baseConfig.plugins.push(vue())
baseConfig.root = projectRoot
baseConfig.server = {
...(baseConfig.server || {}),
fs: {
...(baseConfig.server?.fs || {}),
allow: Array.from(
new Set([
...(baseConfig.server?.fs?.allow || []),
projectRoot
])
)
}
}
return baseConfig
}
}
export default config

23
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Preview } from '@storybook/vue3'
import { setup } from '@storybook/vue3'
import '../app/assets/css/tailwind.css'
setup((app) => {
app.config.globalProperties.$t = (key: string) => key
app.config.globalProperties.$d = (value: any) => value
})
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
}
}
}
export default preview

View File

@@ -0,0 +1,10 @@
// Minimal runtime shim so Vite/Storybook can resolve generated GraphQL imports.
import type { DocumentNode } from 'graphql'
export type TypedDocumentNode<TResult = any, TVariables = Record<string, any>> = DocumentNode & {
__resultType?: TResult
__variablesType?: TVariables
}
// Runtime placeholder; generated files import the symbol but do not use the value.
export const TypedDocumentNode = {} as unknown as TypedDocumentNode

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:22-slim AS build
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
WORKDIR /app
RUN corepack enable
ARG INFISICAL_API_URL
ARG INFISICAL_CLIENT_ID
ARG INFISICAL_CLIENT_SECRET
ARG INFISICAL_PROJECT_ID
ARG INFISICAL_ENV
ENV INFISICAL_API_URL=$INFISICAL_API_URL \
INFISICAL_CLIENT_ID=$INFISICAL_CLIENT_ID \
INFISICAL_CLIENT_SECRET=$INFISICAL_CLIENT_SECRET \
INFISICAL_PROJECT_ID=$INFISICAL_PROJECT_ID \
INFISICAL_ENV=$INFISICAL_ENV
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
COPY --from=build /app/.output ./.output
COPY --from=build /app/public ./public
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs"]

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

14
app/app.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
useHead({
htmlAttrs: {
'data-theme': 'cmyk',
},
script: []
})
</script>

View File

@@ -0,0 +1,72 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "silk";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97% 0.0035 67.78);
--color-base-200: oklch(95% 0.0081 61.42);
--color-base-300: oklch(90% 0.0081 61.42);
--color-base-content: oklch(40% 0.0081 61.42);
--color-primary: oklch(23.27% 0.0249 284.3);
--color-primary-content: oklch(94.22% 0.2505 117.44);
--color-secondary: oklch(23.27% 0.0249 284.3);
--color-secondary-content: oklch(73.92% 0.2135 50.94);
--color-accent: oklch(23.27% 0.0249 284.3);
--color-accent-content: oklch(88.92% 0.2061 189.9);
--color-neutral: oklch(20% 0 0);
--color-neutral-content: oklch(80% 0.0081 61.42);
--color-info: oklch(80.39% 0.1148 241.68);
--color-info-content: oklch(30.39% 0.1148 241.68);
--color-success: oklch(83.92% 0.0901 136.87);
--color-success-content: oklch(23.92% 0.0901 136.87);
--color-warning: oklch(83.92% 0.1085 80);
--color-warning-content: oklch(43.92% 0.1085 80);
--color-error: oklch(75.1% 0.1814 22.37);
--color-error-content: oklch(35.1% 0.1814 22.37);
--radius-selector: 2rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--border: 0.5px;
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "night";
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;
}

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './BankSearchRussia.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'BankSearchRussia',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,130 @@
<template>
<div class="relative">
<input
v-model="query"
@input="onInput"
@focus="showDropdown = true"
class="input input-bordered w-full"
placeholder="Enter bank name or BIC..."
autocomplete="off"
/>
<!-- Dropdown with bank suggestions -->
<div
v-if="showDropdown && suggestions.length > 0"
class="absolute top-full left-0 right-0 z-50 bg-base-100 border border-base-300 rounded-box shadow-lg max-h-60 overflow-y-auto"
>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
@click="selectBank(suggestion)"
class="p-3 cursor-pointer hover:bg-base-200 transition-colors border-b border-base-300 last:border-b-0"
>
<div class="font-medium text-base-content">{{ suggestion.value }}</div>
<div class="text-sm text-base-content/80">
BIC: {{ suggestion.data.bic }}
<span v-if="suggestion.data.correspondent_account">
Corr. account: {{ suggestion.data.correspondent_account }}
</span>
</div>
<div v-if="suggestion.data.address" class="text-xs text-base-content/60 mt-1">
{{ suggestion.data.address.value }}
</div>
</div>
</div>
<!-- Loading indicator -->
<div v-if="loading" class="absolute right-3 top-1/2 -translate-y-1/2">
<span class="loading loading-spinner loading-xs text-primary"></span>
</div>
</div>
</template>
<script setup lang="ts">
interface BankData {
bankName: string
bik: string
correspondentAccount: string
}
interface Props {
modelValue?: BankData
}
interface Emits {
(e: 'update:modelValue', value: BankData): void
(e: 'select', bank: any): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => ({
bankName: '',
bik: '',
correspondentAccount: ''
})
})
const emit = defineEmits<Emits>()
const query = ref('')
const suggestions = ref([])
const loading = ref(false)
const showDropdown = ref(false)
// Hide dropdown when clicking outside
onMounted(() => {
document.addEventListener('click', (e) => {
if (!e.target?.closest('.relative')) {
showDropdown.value = false
}
})
})
const onInput = async () => {
if (query.value.length < 2) {
suggestions.value = []
showDropdown.value = false
return
}
loading.value = true
try {
const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/bank', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Token 8de30547fe1e228dd5d7289439b71f5a97cf7357'
},
body: JSON.stringify({
query: query.value,
count: 10
})
})
const data = await response.json()
suggestions.value = data.suggestions || []
showDropdown.value = true
} catch (error) {
console.error('DADATA Bank error:', error)
suggestions.value = []
} finally {
loading.value = false
}
}
const selectBank = (bank: any) => {
query.value = bank.value
showDropdown.value = false
const bankData: BankData = {
bankName: bank.value,
bik: bank.data.bic,
correspondentAccount: bank.data.correspondent_account || ''
}
emit('update:modelValue', bankData)
emit('select', bank)
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="breadcrumbs text-sm">
<ul>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<NuxtLink v-if="crumb.to" :to="crumb.to">{{ crumb.label }}</NuxtLink>
<span v-else>{{ crumb.label }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const breadcrumbs = computed(() => {
const path = route.path
const crumbs: Array<{ label: string; to?: string }> = []
// Always start with Cabinet
crumbs.push({ label: t('breadcrumbs.cabinet'), to: localePath('/clientarea') })
// Parse path segments after /clientarea
const segments = path.replace(/^\/[a-z]{2}\//, '/').replace('/clientarea', '').split('/').filter(Boolean)
const pathMap: Record<string, string> = {
orders: t('breadcrumbs.orders'),
addresses: t('breadcrumbs.addresses'),
profile: t('breadcrumbs.profile'),
team: t('breadcrumbs.team'),
kyc: t('breadcrumbs.kyc'),
offers: t('breadcrumbs.offers'),
new: t('breadcrumbs.new'),
russia: t('breadcrumbs.russia'),
ai: t('breadcrumbs.ai'),
goods: t('breadcrumbs.goods'),
locations: t('breadcrumbs.locations'),
request: t('breadcrumbs.request'),
'company-switch': t('breadcrumbs.companySwitch'),
'debug-tokens': t('breadcrumbs.tokens'),
}
let currentPath = '/clientarea'
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
currentPath += `/${segment}`
const isLast = i === segments.length - 1
// Check if segment is UUID or ID
const isId = /^[0-9a-f-]{36}$/.test(segment) || /^\d+$/.test(segment)
const label = isId ? `#${segment.slice(0, 8)}...` : (pathMap[segment] || segment)
crumbs.push({
label,
to: isLast ? undefined : localePath(currentPath)
})
}
return crumbs
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './CalcResultContent.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'CalcResultContent',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,248 @@
<template>
<div class="space-y-10">
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
</Card>
<div v-if="pending" class="text-sm text-base-content/60">
Загрузка маршрутов...
</div>
<div v-else-if="error" class="text-sm text-error">
Ошибка загрузки маршрутов: {{ error.message }}
</div>
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
<div v-if="productRouteOptions.length" class="space-y-10">
<div
v-for="(option, optionIndex) in productRouteOptions"
:key="option.sourceUuid || optionIndex"
class="space-y-6"
>
<div class="space-y-1">
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
</div>
<div v-if="option.routes?.length" class="space-y-6">
<Card
v-for="(route, routeIndex) in option.routes"
:key="routeIndex"
padding="lg"
class="border border-base-300"
>
<Stack gap="4">
<div class="flex flex-wrap items-center justify-between gap-2">
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
<Text tone="muted" size="sm">
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
</Text>
</div>
<RouteStagesList :stages="mapRouteStages(route)" />
<div class="divider my-0"></div>
<RequestRoutesMap :routes="[route]" :height="240" />
</Stack>
</Card>
</div>
<Text v-else tone="muted" size="sm">
Маршруты от источника не найдены.
</Text>
</div>
</div>
<template v-if="!productRouteOptions.length && legacyRoutes.length">
<div class="space-y-6">
<Card
v-for="(route, routeIndex) in legacyRoutes"
:key="routeIndex"
padding="lg"
class="border border-base-300"
>
<Stack gap="4">
<div class="flex flex-wrap items-center justify-between gap-2">
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
<Text tone="muted" size="sm">
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
</Text>
</div>
<RouteStagesList :stages="mapRouteStages(route)" />
<div class="divider my-0"></div>
<RequestRoutesMap :routes="[route]" :height="240" />
</Stack>
</Card>
</div>
</template>
</div>
<div v-else class="text-sm text-base-content/60">
Маршруты не найдены. Возможно, нет связи между точками в графе.
</div>
</div>
</template>
<script setup lang="ts">
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
const route = useRoute()
const searchStore = useSearchStore()
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
const summaryTitle = computed(() => `${productName.value}${locationName.value}`)
const summaryMeta = computed(() => {
const meta: string[] = []
if (quantity.value) {
meta.push(`${quantity.value}`)
}
return meta
})
// Determine context (new flow: product + destination; legacy: from param)
const productUuid = computed(() => (route.query.productUuid as string) || searchStore.searchForm.productUuid)
const destinationUuid = computed(() => (route.query.locationUuid as string) || searchStore.searchForm.locationUuid)
const legacyFromUuid = computed(() => route.params.id as string | undefined)
type ProductRouteOption = {
sourceUuid?: string | null
sourceName?: string | null
sourceLat?: number | null
sourceLon?: number | null
distanceKm?: number | null
routes?: RoutePathType[] | null
}
const fetchProductRoutes = async () => {
if (!productUuid.value || !destinationUuid.value) return null
const { client } = useApolloClient('publicGeo')
const { default: gql } = await import('graphql-tag')
const query = gql`
query FindProductRoutes($productUuid: String!, $toUuid: String!, $limitSources: Int, $limitRoutes: Int) {
findProductRoutes(
productUuid: $productUuid
toUuid: $toUuid
limitSources: $limitSources
limitRoutes: $limitRoutes
) {
sourceUuid
sourceName
sourceLat
sourceLon
distanceKm
routes {
totalDistanceKm
totalTimeSeconds
stages {
fromUuid
fromName
fromLat
fromLon
toUuid
toName
toLat
toLon
distanceKm
travelTimeSeconds
transportType
}
}
}
}
`
const { data } = await client.query({
query,
variables: {
productUuid: productUuid.value,
toUuid: destinationUuid.value,
limitSources: 5,
limitRoutes: 1
}
})
return data
}
const fetchLegacyRoutes = async () => {
if (!legacyFromUuid.value || !destinationUuid.value) return null
const { client } = useApolloClient('publicGeo')
const { data } = await client.query({
query: FindRoutesDocument,
variables: {
fromUuid: legacyFromUuid.value,
toUuid: destinationUuid.value,
limit: 3
}
})
return data
}
const { data: productRoutesData, pending, error } = await useAsyncData(
() => `product-routes-${productUuid.value}-${destinationUuid.value}-${legacyFromUuid.value || 'none'}`,
async () => {
// Prefer product-based routes; fallback to legacy if no product
if (productUuid.value && destinationUuid.value) {
return await fetchProductRoutes()
}
if (legacyFromUuid.value && destinationUuid.value) {
return await fetchLegacyRoutes()
}
return null
},
{ watch: [productUuid, destinationUuid, legacyFromUuid] }
)
const productRouteOptions = computed(() => {
const options = productRoutesData.value?.findProductRoutes as ProductRouteOption[] | undefined
return options?.filter(Boolean) || []
})
const legacyRoutes = computed(() => {
const data = productRoutesData.value?.findRoutes
if (!data) return []
return (data as (RoutePathType | null)[]).filter((r): r is RoutePathType => r !== null)
})
const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
return (route.stages || [])
.filter(Boolean)
.map((stage, index) => ({
key: stage?.fromUuid || `stage-${index}`,
from: stage?.fromName || 'Начало',
to: stage?.toName || 'Конец',
distanceKm: stage?.distanceKm,
durationSeconds: stage?.travelTimeSeconds
}))
}
// Formatting helpers
const formatDistance = (km: number | null | undefined) => {
if (!km) return '0'
return Math.round(km).toLocaleString()
}
const formatDuration = (seconds: number | null | undefined) => {
if (!seconds) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 24) {
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
return `${days}д ${remainingHours}ч`
}
if (hours > 0) {
return `${hours}ч ${minutes}м`
}
return `${minutes}м`
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './CompanyCard.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'CompanyCard',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,138 @@
<template>
<div
class="card bg-base-100 border border-base-300 shadow-sm transition-all duration-200 hover:scale-105"
:class="sizeClasses"
>
<div class="flex items-center space-x-3">
<!-- Company logo -->
<div class="flex-shrink-0">
<div
class="rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center text-primary-content font-bold"
:class="logoSizeClasses"
>
{{ getCompanyInitials(company.name) }}
</div>
</div>
<!-- Company info -->
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-base-content truncate" :class="nameSizeClasses">
{{ company.name }}
</h4>
<p class="text-base-content/70 text-xs">
{{ company.country }} {{ company.taxId }}
</p>
<!-- Extra info -->
<div v-if="tripCount" class="mt-2">
<p class="text-xs text-primary font-medium">
{{ tripCount }} trips
</p>
</div>
</div>
<!-- Trust rating -->
<div class="flex-shrink-0 text-center">
<div class="flex items-center space-x-1">
<span class="font-semibold text-sm text-base-content">{{ getTrustRating(company) }}</span>
</div>
<p class="text-xs text-base-content/60">rating</p>
</div>
</div>
<!-- Stats (only for large size) -->
<div v-if="size === 'large'" class="mt-4 pt-4 border-t border-base-300">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().totalTrips }}</p>
<p class="text-xs text-base-content/60">trips</p>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().totalWeight }}t</p>
<p class="text-xs text-base-content/60">carried</p>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().onTimePercentage }}%</p>
<p class="text-xs text-base-content/60">on time</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
company: {
type: Object,
required: true
},
tripCount: {
type: Number,
default: 0
},
size: {
type: String,
default: 'medium', // small, medium, large
validator: value => ['small', 'medium', 'large'].includes(value)
}
})
const sizeClasses = computed(() => {
switch (props.size) {
case 'small': return 'p-3'
case 'large': return 'p-6'
default: return 'p-4'
}
})
const logoSizeClasses = computed(() => {
switch (props.size) {
case 'small': return 'w-8 h-8 text-xs'
case 'large': return 'w-16 h-16 text-xl'
default: return 'w-12 h-12 text-sm'
}
})
const nameSizeClasses = computed(() => {
switch (props.size) {
case 'small': return 'text-sm'
case 'large': return 'text-lg'
default: return 'text-base'
}
})
const getCompanyInitials = (name) => {
if (!name) return '??'
const words = name.split(' ')
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
}
return name.substring(0, 2).toUpperCase()
}
const getTrustRating = (company) => {
// Generate pseudo-rating based on company name
const ratings = {
'Kenya Coffee Logistics Ltd': 4.8,
'Kenya Highland Transport': 4.6,
'Mombasa Express Cargo': 4.4,
'East Africa Shipping Lines': 4.7,
'Truck LLC': 4.5,
'Motor Depot #1': 4.3,
'RailTrans LLC': 4.6,
'Kenya Ports Authority': 4.9
}
return ratings[company.name] || 4.2
}
const getCompanyStats = () => {
// Demo stats stub
return {
totalTrips: props.tripCount || Math.floor(Math.random() * 50) + 10,
totalWeight: (props.tripCount || 25) * 25, // 25t per trip
onTimePercentage: Math.floor(Math.random() * 20) + 80 // 80-100%
}
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './CompanySearchRussia.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'CompanySearchRussia',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,137 @@
<template>
<div class="relative">
<input
v-model="query"
@input="onInput"
@focus="showDropdown = true"
class="input input-bordered w-full"
placeholder="Enter organization name or INN..."
autocomplete="off"
/>
<!-- Dropdown with suggestions -->
<div
v-if="showDropdown && suggestions.length > 0"
class="absolute top-full left-0 right-0 z-50 bg-base-100 border border-base-300 rounded-box shadow-lg max-h-60 overflow-y-auto"
>
<div
v-for="(suggestion, index) in suggestions"
:key="index"
@click="selectCompany(suggestion)"
class="p-3 cursor-pointer hover:bg-base-200 transition-colors border-b border-base-300 last:border-b-0"
>
<div class="font-medium text-base-content">{{ suggestion.value }}</div>
<div class="text-sm text-base-content/80">
INN: {{ suggestion.data.inn }}
<span v-if="suggestion.data.kpp"> KPP: {{ suggestion.data.kpp }}</span>
</div>
<div v-if="suggestion.data.address" class="text-xs text-base-content/60 mt-1">
{{ suggestion.data.address.value }}
</div>
</div>
</div>
<!-- Loading indicator -->
<div v-if="loading" class="absolute right-3 top-1/2 -translate-y-1/2">
<span class="loading loading-spinner loading-xs text-primary"></span>
</div>
</div>
</template>
<script setup lang="ts">
interface CompanyData {
companyName: string
companyFullName: string
inn: string
kpp: string
ogrn: string
address: string
}
interface Props {
modelValue?: CompanyData
}
interface Emits {
(e: 'update:modelValue', value: CompanyData): void
(e: 'select', company: any): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => ({
companyName: '',
companyFullName: '',
inn: '',
kpp: '',
ogrn: '',
address: ''
})
})
const emit = defineEmits<Emits>()
const query = ref('')
const suggestions = ref([])
const loading = ref(false)
const showDropdown = ref(false)
// Hide dropdown when clicking outside
onMounted(() => {
document.addEventListener('click', (e) => {
if (!e.target?.closest('.relative')) {
showDropdown.value = false
}
})
})
const onInput = async () => {
if (query.value.length < 2) {
suggestions.value = []
showDropdown.value = false
return
}
loading.value = true
try {
const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Token 8de30547fe1e228dd5d7289439b71f5a97cf7357'
},
body: JSON.stringify({
query: query.value,
count: 10
})
})
const data = await response.json()
suggestions.value = data.suggestions || []
showDropdown.value = true
} catch (error) {
console.error('DADATA error:', error)
suggestions.value = []
} finally {
loading.value = false
}
}
const selectCompany = (company: any) => {
query.value = company.value
showDropdown.value = false
const companyData: CompanyData = {
companyName: company.value,
companyFullName: company.unrestricted_value,
inn: company.data.inn,
kpp: company.data.kpp || '',
ogrn: company.data.ogrn || '',
address: company.data.address?.value || ''
}
emit('update:modelValue', companyData)
emit('select', company)
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="flex flex-col items-center justify-center py-12 px-4">
<div class="text-6xl mb-4">{{ icon }}</div>
<h3 class="text-lg font-semibold text-base-content mb-2">{{ title }}</h3>
<p v-if="description" class="text-base-content/60 text-center max-w-sm mb-6">{{ description }}</p>
<NuxtLink v-if="actionTo" :to="actionTo">
<button class="btn btn-primary">
<Icon v-if="actionIcon" :name="actionIcon" size="18" class="mr-2" />
{{ actionLabel }}
</button>
</NuxtLink>
<button v-else-if="actionLabel" class="btn btn-primary" @click="$emit('action')">
<Icon v-if="actionIcon" :name="actionIcon" size="18" class="mr-2" />
{{ actionLabel }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
icon: string
title: string
description?: string
actionLabel?: string
actionTo?: string
actionIcon?: string
}>()
defineEmits<{
action: []
}>()
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './FooterPublic.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'FooterPublic',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,9 @@
<template>
<footer class="bg-base-200 text-base-content/80 py-6">
<div class="w-full px-4 sm:px-6 lg:px-8">
<div class="text-center">
<p class="text-sm">© 2025 Optovia. {{ $t('footer.rights') }}</p>
</div>
</div>
</footer>
</template>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './GanttTimeline.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'GanttTimeline',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,272 @@
<template>
<div class="w-full">
<ClientOnly>
<Timeline
:groups="timelineGroups"
:items="timelineItems"
:viewportMin="viewportMin"
:viewportMax="viewportMax"
:minViewportDuration="1000 * 60 * 60 * 24 * 3"
:maxViewportDuration="1000 * 60 * 60 * 24 * 14"
:defaultViewportDuration="1000 * 60 * 60 * 24 * 7"
class="h-96"
@item-click="onTripClick"
@item-hover="onTripHover"
/>
<template #fallback>
<div class="h-96 w-full bg-base-200 rounded-lg flex items-center justify-center">
<p class="text-base-content/60">{{ t('ganttTimeline.states.loading') }}</p>
</div>
</template>
</ClientOnly>
<!-- Legend -->
<div class="mt-4 bg-base-200 rounded-box p-4">
<h4 class="font-medium text-sm text-base-content mb-3">{{ t('ganttTimeline.legend.title') }}</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-primary rounded"></div>
<span>{{ t('ganttTimeline.legend.auto') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-purple-500 rounded"></div>
<span>{{ t('ganttTimeline.legend.sea') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-orange-400 rounded"></div>
<span>{{ t('ganttTimeline.legend.rail') }}</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-4 h-4 bg-success rounded"></div>
<span>{{ t('ganttTimeline.legend.services') }}</span>
</div>
</div>
</div>
<!-- Trip details modal -->
<div v-if="showTripModal" class="fixed inset-0 bg-base-content/50 flex items-center justify-center z-50" @click="closeTripModal">
<div class="bg-base-100 border border-base-300 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl" @click.stop>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ t('ganttTimeline.modal.title') }}</h3>
<button @click="closeTripModal" class="text-base-content/60 hover:text-base-content">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div v-if="selectedTrip" class="space-y-3">
<div>
<h4 class="font-medium text-base">{{ selectedTrip.trip.name }}</h4>
<p class="text-sm text-base-content/70">{{ selectedTrip.stage.name }}</p>
</div>
<div v-if="selectedTrip.trip.company" class="card bg-base-200 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-1">{{ t('ganttTimeline.modal.company.title') }}</h5>
<p class="text-sm">{{ selectedTrip.trip.company.name }}</p>
<p v-if="selectedTrip.trip.company.taxId" class="text-xs text-base-content/60">{{ t('ganttTimeline.modal.company.tax_id') }} {{ selectedTrip.trip.company.taxId }}</p>
</div>
<div class="card bg-primary/10 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.dates.title') }}</h5>
<div class="space-y-1 text-sm">
<div v-if="selectedTrip.trip.plannedLoadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_loading') }}</span>
{{ formatDisplayDate(selectedTrip.trip.plannedLoadingDate) }}
</div>
<div v-if="selectedTrip.trip.actualLoadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_loading') }}</span>
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualLoadingDate) }}</span>
</div>
<div v-if="selectedTrip.trip.plannedUnloadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_unloading') }}</span>
{{ formatDisplayDate(selectedTrip.trip.plannedUnloadingDate) }}
</div>
<div v-if="selectedTrip.trip.actualUnloadingDate">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_unloading') }}</span>
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualUnloadingDate) }}</span>
</div>
</div>
</div>
<div v-if="selectedTrip.trip.plannedWeight || selectedTrip.trip.weightAtLoading || selectedTrip.trip.weightAtUnloading" class="card bg-warning/10 border border-base-300 p-3">
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.weight.title') }}</h5>
<div class="space-y-1 text-sm">
<div v-if="selectedTrip.trip.plannedWeight">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.planned') }}</span> {{ selectedTrip.trip.plannedWeight }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
<div v-if="selectedTrip.trip.weightAtLoading">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_loading') }}</span> {{ selectedTrip.trip.weightAtLoading }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
<div v-if="selectedTrip.trip.weightAtUnloading">
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_unloading') }}</span> {{ selectedTrip.trip.weightAtUnloading }} {{ t('ganttTimeline.modal.weight.unit') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Timeline } from 'vue-timeline-chart'
import 'vue-timeline-chart/style.css'
const props = defineProps({
stages: {
type: Array,
default: () => []
},
showLoading: {
type: Boolean,
default: true
},
showUnloading: {
type: Boolean,
default: true
}
})
const { t } = useI18n()
// Stage groups for timeline (only stages with trips)
const timelineGroups = computed(() => {
return props.stages
.filter(stage => stage.trips && stage.trips.length > 0)
.map(stage => ({
id: stage.uuid,
label: stage.name
}))
})
// Timeline items (trips)
const timelineItems = computed(() => {
const items = []
props.stages.forEach(stage => {
stage.trips?.forEach(trip => {
// Date priority: actualLoadingDate > plannedLoadingDate > current date
const startDate = trip.actualLoadingDate || trip.plannedLoadingDate || new Date().toISOString()
// Date priority: actualUnloadingDate > plannedUnloadingDate > date + 1 day
const endDate = trip.actualUnloadingDate ||
trip.plannedUnloadingDate ||
new Date(new Date(startDate).getTime() + 24 * 60 * 60 * 1000).toISOString()
const startTime = new Date(startDate).getTime()
const endTime = new Date(endDate).getTime()
// Format dates for tooltip
const formatDate = (dateStr) => {
if (!dateStr) return 'Not specified'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
items.push({
group: stage.uuid,
type: 'range',
start: startTime,
end: endTime,
label: trip.name,
tooltip: `
<div class="p-3">
<h4 class="font-semibold text-sm mb-2">${trip.name}</h4>
<div class="text-xs space-y-1">
<div><strong>Stage:</strong> ${stage.name}</div>
${trip.company?.name ? `<div><strong>Company:</strong> ${trip.company.name}</div>` : ''}
<div><strong>Planned load:</strong> ${formatDate(trip.plannedLoadingDate)}</div>
<div><strong>Planned unload:</strong> ${formatDate(trip.plannedUnloadingDate)}</div>
${trip.actualLoadingDate ? `<div><strong>Actual load:</strong> ${formatDate(trip.actualLoadingDate)}</div>` : ''}
${trip.actualUnloadingDate ? `<div><strong>Actual unload:</strong> ${formatDate(trip.actualUnloadingDate)}</div>` : ''}
${trip.plannedWeight ? `<div><strong>Planned weight:</strong> ${trip.plannedWeight} t</div>` : ''}
</div>
</div>
`,
data: {
trip: trip,
stage: stage
},
cssVariables: {
'--item-background': getStageColor(stage)
}
})
})
})
return items
})
// Dynamic viewport based on data
const viewportMin = computed(() => {
if (timelineItems.value.length === 0) {
return new Date().getTime() - (30 * 24 * 60 * 60 * 1000) // one month back
}
const minTime = Math.min(...timelineItems.value.map(item => item.start))
return minTime - (3 * 24 * 60 * 60 * 1000) // 3 days before earliest
})
const viewportMax = computed(() => {
if (timelineItems.value.length === 0) {
return new Date().getTime() + (60 * 24 * 60 * 60 * 1000) // two months ahead
}
const maxTime = Math.max(...timelineItems.value.map(item => item.end))
return maxTime + (3 * 24 * 60 * 60 * 1000) // 3 days after latest
})
// Modal reactive state
const selectedTrip = ref(null)
const showTripModal = ref(false)
// Timeline handlers
const onTripClick = (event) => {
if (event && event.data) {
selectedTrip.value = event.data
showTripModal.value = true
}
}
const onTripHover = (event) => {
// Tooltip handled automatically via tooltip field
console.log('Trip hovered:', event?.data?.trip?.name)
}
const closeTripModal = () => {
showTripModal.value = false
selectedTrip.value = null
}
// Format date for modal display
const formatDisplayDate = (dateStr) => {
if (!dateStr) return 'Not specified'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getStageColor = (stage) => {
const colors = {
auto: '#3b82f6', // blue
sea: '#8b5cf6', // purple
rail: '#f97316', // orange
air: '#06b6d4', // cyan
service: '#10b981' // green
}
return colors[stage.transportType] || colors.service
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './GoodsContent.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'GoodsContent',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,64 @@
<template>
<Stack gap="6">
<PageHeader
:title="$t('goods.title')"
:description="$t('goods.description')"
/>
<div v-if="pending" class="flex items-center justify-center p-8">
<span class="loading loading-spinner loading-lg" />
</div>
<Alert v-else-if="error" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ $t('goods.error.title') }}</Heading>
<Button @click="refresh()">{{ $t('goods.error.retry') }}</Button>
</Stack>
</Alert>
<EmptyState
v-else-if="!productsData?.length"
:title="$t('goods.empty.title')"
:description="$t('goods.empty.description')"
/>
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
<ProductCard
v-for="product in productsData"
:key="product.uuid"
:product="product"
selectable
@select="selectProduct(product)"
/>
</Grid>
</Stack>
</template>
<script setup lang="ts">
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
const searchStore = useSearchStore()
const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
const productsData = computed(() => data.value?.getProducts || [])
const selectProduct = (product: any) => {
searchStore.setProduct(product.name)
searchStore.setProductUuid(product.uuid)
const locationUuid = searchStore.searchForm.locationUuid
if (locationUuid) {
navigateTo({
path: '/request',
query: {
productUuid: product.uuid,
product: product.name,
locationUuid,
location: searchStore.searchForm.location,
quantity: searchStore.searchForm.quantity || undefined
}
})
return
}
history.back()
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './KYCFormRussia.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'KYCFormRussia',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,242 @@
<template>
<div class="p-0 sm:p-2">
<form @submit.prevent="submitKYC" class="space-y-6">
<!-- Company Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Company details</h3>
<div class="space-y-4">
<!-- Company search with DADATA -->
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Organization search
</label>
<CompanySearchRussia v-model="formData.company" @select="onCompanySelect" />
</div>
<!-- Company details (auto-filled from DADATA) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">INN</label>
<input
v-model="formData.company.inn"
class="input input-bordered w-full"
readonly
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">KPP</label>
<input
v-model="formData.company.kpp"
class="input input-bordered w-full"
readonly
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">OGRN</label>
<input
v-model="formData.company.ogrn"
class="input input-bordered w-full"
readonly
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Address</label>
<textarea
v-model="formData.company.address"
class="textarea textarea-bordered w-full min-h-[120px]"
rows="3"
readonly
></textarea>
</div>
</div>
</div>
</div>
<!-- Bank Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Bank details</h3>
<div class="space-y-4">
<!-- Bank search with DADATA -->
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Bank search
</label>
<BankSearchRussia v-model="formData.bank" @select="onBankSelect" />
</div>
<!-- Bank details -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">BIC</label>
<input
v-model="formData.bank.bik"
class="input input-bordered w-full"
readonly
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Corr. account</label>
<input
v-model="formData.bank.correspondentAccount"
class="input input-bordered w-full"
readonly
/>
</div>
</div>
</div>
</div>
</div>
<!-- Contact Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Contact details</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Contact person *
</label>
<input
v-model="formData.contact.person"
type="text"
required
class="input input-bordered w-full"
placeholder="Full name of company representative"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">Email *</label>
<input
v-model="formData.contact.email"
type="email"
required
class="input input-bordered w-full"
placeholder="email@company.ru"
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Phone *</label>
<input
v-model="formData.contact.phone"
type="tel"
required
class="input input-bordered w-full"
placeholder="+7 (xxx) xxx-xx-xx"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="loading || !isFormValid"
class="btn btn-primary"
>
{{ loading ? 'Sending...' : 'Submit for review' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
submit: [data: any]
}>()
const loading = ref(false)
const formData = ref({
company: {
companyName: '',
companyFullName: '',
inn: '',
kpp: '',
ogrn: '',
address: ''
},
bank: {
bankName: '',
bik: '',
correspondentAccount: ''
},
contact: {
person: '',
email: '',
phone: ''
}
})
// Form validation
const isFormValid = computed(() => {
return formData.value.company.companyName &&
formData.value.company.inn &&
formData.value.bank.bankName &&
formData.value.bank.bik &&
formData.value.contact.person &&
formData.value.contact.email &&
formData.value.contact.phone
})
// Handlers
const onCompanySelect = (company: any) => {
formData.value.company = {
companyName: company.value,
companyFullName: company.unrestricted_value,
inn: company.data.inn,
kpp: company.data.kpp || '',
ogrn: company.data.ogrn || '',
address: company.data.address?.value || ''
}
}
const onBankSelect = (bank: any) => {
formData.value.bank = {
bankName: bank.value,
bik: bank.data.bic,
correspondentAccount: bank.data.correspondent_account || ''
}
}
const submitKYC = async () => {
if (!isFormValid.value) return
loading.value = true
try {
const submitData = {
company_name: formData.value.company.companyName,
company_full_name: formData.value.company.companyFullName,
inn: formData.value.company.inn,
kpp: formData.value.company.kpp,
ogrn: formData.value.company.ogrn,
address: formData.value.company.address,
bank_name: formData.value.bank.bankName,
bik: formData.value.bank.bik,
correspondent_account: formData.value.bank.correspondentAccount,
contact_person: formData.value.contact.person,
contact_email: formData.value.contact.email,
contact_phone: formData.value.contact.phone
}
emit('submit', submitData)
} catch (error) {
console.error('Error submitting KYC:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './LangSwitcher.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'LangSwitcher',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,41 @@
<template>
<div class="flex items-center gap-1 rounded-lg p-1" :class="containerClass">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
:class="locale === loc.code ? activeClass : inactiveClass"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
</template>
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'light'
}
})
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const containerClass = computed(() =>
props.variant === 'dark' ? 'bg-base-200' : 'bg-base-100/10'
)
const activeClass = computed(() =>
props.variant === 'dark'
? 'bg-base-100 text-base-content shadow-sm'
: 'bg-base-100/20 text-base-content'
)
const inactiveClass = computed(() =>
props.variant === 'dark'
? 'text-base-content/60 hover:text-base-content'
: 'text-base-content/60 hover:text-base-content'
)
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './LocationsContent.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'LocationsContent',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,120 @@
<template>
<Stack gap="8">
<!-- My addresses (for authenticated users) -->
<Stack v-if="isAuthenticated && teamAddresses?.length" gap="4">
<PageHeader title="My addresses">
<template #actions>
<NuxtLink
:to="localePath('/clientarea/addresses')"
class="btn btn-sm btn-ghost gap-2"
>
<Icon name="lucide:settings" size="16" />
Manage
</NuxtLink>
</template>
</PageHeader>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card
v-for="addr in teamAddresses"
:key="addr.uuid"
padding="small"
interactive
@click="selectTeamAddress(addr)"
>
<Stack gap="2">
<Stack direction="row" align="center" gap="2">
<Icon name="lucide:map-pin" size="18" class="text-primary" />
<Text size="base" weight="semibold">{{ addr.name }}</Text>
<Pill v-if="addr.isDefault" variant="outline" size="sm">Default</Pill>
</Stack>
<Text tone="muted" size="sm">{{ addr.address }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
<!-- Terminals and logistics hubs -->
<Stack gap="4">
<PageHeader title="Terminals and logistics hubs" />
<div v-if="pending" class="flex items-center justify-center p-8">
<span class="loading loading-spinner loading-lg" />
</div>
<Alert v-else-if="error" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">Load error</Heading>
<Button @click="refresh()">Try again</Button>
</Stack>
</Alert>
<EmptyState
v-else-if="!locationsData?.length"
title="No locations"
description="Logistics hubs not added yet"
/>
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
<HubCard
v-for="location in locationsData"
:key="location.uuid"
:hub="location"
selectable
@select="selectLocation(location)"
/>
</Grid>
</Stack>
</Stack>
</template>
<script setup lang="ts">
import { GetNodesDocument } from '~/composables/graphql/public/geo-generated'
const searchStore = useSearchStore()
const { isAuthenticated } = useAuth()
const localePath = useLocalePath()
const calculateDistance = (lat: number, lng: number) => {
const moscowLat = 55.76
const moscowLng = 37.64
const distance = Math.sqrt(Math.pow(lat - moscowLat, 2) + Math.pow(lng - moscowLng, 2)) * 111
return `${Math.round(distance)} km`
}
// Load logistics hubs
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', GetNodesDocument, {}, 'public', 'geo')
const locationsData = computed(() => {
return (locationsDataRaw.value || []).map((location: any) => ({
...location,
distance: location?.latitude && location?.longitude
? calculateDistance(location.latitude, location.longitude)
: undefined,
}))
})
// Load team addresses (if authenticated)
const teamAddresses = ref<any[]>([])
if (isAuthenticated.value) {
try {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
teamAddresses.value = addressData.value?.teamAddresses || []
} catch (e) {
console.log('Team addresses not available')
}
}
const selectLocation = (location: any) => {
searchStore.setLocation(location.name)
searchStore.setLocationUuid(location.uuid)
history.back()
}
const selectTeamAddress = (addr: any) => {
searchStore.setLocation(addr.address)
searchStore.setLocationUuid(addr.uuid)
history.back()
}
</script>

View File

@@ -0,0 +1,117 @@
<template>
<aside class="w-64 bg-base-100 h-screen flex flex-col border-r border-base-300">
<!-- Header with back button -->
<div class="p-4 border-b border-base-300">
<NuxtLink
:to="localePath('/catalog')"
class="btn btn-sm btn-ghost gap-2"
>
<Icon name="lucide:arrow-left" size="18" />
{{ t('catalogMap.actions.list_view') }}
</NuxtLink>
</div>
<!-- Tabs -->
<div role="tablist" class="tabs tabs-bordered px-4 border-b border-base-300">
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'hubs' }"
@click="$emit('update:activeTab', 'hubs')"
>
{{ t('catalogMap.tabs.hubs') }}
<span class="badge badge-sm ml-1">{{ hubs.length }}</span>
</button>
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'suppliers' }"
@click="$emit('update:activeTab', 'suppliers')"
>
{{ t('catalogMap.tabs.suppliers') }}
<span class="badge badge-sm ml-1">{{ suppliers.length }}</span>
</button>
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'offers' }"
@click="$emit('update:activeTab', 'offers')"
>
{{ t('catalogMap.tabs.offers') }}
<span class="badge badge-sm ml-1">{{ offers.length }}</span>
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-y-auto p-2 bg-base-200">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center h-32">
<span class="loading loading-spinner loading-md"></span>
</div>
<!-- Hubs Tab -->
<div v-else-if="activeTab === 'hubs'" class="space-y-2">
<HubCard
v-for="hub in hubs"
:key="hub.uuid"
:hub="hub"
selectable
:is-selected="selectedItemId === hub.uuid"
@select="$emit('select', hub, 'hub')"
/>
<div v-if="hubs.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.hubs') }}
</div>
</div>
<!-- Suppliers Tab -->
<div v-else-if="activeTab === 'suppliers'" class="space-y-2">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
:supplier="supplier"
selectable
:is-selected="selectedItemId === supplier.uuid"
@select="$emit('select', supplier, 'supplier')"
/>
<div v-if="suppliers.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.suppliers') }}
</div>
</div>
<!-- Offers Tab -->
<div v-else-if="activeTab === 'offers'" class="space-y-2">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
selectable
:is-selected="selectedItemId === offer.uuid"
@select="$emit('select', offer, 'offer')"
/>
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.offers') }}
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
defineProps<{
activeTab: 'hubs' | 'suppliers' | 'offers'
hubs: any[]
suppliers: any[]
offers: any[]
selectedItemId: string | null
isLoading: boolean
}>()
defineEmits<{
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
'select': [item: any, type: string]
}>()
const localePath = useLocalePath()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './MapboxGlobe.client.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'MapboxGlobe.client',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,390 @@
<template>
<div class="relative w-full rounded-box overflow-hidden" :style="{ height: computedHeight }">
<MapboxMap
:map-id="mapId"
style="width: 100%; height: 100%"
:options="mapOptions"
@mb-created="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<!-- Location info popup -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-if="selectedLocation"
class="absolute bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-base-100 border border-base-300 rounded-box shadow-lg p-4 z-10"
>
<Stack gap="3">
<Stack direction="row" align="center" justify="between">
<Stack gap="1">
<Heading :level="4" weight="semibold">{{ selectedLocation.name }}</Heading>
<Text tone="muted" size="base">{{ selectedLocation.country }}</Text>
</Stack>
<button
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
@click="selectedLocation = null"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</Stack>
<Button size="small" @click="flyToSelectedLocation">
Fly here
</Button>
</Stack>
</div>
</Transition>
<!-- Animation indicator -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isAnimating"
class="absolute top-4 left-1/2 -translate-x-1/2 bg-base-content/70 text-base-100 px-4 py-2 rounded-full text-sm flex items-center gap-2"
>
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Flying...
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { Map as MapboxMap } from 'mapbox-gl'
interface Location {
uuid?: string | null
name?: string | null
latitude?: number | null
longitude?: number | null
country?: string | null
}
const props = withDefaults(defineProps<{
locations: Location[]
height?: number | string
initialCenter?: [number, number]
initialZoom?: number
selectedLocationId?: string | null
mapId?: string
}>(), {
height: 500,
initialCenter: () => [37.64, 55.76], // Moscow
initialZoom: 2,
selectedLocationId: null,
mapId: 'globe-map'
})
// Compute height: if number add px, else use as provided
const computedHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`
}
return props.height
})
const emit = defineEmits<{
(e: 'location-click', location: Location): void
}>()
// useMapboxRef gives reactive map ref when ready
const mapRef = useMapboxRef(props.mapId)
const selectedLocation = ref<Location | null>(null)
// Promise to wait for map readiness
let mapReadyResolve: (() => void) | null = null
const mapReadyPromise = new Promise<void>((resolve) => {
mapReadyResolve = resolve
})
const isMapReady = ref(false)
const { flyThroughSpace, setupGlobeAtmosphere, isAnimating } = useMapboxFlyAnimation()
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: props.initialCenter,
zoom: props.initialZoom,
projection: 'globe',
pitch: 20
}))
const geoJsonData = computed(() => ({
type: 'FeatureCollection' as const,
features: props.locations
.filter(loc => loc.latitude != null && loc.longitude != null)
.map(location => ({
type: 'Feature' as const,
properties: {
uuid: location.uuid,
name: location.name,
country: location.country
},
geometry: {
type: 'Point' as const,
coordinates: [location.longitude!, location.latitude!]
}
}))
}))
const onMapCreated = (map: MapboxMap) => {
console.log('[MapboxGlobe] onMapCreated, map.loaded():', map.loaded())
const initMap = () => {
console.log('[MapboxGlobe] initMap called')
// Setup globe atmosphere with stars
setupGlobeAtmosphere(map)
console.log('[MapboxGlobe] Map loaded, locations:', props.locations.length)
console.log('[MapboxGlobe] GeoJSON features:', geoJsonData.value.features.length)
// Add clustered source
map.addSource('locations', {
type: 'geojson',
data: geoJsonData.value,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
})
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#10b981',
'circle-radius': [
'step',
['get', 'point_count'],
20, 10,
30, 50,
40
],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'locations',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 14
},
paint: {
'text-color': '#ffffff'
}
})
// Individual points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'locations',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': [
'case',
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
14,
10
],
'circle-color': [
'case',
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
'#f59e0b',
'#10b981'
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
// Click on cluster to zoom in
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties?.cluster_id
const source = map.getSource('locations') as mapboxgl.GeoJSONSource
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return
const geometry = features[0].geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: zoom || 4
})
})
})
// Click on individual point
map.on('click', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
if (!features.length) return
const featureProps = features[0].properties
const geometry = features[0].geometry as GeoJSON.Point
const location: Location = {
uuid: featureProps?.uuid,
name: featureProps?.name,
country: featureProps?.country,
longitude: geometry.coordinates[0],
latitude: geometry.coordinates[1]
}
selectedLocation.value = location
emit('location-click', location)
})
// Cursor changes
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = ''
})
map.on('mouseenter', 'unclustered-point', () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', 'unclustered-point', () => {
map.getCanvas().style.cursor = ''
})
}
// If map already loaded - init immediately, otherwise wait for load
if (map.loaded()) {
initMap()
isMapReady.value = true
mapReadyResolve?.()
} else {
map.on('load', () => {
initMap()
isMapReady.value = true
mapReadyResolve?.()
})
}
}
const flyToSelectedLocation = async () => {
if (!mapRef.value || !selectedLocation.value) return
const { longitude, latitude } = selectedLocation.value
if (longitude == null || latitude == null) return
await flyThroughSpace(mapRef.value, {
targetCenter: [longitude, latitude],
targetZoom: 8,
totalDuration: 6000
})
selectedLocation.value = null
}
// Update source data when locations change
watch(geoJsonData, (newData) => {
if (!mapRef.value) return
const source = mapRef.value.getSource('locations') as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(newData)
}
}, { deep: true })
// Update marker styles when selectedLocationId changes
watch(() => props.selectedLocationId, (newId) => {
if (!mapRef.value || !isMapReady.value) return
const map = mapRef.value
// Check if layer exists before updating
if (!map.getLayer('unclustered-point')) return
// Update circle radius
map.setPaintProperty('unclustered-point', 'circle-radius', [
'case',
['==', ['get', 'uuid'], newId || '__NONE__'],
14,
10
])
// Update circle color
map.setPaintProperty('unclustered-point', 'circle-color', [
'case',
['==', ['get', 'uuid'], newId || '__NONE__'],
'#f59e0b',
'#10b981'
])
})
// Fly to a specific location (exposed for parent component)
const flyToLocation = async (location: Location) => {
console.log('[MapboxGlobe] flyToLocation called:', location.name, location.longitude, location.latitude)
console.log('[MapboxGlobe] isMapReady:', isMapReady.value, 'mapRef.value:', !!mapRef.value)
const { longitude, latitude } = location
if (longitude == null || latitude == null) {
console.log('[MapboxGlobe] ERROR: coordinates are null')
return
}
// Wait for map readiness if not ready
if (!isMapReady.value) {
console.log('[MapboxGlobe] waiting for map to be ready...')
await mapReadyPromise
console.log('[MapboxGlobe] map is now ready!')
}
if (!mapRef.value) {
console.log('[MapboxGlobe] ERROR: mapRef is still null after waiting')
return
}
selectedLocation.value = location
console.log('[MapboxGlobe] calling flyThroughSpace:', [longitude, latitude])
// Space fly animation settings: 5000ms, minZoom 3, targetZoom 12
await flyThroughSpace(mapRef.value, {
targetCenter: [longitude, latitude],
targetZoom: 12,
totalDuration: 5000,
minZoom: 3
})
console.log('[MapboxGlobe] flyThroughSpace completed')
}
// Expose methods for parent component
defineExpose({
flyToLocation,
resize: () => mapRef.value?.resize(),
isMapReady,
waitForReady: () => mapReadyPromise
})
</script>

View File

@@ -0,0 +1,442 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<div v-if="autoEdges.length === 0 && railEdges.length === 0" class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Map -->
<div class="order-2 lg:order-1">
<ClientOnly>
<MapboxMap
:key="mapId"
:map-id="mapId"
style="height: 360px; width: 100%;"
class="rounded-lg border border-base-300"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<template #fallback>
<div class="h-[360px] w-full bg-base-200 rounded-lg flex items-center justify-center">
<Spinner />
</div>
</template>
</ClientOnly>
</div>
<!-- Neighbors lists -->
<div class="order-1 lg:order-2">
<Stack gap="4">
<div>
<div class="flex items-center gap-2 mb-2">
<Icon name="lucide:truck" size="20" />
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRoad') }}</Heading>
</div>
<Stack v-if="autoEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in autoEdges"
:key="edge.toUuid"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Icon name="lucide:map-pin" size="16" class="text-primary" />
</div>
<div>
<div class="font-medium">{{ edge.toName }}</div>
</div>
</div>
<div class="text-right">
<div class="font-semibold text-primary">{{ edge.distanceKm }} km</div>
</div>
</div>
</NuxtLink>
</Stack>
<div v-else class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<Icon name="lucide:train" size="20" />
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRail') }}</Heading>
</div>
<Stack v-if="railEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in railEdges"
:key="edge.toUuid"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center">
<Icon name="lucide:map-pin" size="16" class="text-emerald-500" />
</div>
<div>
<div class="font-medium">{{ edge.toName }}</div>
</div>
</div>
<div class="text-right">
<div class="font-semibold text-emerald-600">{{ edge.distanceKm }} km</div>
</div>
</div>
</NuxtLink>
</Stack>
<div v-else class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
</div>
</Stack>
</div>
</div>
</Stack>
</Section>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
interface CurrentHub {
uuid: string
name: string
latitude: number
longitude: number
}
interface RouteGeometry {
toUuid: string
coordinates: [number, number][]
}
const props = defineProps<{
autoEdges: EdgeType[]
railEdges: EdgeType[]
hub: CurrentHub
railHub: CurrentHub
autoRouteGeometries: RouteGeometry[]
railRouteGeometries: RouteGeometry[]
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const mapRef = ref<MapboxMapType | null>(null)
const isMapReady = ref(false)
const didFitBounds = ref(false)
const mapId = computed(() => `nearby-connections-${props.hub.uuid}`)
const mapCenter = computed<[number, number]>(() => {
const points: [number, number][] = []
if (props.hub.latitude && props.hub.longitude) {
points.push([props.hub.longitude, props.hub.latitude])
}
if (props.railHub.latitude && props.railHub.longitude) {
points.push([props.railHub.longitude, props.railHub.latitude])
}
const allEdges = [...props.autoEdges, ...props.railEdges]
allEdges.forEach((edge) => {
if (edge.toLatitude && edge.toLongitude) {
points.push([edge.toLongitude, edge.toLatitude])
}
})
if (points.length === 0) return [0, 0]
const avgLng = points.reduce((sum, coord) => sum + coord[0], 0) / points.length
const avgLat = points.reduce((sum, coord) => sum + coord[1], 0) / points.length
return [avgLng, avgLat]
})
const mapZoom = computed(() => {
const distances = [...props.autoEdges, ...props.railEdges].map(e => e.distanceKm || 0)
if (distances.length === 0) return 5
const maxDistance = Math.max(...distances)
if (maxDistance > 2000) return 2
if (maxDistance > 1000) return 3
if (maxDistance > 500) return 4
if (maxDistance > 200) return 5
return 6
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: mapZoom.value
}))
const buildRouteFeatureCollection = (routes: RouteGeometry[], transportType: 'auto' | 'rail') => ({
type: 'FeatureCollection' as const,
features: routes.map(route => ({
type: 'Feature' as const,
properties: { toUuid: route.toUuid, transportType },
geometry: {
type: 'LineString' as const,
coordinates: route.coordinates
}
}))
})
const buildNeighborsFeatureCollection = (edges: EdgeType[], transportType: 'auto' | 'rail') => ({
type: 'FeatureCollection' as const,
features: edges
.filter(e => e.toLatitude && e.toLongitude)
.map(edge => ({
type: 'Feature' as const,
properties: {
uuid: edge.toUuid,
name: edge.toName,
distanceKm: edge.distanceKm,
transportType
},
geometry: {
type: 'Point' as const,
coordinates: [edge.toLongitude!, edge.toLatitude!]
}
}))
})
const updateRoutesSource = () => {
const map = mapRef.value
if (!map) return
const autoSource = map.getSource('auto-routes') as mapboxgl.GeoJSONSource | undefined
if (autoSource) {
autoSource.setData(buildRouteFeatureCollection(props.autoRouteGeometries, 'auto'))
}
const railSource = map.getSource('rail-routes') as mapboxgl.GeoJSONSource | undefined
if (railSource) {
railSource.setData(buildRouteFeatureCollection(props.railRouteGeometries, 'rail'))
}
}
const updateNeighborsSource = () => {
const map = mapRef.value
if (!map) return
const autoSource = map.getSource('auto-neighbors') as mapboxgl.GeoJSONSource | undefined
if (autoSource) {
autoSource.setData(buildNeighborsFeatureCollection(props.autoEdges, 'auto'))
}
const railSource = map.getSource('rail-neighbors') as mapboxgl.GeoJSONSource | undefined
if (railSource) {
railSource.setData(buildNeighborsFeatureCollection(props.railEdges, 'rail'))
}
}
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coordinates = [
...props.autoRouteGeometries.flatMap(route => route.coordinates || []),
...props.railRouteGeometries.flatMap(route => route.coordinates || [])
]
if (coordinates.length === 0) return
const bounds = coordinates.reduce(
(acc, coord) => acc.extend(coord as [number, number]),
new LngLatBounds(
coordinates[0] as [number, number],
coordinates[0] as [number, number]
)
)
map.fitBounds(bounds, {
padding: 40,
maxZoom: 8
})
didFitBounds.value = true
}
const addHubSource = (map: MapboxMapType, id: string, hub: CurrentHub, color: string) => {
map.addSource(id, {
type: 'geojson',
data: {
type: 'Feature',
properties: { name: hub.name },
geometry: {
type: 'Point',
coordinates: [hub.longitude, hub.latitude]
}
}
})
map.addLayer({
id: `${id}-circle`,
type: 'circle',
source: id,
paint: {
'circle-radius': 10,
'circle-color': color,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.on('click', `${id}-circle`, (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = e.features![0].properties?.name
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong>`)
.addTo(map)
})
map.on('mouseenter', `${id}-circle`, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', `${id}-circle`, () => { map.getCanvas().style.cursor = '' })
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('auto-routes', {
type: 'geojson',
data: buildRouteFeatureCollection(props.autoRouteGeometries, 'auto')
})
map.addSource('rail-routes', {
type: 'geojson',
data: buildRouteFeatureCollection(props.railRouteGeometries, 'rail')
})
map.addLayer({
id: 'auto-routes-lines',
type: 'line',
source: 'auto-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#3b82f6',
'line-width': 4,
'line-opacity': 0.8
}
})
map.addLayer({
id: 'rail-routes-lines',
type: 'line',
source: 'rail-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#10b981',
'line-width': 4,
'line-opacity': 0.8
}
})
addHubSource(map, 'hub-origin', props.hub, '#3b82f6')
if (props.railHub.uuid && props.railHub.uuid !== props.hub.uuid) {
addHubSource(map, 'rail-origin', props.railHub, '#10b981')
}
map.addSource('auto-neighbors', {
type: 'geojson',
data: buildNeighborsFeatureCollection(props.autoEdges, 'auto')
})
map.addSource('rail-neighbors', {
type: 'geojson',
data: buildNeighborsFeatureCollection(props.railEdges, 'rail')
})
map.addLayer({
id: 'auto-neighbors-circles',
type: 'circle',
source: 'auto-neighbors',
paint: {
'circle-radius': 8,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'rail-neighbors-circles',
type: 'circle',
source: 'rail-neighbors',
paint: {
'circle-radius': 8,
'circle-color': '#22c55e',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
const onNeighborsClick = (e: mapboxgl.MapLayerMouseEvent) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features![0].properties
const name = featureProps?.name
const distanceKm = featureProps?.distanceKm
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong><br/>${distanceKm} km`)
.addTo(map)
}
map.on('click', 'auto-neighbors-circles', onNeighborsClick)
map.on('click', 'rail-neighbors-circles', onNeighborsClick)
map.on('mouseenter', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
isMapReady.value = true
updateRoutesSource()
updateNeighborsSource()
fitMapToRoutes()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => [props.autoRouteGeometries, props.railRouteGeometries],
() => {
didFitBounds.value = false
updateRoutesSource()
if (isMapReady.value) {
fitMapToRoutes()
}
},
{ deep: true }
)
watch(
() => [props.autoEdges, props.railEdges],
() => updateNeighborsSource(),
{ deep: true }
)
</script>

View File

@@ -0,0 +1,374 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<Stack direction="row" align="center" gap="2">
<Icon :name="transportIcon" size="24" />
<Heading :level="2">{{ title }}</Heading>
</Stack>
<div v-if="edges.length === 0" class="text-base-content/60">
{{ t('catalogHub.nearbyHubs.empty') }}
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Map -->
<div class="order-2 lg:order-1">
<ClientOnly>
<MapboxMap
:key="mapId"
:map-id="mapId"
style="height: 300px; width: 100%;"
class="rounded-lg border border-base-300"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<template #fallback>
<div class="h-[300px] w-full bg-base-200 rounded-lg flex items-center justify-center">
<Spinner />
</div>
</template>
</ClientOnly>
</div>
<!-- Neighbors list -->
<div class="order-1 lg:order-2">
<Stack gap="2">
<NuxtLink
v-for="edge in edges"
:key="edge.toUuid"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Icon name="lucide:map-pin" size="16" class="text-primary" />
</div>
<div>
<div class="font-medium">{{ edge.toName }}</div>
</div>
</div>
<div class="text-right">
<div class="font-semibold text-primary">{{ edge.distanceKm }} km</div>
</div>
</div>
</NuxtLink>
</Stack>
</div>
</div>
</Stack>
</Section>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
interface CurrentHub {
uuid: string
name: string
latitude: number
longitude: number
}
interface RouteGeometry {
toUuid: string
coordinates: [number, number][]
}
const props = defineProps<{
edges: EdgeType[]
currentHub: CurrentHub
routeGeometries: RouteGeometry[]
transportType: 'auto' | 'rail'
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const mapRef = ref<MapboxMapType | null>(null)
const isMapReady = ref(false)
const didFitBounds = ref(false)
const mapId = computed(() => `nearby-hubs-${props.currentHub.uuid}-${props.transportType}`)
const transportIcon = computed(() =>
props.transportType === 'auto' ? 'lucide:truck' : 'lucide:train'
)
const title = computed(() =>
props.transportType === 'auto'
? t('catalogHub.nearbyHubs.byRoad')
: t('catalogHub.nearbyHubs.byRail')
)
const geometryByUuid = computed(() => {
const map = new Map<string, RouteGeometry>()
props.routeGeometries.forEach((route) => {
if (route?.toUuid) {
map.set(route.toUuid, route)
}
})
return map
})
const lineColor = computed(() =>
props.transportType === 'auto' ? '#3b82f6' : '#10b981'
)
const mapCenter = computed<[number, number]>(() => {
if (!props.currentHub.latitude || !props.currentHub.longitude) {
return [0, 0]
}
const allLats = [props.currentHub.latitude, ...props.edges.map(e => e.toLatitude!).filter(Boolean)]
const allLngs = [props.currentHub.longitude, ...props.edges.map(e => e.toLongitude!).filter(Boolean)]
const avgLng = allLngs.reduce((a, b) => a + b, 0) / allLngs.length
const avgLat = allLats.reduce((a, b) => a + b, 0) / allLats.length
return [avgLng, avgLat]
})
const mapZoom = computed(() => {
if (props.edges.length === 0) return 5
const distances = props.edges.map(e => e.distanceKm || 0)
const maxDistance = Math.max(...distances)
if (maxDistance > 2000) return 2
if (maxDistance > 1000) return 3
if (maxDistance > 500) return 4
if (maxDistance > 200) return 5
return 6
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: mapZoom.value
}))
const buildRouteFeatureCollection = () => ({
type: 'FeatureCollection' as const,
features: props.routeGeometries.map(route => ({
type: 'Feature' as const,
properties: { toUuid: route.toUuid },
geometry: {
type: 'LineString' as const,
coordinates: route.coordinates
}
}))
})
const buildNeighborsFeatureCollection = () => ({
type: 'FeatureCollection' as const,
features: props.edges.filter(e => e.toLatitude && e.toLongitude).map(edge => ({
type: 'Feature' as const,
properties: {
uuid: edge.toUuid,
name: edge.toName,
distanceKm: edge.distanceKm
},
geometry: {
type: 'Point' as const,
coordinates: [edge.toLongitude!, edge.toLatitude!]
}
}))
})
const updateRoutesSource = () => {
const map = mapRef.value
if (!map) return
const source = map.getSource('routes') as mapboxgl.GeoJSONSource | undefined
if (!source) return
source.setData(buildRouteFeatureCollection())
console.log('[NearbyHubsSection] routes source updated', {
mapId: mapId.value,
routes: props.routeGeometries.length
})
}
const updateNeighborsSource = () => {
const map = mapRef.value
if (!map) return
const source = map.getSource('neighbors') as mapboxgl.GeoJSONSource | undefined
if (!source) return
source.setData(buildNeighborsFeatureCollection())
console.log('[NearbyHubsSection] neighbors source updated', {
mapId: mapId.value,
neighbors: props.edges.length
})
}
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coordinates = props.routeGeometries.flatMap(route => route.coordinates || [])
if (coordinates.length === 0) return
const bounds = coordinates.reduce(
(acc, coord) => acc.extend(coord as [number, number]),
new LngLatBounds(
coordinates[0] as [number, number],
coordinates[0] as [number, number]
)
)
map.fitBounds(bounds, {
padding: 40,
maxZoom: 8
})
didFitBounds.value = true
console.log('[NearbyHubsSection] fitBounds applied', {
mapId: mapId.value,
points: coordinates.length
})
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
console.log('[NearbyHubsSection] map created', { mapId: mapId.value })
const initMap = () => {
map.addSource('routes', {
type: 'geojson',
data: buildRouteFeatureCollection()
})
// Route lines layer
map.addLayer({
id: 'routes-lines',
type: 'line',
source: 'routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': lineColor.value,
'line-width': 4,
'line-opacity': 0.8
}
})
// Add current hub marker source
map.addSource('current-hub', {
type: 'geojson',
data: {
type: 'Feature',
properties: { name: props.currentHub.name },
geometry: {
type: 'Point',
coordinates: [props.currentHub.longitude, props.currentHub.latitude]
}
}
})
// Current hub circle
map.addLayer({
id: 'current-hub-circle',
type: 'circle',
source: 'current-hub',
paint: {
'circle-radius': 10,
'circle-color': lineColor.value,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
// Add neighbor markers source
map.addSource('neighbors', {
type: 'geojson',
data: buildNeighborsFeatureCollection()
})
// Neighbor markers
map.addLayer({
id: 'neighbors-circles',
type: 'circle',
source: 'neighbors',
paint: {
'circle-radius': 8,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
// Popups on click
map.on('click', 'current-hub-circle', (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = e.features![0].properties?.name
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong>`)
.addTo(map)
})
map.on('click', 'neighbors-circles', (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features![0].properties
const name = featureProps?.name
const distanceKm = featureProps?.distanceKm
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong><br/>${distanceKm} km`)
.addTo(map)
})
// Cursor changes
map.on('mouseenter', 'current-hub-circle', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'current-hub-circle', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'neighbors-circles', () => { map.getCanvas().style.cursor = '' })
isMapReady.value = true
updateRoutesSource()
updateNeighborsSource()
fitMapToRoutes()
console.log('[NearbyHubsSection] map ready', { mapId: mapId.value })
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => props.routeGeometries,
() => {
didFitBounds.value = false
updateRoutesSource()
if (isMapReady.value) {
fitMapToRoutes()
}
},
{ deep: true }
)
watch(
() => props.edges,
() => updateNeighborsSource(),
{ deep: true }
)
onMounted(() => {
console.log('[NearbyHubsSection] mounted', {
mapId: mapId.value,
edges: props.edges.length,
routes: props.routeGeometries.length,
transportType: props.transportType
})
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './NovuNotificationBell.client.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'NovuNotificationBell.client',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,202 @@
<template>
<div class="relative">
<!-- Bell Button -->
<button
@click="toggleDropdown"
class="btn btn-ghost btn-sm btn-square"
aria-label="Notifications"
>
<Icon name="lucide:bell" size="18" />
<!-- Unread Badge -->
<span
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 min-w-[1.25rem] h-5 px-1 rounded-full bg-error text-error-content text-xs font-semibold flex items-center justify-center"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<!-- Overlay -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-base-content/30 z-40"
@click="isOpen = false"
/>
</Transition>
<!-- Slide-in Panel -->
<Transition
enter-active-class="transition ease-out duration-300"
enter-from-class="transform translate-x-full"
enter-to-class="transform translate-x-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="transform translate-x-0"
leave-to-class="transform translate-x-full"
>
<div
v-if="isOpen"
class="fixed right-0 top-0 h-full w-full sm:w-96 lg:w-1/3 bg-base-100 border border-base-300 shadow-2xl z-50 flex flex-col"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h3 class="text-lg font-semibold text-base-content">Notifications</h3>
<div class="flex items-center gap-3">
<button
v-if="unreadCount > 0"
@click="handleMarkAllAsRead"
class="text-sm text-primary hover:text-primary/80 font-medium"
>
Mark all as read
</button>
<button
@click="isOpen = false"
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Notifications List -->
<div class="flex-1 overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-base-content/50" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Empty State -->
<div v-else-if="notifications.length === 0" class="flex flex-col items-center justify-center py-8 px-4">
<svg class="w-12 h-12 text-base-content/30 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-sm text-base-content/60">No notifications</p>
</div>
<!-- Notification Items -->
<div v-else>
<button
v-for="notification in notifications"
:key="notification.id"
@click="handleNotificationClick(notification)"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 last:border-b-0 transition-colors"
:class="{ 'bg-primary/10': !notification.read }"
>
<div class="flex items-start gap-3">
<!-- Unread Indicator -->
<div class="flex-shrink-0 mt-1.5">
<div
v-if="!notification.read"
class="w-2 h-2 rounded-full bg-primary"
></div>
<div v-else class="w-2 h-2"></div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-base-content line-clamp-2">
{{ notification.content || notification.payload?.body || 'New notification' }}
</p>
<p class="mt-1 text-xs text-base-content/60">
{{ formatTime(notification.createdAt) }}
</p>
</div>
</div>
</button>
</div>
</div>
<!-- Footer -->
<div v-if="notifications.length > 0" class="px-4 py-2 border-t border-base-300 bg-base-200">
<NuxtLink
to="/clientarea/notifications"
@click="isOpen = false"
class="text-xs text-primary hover:text-primary/80 font-medium"
>
All notifications
</NuxtLink>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
subscriberId: string
}>()
const { init, notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNovu()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
// Init on mount
onMounted(() => {
if (props.subscriberId) {
init(props.subscriberId)
}
})
// Re-init on subscriber change
watch(() => props.subscriberId, (newId) => {
if (newId) {
init(newId)
}
})
// Close on click outside
onClickOutside(dropdownRef, () => {
isOpen.value = false
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const handleMarkAllAsRead = async () => {
await markAllAsRead()
}
const handleNotificationClick = async (notification: { id: string; read: boolean; cta?: { data?: { url?: string } } }) => {
if (!notification.read) {
await markAsRead(notification.id)
}
// Navigate if CTA URL present
if (notification.cta?.data?.url) {
isOpen.value = false
navigateTo(notification.cta.data.url)
}
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min ago`
if (diffHours < 24) return `${diffHours} h ago`
if (diffDays < 7) return `${diffDays} d ago`
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './OrderCalendar.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'OrderCalendar',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,244 @@
<template>
<div class="card bg-base-100 border border-base-300 shadow">
<div class="card-body">
<div class="mb-4 flex items-center justify-between flex-wrap gap-3">
<h3 class="text-lg font-semibold text-base-content">{{ t('orderCalendar.header.title') }}</h3>
<div class="flex items-center gap-4">
<!-- Filters -->
<label class="flex items-center gap-2 cursor-pointer">
<input
id="show-loading"
v-model="showLoading"
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
>
<span class="text-sm text-base-content/80">{{ t('orderCalendar.filters.loading') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
id="show-unloading"
v-model="showUnloading"
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
>
<span class="text-sm text-base-content/80">{{ t('orderCalendar.filters.unloading') }}</span>
</label>
</div>
</div>
<!-- Simplified calendar view -->
<div class="bg-base-200 rounded-box p-4">
<div class="grid grid-cols-7 gap-2 mb-4">
<div v-for="month in months" :key="month.name" class="text-center">
<h4 class="font-medium text-sm text-base-content/80">{{ month.name }}</h4>
<div class="mt-2 space-y-1">
<div
v-for="week in month.weeks"
:key="week.start"
class="text-xs"
>
<div class="flex items-center justify-between">
<span class="text-base-content/60">{{ formatWeek(week.start) }}</span>
<div class="flex space-x-1">
<div
v-for="event in week.events"
:key="event.id"
class="w-2 h-2 rounded-full"
:style="{ backgroundColor: event.color }"
:title="event.title"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Event summary -->
<div class="border-t border-base-300 pt-4">
<h4 class="font-medium text-sm text-base-content/80 mb-3">{{ t('orderCalendar.stats.title') }}</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="period in periodStats" :key="period.name" class="card bg-base-100 border border-base-300">
<div class="card-body p-3 gap-2">
<h5 class="font-medium text-sm text-base-content">{{ period.name }}</h5>
<p class="text-xs text-base-content/70">{{ period.description }}</p>
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span>{{ t('orderCalendar.stats.trips') }}</span>
<span class="font-medium">{{ period.tripCount }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('orderCalendar.stats.companies') }}</span>
<span class="font-medium">{{ period.companyCount }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('orderCalendar.stats.weight') }}</span>
<span class="font-medium">{{ period.totalWeight }}{{ t('orderCalendar.labels.weight_unit') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
stages: {
type: Array,
default: () => []
}
})
const showLoading = ref(true)
const showUnloading = ref(true)
const { t } = useI18n()
const allTrips = computed(() => {
const trips = []
props.stages.forEach(stage => {
if (stage.trips?.length) {
stage.trips.forEach(trip => {
trips.push({
...trip,
stageName: stage.name,
stageType: stage.stageType
})
})
}
})
return trips
})
const calendarEvents = computed(() => {
const events = []
allTrips.value.forEach(trip => {
const companyColor = getCompanyColor(trip.company?.name)
if (showLoading.value && trip.plannedLoadingDate) {
events.push({
id: `loading-${trip.uuid}`,
title: `${trip.name} - ${t('orderCalendar.labels.loading')}`,
date: new Date(trip.plannedLoadingDate),
color: companyColor,
type: 'loading',
trip: trip
})
}
if (showUnloading.value && trip.plannedUnloadingDate) {
events.push({
id: `unloading-${trip.uuid}`,
title: `${trip.name} - ${t('orderCalendar.labels.unloading')}`,
date: new Date(trip.plannedUnloadingDate),
color: companyColor,
type: 'unloading',
trip: trip
})
}
})
return events
})
const months = computed(() => {
const monthsData = []
const startDate = new Date('2024-08-01')
const endDate = new Date('2024-10-31')
const monthNames = t('orderCalendar.months').split('|')
monthNames.forEach((name, index) => {
const monthStart = new Date(2024, 7 + index, 1) // August = 7
const monthEnd = new Date(2024, 8 + index, 0)
const weeks = []
let weekStart = new Date(monthStart)
while (weekStart <= monthEnd) {
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
const weekEvents = calendarEvents.value.filter(event => {
const eventDate = event.date
return eventDate >= weekStart && eventDate <= weekEnd
})
weeks.push({
start: new Date(weekStart),
events: weekEvents.slice(0, 5) // Show max 5 events
})
weekStart.setDate(weekStart.getDate() + 7)
}
monthsData.push({
name,
weeks
})
})
return monthsData
})
const periodStats = computed(() => {
const stats = []
// Stats per stage
props.stages.forEach(stage => {
if (stage.trips?.length) {
const companies = new Set(stage.trips.map(trip => trip.company?.name).filter(Boolean))
const totalWeight = stage.trips.reduce((sum, trip) => sum + (trip.plannedWeight || 0), 0)
stats.push({
name: stage.name,
description: stage.stageType === 'transport' ? getTransportTypeText(stage.transportType) : t('orderCalendar.labels.service'),
tripCount: stage.trips.length,
companyCount: companies.size,
totalWeight: totalWeight
})
} else {
stats.push({
name: stage.name,
description: t('orderCalendar.labels.service_stage'),
tripCount: 0,
companyCount: stage.selectedCompany ? 1 : 0,
totalWeight: 0
})
}
})
return stats
})
const getCompanyColor = (companyName) => {
const colors = {
'Kenya Coffee Logistics Ltd': '#ef4444',
'Kenya Highland Transport': '#f97316',
'Mombasa Express Cargo': '#eab308',
'East Africa Shipping Lines': '#3b82f6',
'TruckCo': '#10b981',
'AutoBase #1': '#8b5cf6',
'RailTrans': '#ec4899'
}
return colors[companyName] || '#6b7280'
}
const getTransportTypeText = (transportType) => {
const texts = {
auto: t('orderCalendar.transport.auto'),
rail: t('orderCalendar.transport.rail'),
sea: t('orderCalendar.transport.sea'),
air: t('orderCalendar.transport.air')
}
return texts[transportType] || transportType
}
const formatWeek = (date) => {
return `${date.getDate()}-${date.getDate() + 6}`
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './OrderMap.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'OrderMap',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

147
app/components/OrderMap.vue Normal file
View File

@@ -0,0 +1,147 @@
<template>
<div class="card bg-base-100 border border-base-300 shadow">
<div class="card-body">
<div class="h-full flex items-center justify-center p-4">
<div class="w-full max-w-2xl">
<h3 class="text-lg font-semibold text-base-content mb-4 text-center">{{ t('orderMap.header.title') }}</h3>
<!-- Route visualization -->
<div class="relative">
<!-- Route line -->
<div class="absolute top-1/2 left-0 right-0 h-1 bg-primary transform -translate-y-1/2 z-0"></div>
<!-- Location points -->
<div class="relative z-10 flex justify-between items-center">
<div
v-for="(point, index) in routePoints"
:key="point.id"
class="flex flex-col items-center"
>
<!-- Point marker -->
<div
class="w-4 h-4 rounded-full border-2 border-base-100 shadow-md mb-2"
:class="getPointColor(point.type)"
></div>
<!-- Point info -->
<div class="text-center card bg-base-200 border border-base-300 p-2 max-w-32">
<p class="text-xs font-medium text-base-content">{{ point.name }}</p>
<p class="text-xs text-base-content/60">{{ point.country }}</p>
<div v-if="point.stage" class="mt-1">
<p class="text-xs text-primary">{{ point.stage.name }}</p>
<p v-if="point.stage.selectedCompany" class="text-xs text-base-content/60">
{{ point.stage.selectedCompany.name }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Route summary -->
<div class="mt-6 grid grid-cols-2 gap-4 text-center">
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-1">
<div class="text-lg font-semibold text-primary">{{ t('orderMap.stats.distance_value', { km: getTotalDistance() }) }}</div>
<div class="text-xs text-base-content/60">{{ t('orderMap.stats.distance_label') }}</div>
</div>
</div>
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-3 gap-1">
<div class="text-lg font-semibold text-success">{{ getCountries() }}</div>
<div class="text-xs text-base-content/60">{{ t('orderMap.stats.countries_label') }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
stages: {
type: Array,
default: () => []
}
})
const { t } = useI18n()
// Coordinates of route points
const locationData = {
'Kenya Coffee Farm': { country: 'Kenya', lat: -1.2921, lng: 36.8219 },
'Port of Mombasa': { country: 'Kenya', lat: -4.0435, lng: 39.6682 },
'Novorossiysk Port': { country: 'Russia', lat: 44.7233, lng: 37.7683 },
'Moscow Distribution Center': { country: 'Russia', lat: 55.7558, lng: 37.6176 }
}
const routePoints = computed(() => {
const points = []
props.stages.forEach((stage) => {
if (stage.stageType === 'transport') {
// Add source point
const sourceData = locationData[stage.sourceLocationName]
if (sourceData && !points.find(p => p.name === stage.sourceLocationName)) {
points.push({
id: `source-${stage.uuid}`,
name: stage.sourceLocationName,
country: sourceData.country,
type: 'source',
stage: stage
})
}
// Add destination point
const destData = locationData[stage.destinationLocationName]
if (destData && !points.find(p => p.name === stage.destinationLocationName)) {
points.push({
id: `dest-${stage.uuid}`,
name: stage.destinationLocationName,
country: destData.country,
type: 'destination',
stage: stage
})
}
} else {
// Service stage
const locationData_service = locationData[stage.locationName]
if (locationData_service && !points.find(p => p.name === stage.locationName)) {
points.push({
id: `service-${stage.uuid}`,
name: stage.locationName,
country: locationData_service.country,
type: 'service',
stage: stage
})
}
}
})
return points
})
const getPointColor = (type) => {
const colors = {
source: 'bg-success',
destination: 'bg-error',
service: 'bg-primary'
}
return colors[type] || 'bg-base-300'
}
const getTotalDistance = () => {
// Approximate distance between points
return '15,000' // Kenya → Russia ~15,000 km
}
const getCountries = () => {
const countries = new Set()
routePoints.value.forEach(point => {
countries.add(point.country)
})
return countries.size
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './OrderTimeline.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'OrderTimeline',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,184 @@
<template>
<div class="space-y-0">
<div
v-for="(stage, index) in stages"
:key="stage.uuid"
class="relative"
>
<!-- Timeline connector -->
<div
v-if="index < stages.length - 1"
class="absolute left-4 top-8 w-0.5 h-16 bg-base-300"
:class="{ 'bg-success': isStageCompleted(stage) }"
></div>
<!-- Stage content -->
<div class="flex items-start space-x-4 pb-8">
<!-- Stage icon -->
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold"
:class="getStageIconClass(stage)"
>
{{ getStageIcon(stage) }}
</div>
<!-- Stage details -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-base-content">{{ stage.name }}</h3>
<span
:class="getStageStatusClass(stage)"
class="badge badge-sm font-semibold"
>
{{ getStageStatusText(stage) }}
</span>
</div>
<!-- Stage info -->
<div class="mt-2 space-y-2">
<!-- Location info -->
<div class="text-sm text-base-content/70">
<span v-if="stage.stageType === 'transport'">
<i class="fas fa-route mr-1"></i>
{{ stage.sourceLocationName }} {{ stage.destinationLocationName }}
</span>
<span v-else>
<i class="fas fa-map-marker-alt mr-1"></i>
{{ stage.locationName }}
</span>
</div>
<!-- Company info -->
<div v-if="stage.selectedCompany" class="text-sm text-base-content/70">
<i class="fas fa-building mr-1"></i>
{{ stage.selectedCompany.name }} ({{ stage.selectedCompany.countryCode }})
</div>
<!-- Transport type -->
<div v-if="stage.stageType === 'transport'" class="text-sm text-base-content/70">
<i class="fas fa-truck mr-1"></i>
{{ getTransportTypeText(stage.transportType) }}
</div>
<!-- Trips progress -->
<div v-if="stage.trips?.length > 0" class="mt-3">
<div class="flex items-center justify-between text-sm text-base-content/70 mb-1">
<span>Trips:</span>
<span>{{ getCompletedTrips(stage) }}/{{ stage.trips.length }}</span>
</div>
<div class="w-full bg-base-200 rounded-full h-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
:style="{ width: `${getTripProgress(stage)}%` }"
></div>
</div>
<!-- Company breakdown -->
<div class="mt-2 text-xs text-base-content/60">
<div v-for="companyGroup in getCompanyGroups(stage.trips)" :key="companyGroup.name">
{{ companyGroup.name }}: {{ companyGroup.completed }}/{{ companyGroup.total }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
stages: {
type: Array,
default: () => []
}
})
const getStageIcon = (stage) => {
if (stage.stageType === 'service') return 'C'
switch (stage.transportType) {
case 'auto': return 'A'
case 'sea': return 'M'
case 'rail': return 'R'
case 'air': return 'A'
default: return 'A'
}
}
const getStageIconClass = (stage) => {
const baseClass = 'border-2'
if (isStageCompleted(stage)) {
return `${baseClass} bg-success/10 border-success text-success`
} else if (stage.status === 'in_progress') {
return `${baseClass} bg-primary/10 border-primary text-primary`
} else {
return `${baseClass} bg-base-200 border-base-300 text-base-content/60`
}
}
const getStageStatusClass = (stage) => {
const classes = {
pending: 'badge-ghost',
in_progress: 'badge-info',
completed: 'badge-success',
cancelled: 'badge-error'
}
return classes[stage.status] || 'badge-ghost'
}
const getStageStatusText = (stage) => {
const texts = {
pending: 'Pending',
in_progress: 'In progress',
completed: 'Completed',
cancelled: 'Cancelled'
}
return texts[stage.status] || stage.status
}
const getTransportTypeText = (transportType) => {
const texts = {
auto: 'Auto transport',
rail: 'Rail transport',
sea: 'Sea transport',
air: 'Air transport'
}
return texts[transportType] || transportType
}
const isStageCompleted = (stage) => {
return stage.status === 'completed'
}
const getCompletedTrips = (stage) => {
if (!stage.trips?.length) return 0
return stage.trips.filter(trip => trip.status === 'completed').length
}
const getTripProgress = (stage) => {
if (!stage.trips?.length) return 0
const completed = getCompletedTrips(stage)
return (completed / stage.trips.length) * 100
}
const getCompanyGroups = (trips) => {
const groups = {}
trips.forEach(trip => {
const companyName = trip.company?.name || 'Unknown'
if (!groups[companyName]) {
groups[companyName] = { name: companyName, total: 0, completed: 0 }
}
groups[companyName].total++
if (trip.status === 'completed') {
groups[companyName].completed++
}
})
return Object.values(groups)
}
</script>

View File

@@ -0,0 +1,417 @@
<template>
<div ref="containerRef" class="w-full h-full">
<MapboxMap
:key="mapId"
:map-id="mapId"
style="height: 100%; width: 100%;"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { getCurrentInstance } from 'vue'
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import { GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
type RouteStage = {
fromLat?: number | null
fromLon?: number | null
fromName?: string | null
toLat?: number | null
toLon?: number | null
toName?: string | null
transportType?: string | null
}
type OrderRoute = {
uuid: string
name: string
status?: string
stages: RouteStage[]
}
const props = defineProps({
routes: {
type: Array as PropType<OrderRoute[]>,
default: () => []
},
selectedOrderId: {
type: String as PropType<string | null>,
default: null
}
})
const emit = defineEmits<{
(e: 'select-order', uuid: string): void
}>()
const { execute } = useGraphQL()
const containerRef = ref<HTMLElement | null>(null)
const mapRef = ref<MapboxMapType | null>(null)
const isMapReady = ref(false)
const didFitBounds = ref(false)
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
const mapId = computed(() => `orders-map-${instanceId}`)
const statusColors: Record<string, string> = {
pending: '#f59e0b',
processing: '#3b82f6',
in_transit: '#06b6d4',
delivered: '#22c55e',
cancelled: '#ef4444'
}
const routeGeometries = ref<Map<string, [number, number][]>>(new Map())
const allCoordinates = computed(() => {
const coords: [number, number][] = []
props.routes.forEach(order => {
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon) {
coords.push([stage.fromLon, stage.fromLat])
}
if (stage.toLat && stage.toLon) {
coords.push([stage.toLon, stage.toLat])
}
})
})
return coords
})
const mapCenter = computed<[number, number]>(() => {
const coords = allCoordinates.value
if (!coords.length) return [50, 50]
const avgLng = coords.reduce((sum, c) => sum + c[0], 0) / coords.length
const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length
return [avgLng, avgLat]
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: 3
}))
const normalizeCoordinates = (geometry: unknown): [number, number][] | null => {
if (!Array.isArray(geometry)) return null
const coords = geometry
.filter(point => Array.isArray(point) && typeof point[0] === 'number' && typeof point[1] === 'number')
.map(point => [point[0], point[1]] as [number, number])
return coords.length > 1 ? coords : null
}
const fetchRouteGeometry = async (stage: RouteStage): Promise<[number, number][] | null> => {
if (!stage.fromLat || !stage.fromLon || !stage.toLat || !stage.toLon) return null
if (stage.transportType === 'auto' || stage.transportType === 'rail') {
try {
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
const routeData = await execute(RouteDocument, {
fromLat: stage.fromLat,
fromLon: stage.fromLon,
toLat: stage.toLat,
toLon: stage.toLon
}, 'public', 'geo')
const geometry = routeData?.[routeField]?.geometry
if (typeof geometry === 'string') {
return normalizeCoordinates(JSON.parse(geometry))
}
return normalizeCoordinates(geometry)
} catch (error) {
console.error('Failed to load route geometry:', error)
}
}
return [
[stage.fromLon, stage.fromLat],
[stage.toLon, stage.toLat]
]
}
let loadCounter = 0
const loadDetailedRoutes = async () => {
if (!process.client) return
const requestId = ++loadCounter
const newGeometries = new Map<string, [number, number][]>()
for (const order of props.routes) {
const allCoords: [number, number][] = []
for (const stage of order.stages) {
const coords = await fetchRouteGeometry(stage)
if (coords) {
allCoords.push(...coords)
}
}
if (allCoords.length > 0) {
newGeometries.set(order.uuid, allCoords)
}
}
if (requestId !== loadCounter) return
routeGeometries.value = newGeometries
updateRoutesSource()
fitMapToRoutes()
}
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
props.routes.forEach(order => {
const coords = routeGeometries.value.get(order.uuid)
if (coords && coords.length > 1) {
const isSelected = props.selectedOrderId === order.uuid
features.push({
type: 'Feature',
properties: {
orderId: order.uuid,
orderName: order.name,
status: order.status,
color: statusColors[order.status || ''] || '#6b7280',
isSelected,
lineWidth: isSelected ? 6 : 4,
opacity: isSelected ? 1 : 0.7
},
geometry: {
type: 'LineString',
coordinates: coords
}
})
}
})
// Sort: selected on top
features.sort((a, b) => {
if (a.properties?.isSelected) return 1
if (b.properties?.isSelected) return -1
return 0
})
return { type: 'FeatureCollection', features }
})
const markersFeatureCollection = computed<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const points: GeoJSON.Feature<GeoJSON.Point>[] = []
const seen = new Set<string>()
props.routes.forEach(order => {
const isSelected = props.selectedOrderId === order.uuid
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon) {
const key = `${order.uuid}-from-${stage.fromLon},${stage.fromLat}`
if (!seen.has(key)) {
seen.add(key)
points.push({
type: 'Feature',
properties: {
orderId: order.uuid,
orderName: order.name,
name: stage.fromName || 'Start',
color: statusColors[order.status || ''] || '#6b7280',
isSelected,
radius: isSelected ? 10 : 6
},
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
})
}
}
if (stage.toLat && stage.toLon) {
const key = `${order.uuid}-to-${stage.toLon},${stage.toLat}`
if (!seen.has(key)) {
seen.add(key)
points.push({
type: 'Feature',
properties: {
orderId: order.uuid,
orderName: order.name,
name: stage.toName || 'End',
color: statusColors[order.status || ''] || '#6b7280',
isSelected,
radius: isSelected ? 10 : 6
},
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
})
}
}
})
})
// Sort: selected on top
points.sort((a, b) => {
if (a.properties?.isSelected) return 1
if (b.properties?.isSelected) return -1
return 0
})
return { type: 'FeatureCollection', features: points }
})
const updateRoutesSource = () => {
const map = mapRef.value
if (!map) return
const source = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
if (source) source.setData(routeLineFeatures.value)
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
if (markersSource) markersSource.setData(markersFeatureCollection.value)
}
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coords = allCoordinates.value
if (coords.length === 0) return
const bounds = coords.reduce(
(acc, coord) => acc.extend(coord),
new LngLatBounds(coords[0], coords[0])
)
map.fitBounds(bounds, { padding: 50, maxZoom: 8 })
didFitBounds.value = true
}
const flyToOrder = (orderId: string) => {
const map = mapRef.value
if (!map) return
const order = props.routes.find(o => o.uuid === orderId)
if (!order) return
const coords: [number, number][] = []
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon) coords.push([stage.fromLon, stage.fromLat])
if (stage.toLat && stage.toLon) coords.push([stage.toLon, stage.toLat])
})
if (coords.length === 0) return
if (coords.length === 1) {
map.flyTo({ center: coords[0], zoom: 8 })
} else {
const bounds = coords.reduce(
(acc, coord) => acc.extend(coord),
new LngLatBounds(coords[0], coords[0])
)
map.fitBounds(bounds, { padding: 80, maxZoom: 8 })
}
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('orders-routes', {
type: 'geojson',
data: routeLineFeatures.value
})
map.addLayer({
id: 'orders-routes-lines',
type: 'line',
source: 'orders-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': ['get', 'lineWidth'],
'line-opacity': ['get', 'opacity']
}
})
map.addSource('orders-markers', {
type: 'geojson',
data: markersFeatureCollection.value
})
map.addLayer({
id: 'orders-markers-circles',
type: 'circle',
source: 'orders-markers',
paint: {
'circle-radius': ['get', 'radius'],
'circle-color': ['get', 'color'],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
// Click on route line
map.on('click', 'orders-routes-lines', (e) => {
const orderId = e.features?.[0]?.properties?.orderId
if (orderId) {
emit('select-order', orderId)
}
})
// Click on marker
map.on('click', 'orders-markers-circles', (e) => {
const props = e.features?.[0]?.properties
const orderId = props?.orderId
if (orderId) {
emit('select-order', orderId)
}
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${props?.name || 'Point'}</strong><br/>${props?.orderName || ''}`)
.addTo(map)
})
map.on('mouseenter', 'orders-routes-lines', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'orders-routes-lines', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'orders-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'orders-markers-circles', () => { map.getCanvas().style.cursor = '' })
isMapReady.value = true
fitMapToRoutes()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
// Watch for selection changes
watch(() => props.selectedOrderId, (newId) => {
updateRoutesSource()
if (newId) {
flyToOrder(newId)
}
})
// Watch for routes changes
watch(
() => props.routes,
() => {
didFitBounds.value = false
loadDetailedRoutes()
},
{ deep: true, immediate: true }
)
// Expose flyTo method
defineExpose({
flyTo: flyToOrder
})
</script>

View File

@@ -0,0 +1,234 @@
<template>
<div class="w-full">
<MapboxMap
:key="mapId"
:map-id="mapId"
:style="`height: ${height}px; width: 100%;`"
class="rounded-lg"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { getCurrentInstance } from 'vue'
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds } from 'mapbox-gl'
type RouteStage = {
fromLat?: number | null
fromLon?: number | null
toLat?: number | null
toLon?: number | null
transportType?: string | null
}
type OrderRoute = {
uuid: string
name: string
status?: string
stages: RouteStage[]
}
const props = defineProps({
routes: {
type: Array as PropType<OrderRoute[]>,
default: () => []
},
height: {
type: Number,
default: 192
}
})
const mapRef = ref<MapboxMapType | null>(null)
const didFitBounds = ref(false)
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
const mapId = computed(() => `orders-preview-${instanceId}`)
const statusColors: Record<string, string> = {
pending: '#f59e0b',
processing: '#3b82f6',
in_transit: '#06b6d4',
delivered: '#22c55e',
cancelled: '#ef4444'
}
const allCoordinates = computed(() => {
const coords: [number, number][] = []
props.routes.forEach(order => {
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon) {
coords.push([stage.fromLon, stage.fromLat])
}
if (stage.toLat && stage.toLon) {
coords.push([stage.toLon, stage.toLat])
}
})
})
return coords
})
const mapCenter = computed<[number, number]>(() => {
const coords = allCoordinates.value
if (!coords.length) return [50, 50]
const avgLng = coords.reduce((sum, c) => sum + c[0], 0) / coords.length
const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length
return [avgLng, avgLat]
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: 2,
interactive: false
}))
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
props.routes.forEach(order => {
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon && stage.toLat && stage.toLon) {
features.push({
type: 'Feature',
properties: {
orderId: order.uuid,
status: order.status,
color: statusColors[order.status || ''] || '#6b7280'
},
geometry: {
type: 'LineString',
coordinates: [
[stage.fromLon, stage.fromLat],
[stage.toLon, stage.toLat]
]
}
})
}
})
})
return { type: 'FeatureCollection', features }
})
const markersFeatureCollection = computed<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
const points: GeoJSON.Feature<GeoJSON.Point>[] = []
const seen = new Set<string>()
props.routes.forEach(order => {
order.stages.forEach(stage => {
if (stage.fromLat && stage.fromLon) {
const key = `${stage.fromLon},${stage.fromLat}`
if (!seen.has(key)) {
seen.add(key)
points.push({
type: 'Feature',
properties: { color: statusColors[order.status || ''] || '#6b7280' },
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
})
}
}
if (stage.toLat && stage.toLon) {
const key = `${stage.toLon},${stage.toLat}`
if (!seen.has(key)) {
seen.add(key)
points.push({
type: 'Feature',
properties: { color: statusColors[order.status || ''] || '#6b7280' },
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
})
}
}
})
})
return { type: 'FeatureCollection', features: points }
})
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coords = allCoordinates.value
if (coords.length === 0) return
const bounds = coords.reduce(
(acc, coord) => acc.extend(coord),
new LngLatBounds(coords[0], coords[0])
)
map.fitBounds(bounds, { padding: 30, maxZoom: 6 })
didFitBounds.value = true
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('orders-routes', {
type: 'geojson',
data: routeLineFeatures.value
})
map.addLayer({
id: 'orders-routes-lines',
type: 'line',
source: 'orders-routes',
paint: {
'line-color': ['get', 'color'],
'line-width': 3,
'line-opacity': 0.8
}
})
map.addSource('orders-markers', {
type: 'geojson',
data: markersFeatureCollection.value
})
map.addLayer({
id: 'orders-markers-circles',
type: 'circle',
source: 'orders-markers',
paint: {
'circle-radius': 5,
'circle-color': ['get', 'color'],
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff'
}
})
fitMapToRoutes()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => props.routes,
() => {
didFitBounds.value = false
const map = mapRef.value
if (!map) return
const routesSource = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
if (routesSource) routesSource.setData(routeLineFeatures.value)
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
if (markersSource) markersSource.setData(markersFeatureCollection.value)
fitMapToRoutes()
},
{ deep: true }
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<Stack v-if="total > 0" gap="2" align="center" justify="center">
<Text tone="muted">
{{ t('common.pagination.showing', { shown, total }) }}
</Text>
<Button
v-if="canLoadMore"
variant="outline"
@click="$emit('load-more')"
:disabled="loading"
>
{{ t('common.actions.load_more') }}
</Button>
</Stack>
</template>
<script setup lang="ts">
const props = defineProps<{
shown: number
total: number
canLoadMore: boolean
loading?: boolean
}>()
defineEmits<{
(e: 'load-more'): void
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,401 @@
<template>
<div class="w-full">
<ClientOnly>
<MapboxMap
:key="mapId"
:map-id="mapId"
:style="`height: ${height}px; width: 100%;`"
class="rounded-lg border border-base-300"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<template #fallback>
<div class="h-72 w-full bg-base-200 rounded-lg flex items-center justify-center">
<p class="text-base-content/60">Загрузка карты...</p>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { getCurrentInstance } from 'vue'
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import { GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
type RouteStage = {
fromLat?: number | null
fromLon?: number | null
fromName?: string | null
toLat?: number | null
toLon?: number | null
toName?: string | null
transportType?: string | null
}
type RouteWithStages = {
stages?: Array<RouteStage | null> | null
}
type RouteMarker = {
lng: number
lat: number
name: string
label: string
}
type StageGeometry = {
routeIndex: number
stageIndex: number
transportType?: string | null
coordinates: [number, number][]
}
const props = defineProps({
routes: {
type: Array as PropType<RouteWithStages[]>,
default: () => []
},
height: {
type: Number,
default: 360
}
})
const { execute } = useGraphQL()
const mapRef = ref<MapboxMapType | null>(null)
const isMapReady = ref(false)
const didFitBounds = ref(false)
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
const mapId = computed(() => `request-routes-${instanceId}`)
const routeColors = [
'#2563eb',
'#0f766e',
'#7c3aed',
'#ea580c',
'#16a34a'
]
const routeMarkers = computed(() => {
const markers: RouteMarker[] = []
props.routes.forEach((route, routeIndex) => {
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
if (!stages.length) return
const first = stages[0]
const last = stages[stages.length - 1]
if (typeof first.fromLat === 'number' && typeof first.fromLon === 'number') {
markers.push({
lat: first.fromLat,
lng: first.fromLon,
name: first.fromName || 'Старт',
label: `Маршрут ${routeIndex + 1} — старт`
})
}
if (typeof last.toLat === 'number' && typeof last.toLon === 'number') {
markers.push({
lat: last.toLat,
lng: last.toLon,
name: last.toName || 'Финиш',
label: `Маршрут ${routeIndex + 1} — финиш`
})
}
})
return markers
})
const mapCenter = computed<[number, number]>(() => {
const points: [number, number][] = []
routeMarkers.value.forEach(marker => {
points.push([marker.lng, marker.lat])
})
if (!points.length) return [0, 0]
const avgLng = points.reduce((sum, coord) => sum + coord[0], 0) / points.length
const avgLat = points.reduce((sum, coord) => sum + coord[1], 0) / points.length
return [avgLng, avgLat]
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: 2.5
}))
const routeLineFeatures = ref<GeoJSON.FeatureCollection<GeoJSON.LineString>>({
type: 'FeatureCollection',
features: []
})
const markersFeatureCollection = computed(() => ({
type: 'FeatureCollection' as const,
features: routeMarkers.value.map(marker => ({
type: 'Feature' as const,
properties: {
name: marker.name,
label: marker.label
},
geometry: {
type: 'Point' as const,
coordinates: [marker.lng, marker.lat]
}
}))
}))
const normalizeCoordinates = (geometry: unknown): [number, number][] | null => {
if (!Array.isArray(geometry)) return null
const coords = geometry
.filter(point => Array.isArray(point) && typeof point[0] === 'number' && typeof point[1] === 'number')
.map(point => [point[0], point[1]] as [number, number])
return coords.length > 1 ? coords : null
}
const fetchStageGeometry = async (stage: RouteStage, routeIndex: number, stageIndex: number): Promise<StageGeometry | null> => {
if (
typeof stage.fromLat !== 'number' ||
typeof stage.fromLon !== 'number' ||
typeof stage.toLat !== 'number' ||
typeof stage.toLon !== 'number'
) {
return null
}
const fromLat = stage.fromLat
const fromLon = stage.fromLon
const toLat = stage.toLat
const toLon = stage.toLon
let coordinates: [number, number][] | null = null
if (stage.transportType === 'auto' || stage.transportType === 'rail') {
try {
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
const routeData = await execute(RouteDocument, { fromLat, fromLon, toLat, toLon }, 'public', 'geo')
const geometry = routeData?.[routeField]?.geometry
if (typeof geometry === 'string') {
coordinates = normalizeCoordinates(JSON.parse(geometry))
} else {
coordinates = normalizeCoordinates(geometry)
}
} catch (error) {
console.error('Failed to load detailed route geometry:', error)
}
}
if (!coordinates) {
coordinates = [
[fromLon, fromLat],
[toLon, toLat]
]
}
return {
routeIndex,
stageIndex,
transportType: stage.transportType,
coordinates
}
}
let loadCounter = 0
const loadDetailedRoutes = async () => {
if (!process.client) return
const requestId = ++loadCounter
const stageTasks: Array<Promise<StageGeometry | null>> = []
props.routes.forEach((route, routeIndex) => {
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
stages.forEach((stage, stageIndex) => {
stageTasks.push(fetchStageGeometry(stage, routeIndex, stageIndex))
})
})
const results = await Promise.all(stageTasks)
if (requestId !== loadCounter) return
const features = results
.filter((result): result is StageGeometry => Boolean(result))
.map((result) => ({
type: 'Feature' as const,
properties: {
routeIndex: result.routeIndex,
stageIndex: result.stageIndex,
transportType: result.transportType,
color: routeColors[result.routeIndex % routeColors.length]
},
geometry: {
type: 'LineString' as const,
coordinates: result.coordinates
}
}))
routeLineFeatures.value = {
type: 'FeatureCollection',
features
}
updateRoutesSource()
fitMapToRoutes()
}
const updateRoutesSource = () => {
const map = mapRef.value
if (!map) return
const source = map.getSource('request-routes') as mapboxgl.GeoJSONSource | undefined
if (!source) return
source.setData(routeLineFeatures.value)
}
const updateMarkersSource = () => {
const map = mapRef.value
if (!map) return
const source = map.getSource('request-markers') as mapboxgl.GeoJSONSource | undefined
if (!source) return
source.setData(markersFeatureCollection.value)
}
const fitMapToRoutes = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const lineCoords = routeLineFeatures.value.features.flatMap((feature) => feature.geometry.coordinates)
const markerCoords = routeMarkers.value.map(marker => [marker.lng, marker.lat] as [number, number])
const coordinates = lineCoords.length > 0 ? lineCoords : markerCoords
if (coordinates.length === 0) return
const bounds = coordinates.reduce(
(acc, coord) => acc.extend(coord as [number, number]),
new LngLatBounds(
coordinates[0] as [number, number],
coordinates[0] as [number, number]
)
)
map.fitBounds(bounds, {
padding: 36,
maxZoom: 8
})
didFitBounds.value = true
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('request-routes', {
type: 'geojson',
data: routeLineFeatures.value
})
map.addLayer({
id: 'request-routes-lines',
type: 'line',
source: 'request-routes',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': 0.85
}
})
map.addSource('request-markers', {
type: 'geojson',
data: markersFeatureCollection.value
})
map.addLayer({
id: 'request-markers-circles',
type: 'circle',
source: 'request-markers',
paint: {
'circle-radius': 8,
'circle-color': '#f97316',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.on('click', 'request-markers-circles', (e) => {
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features?.[0].properties
const title = featureProps?.name || 'Точка'
const label = featureProps?.label || ''
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${title}</strong><br/>${label}`)
.addTo(map)
})
map.on('mouseenter', 'request-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'request-markers-circles', () => { map.getCanvas().style.cursor = '' })
isMapReady.value = true
updateMarkersSource()
updateRoutesSource()
fitMapToRoutes()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => props.routes,
() => {
didFitBounds.value = false
loadDetailedRoutes()
},
{ deep: true, immediate: true }
)
watch(
() => markersFeatureCollection.value,
() => {
updateMarkersSource()
if (isMapReady.value) {
fitMapToRoutes()
}
},
{ deep: true }
)
watch(
() => routeLineFeatures.value,
() => {
updateRoutesSource()
if (isMapReady.value) {
fitMapToRoutes()
}
},
{ deep: true }
)
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './RouteMap.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'RouteMap',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

281
app/components/RouteMap.vue Normal file
View File

@@ -0,0 +1,281 @@
<template>
<div class="w-full">
<ClientOnly>
<MapboxMap
:key="mapId"
:map-id="mapId"
:style="`height: ${height}px; width: 100%;`"
class="rounded-lg border border-base-300"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
<template #fallback>
<div class="h-96 w-full bg-base-200 rounded-lg flex items-center justify-center">
<p class="text-base-content/60">{{ t('routeMap.states.loading') }}</p>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import { getCurrentInstance } from 'vue'
const props = defineProps({
stages: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 400
}
})
const { t } = useI18n()
const mapRef = ref<MapboxMapType | null>(null)
const didFitBounds = ref(false)
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
const mapId = computed(() => `route-map-${instanceId}`)
const routePoints = computed(() => {
const points: Array<{ id: string; name: string; lat: number; lng: number; companies: any[] }> = []
props.stages.forEach((stage: any) => {
if (stage.stageType === 'transport') {
if (stage.sourceLatitude && stage.sourceLongitude) {
const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude)
if (!existingPoint) {
points.push({
id: `source-${stage.uuid}`,
name: stage.sourceLocationName || t('routeMap.points.source'),
lat: stage.sourceLatitude,
lng: stage.sourceLongitude,
companies: getStageCompanies(stage)
})
}
}
if (stage.destinationLatitude && stage.destinationLongitude) {
const existingPoint = points.find(p => p.lat === stage.destinationLatitude && p.lng === stage.destinationLongitude)
if (!existingPoint) {
points.push({
id: `dest-${stage.uuid}`,
name: stage.destinationLocationName || t('routeMap.points.destination'),
lat: stage.destinationLatitude,
lng: stage.destinationLongitude,
companies: getStageCompanies(stage)
})
}
}
} else if (stage.locationLatitude && stage.locationLongitude) {
const existingPoint = points.find(p => p.lat === stage.locationLatitude && p.lng === stage.locationLongitude)
if (!existingPoint) {
points.push({
id: `service-${stage.uuid}`,
name: stage.locationName || t('routeMap.points.service'),
lat: stage.locationLatitude,
lng: stage.locationLongitude,
companies: getStageCompanies(stage)
})
}
}
})
return points
})
const mapCenter = computed<[number, number]>(() => {
if (!routePoints.value.length) return [0, 0]
const lats = routePoints.value.map(p => p.lat).filter(Boolean)
const lngs = routePoints.value.map(p => p.lng).filter(Boolean)
if (!lats.length || !lngs.length) return [0, 0]
const avgLat = lats.reduce((a, b) => a + b, 0) / lats.length
const avgLng = lngs.reduce((a, b) => a + b, 0) / lngs.length
return [avgLng, avgLat]
})
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/streets-v12',
center: mapCenter.value,
zoom: 4
}))
const routeCoordinates = computed(() => {
return routePoints.value.map(point => [point.lng, point.lat])
})
const pointsFeatureCollection = computed(() => ({
type: 'FeatureCollection' as const,
features: routePoints.value.map(point => ({
type: 'Feature' as const,
properties: {
id: point.id,
name: point.name,
companies: point.companies
},
geometry: {
type: 'Point' as const,
coordinates: [point.lng, point.lat]
}
}))
}))
const lineFeatureCollection = computed(() => ({
type: 'FeatureCollection' as const,
features: routeCoordinates.value.length > 1
? [
{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: routeCoordinates.value
}
}
]
: []
}))
const updateSources = () => {
const map = mapRef.value
if (!map) return
const lineSource = map.getSource('route-line') as mapboxgl.GeoJSONSource | undefined
if (lineSource) {
lineSource.setData(lineFeatureCollection.value)
}
const pointSource = map.getSource('route-points') as mapboxgl.GeoJSONSource | undefined
if (pointSource) {
pointSource.setData(pointsFeatureCollection.value)
}
}
const fitMapToData = () => {
const map = mapRef.value
if (!map || didFitBounds.value) return
const coordinates = routeCoordinates.value
if (!coordinates.length) return
const bounds = coordinates.reduce(
(acc, coord) => acc.extend(coord as [number, number]),
new LngLatBounds(
coordinates[0] as [number, number],
coordinates[0] as [number, number]
)
)
map.fitBounds(bounds, {
padding: 40,
maxZoom: 8
})
didFitBounds.value = true
}
const onMapCreated = (map: MapboxMapType) => {
mapRef.value = map
const initMap = () => {
map.addSource('route-line', {
type: 'geojson',
data: lineFeatureCollection.value
})
map.addLayer({
id: 'route-line-layer',
type: 'line',
source: 'route-line',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': 'var(--color-primary, #3b82f6)',
'line-width': 4,
'line-opacity': 0.75
}
})
map.addSource('route-points', {
type: 'geojson',
data: pointsFeatureCollection.value
})
map.addLayer({
id: 'route-points-layer',
type: 'circle',
source: 'route-points',
paint: {
'circle-radius': 7,
'circle-color': '#f97316',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.on('click', 'route-points-layer', (e) => {
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const props = e.features?.[0].properties
const name = props?.name || t('routeMap.points.service')
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${name}</strong>`)
.addTo(map)
})
map.on('mouseenter', 'route-points-layer', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'route-points-layer', () => { map.getCanvas().style.cursor = '' })
updateSources()
fitMapToData()
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
watch(
() => [routePoints.value, routeCoordinates.value],
() => {
didFitBounds.value = false
updateSources()
fitMapToData()
},
{ deep: true }
)
const getStageCompanies = (stage: any) => {
const companies: any[] = []
if (stage.selectedCompany) {
companies.push(stage.selectedCompany)
}
const uniqueCompanies = new Set()
stage.trips?.forEach((trip: any) => {
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) {
uniqueCompanies.add(trip.company.uuid)
companies.push(trip.company)
}
})
return companies
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="space-y-3">
<Heading v-if="title" :level="3" weight="semibold">{{ title }}</Heading>
<RequestRoutesMap :routes="routes" :height="height" />
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
type RouteStage = {
fromLat?: number | null
fromLon?: number | null
fromName?: string | null
toLat?: number | null
toLon?: number | null
toName?: string | null
transportType?: string | null
}
type RouteWithStages = {
stages?: Array<RouteStage | null> | null
}
defineProps({
title: {
type: String,
default: ''
},
routes: {
type: Array as PropType<RouteWithStages[]>,
default: () => []
},
height: {
type: Number,
default: 320
}
})
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="space-y-3">
<Heading v-if="title" :level="3" weight="semibold">{{ title }}</Heading>
<div v-if="stages.length" class="divide-y divide-base-200">
<div
v-for="(stage, index) in stages"
:key="stage.key || `${index}-${stage.from || ''}-${stage.to || ''}`"
class="py-3 flex flex-wrap items-start justify-between gap-3"
>
<div class="min-w-0">
<div class="text-sm font-medium text-base-content">
<span v-if="stage.from || stage.to">
{{ stage.from || stage.label || '' }} {{ stage.to || stage.label || '' }}
</span>
<span v-else>
{{ stage.label || '' }}
</span>
</div>
<div v-if="stage.meta?.length" class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-x-3 gap-y-1">
<span v-for="item in stage.meta" :key="item">{{ item }}</span>
</div>
</div>
<div v-if="stage.distanceKm" class="text-sm text-base-content/70 shrink-0">
<span>{{ formatDistance(stage.distanceKm) }} км</span>
</div>
</div>
</div>
<Text v-else tone="muted">{{ emptyText }}</Text>
</div>
</template>
<script setup lang="ts">
export type RouteStageItem = {
key?: string
from?: string | null
to?: string | null
label?: string | null
distanceKm?: number | null
durationSeconds?: number | null
meta?: string[]
}
const props = withDefaults(defineProps<{ title?: string; stages?: RouteStageItem[]; emptyText?: string }>(), {
stages: () => [],
title: '',
emptyText: 'Этапы не найдены.'
})
const stages = computed(() => (props.stages || []).filter(Boolean))
const formatDistance = (km: number | null | undefined) => {
if (!km) return '0'
return Math.round(km).toLocaleString()
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="space-y-2">
<Heading :level="2" weight="semibold">{{ title }}</Heading>
<div v-if="meta.length" class="text-sm text-base-content/60 flex flex-wrap gap-x-4 gap-y-1">
<span v-for="item in meta" :key="item">{{ item }}</span>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{ title: string; meta?: string[] }>(), {
meta: () => []
})
const meta = computed(() => (props.meta || []).filter(Boolean))
</script>

395
app/components/Sidebar.vue Normal file
View File

@@ -0,0 +1,395 @@
<template>
<aside
class="bg-base-100 h-screen flex flex-col border-r border-base-300 transition-all duration-300"
:class="collapsed ? 'w-16' : 'w-64'"
>
<!-- Logo + Collapse button -->
<div class="p-4 border-b border-base-300 flex items-center justify-between">
<NuxtLink v-if="!collapsed" :to="localePath('/')" class="text-2xl font-bold text-primary">
Optovia
</NuxtLink>
<NuxtLink v-else :to="localePath('/')" class="text-xl font-bold text-primary">
O
</NuxtLink>
<button
@click="collapsed = !collapsed"
class="btn btn-ghost btn-xs btn-square"
:title="collapsed ? 'Expand' : 'Collapse'"
>
<Icon :name="collapsed ? 'lucide:chevron-right' : 'lucide:chevron-left'" size="16" />
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto py-2">
<!-- Collapsed view: show only menu item icons -->
<template v-if="collapsed">
<ul class="menu menu-vertical menu-compact rounded-none w-full px-2 py-1">
<!-- Exchange items -->
<li>
<NuxtLink
:to="localePath('/')"
:class="{ active: isExactActive('/') }"
class="tooltip tooltip-right"
:data-tip="t('nav.search')"
>
<Icon name="lucide:search" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/catalog/offers')"
:class="{ active: isActive('/catalog/offers') }"
class="tooltip tooltip-right"
:data-tip="t('nav.offers')"
>
<Icon name="lucide:tag" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/catalog/suppliers')"
:class="{ active: isActive('/catalog/suppliers') }"
class="tooltip tooltip-right"
:data-tip="t('nav.suppliers')"
>
<Icon name="lucide:building-2" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/catalog/hubs')"
:class="{ active: isActive('/catalog/hubs') }"
class="tooltip tooltip-right"
:data-tip="t('nav.hubs')"
>
<Icon name="lucide:warehouse" size="18" />
</NuxtLink>
</li>
<!-- Logged in items -->
<template v-if="loggedIn">
<li class="my-2"><div class="divider my-0 h-px"></div></li>
<li>
<NuxtLink
:to="localePath('/clientarea/orders')"
:class="{ active: isActive('/clientarea/orders') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.orders')"
>
<Icon name="lucide:package" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/clientarea/addresses')"
:class="{ active: isActive('/clientarea/addresses') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.addresses')"
>
<Icon name="lucide:map-pin" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/clientarea/billing')"
:class="{ active: isActive('/clientarea/billing') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.billing')"
>
<Icon name="lucide:wallet" size="18" />
</NuxtLink>
</li>
<template v-if="isSeller">
<li>
<NuxtLink
:to="localePath('/clientarea/offers')"
:class="{ active: isActive('/clientarea/offers') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.offers')"
>
<Icon name="lucide:tag" size="18" />
</NuxtLink>
</li>
</template>
<li class="my-2"><div class="divider my-0 h-px"></div></li>
<li>
<NuxtLink
:to="localePath('/clientarea/ai')"
:class="{ active: isActive('/clientarea/ai') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.ai')"
>
<Icon name="lucide:sparkles" size="18" />
</NuxtLink>
</li>
<li class="my-2"><div class="divider my-0 h-px"></div></li>
<li>
<NuxtLink
:to="localePath('/clientarea/profile')"
:class="{ active: isActive('/clientarea/profile') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.profile')"
>
<Icon name="lucide:user" size="18" />
</NuxtLink>
</li>
<li>
<NuxtLink
:to="localePath('/clientarea/team')"
:class="{ active: isActive('/clientarea/team') }"
class="tooltip tooltip-right"
:data-tip="t('cabinetNav.team')"
>
<Icon name="lucide:users" size="18" />
</NuxtLink>
</li>
</template>
</ul>
</template>
<!-- Expanded view -->
<template v-else>
<ul class="menu rounded-none w-full gap-1 px-2 py-3">
<!-- Exchange section -->
<li>
<details :open="sections.exchange">
<summary @click.prevent="toggleSection('exchange')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
<Icon name="lucide:layers" size="16" />
<span>{{ t('sidebar.exchange') }}</span>
</summary>
<ul>
<li>
<NuxtLink :to="localePath('/')" :class="{ active: isExactActive('/') }">
<Icon name="lucide:search" size="18" />
{{ t('nav.search') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/offers')" :class="{ active: isActive('/catalog/offers') }">
<Icon name="lucide:tag" size="18" />
{{ t('nav.offers') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/suppliers')" :class="{ active: isActive('/catalog/suppliers') }">
<Icon name="lucide:building-2" size="18" />
{{ t('nav.suppliers') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/hubs')" :class="{ active: isActive('/catalog/hubs') }">
<Icon name="lucide:warehouse" size="18" />
{{ t('nav.hubs') }}
</NuxtLink>
</li>
</ul>
</details>
</li>
<!-- Cabinet section - only for logged in users -->
<template v-if="loggedIn">
<!-- Orders & Logistics -->
<li>
<details :open="sections.orders">
<summary @click.prevent="toggleSection('orders')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
<Icon name="lucide:truck" size="16" />
<span>{{ t('sidebar.ordersLogistics') }}</span>
</summary>
<ul>
<li>
<NuxtLink :to="localePath('/clientarea/orders')" :class="{ active: isActive('/clientarea/orders') }">
<Icon name="lucide:package" size="18" />
{{ t('cabinetNav.orders') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/addresses')" :class="{ active: isActive('/clientarea/addresses') }">
<Icon name="lucide:map-pin" size="18" />
{{ t('cabinetNav.addresses') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/billing')" :class="{ active: isActive('/clientarea/billing') }">
<Icon name="lucide:wallet" size="18" />
{{ t('cabinetNav.billing') }}
</NuxtLink>
</li>
</ul>
</details>
</li>
<!-- Seller (only for sellers) -->
<li v-if="isSeller">
<details :open="sections.seller">
<summary @click.prevent="toggleSection('seller')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
<Icon name="lucide:badge-dollar-sign" size="16" />
<span>{{ t('sidebar.seller') }}</span>
</summary>
<ul>
<li>
<NuxtLink :to="localePath('/clientarea/offers')" :class="{ active: isActive('/clientarea/offers') }">
<Icon name="lucide:tag" size="18" />
{{ t('cabinetNav.offers') }}
</NuxtLink>
</li>
</ul>
</details>
</li>
<!-- AI & Tools -->
<li>
<details :open="sections.ai">
<summary @click.prevent="toggleSection('ai')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
<Icon name="lucide:sparkles" size="16" />
<span>{{ t('sidebar.aiTools') }}</span>
</summary>
<ul>
<li>
<NuxtLink :to="localePath('/clientarea/ai')" :class="{ active: isActive('/clientarea/ai') }">
<Icon name="lucide:sparkles" size="18" />
{{ t('cabinetNav.ai') }}
</NuxtLink>
</li>
</ul>
</details>
</li>
<!-- Settings -->
<li>
<details :open="sections.settings">
<summary @click.prevent="toggleSection('settings')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
<Icon name="lucide:settings" size="16" />
<span>{{ t('sidebar.settings') }}</span>
</summary>
<ul>
<li>
<NuxtLink :to="localePath('/clientarea/profile')" :class="{ active: isActive('/clientarea/profile') }">
<Icon name="lucide:user" size="18" />
{{ t('cabinetNav.profile') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/team')" :class="{ active: isActive('/clientarea/team') }">
<Icon name="lucide:users" size="18" />
{{ t('cabinetNav.team') }}
</NuxtLink>
</li>
</ul>
</details>
</li>
</template>
</ul>
</template>
</nav>
<!-- Company switcher (bottom) - only for logged in users -->
<div v-if="!collapsed" class="p-3 border-t border-base-300">
<template v-if="userData?.teams?.length">
<div class="dropdown dropdown-top w-full">
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-sm w-full justify-start gap-2"
>
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded w-8 h-8 flex items-center justify-center">
<span class="text-xs">{{ getTeamInitials(userData.activeTeam?.name) }}</span>
</div>
</div>
<div class="flex-1 text-left truncate">
<div class="text-sm font-medium truncate">{{ userData.activeTeam?.name || 'Select company' }}</div>
</div>
<Icon name="lucide:chevrons-up-down" size="16" class="opacity-50" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-full p-2 shadow-lg border border-base-300 mb-2"
>
<li v-for="team in userData.teams" :key="team?.id">
<a
@click="$emit('switch-team', team)"
:class="{ active: team?.id === userData.activeTeamId }"
>
{{ team?.name }}
</a>
</li>
<div class="divider my-1"></div>
<li>
<NuxtLink :to="localePath('/clientarea/team')">
<Icon name="lucide:plus" size="16" />
{{ t('sidebar.createCompany') }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<template v-else-if="loggedIn">
<NuxtLink
:to="localePath('/clientarea/team')"
class="btn btn-ghost btn-sm w-full justify-start gap-2"
>
<Icon name="lucide:building-2" size="18" />
<span>{{ t('sidebar.joinCompany') }}</span>
</NuxtLink>
</template>
</div>
</aside>
</template>
<script setup lang="ts">
defineEmits(['switch-team'])
defineProps<{
userData?: {
activeTeam?: { name?: string }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string }>
} | null
isSeller?: boolean
loggedIn?: boolean
}>()
const localePath = useLocalePath()
const route = useRoute()
const { t } = useI18n()
// Sidebar collapse state
const collapsed = ref(false)
// Section open states
const sections = reactive({
exchange: true,
orders: true,
seller: true,
ai: true,
settings: true
})
const toggleSection = (section: keyof typeof sections) => {
sections[section] = !sections[section]
}
const isActive = (path: string) => {
const current = route.path
if (current === localePath(path)) return true
return current.startsWith(localePath(path) + '/')
}
const isExactActive = (path: string) => {
return route.path === localePath(path)
}
const getTeamInitials = (name?: string) => {
if (!name) return '?'
return name
.split(' ')
.map(w => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './TeamCard.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'TeamCard',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,65 @@
<template>
<div class="card bg-base-100 border border-base-300 shadow">
<div class="card-body gap-4">
<div class="flex justify-between items-start gap-3">
<div>
<h3 class="font-semibold text-base-content text-lg">{{ team.name }}</h3>
<p class="text-sm text-base-content/60 mt-1">
{{ $t('teams.created') }}: {{ formatDate(team.createdAt) }}
</p>
</div>
</div>
<!-- Members Preview -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-base-content/80">{{ $t('teams.members') }}</span>
<span class="text-sm text-base-content/60">{{ membersCount }}</span>
</div>
<div class="flex -space-x-2">
<div
v-for="(member, index) in displayMembers"
:key="member.id"
class="w-8 h-8 rounded-full bg-primary text-primary-content border-2 border-base-100 flex items-center justify-center text-xs font-medium"
:title="member.userId"
>
{{ getInitials(member.userId) }}
</div>
<div
v-if="remainingMembers > 0"
class="w-8 h-8 rounded-full bg-base-300 text-base-content border-2 border-base-100 flex items-center justify-center text-xs font-medium"
>
+{{ remainingMembers }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
team: Team
}
const props = defineProps<Props>()
const membersCount = computed(() => props.team?.members?.length || 1)
const displayMembers = computed(() => (props.team?.members || []).slice(0, 3))
const remainingMembers = computed(() => Math.max(0, membersCount.value - 3))
const formatDate = (dateString: string) => {
if (!dateString) return ''
try {
return new Date(dateString).toLocaleDateString('ru-RU')
} catch (e) {
return dateString
}
}
const getInitials = (userId: string) => {
if (!userId) return '??'
return userId.substring(0, 2).toUpperCase()
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './TeamCreateForm.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'TeamCreateForm',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,104 @@
<template>
<div class="card bg-base-100 border border-base-300 shadow p-6">
<h3 class="font-semibold text-base-content mb-6">{{ $t('teams.create_team') }}</h3>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-base-content mb-2">
{{ $t('teams.team_name') }}
</label>
<input
v-model="teamName"
type="text"
required
:placeholder="$t('teams.team_name_placeholder')"
class="input input-bordered w-full"
:class="{ 'input-error': hasError }"
/>
<p v-if="hasError" class="mt-1 text-sm text-error">
{{ error }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">
{{ $t('teams.company_type.label') }}
</label>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="teamType"
type="radio"
value="BUYER"
class="radio radio-primary"
/>
<span class="text-base-content">{{ $t('teams.company_type.buyer') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="teamType"
type="radio"
value="SELLER"
class="radio radio-primary"
/>
<span class="text-base-content">{{ $t('teams.company_type.seller') }}</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
@click="$emit('cancel')"
class="btn btn-ghost"
>
{{ $t('common.cancel') }}
</button>
<button
type="submit"
:disabled="isLoading || !teamName.trim()"
class="btn btn-primary"
>
<span v-if="isLoading" class="loading loading-spinner loading-xs mr-2"></span>
<span>{{ isLoading ? $t('teams.creating') : $t('teams.create_team') }}</span>
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated'
const emit = defineEmits(['teamCreated', 'cancel'])
const teamName = ref('')
const teamType = ref('BUYER')
const isLoading = ref(false)
const hasError = ref(false)
const error = ref('')
const { mutate } = useGraphQL()
const handleSubmit = async () => {
try {
isLoading.value = true
hasError.value = false
const result = await mutate(CreateTeamDocument, {
input: {
name: teamName.value.trim(),
teamType: teamType.value
}
}, 'user', 'teams')
emit('teamCreated', result.createTeam?.team)
teamName.value = ''
teamType.value = 'BUYER'
} catch (err: any) {
hasError.value = true
error.value = err?.message || $t('teams.errors.create_failed')
console.error('Error creating team:', err)
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './TimelineStages.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'TimelineStages',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,129 @@
<template>
<div class="w-full space-y-4">
<div
v-for="(stage, index) in stages"
:key="stage.uuid"
class="p-6 border border-base-300 rounded-lg hover:bg-base-200 transition-all duration-200"
>
<!-- Top row: number + title + location -->
<div class="flex items-center space-x-6 mb-6">
<!-- Stage number -->
<div class="flex-shrink-0 w-12 h-12 bg-primary text-primary-content rounded-full flex items-center justify-center font-bold text-lg">
{{ index + 1 }}
</div>
<!-- Title and location -->
<div class="flex-1 flex items-start justify-between">
<h3 class="text-xl font-semibold text-base-content">{{ stage.name }}</h3>
<div class="text-right text-sm text-base-content/60">
<div v-if="stage.stageType === 'transport'">
{{ stage.sourceLocationName }} {{ stage.destinationLocationName }}
</div>
<div v-else>
{{ stage.locationName }}
</div>
</div>
</div>
</div>
<!-- Companies row -->
<div v-if="getStageCompanies(stage).length" class="mb-4">
<div class="flex flex-wrap gap-4">
<div
v-for="company in getStageCompanies(stage)"
:key="company.uuid"
class="flex flex-col items-center bg-base-100 rounded-lg p-4 shadow-sm border border-base-300 min-w-32"
>
<!-- Company logo -->
<div class="w-12 h-12 bg-gradient-to-br from-primary to-accent rounded-full flex items-center justify-center text-primary-content text-sm font-bold mb-2">
{{ getCompanyInitials(company.name) }}
</div>
<!-- Name and rating -->
<div class="text-center">
<p class="text-sm font-medium text-base-content mb-1">{{ company.name }}</p>
<div class="flex items-center justify-center space-x-1">
<span class="text-yellow-400 text-xs"></span>
<span class="text-xs text-base-content/70">{{ getTrustRating(company) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Stage stats -->
<div v-if="stage.trips?.length" class="text-left">
<div class="inline-flex items-center bg-primary/10 rounded-lg px-4 py-2">
<span class="text-sm font-medium text-primary">{{ t('timelineStages.labels.trips', { count: stage.trips.length }) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
stages: {
type: Array,
default: () => []
}
})
const { t } = useI18n()
const getStageIcon = (stage) => {
if (stage.stageType === 'service') return 'Service'
switch (stage.transportType) {
case 'auto': return 'Auto'
case 'sea': return 'Sea'
case 'rail': return 'Rail'
case 'air': return 'Air'
default: return 'Auto'
}
}
const getStageCompanies = (stage) => {
const companies = []
if (stage.selectedCompany) {
companies.push(stage.selectedCompany)
}
const uniqueCompanies = new Set()
stage.trips?.forEach(trip => {
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) {
uniqueCompanies.add(trip.company.uuid)
companies.push(trip.company)
}
})
return companies
}
const getCompanyInitials = (name) => {
if (!name) return '??'
const words = name.split(' ')
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
}
return name.substring(0, 2).toUpperCase()
}
const getTrustRating = (company) => {
const ratings = {
'Kenya Coffee Logistics Ltd': 4.8,
'Kenya Highland Transport': 4.6,
'Mombasa Express Cargo': 4.4,
'East Africa Shipping Lines': 4.7,
'Truck LLC': 4.5,
'Motor Depot #1': 4.3,
'RailTrans LLC': 4.6,
'Kenya Ports Authority': 4.9
}
return ratings[company.name] || 4.2
}
</script>

182
app/components/TopBar.vue Normal file
View File

@@ -0,0 +1,182 @@
<template>
<header class="sticky top-0 z-40 bg-base-100 border-b border-base-300">
<div class="flex items-center justify-between h-16 px-4 lg:px-6">
<!-- Left: Mobile menu button + Breadcrumbs -->
<div class="flex items-center gap-3">
<!-- Mobile menu button -->
<button
@click="$emit('toggle-sidebar')"
class="btn btn-ghost btn-square lg:hidden"
>
<Icon name="lucide:menu" size="20" />
</button>
<!-- Breadcrumbs -->
<CabinetBreadcrumbs />
</div>
<!-- Right: Location + Search + Actions + User -->
<div class="flex items-center gap-2">
<!-- Location selector -->
<NuxtLink
:to="localePath('/select-location')"
class="btn btn-ghost gap-1 max-w-48 truncate"
>
<Icon name="heroicons:map-pin" size="18" />
<span class="hidden sm:inline truncate">
{{ locationStore.locationName || $t('common.selectLocation') }}
</span>
<Icon name="heroicons:chevron-down" size="14" class="hidden sm:inline" />
</NuxtLink>
<!-- Search (desktop) -->
<div class="hidden sm:block dropdown dropdown-end">
<input
ref="searchInput"
type="text"
:placeholder="$t('search.placeholder') || 'Search...'"
v-model="searchQuery"
class="input input-bordered w-72"
@focus="showCommandPalette = true"
@blur="hideCommandPalette"
/>
<ul
v-if="showCommandPalette"
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-72 p-2 shadow-lg border border-base-300 mt-1"
>
<li class="menu-title"><span>Quick actions</span></li>
<li><a @click="navigateToAction('/catalog')">Find materials</a></li>
<li><a @click="navigateToAction('/clientarea/orders')">My orders</a></li>
<li><a @click="navigateToAction('/clientarea/profile')">Profile settings</a></li>
</ul>
</div>
<!-- Theme toggle -->
<button
class="btn btn-ghost btn-square"
:title="theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark')"
@click="$emit('toggle-theme')"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="18" />
</button>
<!-- AI button -->
<NuxtLink
:to="localePath('/clientarea/ai')"
class="btn btn-ghost btn-square"
:title="$t('cabinetNav.ai')"
>
<Icon name="lucide:sparkles" size="18" />
</NuxtLink>
<!-- Notifications -->
<ClientOnly>
<NovuNotificationBell
v-if="novuSubscriberId"
:subscriber-id="novuSubscriberId"
/>
</ClientOnly>
<!-- User menu -->
<template v-if="sessionChecked">
<template v-if="loggedIn">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
<div
v-else
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
>
{{ userInitials }}
</div>
</div>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300"
>
<li class="menu-title">
<span>{{ userName }}</span>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/profile')">
<Icon name="lucide:user" size="16" />
{{ $t('dashboard.profile') }}
</NuxtLink>
</li>
<li>
<details>
<summary>
<Icon name="lucide:globe" size="16" />
Language
</summary>
<ul>
<li v-for="loc in locales" :key="loc.code">
<NuxtLink
:to="switchLocalePath(loc.code)"
:class="{ active: locale === loc.code }"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</li>
</ul>
</details>
</li>
<div class="divider my-1"></div>
<li>
<a @click="$emit('sign-out')" class="text-error">
<Icon name="lucide:log-out" size="16" />
{{ $t('auth.logout') }}
</a>
</li>
</ul>
</div>
</template>
<template v-else>
<button @click="$emit('sign-in')" class="btn btn-ghost">
{{ $t('auth.login') }}
</button>
</template>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { useLocationStore } from '~/stores/location'
defineEmits(['toggle-sidebar', 'sign-out', 'sign-in', 'toggle-theme'])
defineProps<{
sessionChecked?: boolean
loggedIn?: boolean
novuSubscriberId?: string
userAvatarSvg?: string
userName?: string
userInitials?: string
theme?: 'default' | 'night'
}>()
const localePath = useLocalePath()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const locationStore = useLocationStore()
const searchQuery = ref('')
const searchInput = ref(null)
const showCommandPalette = ref(false)
const hideCommandPalette = () => {
setTimeout(() => {
showCommandPalette.value = false
}, 150)
}
const navigateToAction = (path: string) => {
showCommandPalette.value = false
navigateTo(localePath(path))
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './TripBadge.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'TripBadge',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,129 @@
<template>
<div
class="group cursor-pointer transition-all duration-200"
:class="getTripStatusClass(trip)"
@click="$emit('click', trip)"
>
<!-- Main info -->
<div class="flex items-center space-x-2">
<span class="font-medium text-xs">{{ trip.name }}</span>
<span class="text-xs opacity-75">{{ trip.plannedWeight }}t</span>
</div>
<!-- Time -->
<div class="text-xs opacity-75 mt-1">
{{ formatTime(trip.plannedLoadingDate) }} - {{ formatTime(trip.plannedUnloadingDate) }}
</div>
<!-- Status -->
<div class="flex items-center mt-1">
<div class="w-full bg-black bg-opacity-20 rounded-full h-1">
<div
class="bg-white rounded-full h-1 transition-all duration-300"
:style="{ width: `${getTripProgress(trip)}%` }"
></div>
</div>
</div>
<!-- Tooltip on hover -->
<div class="hidden group-hover:block absolute z-10 bg-base-200 text-base-content p-3 rounded-lg shadow-lg min-w-64 -translate-y-full -translate-x-1/2 left-1/2 border border-base-300 pointer-events-none group-hover:pointer-events-auto">
<div class="font-semibold">{{ trip.name }}</div>
<div class="text-sm mt-1">{{ trip.company?.name }}</div>
<div class="mt-2 space-y-1 text-xs">
<div>Planned weight: {{ trip.plannedWeight }}t</div>
<div v-if="trip.weightAtLoading">Weight at loading: {{ trip.weightAtLoading }}t</div>
<div v-if="trip.weightAtUnloading">Weight at unloading: {{ trip.weightAtUnloading }}t</div>
</div>
<div class="mt-2 space-y-1 text-xs">
<div v-if="trip.plannedLoadingDate">
📅 Planned loading: {{ formatDateTime(trip.plannedLoadingDate) }}
</div>
<div v-if="trip.actualLoadingDate">
Actual loading: {{ formatDateTime(trip.actualLoadingDate) }}
</div>
<div v-if="trip.plannedUnloadingDate">
📅 Planned unloading: {{ formatDateTime(trip.plannedUnloadingDate) }}
</div>
<div v-if="trip.actualUnloadingDate">
Actual unloading: {{ formatDateTime(trip.actualUnloadingDate) }}
</div>
</div>
<!-- Tooltip arrow -->
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-base-300"></div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
trip: {
type: Object,
required: true
}
})
defineEmits(['click'])
const getTripStatusClass = (trip) => {
const baseClass = "relative inline-flex px-3 py-2 rounded-lg text-sm min-w-20 text-center text-base-100"
// Determine status by dates
const now = new Date()
const plannedStart = trip.plannedLoadingDate ? new Date(trip.plannedLoadingDate) : null
const actualEnd = trip.actualUnloadingDate ? new Date(trip.actualUnloadingDate) : null
const plannedEnd = trip.plannedUnloadingDate ? new Date(trip.plannedUnloadingDate) : null
if (actualEnd) {
// Completed
return `${baseClass} bg-success hover:bg-success/80`
} else if (plannedStart && now >= plannedStart) {
// In progress
return `${baseClass} bg-primary hover:bg-primary/80`
} else {
// Planned
return `${baseClass} bg-base-300 text-base-content hover:bg-base-400`
}
}
const getTripProgress = (trip) => {
const plannedStart = trip.plannedLoadingDate ? new Date(trip.plannedLoadingDate) : null
const plannedEnd = trip.plannedUnloadingDate ? new Date(trip.plannedUnloadingDate) : null
const actualEnd = trip.actualUnloadingDate ? new Date(trip.actualUnloadingDate) : null
if (!plannedStart || !plannedEnd) return 0
if (actualEnd) return 100
const now = new Date()
if (now < plannedStart) return 0
if (now > plannedEnd) return 90 // Overdue but not completed
const total = plannedEnd - plannedStart
const current = now - plannedStart
return Math.min((current / total) * 100, 90)
}
const formatTime = (dateStr) => {
if (!dateStr) return '--:--'
const date = new Date(dateStr)
return date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
}
const formatDateTime = (dateStr) => {
if (!dateStr) return 'Not specified'
const date = new Date(dateStr)
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
}
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './UserAvatar.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'UserAvatar',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,123 @@
<template>
<div class="flex flex-col items-center space-y-4">
<!-- Avatar -->
<div class="relative">
<div
class="w-24 h-24 rounded-full overflow-hidden border-4 border-base-300 shadow-lg"
:class="{ 'animate-pulse bg-base-200': loading }"
>
<div
v-if="!loading && avatarSvg"
v-html="avatarSvg"
class="w-full h-full"
/>
<div
v-else-if="!loading"
class="w-full h-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-primary-content text-2xl font-bold"
>
{{ initials }}
</div>
</div>
<!-- Change avatar button -->
<button
@click="regenerateAvatar"
:disabled="loading"
class="absolute -bottom-1 -right-1 w-8 h-8 bg-primary hover:bg-primary/80 disabled:bg-base-300 text-primary-content rounded-full flex items-center justify-center shadow-lg transition-colors"
:title="$t('profile.regenerate_avatar')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<!-- User name -->
<div class="text-center">
<p class="font-semibold text-base-content">{{ displayName }}</p>
<p class="text-sm text-base-content/60" v-if="userId">ID: {{ userId }}</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
userId: String,
firstName: String,
lastName: String,
avatarId: String,
editable: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['avatar-changed'])
const loading = ref(false)
const avatarSvg = ref('')
// Computed properties
const displayName = computed(() => {
const first = props.firstName || ''
const last = props.lastName || ''
return `${first} ${last}`.trim() || 'User'
})
const initials = computed(() => {
const first = props.firstName?.charAt(0) || ''
const last = props.lastName?.charAt(0) || ''
return (first + last).toUpperCase() || '?'
})
// Generate avatar via DiceBear API
const generateAvatar = async (seed) => {
if (!seed) return
try {
loading.value = true
// Use DiceBear API to generate SVG avatar
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
if (response.ok) {
avatarSvg.value = await response.text()
} else {
console.error('Failed to generate avatar:', response.status)
}
} catch (error) {
console.error('Error generating avatar:', error)
} finally {
loading.value = false
}
}
// Generate new random avatar ID
const regenerateAvatar = async () => {
if (!props.editable || loading.value) return
const newAvatarId = Math.random().toString(36).substring(2, 15)
// Update avatar locally first
await generateAvatar(newAvatarId)
// Notify parent about avatar change
emit('avatar-changed', newAvatarId)
}
// Watch avatarId changes
watch(() => props.avatarId, (newAvatarId) => {
if (newAvatarId) {
generateAvatar(newAvatarId)
}
}, { immediate: true })
// If no avatarId, generate deterministic one based on userId
onMounted(async () => {
if (!props.avatarId && props.userId) {
// Build deterministic ID from userId
const fallbackSeed = props.userId
await generateAvatar(fallbackSeed)
}
})
</script>

View File

@@ -0,0 +1,52 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath('/clientarea/addresses') : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="sm"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<Stack gap="2">
<Stack direction="row" align="center" gap="2">
<Icon name="lucide:map-pin" size="18" class="text-primary" />
<Text size="base" weight="semibold">{{ address.name }}</Text>
<Pill v-if="address.isDefault" variant="outline" size="sm">{{ t('catalogAddress.badges.default') }}</Pill>
</Stack>
<Text tone="muted" size="sm">{{ address.address }}</Text>
</Stack>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Address {
uuid?: string | null
name?: string | null
address?: string | null
isDefault?: boolean | null
}
const props = defineProps<{
address: Address
selectable?: boolean
isSelected?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.address.uuid)
</script>

View File

@@ -0,0 +1,29 @@
<template>
<Stack direction="row" gap="2" wrap>
<Pill
v-for="filter in filters"
:key="filter.id"
:variant="filter.id === modelValue ? 'primary' : 'outline'"
class="cursor-pointer"
@click="$emit('update:modelValue', filter.id)"
>
{{ filter.label }}
</Pill>
</Stack>
</template>
<script setup lang="ts">
interface Filter {
id: string
label: string
}
defineProps<{
filters: Filter[]
modelValue: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
</script>

View File

@@ -0,0 +1,80 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogHubsSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/hubs')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogHubsSection.actions.view_all') }}</span>
<Icon name="lucide:arrow-right" size="16" />
</NuxtLink>
</Stack>
<Stack gap="6">
<div v-for="country in hubsByCountry" :key="country.name">
<Text weight="semibold" class="mb-3">{{ country.name }}</Text>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<HubCard
v-for="hub in country.hubs"
:key="hub.uuid"
:hub="hub"
/>
</Grid>
</div>
<Stack v-if="totalHubs > 0" direction="row" align="center" justify="between">
<Text tone="muted">
{{ t('common.pagination.showing', { shown: hubs.length, total: totalHubs }) }}
</Text>
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
{{ t('common.actions.load_more') }}
</Button>
</Stack>
</Stack>
</Stack>
</Section>
</template>
<script setup lang="ts">
interface Hub {
uuid?: string | null
name?: string | null
country?: string | null
latitude?: number | null
longitude?: number | null
distance?: string
}
const props = defineProps<{
hubs: Hub[]
total?: number
canLoadMore?: boolean
onLoadMore?: () => void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const totalHubs = computed(() => props.total ?? props.hubs.length)
const canLoadMore = computed(() => props.canLoadMore ?? false)
const loadMore = () => {
props.onLoadMore?.()
}
const hubsByCountry = computed(() => {
const grouped = new Map<string, Hub[]>()
props.hubs.forEach(hub => {
const country = hub.country || 'Other'
if (!grouped.has(country)) {
grouped.set(country, [])
}
grouped.get(country)!.push(hub)
})
return Array.from(grouped.entries())
.map(([name, hubs]) => ({ name, hubs }))
.sort((a, b) => a.name.localeCompare(b.name))
})
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex flex-col flex-1 min-h-0 w-full h-full">
<ClientOnly>
<MapboxMap
:map-id="mapId"
class="flex-1 min-h-0"
style="width: 100%; height: 100%"
:options="mapOptions"
@load="onMapCreated"
>
<MapboxNavigationControl position="top-right" />
</MapboxMap>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
interface MapItem {
uuid: string
name: string
latitude: number
longitude: number
country?: string
}
const props = withDefaults(defineProps<{
mapId: string
items: MapItem[]
pointColor?: string
initialCenter?: [number, number]
initialZoom?: number
}>(), {
pointColor: '#10b981',
initialCenter: () => [37.64, 55.76],
initialZoom: 2
})
const emit = defineEmits<{
'select-item': [uuid: string]
}>()
const mapRef = useMapboxRef(props.mapId)
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: props.initialCenter,
zoom: props.initialZoom,
projection: 'globe',
pitch: 20
}))
const geoJsonData = computed(() => ({
type: 'FeatureCollection' as const,
features: props.items.map(item => ({
type: 'Feature' as const,
properties: { uuid: item.uuid, name: item.name, country: item.country },
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] }
}))
}))
const sourceId = computed(() => `${props.mapId}-points`)
const onMapCreated = (map: MapboxMapType) => {
const initMap = () => {
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
'horizon-blend': 0.02,
'space-color': 'rgb(11, 11, 25)',
'star-intensity': 0.6
})
map.addSource(sourceId.value, {
type: 'geojson',
data: geoJsonData.value,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
})
map.addLayer({
id: 'clusters',
type: 'circle',
source: sourceId.value,
filter: ['has', 'point_count'],
paint: {
'circle-color': props.pointColor,
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: sourceId.value,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 12,
'circle-color': props.pointColor,
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'point-labels',
type: 'symbol',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.5],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties?.cluster_id
const source = map.getSource(sourceId.value) as mapboxgl.GeoJSONSource
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return
const geometry = features[0].geometry as GeoJSON.Point
map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 })
})
})
map.on('click', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
if (!features.length) return
emit('select-item', features[0].properties?.uuid)
})
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
}
if (map.loaded()) {
initMap()
} else {
map.on('load', initMap)
}
}
// Update map data when items change
watch(geoJsonData, (newData) => {
if (!mapRef.value) return
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(newData)
}
}, { deep: true })
// Expose flyTo method for external use
const flyTo = (lat: number, lng: number, zoom = 8) => {
if (!mapRef.value) return
mapRef.value.easeTo({
center: [lng, lat],
zoom,
duration: 2000,
easing: (t) => t * (2 - t)
})
}
defineExpose({ flyTo })
</script>

View File

@@ -0,0 +1,155 @@
<template>
<Card class="h-full flex flex-col overflow-hidden">
<!-- Tabs -->
<div class="flex border-b border-base-300 flex-shrink-0 bg-base-200/70 rounded-t-xl">
<button
v-for="tab in tabs"
:key="tab.id"
class="flex-1 px-4 py-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring focus-visible:ring-primary/30"
:class="[
activeTab === tab.id
? 'text-primary border-b-2 border-primary bg-primary/10'
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
]"
@click="activeTab = tab.id"
>
{{ t(tab.label) }}
<span class="ml-1 text-xs text-base-content/60">({{ tab.count }})</span>
</button>
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto p-3 space-y-2 bg-base-100">
<!-- Hubs Tab -->
<template v-if="activeTab === 'hubs'">
<HubCard
v-for="hub in hubs"
:key="hub.uuid"
:hub="hub"
selectable
:is-selected="selectedId === hub.uuid"
@select="selectHub(hub)"
/>
<Text v-if="hubs.length === 0" tone="muted" size="sm" class="text-center py-4">
{{ t('catalogMap.empty.hubs') }}
</Text>
</template>
<!-- Offers Tab -->
<template v-if="activeTab === 'offers'">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
selectable
compact
:is-selected="selectedId === offer.uuid"
@select="selectOffer(offer)"
/>
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
{{ t('catalogMap.empty.offers') }}
</Text>
</template>
<!-- Suppliers Tab -->
<template v-if="activeTab === 'suppliers'">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
:supplier="supplier"
selectable
:is-selected="selectedId === supplier.uuid"
@select="selectSupplier(supplier)"
/>
<Text v-if="suppliers.length === 0" tone="muted" size="sm" class="text-center py-4">
{{ t('catalogMap.empty.suppliers') }}
</Text>
</template>
</div>
</Card>
</template>
<script setup lang="ts">
interface Hub {
uuid?: string | null
name?: string | null
country?: string | null
latitude?: number | null
longitude?: number | null
distance?: string
}
interface Offer {
uuid?: string | null
title?: string | null
locationName?: string | null
status?: string | null
latitude?: number | null
longitude?: number | null
lines?: any[] | null
}
interface Supplier {
uuid?: string | null
name?: string | null
country?: string | null
offersCount?: number | null
isVerified?: boolean | null
}
const props = defineProps<{
hubs: Hub[]
offers: Offer[]
suppliers: Supplier[]
}>()
const emit = defineEmits<{
(e: 'fly-to', location: { uuid: string; name: string; latitude: number; longitude: number; country?: string }): void
}>()
type TabId = 'hubs' | 'offers' | 'suppliers'
const activeTab = ref<TabId>('hubs')
const selectedId = ref<string | null>(null)
const { t } = useI18n()
const tabs = computed(() => [
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: props.offers.length },
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
])
const selectHub = (hub: Hub) => {
selectedId.value = hub.uuid || null
if (hub.latitude && hub.longitude) {
emit('fly-to', {
uuid: hub.uuid!,
name: hub.name || '',
latitude: hub.latitude,
longitude: hub.longitude,
country: hub.country || undefined
})
}
}
const selectOffer = (offer: Offer) => {
selectedId.value = offer.uuid || null
if (offer.latitude && offer.longitude) {
emit('fly-to', {
uuid: offer.uuid!,
name: offer.title || '',
latitude: offer.latitude,
longitude: offer.longitude,
country: offer.locationName || undefined
})
}
}
const selectSupplier = (supplier: Supplier) => {
selectedId.value = supplier.uuid || null
// Suppliers don't have coordinates currently
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<aside class="w-64 bg-base-100 h-screen flex flex-col border-r border-base-300">
<div class="p-4 border-b border-base-300">
<NuxtLink :to="backLink" class="btn btn-sm btn-ghost gap-2">
<Icon name="lucide:arrow-left" size="18" />
{{ backLabel }}
</NuxtLink>
</div>
<div class="p-4 border-b border-base-300">
<Text weight="semibold">{{ title }}</Text>
<Text tone="muted" size="sm">{{ itemsCount }} {{ t('catalogMap.labels.items') }}</Text>
</div>
<div v-if="filters && filters.length > 0" class="p-4 border-b border-base-300">
<CatalogFilters
:filters="filters"
:model-value="selectedFilter"
@update:model-value="$emit('update:selectedFilter', $event)"
/>
</div>
<div class="flex-1 overflow-y-auto p-2 bg-base-200">
<div v-if="loading" class="flex items-center justify-center h-32">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else class="space-y-2">
<slot name="cards" />
<div v-if="itemsCount === 0" class="text-center text-base-content/50 py-8">
{{ emptyText }}
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
interface Filter {
id: string
label: string
}
const { t } = useI18n()
defineProps<{
title: string
backLink: string
backLabel: string
itemsCount: number
loading?: boolean
filters?: Filter[]
selectedFilter?: string
emptyText?: string
}>()
defineEmits<{
'update:selectedFilter': [value: string]
}>()
</script>

View File

@@ -0,0 +1,69 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogOffersSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/offers')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogOffersSection.actions.view_all') }}</span>
<Icon name="lucide:arrow-right" size="16" />
</NuxtLink>
</Stack>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
/>
</Grid>
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
<Text tone="muted">
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
</Text>
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
{{ t('common.actions.load_more') }}
</Button>
</Stack>
<Stack v-if="offers.length === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</Stack>
</Stack>
</Section>
</template>
<script setup lang="ts">
interface OfferLine {
uuid?: string | null
productName?: string | null
}
interface Offer {
uuid?: string | null
title?: string | null
locationName?: string | null
status?: string | null
validUntil?: string | null
lines?: (OfferLine | null)[] | null
}
const props = defineProps<{
offers: Offer[]
total?: number
canLoadMore?: boolean
onLoadMore?: () => void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const totalOffers = computed(() => props.total ?? props.offers.length)
const canLoadMore = computed(() => props.canLoadMore ?? false)
const loadMore = () => {
props.onLoadMore?.()
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="4">
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogSuppliersSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/suppliers')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogSuppliersSection.actions.view_all') }}</span>
<Icon name="lucide:arrow-right" size="16" />
</NuxtLink>
</Stack>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
:supplier="supplier"
/>
</Grid>
<Stack v-if="totalSuppliers > 0" direction="row" align="center" justify="between">
<Text tone="muted">
{{ t('common.pagination.showing', { shown: suppliers.length, total: totalSuppliers }) }}
</Text>
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
{{ t('common.actions.load_more') }}
</Button>
</Stack>
<Stack v-if="suppliers.length === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
</Stack>
</Stack>
</Section>
</template>
<script setup lang="ts">
interface Supplier {
uuid?: string | null
name?: string | null
country?: string | null
offersCount?: number | null
isVerified?: boolean | null
}
const props = defineProps<{
suppliers: Supplier[]
total?: number
canLoadMore?: boolean
onLoadMore?: () => void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const totalSuppliers = computed(() => props.total ?? props.suppliers.length)
const canLoadMore = computed(() => props.canLoadMore ?? false)
const loadMore = () => {
props.onLoadMore?.()
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/hubs/${hub.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="small"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<div class="flex flex-col gap-1">
<!-- Title -->
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
<!-- Country left, distance right -->
<div class="flex items-center justify-between">
<Text tone="muted" size="sm">
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
</Text>
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
{{ hub.distance }}
</span>
</div>
</div>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Hub {
uuid?: string | null
name?: string | null
country?: string | null
countryCode?: string | null
distance?: string
}
const props = defineProps<{
hub: Hub
selectable?: boolean
isSelected?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.hub.uuid)
// ISO code to emoji flag
const isoToEmoji = (code: string): string => {
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
}
const countryFlag = computed(() => {
if (props.hub.countryCode) {
return isoToEmoji(props.hub.countryCode)
}
return '🌍'
})
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="relative h-80 sm:h-96 overflow-hidden rounded-2xl border border-base-300 bg-base-200">
<!-- Map -->
<ClientOnly>
<MapboxGlobe
v-if="hasCoordinates"
:key="mapKey"
:map-id="`hero-${mapKey}`"
:locations="[mapLocation]"
height="100%"
:initial-center="mapCenter"
:initial-zoom="initialZoom"
/>
<div v-else class="w-full h-full bg-gradient-to-br from-primary/20 via-primary/10 to-base-200" />
</ClientOnly>
<!-- Overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 sm:p-8">
<Stack gap="3">
<!-- Title -->
<h1 class="text-2xl sm:text-3xl font-bold text-white">{{ title }}</h1>
<p v-if="location?.country" class="text-white/80 flex items-center gap-2">
<span class="text-xl leading-none">{{ countryFlag }}</span>
<span>{{ location.country }}</span>
</p>
<!-- Badges -->
<div v-if="badges.length > 0" class="flex flex-wrap items-center gap-2">
<span
v-for="(badge, index) in badges"
:key="index"
class="badge badge-dash"
:class="badgeTone(badge, index)"
>
<Icon v-if="badge.icon" :name="badge.icon" size="14" />
{{ badge.text }}
</span>
</div>
</Stack>
</div>
</div>
</template>
<script setup lang="ts">
interface Badge {
icon?: string
text: string
tone?: string
}
interface Location {
uuid?: string | null
name?: string | null
latitude?: number | null
longitude?: number | null
country?: string | null
countryCode?: string | null
}
const props = withDefaults(defineProps<{
title: string
badges?: Badge[]
location?: Location | null
initialZoom?: number
}>(), {
badges: () => [],
location: null,
initialZoom: 6
})
const hasCoordinates = computed(() =>
props.location?.latitude != null && props.location?.longitude != null
)
// Key to force map recreation when location changes
const mapKey = computed(() =>
`${props.location?.uuid}-${props.location?.latitude}-${props.location?.longitude}`
)
const mapCenter = computed<[number, number]>(() => {
if (hasCoordinates.value) {
return [props.location!.longitude!, props.location!.latitude!]
}
return [37.64, 55.76]
})
const mapLocation = computed(() => ({
uuid: props.location?.uuid,
name: props.location?.name,
latitude: props.location?.latitude,
longitude: props.location?.longitude,
country: props.location?.country
}))
const badgeTone = (badge: Badge, index: number) => {
if (badge.tone) return `badge-${badge.tone}`
const palette = ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success']
return palette[index % palette.length]
}
const isoToEmoji = (code: string): string => {
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
}
const countryFlag = computed(() => {
if (props.location?.countryCode) {
return isoToEmoji(props.location.countryCode)
}
if (props.location?.country) {
return '🌍'
}
return ''
})
</script>

View File

@@ -0,0 +1,118 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/offers/${offer.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="small"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<div class="flex flex-col gap-1">
<!-- Product title -->
<Text size="base" weight="semibold" class="truncate">{{ offer.productName }}</Text>
<!-- Quantity -->
<div v-if="offer.quantity" class="flex">
<span class="badge badge-neutral badge-dash text-xs">
{{ t('catalogOfferCard.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit }) }}
</span>
</div>
<!-- Price -->
<div v-if="offer.pricePerUnit" class="font-semibold text-primary text-sm">
{{ formatPrice(offer.pricePerUnit, offer.currency) }}/{{ displayUnit }}
</div>
<!-- Country below -->
<Text v-if="!compact" tone="muted" size="sm">
{{ countryFlag }} {{ offer.locationCountry || offer.locationName || t('catalogOfferCard.labels.country_unknown') }}
</Text>
</div>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Offer {
uuid?: string | null
// Product
productUuid?: string | null
productName?: string | null
categoryName?: string | null
// Location
locationUuid?: string | null
locationName?: string | null
locationCountry?: string | null
locationCountryCode?: string | null
// Price
quantity?: number | string | null
unit?: string | null
pricePerUnit?: number | string | null
currency?: string | null
// Misc
status?: string | null
validUntil?: string | null
}
const props = defineProps<{
offer: Offer
selectable?: boolean
isSelected?: boolean
compact?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.offer.uuid)
const formattedDate = computed(() => {
if (!props.offer.validUntil) return ''
try {
return new Intl.DateTimeFormat('ru', {
day: 'numeric',
month: 'short'
}).format(new Date(props.offer.validUntil))
} catch {
return props.offer.validUntil
}
})
const formatPrice = (price: number | string | null | undefined, currency: string | null | undefined) => {
if (!price) return ''
const num = typeof price === 'string' ? parseFloat(price) : price
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru', {
style: 'currency',
currency: curr,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
// ISO code to emoji flag
const isoToEmoji = (code: string): string => {
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
}
const countryFlag = computed(() => {
if (props.offer.locationCountryCode) {
return isoToEmoji(props.offer.locationCountryCode)
}
return '🌍'
})
const displayUnit = computed(() => props.offer.unit || t('catalogOfferCard.labels.default_unit'))
</script>

View File

@@ -0,0 +1,52 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/products/${product.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="sm"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<Stack gap="2">
<Stack gap="1">
<Text size="base" weight="semibold">{{ product.name }}</Text>
<Text tone="muted">{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}</Text>
</Stack>
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
</Stack>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Product {
uuid?: string | null
name?: string | null
categoryName?: string | null
description?: string | null
}
const props = defineProps<{
product: Product
selectable?: boolean
isSelected?: boolean
compact?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.product.uuid)
</script>

View File

@@ -0,0 +1,95 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/suppliers/${supplier.teamUuid || supplier.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
>
<Card
padding="small"
:interactive="linkable || selectable"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<div class="flex flex-col gap-1">
<!-- Logo -->
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
</div>
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
{{ supplier.name?.charAt(0) }}
</div>
<!-- Title -->
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
<!-- Badges -->
<div class="flex flex-wrap gap-1">
<span v-if="supplier.isVerified" class="badge badge-neutral badge-dash text-xs">
{{ t('catalogSupplier.badges.verified') }}
</span>
<span class="badge badge-neutral badge-dash text-xs">
{{ reliabilityLabel }}
</span>
</div>
<!-- Country below -->
<Text tone="muted" size="sm">
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
</Text>
</div>
</Card>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
interface Supplier {
uuid?: string | null
teamUuid?: string | null
name?: string | null
country?: string | null
countryCode?: string | null
logo?: string | null
onTimeRate?: number | null
offersCount?: number | null
isVerified?: boolean | null
}
const props = defineProps<{
supplier: Supplier
selectable?: boolean
isSelected?: boolean
}>()
defineEmits<{
(e: 'select'): void
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && (props.supplier.teamUuid || props.supplier.uuid))
const reliabilityLabel = computed(() => {
if (props.supplier.onTimeRate !== undefined && props.supplier.onTimeRate !== null) {
return t('catalogSupplier.labels.on_time', { percent: Math.round(props.supplier.onTimeRate * 100) })
}
if (props.supplier.isVerified) {
return t('catalogSupplier.labels.trusted_partner')
}
return t('catalogSupplier.labels.on_time_default')
})
// ISO code to emoji flag
const isoToEmoji = (code: string): string => {
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
}
const countryFlag = computed(() => {
if (props.supplier.countryCode) {
return isoToEmoji(props.supplier.countryCode)
}
return '🌍'
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Alert.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Alert',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,23 @@
<template>
<div :class="alertClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
variant: {
type: String,
default: 'info', // info | error | success | warning
},
})
const variantMap: Record<string, string> = {
info: 'alert alert-info',
error: 'alert alert-error',
success: 'alert alert-success',
warning: 'alert alert-warning',
}
const alertClass = computed(() => variantMap[props.variant] || variantMap.info)
</script>

View File

@@ -0,0 +1,40 @@
<template>
<span :class="badgeClass">
<slot />
</span>
</template>
<script setup lang="ts">
const props = defineProps({
variant: {
type: String,
default: 'default', // default | success | warning | error | muted | primary
},
size: {
type: String,
default: 'sm', // xs | sm | md
},
})
const variantMap: Record<string, string> = {
default: 'badge-neutral',
success: 'badge-success',
warning: 'badge-warning',
error: 'badge-error',
muted: 'badge-ghost',
primary: 'badge-primary',
}
const sizeMap: Record<string, string> = {
xs: 'badge-xs',
sm: 'badge-sm',
md: 'badge-md',
}
const badgeClass = computed(() => {
const base = 'badge'
const variantClass = variantMap[props.variant] || variantMap.default
const sizeClass = sizeMap[props.size] || sizeMap.sm
return [base, variantClass, sizeClass].join(' ')
})
</script>

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import Button from './Button.vue'
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">{{ args.label }}</Button>'
}),
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'outline']
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
label: 'Primary button',
variant: 'primary'
}
}
export const Outline: Story = {
args: {
label: 'Outline button',
variant: 'outline'
}
}
export const FullWidth: Story = {
args: {
label: 'Full width',
variant: 'primary',
fullWidth: true
}
}

View File

@@ -0,0 +1,45 @@
<template>
<component
:is="componentTag"
:type="componentType"
:class="['btn', variantClass, fullWidth ? 'w-full' : '']"
v-bind="$attrs"
>
<slot />
</component>
</template>
<script setup lang="ts">
const props = defineProps({
variant: {
type: String,
default: 'primary',
},
type: {
type: String,
default: 'button',
},
fullWidth: {
type: Boolean,
default: false,
},
as: {
type: [String, Object],
default: 'button', // button | NuxtLink | a | etc
},
})
const componentTag = computed(() => {
if (props.as === 'NuxtLink') {
return resolveComponent('NuxtLink')
}
return props.as || 'button'
})
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
const variantClass = computed(() => {
if (props.variant === 'outline') return 'btn-outline btn-primary'
if (props.variant === 'ghost') return 'btn-ghost'
return 'btn-primary'
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Card.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Card',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,42 @@
<template>
<div :class="cardClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
padding: {
type: String,
default: 'medium', // none | small | medium
},
tone: {
type: String,
default: 'default', // default | muted | primary
},
interactive: {
type: Boolean,
default: false,
},
})
const paddingMap: Record<string, string> = {
none: '',
small: 'p-4',
medium: 'p-6',
}
const toneMap: Record<string, string> = {
default: 'bg-base-100',
muted: 'bg-base-200',
primary: 'bg-primary/10',
}
const cardClass = computed(() => {
const paddingClass = paddingMap[props.padding] || paddingMap.medium
const toneClass = toneMap[props.tone] || toneMap.default
const interactiveClass = props.interactive ? 'cursor-pointer hover:shadow-lg' : ''
const baseClass = 'card transition-all duration-200'
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Container.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Container',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,29 @@
<template>
<div :class="containerClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
size: {
type: String,
default: 'content', // content (1400px) | narrow (800px)
},
padding: {
type: Boolean,
default: true,
},
})
const sizeMap: Record<string, string> = {
content: 'max-w-7xl',
narrow: 'max-w-3xl',
}
const containerClass = computed(() => {
const sizeClass = sizeMap[props.size] || sizeMap.content
const paddingClass = props.padding ? 'px-4 sm:px-6 lg:px-8' : ''
return ['mx-auto w-full', sizeClass, paddingClass].filter(Boolean).join(' ')
})
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="card bg-base-200 p-8 sm:p-12">
<div class="flex flex-col items-center text-center gap-4">
<div class="w-16 h-16 rounded-full bg-base-300 flex items-center justify-center">
<slot name="icon">
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
</svg>
</slot>
</div>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-base-content">{{ title }}</h3>
<p v-if="description" class="text-base-content/70 max-w-md">{{ description }}</p>
</div>
<div v-if="actionLabel" class="mt-2">
<NuxtLink v-if="actionTo" :to="actionTo">
<button class="btn btn-primary gap-2">
<Icon v-if="actionIcon" :name="actionIcon" size="18" />
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{ actionLabel }}
</button>
</NuxtLink>
<button v-else class="btn btn-primary gap-2" @click="$emit('action')">
<Icon v-if="actionIcon" :name="actionIcon" size="18" />
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{ actionLabel }}
</button>
</div>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description?: string
actionLabel?: string
actionTo?: string
actionIcon?: string
}>()
defineEmits<{
(e: 'action'): void
}>()
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './FieldButton.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/FieldButton',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,31 @@
<template>
<button :type="type" class="input input-bordered w-full flex items-center justify-between text-left" v-bind="$attrs">
<span :class="value ? 'text-base-content' : 'text-base-content/60'">
{{ value || placeholder }}
</span>
<svg v-if="chevron" class="w-4 h-4 text-base-content/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</template>
<script setup lang="ts">
const props = defineProps({
value: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
type: {
type: String,
default: 'button',
},
chevron: {
type: Boolean,
default: true,
},
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Grid.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Grid',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,52 @@
<template>
<div :class="gridClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
cols: {
type: [String, Number],
default: 1,
},
md: {
type: [String, Number],
default: null,
},
lg: {
type: [String, Number],
default: null,
},
gap: {
type: [String, Number],
default: 6,
},
})
const colMap: Record<string, string> = {
'1': 'grid-cols-1',
'2': 'grid-cols-2',
'3': 'grid-cols-3',
'4': 'grid-cols-4',
}
const gapMap: Record<string, string> = {
'4': 'gap-4',
'6': 'gap-6',
'8': 'gap-8',
}
const gridClass = computed(() => {
const baseCols = colMap[String(props.cols)] || colMap['1']
const mdClass = props.md ? colMap[String(props.md)] : ''
const lgClass = props.lg ? colMap[String(props.lg)] : ''
const gapClass = gapMap[String(props.gap)] || gapMap['6']
const classes = ['grid', baseCols, gapClass]
if (mdClass) classes.push(`md:${mdClass}`)
if (lgClass) classes.push(`lg:${lgClass}`)
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './GridItem.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/GridItem',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,32 @@
<template>
<div :class="itemClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
md: {
type: [String, Number],
default: null,
},
lg: {
type: [String, Number],
default: null,
},
})
const spanMap: Record<string, string> = {
'1': 'col-span-1',
'2': 'col-span-2',
'3': 'col-span-3',
'4': 'col-span-4',
}
const itemClass = computed(() => {
const classes = [] as string[]
if (props.md && spanMap[String(props.md)]) classes.push(`md:${spanMap[String(props.md)]}`)
if (props.lg && spanMap[String(props.lg)]) classes.push(`lg:${spanMap[String(props.lg)]}`)
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Heading.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Heading',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,45 @@
<template>
<component :is="tag" :class="headingClass">
<slot />
</component>
</template>
<script setup lang="ts">
const props = defineProps({
level: {
type: Number,
default: 1,
},
weight: {
type: String,
default: 'bold',
},
tone: {
type: String,
default: 'default', // default | inverse | muted
},
})
const tag = computed(() => {
const n = Math.min(Math.max(props.level, 1), 4)
return `h${n}`
})
const headingClass = computed(() => {
const sizeMap: Record<number, string> = {
1: 'text-3xl lg:text-4xl',
2: 'text-2xl lg:text-3xl',
3: 'text-xl lg:text-2xl',
4: 'text-lg',
}
const sizeClass = sizeMap[Math.min(Math.max(props.level, 1), 4)]
const weightClass = props.weight === 'semibold' ? 'font-semibold' : 'font-bold'
const toneMap: Record<string, string> = {
default: 'text-base-content',
inverse: 'text-primary-content',
muted: 'text-base-content/80',
}
const toneClass = toneMap[props.tone] || toneMap.default
return [sizeClass, weightClass, toneClass].join(' ')
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './IconCircle.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/IconCircle',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,34 @@
<template>
<div :class="circleClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
size: {
type: String,
default: 'md', // md | lg
},
tone: {
type: String,
default: 'primary', // primary | neutral
},
})
const sizeMap: Record<string, string> = {
md: 'w-16 h-16 text-2xl',
lg: 'w-20 h-20 text-3xl',
}
const toneMap: Record<string, string> = {
primary: 'bg-primary/10 text-primary',
neutral: 'bg-base-200 text-base-content',
}
const circleClass = computed(() => {
const sizeClass = sizeMap[props.size] || sizeMap.md
const toneClass = toneMap[props.tone] || toneMap.primary
return ['rounded-full flex items-center justify-center', sizeClass, toneClass].join(' ')
})
</script>

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import StoryComponent from './Input.vue'
const meta: Meta<typeof StoryComponent> = {
title: 'Ui/Input',
component: StoryComponent,
render: (args) => ({
components: { StoryComponent },
setup() {
return { args }
},
template: '<StoryComponent v-bind="args" />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {}
}

View File

@@ -0,0 +1,36 @@
<template>
<label v-if="label" class="w-full space-y-1">
<span class="text-base font-semibold text-base-content">{{ label }}</span>
<input
v-bind="$attrs"
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
:value="modelValue"
@input="onInput"
/>
</label>
<input
v-else
v-bind="$attrs"
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
:value="modelValue"
@input="onInput"
/>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
label: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const onInput = (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

Some files were not shown because too many files have changed in this diff Show More