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

16
app/pages/callback.vue Normal file
View File

@@ -0,0 +1,16 @@
<template>
<Section variant="plain" paddingY="md">
<Stack align="center" justify="center" gap="4" fullHeight>
<Spinner />
<Text tone="muted">Redirecting...</Text>
</Stack>
</Section>
</template>
<script setup>
const localePath = useLocalePath()
onMounted(() => {
navigateTo(localePath('/clientarea'))
})
</script>

View File

@@ -0,0 +1,310 @@
<template>
<Stack gap="0">
<!-- Loading -->
<Section v-if="isLoading" variant="plain" paddingY="lg">
<Stack align="center" justify="center" gap="4">
<Spinner />
<Text tone="muted">{{ t('catalogHub.states.loading') }}</Text>
</Stack>
</Section>
<!-- Error / Not Found -->
<Section v-else-if="!hub" variant="plain" paddingY="lg">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:map-pin" size="24" />
</IconCircle>
<Heading :level="2">{{ t('catalogHub.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogHub.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
{{ t('catalogHub.actions.back_to_catalog') }}
</Button>
</Stack>
</Card>
</Section>
<template v-else>
<!-- Map Hero -->
<MapHero
:title="hub.name"
:location="mapLocation"
:badges="hubBadges"
/>
<!-- Offers Section -->
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogHub.sections.offers.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
/>
</Grid>
</Stack>
</Section>
<!-- Empty offers state -->
<Section v-else variant="plain" paddingY="md">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:package-x" size="24" />
</IconCircle>
<Heading :level="3">{{ t('catalogHub.empty.offers.title') }}</Heading>
<Text tone="muted" align="center">
{{ t('catalogHub.empty.offers.subtitle') }}
</Text>
</Stack>
</Card>
</Section>
<!-- Suppliers Section -->
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogHub.sections.suppliers.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<SupplierCard
v-for="supplier in uniqueSuppliers"
:key="supplier.teamUuid"
:supplier="supplier"
/>
</Grid>
</Stack>
</Section>
<!-- Products Section -->
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogHub.sections.products.title') }}</Heading>
<Stack direction="row" gap="2" wrap>
<NuxtLink
v-for="product in uniqueProducts"
:key="product.uuid"
:to="localePath(`/catalog/products/${product.uuid}`)"
>
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
{{ product.name }}
</Pill>
</NuxtLink>
</Stack>
</Stack>
</Section>
<!-- Nearby Connections -->
<NearbyConnectionsSection
:auto-edges="autoEdges"
:rail-edges="railEdges"
:hub="currentHubForMap"
:rail-hub="railHubForMap"
:auto-route-geometries="autoRouteGeometries"
:rail-route-geometries="railRouteGeometries"
/>
</template>
</Stack>
</template>
<script setup lang="ts">
import { GetLocationOffersDocument, GetSupplierProfilesDocument } from '~/composables/graphql/public/exchange-generated'
import { GetNodeConnectionsDocument, GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
interface RouteGeometry {
toUuid: string
coordinates: [number, number][]
}
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const isLoading = ref(true)
const hub = ref<any>(null)
const railHub = ref<any>(null)
const offers = ref<any[]>([])
const allSuppliers = ref<any[]>([])
const autoEdges = ref<EdgeType[]>([])
const railEdges = ref<EdgeType[]>([])
const autoRouteGeometries = ref<RouteGeometry[]>([])
const railRouteGeometries = ref<RouteGeometry[]>([])
const hubId = computed(() => route.params.id as string)
// Map location
const mapLocation = computed(() => ({
uuid: hub.value?.uuid,
name: hub.value?.name,
latitude: hub.value?.latitude,
longitude: hub.value?.longitude,
country: hub.value?.country,
countryCode: hub.value?.countryCode
}))
// Badges for MapHero
const hubBadges = computed(() => {
const badges: Array<{ icon?: string; text: string }> = []
if (hub.value?.country) {
badges.push({ icon: 'lucide:globe', text: hub.value.country })
}
if (offers.value.length > 0) {
badges.push({ icon: 'lucide:package', text: t('catalogHub.badges.offers', { count: offers.value.length }) })
}
if (hub.value?.latitude && hub.value?.longitude) {
badges.push({ icon: 'lucide:map-pin', text: `${hub.value.latitude.toFixed(2)}°, ${hub.value.longitude.toFixed(2)}°` })
}
return badges
})
// Unique suppliers
const uniqueSuppliers = computed(() => {
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
offers.value.forEach(offer => {
if (offer.teamUuid) {
const existing = suppliers.get(offer.teamUuid)
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
if (existing) {
existing.offersCount++
} else {
suppliers.set(offer.teamUuid, {
teamUuid: offer.teamUuid,
name: supplierInfo?.name || t('catalogHub.labels.default_supplier'),
offersCount: 1
})
}
}
})
return Array.from(suppliers.values())
})
// Unique products
const uniqueProducts = computed(() => {
const products = new Map<string, { uuid: string; name: string }>()
offers.value.forEach(offer => {
offer.lines?.forEach((line: any) => {
if (line?.productUuid && line?.productName) {
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
}
})
})
return Array.from(products.values())
})
// Current hub for NearbyConnectionsSection
const currentHubForMap = computed(() => ({
uuid: hub.value?.uuid || '',
name: hub.value?.name || '',
latitude: hub.value?.latitude || 0,
longitude: hub.value?.longitude || 0
}))
const railHubForMap = computed(() => ({
uuid: railHub.value?.uuid || hub.value?.uuid || '',
name: railHub.value?.name || hub.value?.name || '',
latitude: railHub.value?.latitude || hub.value?.latitude || 0,
longitude: railHub.value?.longitude || hub.value?.longitude || 0
}))
// Load route geometries for edges
const loadRouteGeometries = async (
edges: EdgeType[],
hubLat: number,
hubLon: number,
transportType: 'auto' | 'rail'
): Promise<RouteGeometry[]> => {
const RouteDocument = transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
const routeField = transportType === 'auto' ? 'autoRoute' : 'railRoute'
const filteredEdges = edges
.filter(e => e?.transportType === transportType && e?.toLatitude && e?.toLongitude)
.sort((a, b) => (a.distanceKm || 0) - (b.distanceKm || 0))
.slice(0, 12)
const routePromises = filteredEdges.map(async (edge) => {
try {
const { data: routeDataResponse } = await useServerQuery(
`hub-route-${transportType}-${edge.toUuid}`,
RouteDocument,
{
fromLat: hubLat,
fromLon: hubLon,
toLat: edge.toLatitude!,
toLon: edge.toLongitude!
},
'public',
'geo'
)
const routeData = routeDataResponse.value?.[routeField]
if (routeData?.geometry) {
const geometryArray = typeof routeData.geometry === 'string'
? JSON.parse(routeData.geometry)
: routeData.geometry
if (Array.isArray(geometryArray) && geometryArray.length > 0) {
return {
toUuid: edge.toUuid!,
coordinates: geometryArray as [number, number][]
}
}
}
} catch (error) {
console.error(`Failed to load ${transportType} route to ${edge.toName}:`, error)
}
return null
})
const results = await Promise.all(routePromises)
return results.filter(Boolean) as RouteGeometry[]
}
try {
const [{ data: connectionsData }, { data: offersData }, { data: suppliersData }] = await Promise.all([
useServerQuery('hub-connections', GetNodeConnectionsDocument, { uuid: hubId.value }, 'public', 'geo'),
useServerQuery('hub-offers', GetLocationOffersDocument, { locationUuid: hubId.value }, 'public', 'exchange'),
useServerQuery('hub-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
])
const connectionsResult = connectionsData.value
hub.value = connectionsResult?.nodeConnections?.hub || null
railHub.value = connectionsResult?.nodeConnections?.railNode || null
offers.value = offersData.value?.getOffers || []
allSuppliers.value = suppliersData.value?.getSupplierProfiles || []
autoEdges.value = (connectionsResult?.nodeConnections?.autoEdges || []).filter((e): e is EdgeType => e !== null)
railEdges.value = (connectionsResult?.nodeConnections?.railEdges || []).filter((e): e is EdgeType => e !== null)
if (hub.value?.latitude && hub.value?.longitude) {
const railOrigin = railHub.value || hub.value
const [autoGeometries, railGeometries] = await Promise.all([
loadRouteGeometries(autoEdges.value, hub.value.latitude, hub.value.longitude, 'auto'),
loadRouteGeometries(railEdges.value, railOrigin.latitude, railOrigin.longitude, 'rail')
])
autoRouteGeometries.value = autoGeometries
railRouteGeometries.value = railGeometries
}
} catch (error) {
console.error('Error loading hub:', error)
} finally {
isLoading.value = false
}
// SEO
useHead(() => ({
title: hub.value?.name
? t('catalogHub.meta.title_with_name', { name: hub.value.name })
: t('catalogHub.meta.title'),
meta: [
{
name: 'description',
content: t('catalogHub.meta.description', {
name: hub.value?.name || '',
country: hub.value?.country || '',
offers: offers.value.length,
suppliers: uniqueSuppliers.value.length
})
}
]
}))
</script>

View File

@@ -0,0 +1,80 @@
<template>
<Stack gap="6">
<Section variant="plain" paddingY="md">
<PageHeader :title="t('catalogHubsSection.header.title')" />
</Section>
<Section v-if="isLoading" variant="plain" paddingY="md">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</Section>
<Section v-else variant="plain" paddingY="md">
<Stack gap="6">
<NuxtLink :to="localePath('/catalog/hubs/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<MapboxGlobe
map-id="hubs-map"
:locations="itemsWithCoords"
:height="192"
/>
</ClientOnly>
</NuxtLink>
<CatalogFilters :filters="filters" v-model="selectedFilter" />
<div v-for="country in itemsByCountry" :key="country.name" class="space-y-3">
<Text weight="semibold">{{ 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>
<PaginationLoadMore
:shown="items.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
@load-more="loadMore"
/>
<Stack v-if="items.length === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogHubsSection.empty.no_hubs') }}</Text>
</Stack>
</Stack>
</Section>
</Stack>
</template>
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
total,
selectedFilter,
filters,
isLoading,
isLoadingMore,
itemsWithCoords,
itemsByCountry,
canLoadMore,
loadMore,
init
} = useCatalogHubs()
await init()
useHead(() => ({
title: t('catalogHubsSection.header.title')
}))
</script>

View File

@@ -0,0 +1,70 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('catalogHubsSection.header.title')"
:back-link="localePath('/catalog/hubs')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="items.length"
:filters="filters"
:selected-filter="selectedFilter"
:loading="isLoading"
:empty-text="t('catalogMap.empty.hubs')"
@update:selected-filter="selectedFilter = $event"
>
<template #cards>
<HubCard
v-for="hub in items"
:key="hub.uuid"
:hub="hub"
selectable
:is-selected="selectedItemId === hub.uuid"
@select="selectItem(hub)"
/>
</template>
</CatalogMapSidebar>
</template>
<CatalogMap
ref="mapRef"
map-id="hubs-fullscreen-map"
:items="itemsWithCoords"
point-color="#10b981"
@select-item="onMapSelectItem"
/>
</NuxtLayout>
</template>
<script setup lang="ts">
definePageMeta({
layout: false
})
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
selectedFilter,
filters,
isLoading,
itemsWithCoords,
init
} = useCatalogHubs()
await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null)
const selectItem = (item: any) => {
selectedItemId.value = item.uuid
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
}
const onMapSelectItem = (uuid: string) => {
selectedItemId.value = uuid
}
</script>

View File

@@ -0,0 +1,219 @@
<template>
<Stack gap="0">
<!-- Loading -->
<Section v-if="isLoading" variant="plain" paddingY="lg">
<Stack align="center" justify="center" gap="4">
<Spinner />
<Text tone="muted">{{ t('catalogOffer.states.loading') }}</Text>
</Stack>
</Section>
<!-- Error / Not Found -->
<Section v-else-if="!offer" variant="plain" paddingY="lg">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:package" size="24" />
</IconCircle>
<Heading :level="2">{{ t('catalogOffer.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogOffer.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
{{ t('catalogOffer.actions.back_to_catalog') }}
</Button>
</Stack>
</Card>
</Section>
<template v-else>
<!-- Map Hero -->
<MapHero
:title="offer.productName"
:location="mapLocation"
:badges="offerBadges"
/>
<!-- Product and price -->
<Section variant="plain" paddingY="md">
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" align="start" justify="between" gap="4">
<div>
<NuxtLink
v-if="offer.productUuid"
:to="localePath(`/catalog/products/${offer.productUuid}`)"
class="hover:text-primary transition-colors"
>
<Heading :level="2">{{ offer.productName }}</Heading>
</NuxtLink>
<Heading v-else :level="2">{{ offer.productName }}</Heading>
<Pill v-if="offer.categoryName" variant="outline" size="sm" class="mt-2">
{{ offer.categoryName }}
</Pill>
</div>
<div v-if="offer.pricePerUnit" class="text-right">
<div class="text-2xl font-bold text-primary">
{{ formatPrice(offer.pricePerUnit, offer.currency) }}
</div>
<Text tone="muted" size="sm">{{ t('catalogOffer.labels.per_unit', { unit: displayUnit(offer.unit) }) }}</Text>
</div>
</Stack>
<div class="flex items-center gap-4">
<Badge variant="primary" size="md">{{ t('catalogOffer.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit(offer.unit) }) }}</Badge>
<span v-if="offer.validUntil" class="text-base-content/60">
{{ t('catalogOffer.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
</span>
</div>
</Stack>
</Card>
</Section>
<!-- Description -->
<Section v-if="offer.description" variant="plain" paddingY="md">
<Card padding="lg">
<Stack gap="3">
<Heading :level="3">{{ t('catalogOffer.sections.description.title') }}</Heading>
<Text>{{ offer.description }}</Text>
</Stack>
</Card>
</Section>
<!-- Supplier -->
<Section v-if="supplier" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="3">{{ t('catalogOffer.sections.supplier.title') }}</Heading>
<SupplierCard :supplier="supplier" />
</Stack>
</Section>
<!-- Location -->
<Section v-if="offer.locationUuid" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="3">{{ t('catalogOffer.sections.location.title') }}</Heading>
<NuxtLink :to="localePath(`/catalog/hubs/${offer.locationUuid}`)">
<Card padding="lg" interactive>
<Stack direction="row" align="center" gap="3">
<span class="text-2xl">{{ countryFlag }}</span>
<Stack gap="1">
<Text weight="semibold">{{ offer.locationName }}</Text>
<Text v-if="offer.locationCountry" tone="muted" size="sm">{{ offer.locationCountry }}</Text>
</Stack>
<Icon name="lucide:arrow-right" size="20" class="ml-auto text-base-content/50" />
</Stack>
</Card>
</NuxtLink>
</Stack>
</Section>
</template>
</Stack>
</template>
<script setup lang="ts">
import {
GetOfferDocument,
GetSupplierProfilesDocument,
} from '~/composables/graphql/public/exchange-generated'
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const { data: offerData, pending: offerPending } = await useServerQuery('catalog-offer-detail', GetOfferDocument, { uuid: route.params.uuid as string }, 'public', 'exchange')
const { data: suppliersData, pending: suppliersPending } = await useServerQuery('catalog-offer-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
const isLoading = computed(() => offerPending.value || suppliersPending.value)
const offer = computed(() => offerData.value?.getOffer || null)
const supplier = computed(() => {
const suppliers = suppliersData.value?.getSupplierProfiles || []
if (offer.value?.teamUuid) {
return suppliers.find((s: any) => s?.teamUuid === offer.value.teamUuid)
}
return null
})
const offerUuid = computed(() => route.params.uuid as string)
// Map location
const mapLocation = computed(() => ({
uuid: offer.value?.locationUuid,
name: offer.value?.locationName,
latitude: offer.value?.locationLatitude,
longitude: offer.value?.locationLongitude,
country: offer.value?.locationCountry,
countryCode: offer.value?.locationCountryCode
}))
// Badges for MapHero
const offerBadges = computed(() => {
const badges: Array<{ icon?: string; text: string }> = []
if (offer.value?.locationName) {
badges.push({ icon: 'lucide:map-pin', text: offer.value.locationName })
}
if (offer.value?.locationCountry) {
badges.push({ icon: 'lucide:globe', text: offer.value.locationCountry })
}
if (offer.value?.validUntil) {
badges.push({ icon: 'lucide:calendar', text: t('catalogOffer.badges.valid_until', { date: formatDate(offer.value.validUntil) }) })
}
return badges
})
const formatDate = (date: string) => {
try {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(date))
} catch {
return date
}
}
const formatPrice = (price: number | string, currency?: string) => {
const num = typeof price === 'string' ? parseFloat(price) : price
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: curr,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
const displayUnit = (unit?: string | null) => unit || t('catalogOffer.units.ton_short')
// Convert 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 (offer.value?.locationCountryCode) {
return isoToEmoji(offer.value.locationCountryCode)
}
return '📍'
})
// SEO
useHead(() => ({
title: offer.value?.title
? t('catalogOffer.meta.title_with_name', { title: offer.value.title })
: t('catalogOffer.meta.title'),
meta: [
{
name: 'description',
content: offer.value?.description || t('catalogOffer.meta.description', { title: offer.value?.title || '' })
}
]
}))
</script>

View File

@@ -0,0 +1,76 @@
<template>
<Stack gap="6">
<Section variant="plain" paddingY="md">
<PageHeader :title="t('catalogOffersSection.header.title')" />
</Section>
<Section v-if="isLoading" variant="plain" paddingY="md">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</Section>
<Section v-else variant="plain" paddingY="md">
<Stack gap="4">
<NuxtLink :to="localePath('/catalog/offers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<MapboxGlobe
map-id="offers-map"
:locations="itemsWithCoords"
:height="192"
/>
</ClientOnly>
</NuxtLink>
<CatalogFilters :filters="filters" v-model="selectedFilter" />
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="offer in items"
:key="offer.uuid"
:offer="offer"
/>
</Grid>
<PaginationLoadMore
:shown="items.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
@load-more="loadMore"
/>
<Stack v-if="total === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</Stack>
</Stack>
</Section>
</Stack>
</template>
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
total,
selectedFilter,
filters,
isLoading,
isLoadingMore,
itemsWithCoords,
canLoadMore,
loadMore,
init
} = useCatalogOffers()
await init()
useHead(() => ({
title: t('catalogOffersSection.header.title')
}))
</script>

View File

@@ -0,0 +1,72 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('catalogOffersSection.header.title')"
:back-link="localePath('/catalog/offers')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="items.length"
:filters="filters"
:selected-filter="selectedFilter"
:loading="isLoading"
:empty-text="t('catalogMap.empty.offers')"
@update:selected-filter="selectedFilter = $event"
>
<template #cards>
<OfferCard
v-for="offer in items"
:key="offer.uuid"
:offer="offer"
selectable
:is-selected="selectedItemId === offer.uuid"
@select="selectItem(offer)"
/>
</template>
</CatalogMapSidebar>
</template>
<CatalogMap
ref="mapRef"
map-id="offers-fullscreen-map"
:items="itemsWithCoords"
point-color="#f59e0b"
@select-item="onMapSelectItem"
/>
</NuxtLayout>
</template>
<script setup lang="ts">
definePageMeta({
layout: false
})
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
selectedFilter,
filters,
isLoading,
itemsWithCoords,
init
} = useCatalogOffers()
await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null)
const selectItem = (item: any) => {
selectedItemId.value = item.uuid
const lat = item.locationLatitude
const lng = item.locationLongitude
if (lat && lng) {
mapRef.value?.flyTo(lat, lng, 8)
}
}
const onMapSelectItem = (uuid: string) => {
selectedItemId.value = uuid
}
</script>

View File

@@ -0,0 +1,384 @@
<template>
<Stack gap="0">
<!-- Loading -->
<Section v-if="isLoading" variant="plain" paddingY="lg">
<Stack align="center" justify="center" gap="4">
<Spinner />
<Text tone="muted">{{ t('catalogProduct.states.loading') }}</Text>
</Stack>
</Section>
<!-- Error / Not Found -->
<Section v-else-if="!product" variant="plain" paddingY="lg">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:package" size="24" />
</IconCircle>
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
{{ t('catalogProduct.actions.back_to_catalog') }}
</Button>
</Stack>
</Card>
</Section>
<template v-else>
<!-- HERO Section -->
<Section variant="hero" paddingY="lg">
<Stack gap="6">
<Stack direction="row" align="start" gap="6">
<!-- Icon -->
<div class="flex-shrink-0">
<IconCircle tone="primary" class="w-20 h-20 text-3xl bg-white/20">
<Icon :name="getCategoryIcon(product.categoryName)" size="32" />
</IconCircle>
</div>
<!-- Info -->
<Stack gap="3" class="flex-1">
<Heading :level="1" tone="inverse">{{ product.name }}</Heading>
<Stack direction="row" align="center" gap="4">
<Pill variant="inverse">
<Icon name="lucide:folder" size="14" />
{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}
</Pill>
<Pill v-if="offers.length > 0" variant="inverse">
<Icon name="lucide:tag" size="14" />
{{ t('catalogProduct.labels.offers_count', { count: offers.length }) }}
</Pill>
</Stack>
</Stack>
</Stack>
</Stack>
</Section>
<!-- Stats Section -->
<Section variant="plain" paddingY="md">
<Grid :cols="2" :md="4" :gap="4">
<Card padding="md">
<Stack gap="1" align="center">
<Text size="2xl" weight="bold" class="text-primary">{{ offers.length }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.offers') }}</Text>
</Stack>
</Card>
<Card padding="md">
<Stack gap="1" align="center">
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueSuppliers.length }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.suppliers') }}</Text>
</Stack>
</Card>
<Card padding="md">
<Stack gap="1" align="center">
<Text size="2xl" weight="bold" class="text-primary">{{ uniqueLocations.length }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.locations') }}</Text>
</Stack>
</Card>
<Card padding="md">
<Stack gap="1" align="center">
<Text size="2xl" weight="bold" class="text-primary">{{ priceRange }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.stats.price_range') }}</Text>
</Stack>
</Card>
</Grid>
</Section>
<!-- Offers Section -->
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogProduct.sections.offers.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card
v-for="offer in offers"
:key="offer.uuid"
padding="md"
interactive
@click="navigateTo(localePath(`/catalog/offers/${offer.uuid}`))"
>
<Stack gap="3">
<Stack gap="1">
<Text size="lg" weight="semibold">{{ offer.title }}</Text>
<Stack direction="row" align="center" gap="2">
<Icon name="lucide:map-pin" size="14" class="text-base-content/60" />
<Text tone="muted">{{ offer.locationName }}, {{ offer.locationCountry }}</Text>
</Stack>
</Stack>
<!-- Line with this product -->
<template v-for="line in getProductLines(offer)" :key="line?.uuid">
<Card padding="sm" class="bg-base-200">
<Stack direction="row" align="center" justify="between">
<Stack gap="0">
<Text weight="semibold">{{ line?.quantity }} {{ line?.unit }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.in_stock') }}</Text>
</Stack>
<Stack gap="0" align="end">
<Text weight="bold" class="text-primary">
{{ formatPrice(line?.pricePerUnit, line?.currency) }}
</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.per_unit', { unit: line?.unit }) }}</Text>
</Stack>
</Stack>
</Card>
</template>
<Stack direction="row" align="center" justify="between">
<Badge :variant="getStatusVariant(offer.status)">
{{ getStatusLabel(offer.status) }}
</Badge>
<Text v-if="offer.validUntil" tone="muted" size="sm">
{{ t('catalogProduct.labels.valid_until', { date: formatDate(offer.validUntil) }) }}
</Text>
</Stack>
</Stack>
</Card>
</Grid>
</Stack>
</Section>
<!-- Suppliers Section -->
<Section v-if="uniqueSuppliers.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogProduct.sections.suppliers.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card
v-for="supplier in uniqueSuppliers"
:key="supplier.teamUuid"
padding="md"
interactive
@click="navigateTo(localePath(`/catalog/suppliers/${supplier.teamUuid}`))"
>
<Stack direction="row" align="center" gap="3">
<IconCircle tone="primary">
{{ supplier.name?.charAt(0) || '?' }}
</IconCircle>
<Stack gap="1" class="flex-1">
<Text weight="semibold">{{ supplier.name || t('catalogProduct.labels.default_supplier') }}</Text>
<Text tone="muted" size="sm">{{ t('catalogProduct.labels.supplier_offers', { count: supplier.offersCount }) }}</Text>
</Stack>
<Icon name="lucide:chevron-right" size="20" class="text-base-content/60" />
</Stack>
</Card>
</Grid>
</Stack>
</Section>
<!-- Map Section -->
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogProduct.sections.locations.title') }}</Heading>
<div class="h-80 rounded-lg overflow-hidden">
<ClientOnly>
<MapboxGlobe
:locations="mapLocations"
:height="320"
:initial-zoom="2"
/>
</ClientOnly>
</div>
</Stack>
</Section>
<!-- CTA Section -->
<Section variant="plain" paddingY="lg">
<Card padding="lg" class="bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10 border border-base-300">
<Stack gap="4" align="center">
<Heading :level="3">{{ t('catalogProduct.cta.title') }}</Heading>
<Text tone="muted" align="center">
{{ t('catalogProduct.cta.subtitle') }}
</Text>
<Button size="large">
<Icon name="lucide:plus" size="18" class="mr-2" />
{{ t('catalogProduct.cta.action') }}
</Button>
</Stack>
</Card>
</Section>
</template>
</Stack>
</template>
<script setup lang="ts">
import {
GetProductsDocument,
GetProductOffersDocument,
GetSupplierProfilesDocument,
} from '~/composables/graphql/public/exchange-generated'
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const {
data: productsData,
pending: productsPending
} = await useServerQuery('catalog-product-list', GetProductsDocument, {}, 'public', 'exchange')
const {
data: productOffersData,
pending: productOffersPending
} = await useServerQuery('catalog-product-offers', GetProductOffersDocument, { productUuid: route.params.id as string }, 'public', 'exchange')
const {
data: suppliersData,
pending: suppliersPending
} = await useServerQuery('catalog-product-suppliers', GetSupplierProfilesDocument, {}, 'public', 'exchange')
const isLoading = computed(() => productsPending.value || productOffersPending.value || suppliersPending.value)
const product = computed(() => findProduct(productsData.value?.getProducts || []))
const offers = computed(() => productOffersData.value?.getOffers || [])
const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles || [])
const productId = computed(() => route.params.id as string)
// Find product by uuid from list
const findProduct = (products: any[]) => {
return products.find(p => p?.uuid === productId.value)
}
// Unique suppliers from offers
const uniqueSuppliers = computed(() => {
const suppliers = new Map<string, { teamUuid: string; name: string; offersCount: number }>()
offers.value.forEach(offer => {
if (offer.teamUuid) {
const existing = suppliers.get(offer.teamUuid)
const supplierInfo = allSuppliers.value.find(s => s.teamUuid === offer.teamUuid)
if (existing) {
existing.offersCount++
} else {
suppliers.set(offer.teamUuid, {
teamUuid: offer.teamUuid,
name: supplierInfo?.name || t('catalogProduct.labels.default_supplier'),
offersCount: 1
})
}
}
})
return Array.from(suppliers.values())
})
// Unique locations
const uniqueLocations = computed(() => {
const locations = new Map<string, { uuid: string; name: string; country: string; latitude: number; longitude: number }>()
offers.value.forEach(offer => {
if (offer.locationUuid && offer.locationLatitude && offer.locationLongitude) {
locations.set(offer.locationUuid, {
uuid: offer.locationUuid,
name: offer.locationName,
country: offer.locationCountry || '',
latitude: offer.locationLatitude,
longitude: offer.locationLongitude
})
}
})
return Array.from(locations.values())
})
// Locations for map
const mapLocations = computed(() => {
return uniqueLocations.value.map(loc => ({
uuid: loc.uuid,
name: loc.name,
latitude: loc.latitude,
longitude: loc.longitude,
country: loc.country
}))
})
// Price range
const priceRange = computed(() => {
const prices: number[] = []
offers.value.forEach(offer => {
offer.lines?.forEach((line: any) => {
if (line?.productUuid === productId.value && line?.pricePerUnit) {
prices.push(Number(line.pricePerUnit))
}
})
})
if (prices.length === 0) return t('common.values.not_available')
const min = Math.min(...prices)
const max = Math.max(...prices)
if (min === max) return t('catalogProduct.labels.price_single', { price: min.toLocaleString() })
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
})
// Get lines with this product
const getProductLines = (offer: any) => {
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
}
const getCategoryIcon = (categoryName: string | null | undefined) => {
const name = (categoryName || '').toLowerCase()
if (name.includes('metal')) return 'lucide:boxes'
if (name.includes('food')) return 'lucide:wheat'
if (name.includes('chem')) return 'lucide:flask-conical'
if (name.includes('build')) return 'lucide:building'
return 'lucide:package'
}
const getStatusVariant = (status: string | null | undefined) => {
const variants: Record<string, string> = {
ACTIVE: 'success',
DRAFT: 'warning',
CANCELLED: 'error',
CLOSED: 'muted'
}
return variants[status || ''] || 'muted'
}
const getStatusLabel = (status: string | null | undefined) => {
const labels: Record<string, string> = {
ACTIVE: t('catalogProduct.status.active'),
DRAFT: t('catalogProduct.status.draft'),
CANCELLED: t('catalogProduct.status.cancelled'),
CLOSED: t('catalogProduct.status.closed')
}
return labels[status || ''] || status || t('catalogProduct.status.unknown')
}
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return ''
try {
return new Intl.DateTimeFormat('ru', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(dateStr))
} catch {
return dateStr
}
}
const formatPrice = (price: any, currency: string | null | undefined) => {
if (!price) return '—'
const num = Number(price)
const curr = currency || 'USD'
try {
return new Intl.NumberFormat('ru', {
style: 'currency',
currency: curr,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
// SEO
useHead(() => ({
title: product.value?.name
? t('catalogProduct.meta.title_with_name', { name: product.value.name })
: t('catalogProduct.meta.title'),
meta: [
{
name: 'description',
content: t('catalogProduct.meta.description', {
name: product.value?.name || '',
offers: offers.value.length,
suppliers: uniqueSuppliers.value.length
})
}
]
}))
</script>

View File

@@ -0,0 +1,251 @@
<template>
<Stack gap="0">
<!-- Loading -->
<Section v-if="isLoading" variant="plain" paddingY="lg">
<Stack align="center" justify="center" gap="4">
<Spinner />
<Text tone="muted">{{ t('catalogSupplier.states.loading') }}</Text>
</Stack>
</Section>
<!-- Error / Not Found -->
<Section v-else-if="!supplier" variant="plain" paddingY="lg">
<Card padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="primary">
<Icon name="lucide:building-2" size="24" />
</IconCircle>
<Heading :level="2">{{ t('catalogSupplier.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogSupplier.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
{{ t('catalogSupplier.actions.back_to_catalog') }}
</Button>
</Stack>
</Card>
</Section>
<template v-else>
<!-- Map Hero -->
<MapHero
:title="supplier.name"
:location="supplierLocation"
:badges="supplierBadges"
:initial-zoom="3"
/>
<!-- Offers Section -->
<Section v-if="offers.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogSupplier.sections.offers.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
/>
</Grid>
</Stack>
</Section>
<!-- Products Section -->
<Section v-if="uniqueProducts.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogSupplier.sections.products.title') }}</Heading>
<Stack direction="row" gap="2" wrap>
<NuxtLink
v-for="product in uniqueProducts"
:key="product.uuid"
:to="localePath(`/catalog/products/${product.uuid}`)"
>
<Pill variant="primary" class="hover:bg-primary hover:text-white transition-colors cursor-pointer">
{{ product.name }}
</Pill>
</NuxtLink>
</Stack>
</Stack>
</Section>
<!-- Locations Map Section -->
<Section v-if="uniqueLocations.length > 0" variant="plain" paddingY="md">
<Stack gap="4">
<Heading :level="2">{{ t('catalogSupplier.sections.locations.title') }}</Heading>
<div class="h-64 rounded-lg overflow-hidden border border-base-300 bg-base-100">
<ClientOnly>
<MapboxGlobe
:key="`supplier-locations-${supplierId}`"
:map-id="`supplier-locations-${supplierId}`"
:locations="mapLocations"
:height="256"
:initial-zoom="3"
/>
</ClientOnly>
</div>
<Stack direction="row" gap="2" wrap>
<NuxtLink
v-for="location in uniqueLocations"
:key="location.uuid"
:to="localePath(`/catalog/hubs/${location.uuid}`)"
>
<Pill variant="outline" class="hover:bg-base-200 transition-colors cursor-pointer">
<Icon name="lucide:map-pin" size="14" />
{{ location.name }}
</Pill>
</NuxtLink>
</Stack>
</Stack>
</Section>
</template>
</Stack>
</template>
<script setup lang="ts">
import {
GetSupplierProfileDocument,
GetSupplierOffersDocument,
GetSupplierProfilesDocument,
GetOffersDocument,
} from '~/composables/graphql/public/exchange-generated'
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const isLoading = ref(true)
const supplier = ref<any>(null)
const offers = ref<any[]>([])
const supplierId = computed(() => route.params.id as string)
// Supplier location for map - use supplier's own coordinates
const supplierLocation = computed(() => {
if (supplier.value?.latitude && supplier.value?.longitude) {
return {
uuid: supplier.value.uuid,
name: supplier.value.name,
latitude: supplier.value.latitude,
longitude: supplier.value.longitude,
country: supplier.value.country,
countryCode: supplier.value.countryCode
}
}
// Fallback to first offer location if supplier has no coordinates
const firstOffer = offers.value.find(o => o.locationLatitude && o.locationLongitude)
if (firstOffer) {
return {
uuid: firstOffer.locationUuid,
name: firstOffer.locationName,
latitude: firstOffer.locationLatitude,
longitude: firstOffer.locationLongitude,
country: firstOffer.locationCountry,
countryCode: firstOffer.locationCountryCode
}
}
return null
})
// Badges for MapHero
const supplierBadges = computed(() => {
const badges: Array<{ icon?: string; text: string }> = []
if (supplier.value?.country) {
badges.push({ icon: 'lucide:globe', text: supplier.value.country })
}
if (supplier.value?.isVerified) {
badges.push({ icon: 'lucide:check-circle', text: t('catalogSupplier.badges.verified') })
}
if (offers.value.length > 0) {
badges.push({ icon: 'lucide:package', text: t('catalogSupplier.badges.offers', { count: offers.value.length }) })
}
return badges
})
// Unique products from offers
const uniqueProducts = computed(() => {
const products = new Map<string, { uuid: string; name: string }>()
offers.value.forEach(offer => {
offer.lines?.forEach((line: any) => {
if (line?.productUuid && line?.productName) {
products.set(line.productUuid, { uuid: line.productUuid, name: line.productName })
}
})
})
return Array.from(products.values())
})
// Unique locations from offers
const uniqueLocations = computed(() => {
const locations = new Map<string, { uuid: string; name: string; latitude: number; longitude: number; country?: string | null; countryCode?: string | null }>()
offers.value.forEach(offer => {
if (offer.locationUuid && offer.locationName && offer.locationLatitude && offer.locationLongitude) {
locations.set(offer.locationUuid, {
uuid: offer.locationUuid,
name: offer.locationName,
latitude: offer.locationLatitude,
longitude: offer.locationLongitude,
country: offer.locationCountry,
countryCode: offer.locationCountryCode
})
}
})
return Array.from(locations.values())
})
// Locations for the map
const mapLocations = computed(() => {
return uniqueLocations.value.map(loc => ({
uuid: loc.uuid,
name: loc.name,
latitude: loc.latitude,
longitude: loc.longitude,
country: ''
}))
})
try {
const { data: supplierData } = await useServerQuery('catalog-supplier-detail', GetSupplierProfileDocument, { uuid: supplierId.value }, 'public', 'exchange')
supplier.value = supplierData.value?.getSupplierProfile || null
if (!supplier.value) {
const { data: suppliersList } = await useServerQuery('catalog-suppliers-fallback', GetSupplierProfilesDocument, {}, 'public', 'exchange')
supplier.value = (suppliersList.value?.getSupplierProfiles || []).find((s: any) => s?.teamUuid === supplierId.value || s?.uuid === supplierId.value) || null
}
const teamIds = [
supplier.value?.teamUuid,
supplier.value?.uuid,
supplierId.value
].filter(Boolean)
if (teamIds.length) {
const primaryId = teamIds[0] as string
const { data: offersData } = await useServerQuery('catalog-supplier-offers', GetSupplierOffersDocument, { teamUuid: primaryId }, 'public', 'exchange')
offers.value = offersData.value?.getOffers || []
if (!offers.value.length) {
const { data: allOffersData } = await useServerQuery('catalog-supplier-offers-fallback', GetOffersDocument, {}, 'public', 'exchange')
const ids = new Set(teamIds)
offers.value = (allOffersData.value?.getOffers || []).filter((o: any) => o?.teamUuid && ids.has(o.teamUuid))
}
}
} catch (error) {
console.error('Error loading supplier:', error)
} finally {
isLoading.value = false
}
// SEO
useHead(() => ({
title: supplier.value?.name
? t('catalogSupplier.meta.title_with_name', { name: supplier.value.name })
: t('catalogSupplier.meta.title'),
meta: [
{
name: 'description',
content: supplier.value?.description
|| (supplier.value?.name
? t('catalogSupplier.meta.description_with_name', { name: supplier.value.name })
: t('catalogSupplier.meta.description'))
}
]
}))
</script>

View File

@@ -0,0 +1,76 @@
<template>
<Stack gap="6">
<Section variant="plain" paddingY="md">
<PageHeader :title="t('catalogSuppliersSection.header.title')" />
</Section>
<Section v-if="isLoading" variant="plain" paddingY="md">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</Section>
<Section v-else variant="plain" paddingY="md">
<Stack gap="4">
<NuxtLink :to="localePath('/catalog/suppliers/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<MapboxGlobe
map-id="suppliers-map"
:locations="itemsWithCoords"
:height="192"
/>
</ClientOnly>
</NuxtLink>
<CatalogFilters :filters="filters" v-model="selectedFilter" />
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<SupplierCard
v-for="supplier in items"
:key="supplier.uuid || supplier.teamUuid"
:supplier="supplier"
/>
</Grid>
<PaginationLoadMore
:shown="items.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
@load-more="loadMore"
/>
<Stack v-if="total === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
</Stack>
</Stack>
</Section>
</Stack>
</template>
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
total,
selectedFilter,
filters,
isLoading,
isLoadingMore,
itemsWithCoords,
canLoadMore,
loadMore,
init
} = useCatalogSuppliers()
await init()
useHead(() => ({
title: t('catalogSuppliersSection.header.title')
}))
</script>

View File

@@ -0,0 +1,70 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('catalogSuppliersSection.header.title')"
:back-link="localePath('/catalog/suppliers')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="items.length"
:filters="filters"
:selected-filter="selectedFilter"
:loading="isLoading"
:empty-text="t('catalogMap.empty.suppliers')"
@update:selected-filter="selectedFilter = $event"
>
<template #cards>
<SupplierCard
v-for="supplier in items"
:key="supplier.uuid"
:supplier="supplier"
selectable
:is-selected="selectedItemId === supplier.uuid"
@select="selectItem(supplier)"
/>
</template>
</CatalogMapSidebar>
</template>
<CatalogMap
ref="mapRef"
map-id="suppliers-fullscreen-map"
:items="itemsWithCoords"
point-color="#3b82f6"
@select-item="onMapSelectItem"
/>
</NuxtLayout>
</template>
<script setup lang="ts">
definePageMeta({
layout: false
})
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
selectedFilter,
filters,
isLoading,
itemsWithCoords,
init
} = useCatalogSuppliers()
await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null)
const selectItem = (item: any) => {
selectedItemId.value = item.uuid
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
}
const onMapSelectItem = (uuid: string) => {
selectedItemId.value = uuid
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader
:title="t('profileAddresses.header.title')"
:actions="[{ label: t('profileAddresses.actions.add'), icon: 'lucide:plus', to: localePath('/clientarea/addresses/new') }]"
/>
<Card v-if="isLoading" padding="lg">
<Stack align="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('profileAddresses.states.loading') }}</Text>
</Stack>
</Card>
<template v-else-if="items.length">
<NuxtLink :to="localePath('/clientarea/addresses/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<MapboxGlobe
map-id="addresses-map"
:locations="itemsWithCoords"
:height="192"
/>
</ClientOnly>
</NuxtLink>
<Grid :cols="1" :md="2" :gap="4">
<Card v-for="addr in items" :key="addr.uuid" padding="small" interactive>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<Text size="base" weight="semibold" class="truncate">{{ addr.name }}</Text>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-xs btn-circle">
<Icon name="lucide:more-vertical" size="16" />
</label>
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-40">
<li>
<a class="text-error" @click="deleteAddress(addr.uuid)">
<Icon name="lucide:trash-2" size="16" />
{{ t('profileAddresses.actions.delete') }}
</a>
</li>
</ul>
</div>
</div>
<Text tone="muted" size="sm" class="line-clamp-2">{{ addr.address }}</Text>
<div class="flex items-center justify-between mt-1">
<span class="text-lg">{{ isoToEmoji(addr.countryCode) }}</span>
</div>
</div>
</Card>
</Grid>
</template>
<EmptyState
v-else
icon="📍"
:title="t('profileAddresses.empty.title')"
:description="t('profileAddresses.empty.description')"
:action-label="t('profileAddresses.empty.cta')"
:action-to="localePath('/clientarea/addresses/new')"
action-icon="lucide:plus"
/>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth-oidc']
})
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
isLoading,
itemsWithCoords,
deleteAddress,
isoToEmoji,
init
} = useTeamAddresses()
await init()
</script>

View File

@@ -0,0 +1,75 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('profileAddresses.header.title')"
:back-link="localePath('/clientarea/addresses')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="items.length"
:loading="isLoading"
:empty-text="t('profileAddresses.empty.title')"
>
<template #cards>
<Card
v-for="addr in items"
:key="addr.uuid"
padding="small"
interactive
:class="{ 'ring-2 ring-primary': selectedItemId === addr.uuid }"
@click="selectItem(addr)"
>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<Text size="base" weight="semibold" class="truncate">{{ addr.name }}</Text>
<span class="text-lg">{{ isoToEmoji(addr.countryCode) }}</span>
</div>
<Text tone="muted" size="sm" class="line-clamp-2">{{ addr.address }}</Text>
</div>
</Card>
</template>
</CatalogMapSidebar>
</template>
<CatalogMap
ref="mapRef"
map-id="addresses-fullscreen-map"
:items="itemsWithCoords"
point-color="#3b82f6"
@select-item="onMapSelectItem"
/>
</NuxtLayout>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: ['auth-oidc']
})
const { t } = useI18n()
const localePath = useLocalePath()
const {
items,
isLoading,
itemsWithCoords,
isoToEmoji,
init
} = useTeamAddresses()
await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null)
const selectItem = (item: any) => {
selectedItemId.value = item.uuid
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
}
const onMapSelectItem = (uuid: string) => {
selectedItemId.value = uuid
}
</script>

View File

@@ -0,0 +1,247 @@
<template>
<Stack gap="6">
<PageHeader :title="t('profileAddresses.form.title')" />
<Stack gap="4">
<Input
v-model="newAddress.name"
:label="t('profileAddresses.form.name.label')"
:placeholder="t('profileAddresses.form.name.placeholder')"
/>
<!-- Address search with Mapbox -->
<div class="form-control">
<label class="label">
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
</label>
<div ref="searchBoxContainer" class="w-full" />
<input
v-if="newAddress.address"
type="hidden"
:value="newAddress.address"
/>
</div>
<!-- Mapbox map for selecting coordinates -->
<div class="form-control">
<label class="label">
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
</label>
<div class="h-80 rounded-lg overflow-hidden border border-base-300">
<MapboxMap
ref="mapComponentRef"
:map-id="'address-picker'"
style="width: 100%; height: 100%"
:options="{
style: 'mapbox://styles/mapbox/streets-v12',
center: [newAddress.longitude || 37.6173, newAddress.latitude || 55.7558],
zoom: 10
}"
@mb-click="onMapClick"
@mb-created="onMapCreated"
>
<MapboxDefaultMarker
v-if="newAddress.latitude && newAddress.longitude"
:marker-id="'selected-location'"
:lnglat="[newAddress.longitude, newAddress.latitude]"
color="#3b82f6"
/>
</MapboxMap>
</div>
<label class="label" v-if="newAddress.latitude && newAddress.longitude">
<span class="label-text-alt text-success">
{{ newAddress.latitude.toFixed(6) }}, {{ newAddress.longitude.toFixed(6) }}
</span>
</label>
</div>
<Stack direction="row" gap="3">
<Button @click="createAddress" :disabled="isCreating || !newAddress.latitude">
{{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }}
</Button>
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
{{ t('common.cancel') }}
</Button>
</Stack>
</Stack>
</Stack>
</template>
<script setup lang="ts">
import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
definePageMeta({
middleware: ['auth-oidc']
})
const { mutate } = useGraphQL()
const { t } = useI18n()
const localePath = useLocalePath()
const config = useRuntimeConfig()
const isCreating = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null)
const newAddress = reactive({
name: '',
address: '',
latitude: null as number | null,
longitude: null as number | null,
countryCode: null as string | null
})
const onMapCreated = (map: MapboxMapType) => {
mapInstance.value = map
}
// Reverse geocode: get address by coordinates (local language)
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
try {
const token = config.public.mapboxAccessToken
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}`
)
const data = await response.json()
const feature = data.features?.[0]
if (!feature) return { address: null, countryCode: null }
// Extract country code from context
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.'))
const countryCode = countryContext?.short_code?.toUpperCase() || null
return { address: feature.place_name, countryCode }
} catch {
return { address: null, countryCode: null }
}
}
const onMapClick = async (event: MapMouseEvent) => {
const lat = event.lngLat.lat
const lng = event.lngLat.lng
newAddress.latitude = lat
newAddress.longitude = lng
// Reverse geocode: load address by coordinates
const result = await reverseGeocode(lat, lng)
if (result.address) {
newAddress.address = result.address
newAddress.countryCode = result.countryCode
// Update SearchBox input
if (searchBoxRef.value) {
searchBoxRef.value.value = result.address
}
}
}
// Initialize Mapbox SearchBox
onMounted(async () => {
if (!searchBoxContainer.value) return
const { MapboxSearchBox } = await import('@mapbox/search-js-web')
const searchBox = new MapboxSearchBox()
searchBox.accessToken = config.public.mapboxAccessToken as string
searchBox.options = {
// Without language: uses local country language
}
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
searchBox.addEventListener('retrieve', (event: any) => {
const feature = event.detail.features?.[0]
if (feature) {
const [lng, lat] = feature.geometry.coordinates
newAddress.address = feature.properties.full_address || feature.properties.name
newAddress.latitude = lat
newAddress.longitude = lng
// Extract country code from context
const countryContext = feature.properties.context?.country
newAddress.countryCode = countryContext?.country_code?.toUpperCase() || null
// Fly to selected location
if (mapInstance.value) {
mapInstance.value.flyTo({
center: [lng, lat],
zoom: 15
})
}
}
})
searchBoxRef.value = searchBox
searchBoxContainer.value.appendChild(searchBox as unknown as Node)
})
const createAddress = async () => {
if (!newAddress.name || !newAddress.address || !newAddress.latitude) return
isCreating.value = true
try {
const { CreateTeamAddressDocument } = await import('~/composables/graphql/team/teams-generated')
const result = await mutate(CreateTeamAddressDocument, {
input: {
name: newAddress.name,
address: newAddress.address,
latitude: newAddress.latitude,
longitude: newAddress.longitude,
countryCode: newAddress.countryCode,
isDefault: false
}
}, 'team', 'teams')
if (result.createTeamAddress?.success) {
// Address is created asynchronously via workflow
// Redirect to list; address will appear after sync
navigateTo(localePath('/clientarea/addresses'))
} else {
console.error('Failed to create address:', result.createTeamAddress?.message)
}
} catch (e) {
console.error('Failed to create address', e)
} finally {
isCreating.value = false
}
}
</script>
<style>
/* Mapbox SearchBox styling */
mapbox-search-box {
width: 100%;
}
mapbox-search-box::part(input) {
height: 3rem;
padding: 0.5rem 1rem;
font-size: 1rem;
border: 1px solid oklch(var(--bc) / 0.2);
border-radius: var(--rounded-btn, 0.5rem);
background-color: oklch(var(--b1));
color: oklch(var(--bc));
}
mapbox-search-box::part(input):focus {
outline: 2px solid oklch(var(--p));
outline-offset: 2px;
}
mapbox-search-box::part(results-list) {
background-color: oklch(var(--b1));
border: 1px solid oklch(var(--bc) / 0.2);
border-radius: var(--rounded-btn, 0.5rem);
margin-top: 0.25rem;
}
mapbox-search-box::part(result-item) {
padding: 0.75rem 1rem;
color: oklch(var(--bc));
}
mapbox-search-box::part(result-item):hover {
background-color: oklch(var(--b2));
}
</style>

View File

@@ -0,0 +1,4 @@
<script setup lang="ts">
const localePath = useLocalePath()
await navigateTo(localePath('/clientarea/ai'))
</script>

View File

@@ -0,0 +1,120 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader :title="t('aiAssistants.header.title')" />
<Card padding="lg" class="w-full">
<Stack gap="4">
<Stack direction="row" gap="3" align="center">
<IconCircle tone="primary" size="lg">🛰</IconCircle>
<div>
<Heading :level="3" weight="semibold">{{ t('aiAssistants.view.agentTitle') }}</Heading>
<Text tone="muted">{{ t('aiAssistants.view.agentSubtitle') }}</Text>
</div>
</Stack>
<div class="bg-base-200 rounded-box p-4 h-[70vh] overflow-y-auto space-y-3">
<div
v-for="(message, idx) in chat"
:key="idx"
class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[80%] rounded-2xl px-4 py-3 shadow-sm"
:class="message.role === 'user' ? 'bg-primary text-primary-content' : 'bg-base-100 text-base-content'"
>
<Text weight="semibold" class="mb-1">
{{ message.role === 'user' ? t('aiAssistants.view.you') : t('aiAssistants.view.agentName') }}
</Text>
<Text :tone="message.role === 'user' ? undefined : 'muted'">
{{ message.content }}
</Text>
</div>
</div>
<div v-if="isStreaming" class="text-sm text-base-content/60">
{{ t('aiAssistants.view.typing') }}
</div>
</div>
<form class="flex flex-col gap-3" @submit.prevent="handleSend">
<Textarea
v-model="input"
:placeholder="t('aiAssistants.view.placeholder')"
rows="3"
/>
<div class="flex items-center gap-3">
<Button type="submit" :loading="isSending" :disabled="!input.trim()">
{{ t('aiAssistants.view.send') }}
</Button>
<Button type="button" variant="ghost" @click="resetChat" :disabled="isSending">
{{ t('aiAssistants.view.reset') }}
</Button>
<div class="text-sm text-base-content/60" v-if="error">
{{ error }}
</div>
</div>
</form>
</Stack>
</Card>
</Stack>
</Section>
</template>
<script setup lang="ts">
const { t } = useI18n()
const runtimeConfig = useRuntimeConfig()
const agentUrl = computed(() => runtimeConfig.public.langAgentUrl || '')
const chat = ref<{ role: 'user' | 'assistant', content: string }[]>([
{ role: 'assistant', content: t('aiAssistants.view.welcome') }
])
const input = ref('')
const isSending = ref(false)
const isStreaming = ref(false)
const error = ref('')
const handleSend = async () => {
if (!input.value.trim()) return
error.value = ''
const userMessage = input.value.trim()
chat.value.push({ role: 'user', content: userMessage })
input.value = ''
isSending.value = true
isStreaming.value = true
try {
const body = {
input: {
messages: chat.value.map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: m.content
}))
}
}
const response = await $fetch(`${agentUrl.value}/invoke`, {
method: 'POST',
body
})
const outputMessages = (response as any)?.output?.messages || []
const last = outputMessages[outputMessages.length - 1]
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
chat.value.push({ role: 'assistant', content })
} catch (e: any) {
console.error('Agent error', e)
error.value = e?.message || t('aiAssistants.view.error')
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
} finally {
isSending.value = false
isStreaming.value = false
}
}
const resetChat = () => {
chat.value = [{ role: 'assistant', content: t('aiAssistants.view.welcome') }]
input.value = ''
error.value = ''
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader :title="t('billing.header.title')" />
<!-- Loading state -->
<Card v-if="isLoading" padding="lg">
<Stack align="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('billing.states.loading') }}</Text>
</Stack>
</Card>
<!-- Error state -->
<Alert v-else-if="error" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('billing.errors.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadBalance">{{ t('billing.errors.retry') }}</Button>
</Stack>
</Alert>
<!-- Balance card -->
<template v-else>
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" gap="4" align="center" justify="between">
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.label') }}</Text>
<Heading :level="2" weight="bold">
{{ formatCurrency(balance.balance) }}
</Heading>
</Stack>
<IconCircle tone="primary" size="lg">
<Icon name="lucide:wallet" size="24" />
</IconCircle>
</Stack>
<div class="divider my-0"></div>
<Grid :cols="2" :gap="4">
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.credits') }}</Text>
<Text weight="semibold" class="text-success">
+{{ formatCurrency(balance.creditsPosted) }}
</Text>
</Stack>
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.debits') }}</Text>
<Text weight="semibold" class="text-error">
-{{ formatCurrency(balance.debitsPosted) }}
</Text>
</Stack>
</Grid>
</Stack>
</Card>
<!-- Transactions section -->
<Stack gap="3">
<Heading :level="3">{{ t('billing.transactions.title') }}</Heading>
<Card v-if="transactions.length === 0" padding="lg" tone="muted">
<Stack align="center" gap="2">
<Icon name="lucide:receipt" size="32" class="opacity-50" />
<Text tone="muted">{{ t('billing.transactions.empty') }}</Text>
</Stack>
</Card>
<Card v-else padding="none">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ t('billing.transactions.date') }}</th>
<th>{{ t('billing.transactions.code') }}</th>
<th>{{ t('billing.transactions.amount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id">
<td>{{ formatTimestamp(tx.timestamp) }}</td>
<td>{{ tx.codeName || tx.code || '—' }}</td>
<td :class="tx.direction === 'credit' ? 'text-success' : 'text-error'">
{{ tx.direction === 'credit' ? '+' : '-' }}{{ formatAmount(tx.amount) }}
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</Stack>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth-oidc']
})
const { t } = useI18n()
const isLoading = ref(true)
const error = ref<string | null>(null)
const balance = ref({
balance: 0,
creditsPosted: 0,
debitsPosted: 0,
exists: false
})
const transactions = ref<any[]>([])
const formatCurrency = (amount: number) => {
// Amount is in kopecks, convert to base units
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount / 100)
}
const formatAmount = (amount: number) => {
// Amount is in kopecks, convert to rubles
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount / 100)
}
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '—'
// TigerBeetle timestamp is in nanoseconds since epoch
const date = new Date(timestamp / 1000000)
return date.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadBalance = async () => {
isLoading.value = true
error.value = null
try {
// Import will work after codegen runs
const { GetTeamBalanceDocument } = await import('~/composables/graphql/team/billing-generated')
const { data, error: balanceError } = await useServerQuery('team-balance', GetTeamBalanceDocument, {}, 'team', 'billing')
if (balanceError.value) throw balanceError.value
if (data.value?.teamBalance) {
balance.value = data.value.teamBalance
}
} catch (e: any) {
error.value = e.message || t('billing.errors.load_failed')
} finally {
isLoading.value = false
}
}
const loadTransactions = async () => {
try {
const { GetTeamTransactionsDocument } = await import('~/composables/graphql/team/billing-generated')
const { data, error: txError } = await useServerQuery('team-transactions', GetTeamTransactionsDocument, { limit: 50 }, 'team', 'billing')
if (txError.value) throw txError.value
transactions.value = data.value?.teamTransactions || []
} catch (e) {
console.error('Failed to load transactions', e)
}
}
await loadBalance()
await loadTransactions()
</script>

View File

@@ -0,0 +1,160 @@
<template>
<Section variant="plain">
<Stack gap="6">
<Stack gap="2">
<Heading :level="1">{{ $t('dashboard.switch_company') }}</Heading>
<Text tone="muted" size="base">{{ $t('teams.switch_description') }}</Text>
</Stack>
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button>
</Stack>
</Alert>
<Card v-else-if="isLoading" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientTeamSwitch.loading.message') }}</Text>
</Stack>
</Card>
<Card v-else-if="!userTeams.length && !showCreateForm" padding="lg">
<Stack align="center" gap="3">
<IconCircle tone="primary">🏢</IconCircle>
<Heading :level="3" align="center">{{ $t('teams.no_team') }}</Heading>
<Text tone="muted" align="center">{{ $t('teams.no_team_description') }}</Text>
<Button @click="showCreateForm = true">
{{ $t('teams.create_first_team') }}
</Button>
</Stack>
</Card>
<template v-else>
<Grid :cols="1" :md="2" :lg="3" :gap="4" v-if="!showCreateForm">
<Card
v-for="team in userTeams"
:key="team.id"
padding="lg"
:class="[
'cursor-pointer transition-all',
team.isActive ? 'ring-2 ring-primary bg-primary/5' : 'hover:shadow-md'
]"
@click="switchToTeam(team.id)"
>
<Stack gap="3">
<Stack direction="row" gap="3" align="center">
<IconCircle :tone="team.isActive ? 'primary' : 'neutral'">
{{ team.name?.charAt(0)?.toUpperCase() || '?' }}
</IconCircle>
<Stack gap="1">
<Heading :level="4" weight="semibold">{{ team.name }}</Heading>
</Stack>
</Stack>
<Pill v-if="team.isActive" variant="primary">{{ $t('teams.active') }}</Pill>
</Stack>
</Card>
<Card padding="lg" class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors" @click="showCreateForm = true">
<Stack align="center" gap="3">
<IconCircle tone="neutral"></IconCircle>
<Heading :level="4" weight="semibold">{{ $t('teams.create_new_team') }}</Heading>
<Text tone="muted" align="center">{{ $t('teams.create_description') }}</Text>
</Stack>
</Card>
</Grid>
<TeamCreateForm
v-else
@team-created="handleTeamCreated"
@cancel="showCreateForm = false"
/>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
import { SwitchTeamDocument } from '~/composables/graphql/user/teams-generated'
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t } = useI18n()
const { mutate } = useGraphQL()
const { setActiveTeam } = useActiveTeam()
const me = useState<{
teams?: Array<{ id?: string | null; name: string; logtoOrgId?: string | null } | null> | null
activeTeamId?: string | null
} | null>('me', () => null)
const userTeams = ref<Array<{ id: string; name: string; logtoOrgId?: string | null; isActive?: boolean }>>([])
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
const showCreateForm = ref(false)
const currentActiveTeam = computed(() => userTeams.value.find(team => team.isActive) || null)
const otherTeams = computed(() => userTeams.value.filter(team => !team.isActive))
const markActiveTeam = (teamId: string) => {
if (me.value) {
me.value = { ...me.value, activeTeamId: teamId }
}
userTeams.value = userTeams.value.map(team => ({
...team,
isActive: team.id === teamId
}))
}
const loadUserTeams = () => {
isLoading.value = true
hasError.value = false
if (!me.value?.teams) {
hasError.value = true
error.value = t('clientTeamSwitch.error.load')
isLoading.value = false
return
}
userTeams.value = me.value.teams
.filter((t): t is NonNullable<typeof t> => t !== null)
.map(t => ({
id: t.id || '',
name: t.name,
logtoOrgId: t.logtoOrgId,
isActive: t.id === me.value?.activeTeamId
}))
isLoading.value = false
}
const switchToTeam = async (teamId: string) => {
try {
const selectedTeam = userTeams.value.find(team => team.id === teamId)
if (selectedTeam) setActiveTeam(teamId, selectedTeam.logtoOrgId)
const result = await mutate(SwitchTeamDocument, { teamId }, 'user', 'teams')
if (result.switchTeam?.user) {
const newActiveId = result.switchTeam.user.activeTeamId || teamId
setActiveTeam(newActiveId, selectedTeam?.logtoOrgId)
markActiveTeam(newActiveId)
navigateTo(localePath('/clientarea/team'))
}
} catch (err: any) {
error.value = err.message || t('clientTeamSwitch.error.switch')
hasError.value = true
}
}
const handleTeamCreated = () => {
showCreateForm.value = false
navigateTo(localePath('/clientarea/team'))
}
loadUserTeams()
</script>

View File

@@ -0,0 +1,9 @@
<template>
<GoodsContent />
</template>
<script setup>
definePageMeta({
middleware: ['auth-oidc']
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<Section variant="plain" paddingY="md">
<Stack align="center" justify="center" gap="2">
<Text tone="muted">{{ t('clientRedirect.status.redirecting') }}</Text>
</Stack>
</Section>
</template>
<script setup>
const { t } = useI18n()
const localePath = useLocalePath()
await navigateTo(localePath('/'))
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div>
<Alert v-if="error" variant="error" class="mb-4">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('kycOverview.errors.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadKYCStatus">{{ t('kycOverview.errors.retry') }}</Button>
</Stack>
</Alert>
<Card v-else-if="loading" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('kycOverview.states.loading') }}</Text>
</Stack>
</Card>
<template v-else>
<Stack gap="6">
<!-- Список существующих заявок -->
<Stack v-if="kycRequests.length > 0" gap="4">
<Heading :level="2">{{ t('kycOverview.list.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card v-for="request in kycRequests" :key="request.uuid" padding="lg">
<Stack gap="3">
<Stack direction="row" gap="2" align="center" justify="between">
<Heading :level="4" weight="semibold">{{ request.companyName || t('kycOverview.list.unnamed') }}</Heading>
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
{{ getStatusText(request) }}
</Pill>
</Stack>
<Text tone="muted" size="base">
{{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }}
</Text>
<Text v-if="request.inn" tone="muted" size="base">
{{ t('kycOverview.list.inn') }}: {{ request.inn }}
</Text>
</Stack>
</Card>
</Grid>
</Stack>
<!-- Добавить новую заявку -->
<Stack gap="4">
<Heading :level="2">{{ t('kycOverview.choose_country.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card padding="lg" interactive @click="selectCountry('russia')">
<Stack gap="3">
<Stack direction="row" gap="2" align="center">
<IconCircle tone="primary">🇷🇺</IconCircle>
<Heading :level="4" weight="semibold">{{ t('kycOverview.countries.russia.title') }}</Heading>
</Stack>
<Text tone="muted" size="base">
{{ t('kycOverview.countries.russia.description') }}
</Text>
<Stack direction="row" align="center" justify="between">
<Pill variant="primary">{{ t('kycOverview.countries.russia.badge') }}</Pill>
<Text tone="muted" weight="semibold"></Text>
</Stack>
</Stack>
</Card>
<Card padding="lg" tone="muted">
<Stack gap="3">
<Stack direction="row" gap="2" align="center">
<IconCircle tone="neutral">🇰🇿</IconCircle>
<Heading :level="4" weight="semibold">{{ t('kycOverview.countries.kazakhstan.title') }}</Heading>
</Stack>
<Text tone="muted" size="base">
{{ t('kycOverview.countries.kazakhstan.description') }}
</Text>
<Stack direction="row" align="center" justify="between">
<Pill variant="outline" tone="warning">{{ t('kycOverview.countries.kazakhstan.badge') }}</Pill>
<Text tone="muted" weight="semibold"></Text>
</Stack>
</Stack>
</Card>
</Grid>
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('kycOverview.info.title') }}</Heading>
<Text tone="muted" size="base"> {{ t('kycOverview.info.point1') }}</Text>
<Text tone="muted" size="base"> {{ t('kycOverview.info.point2') }}</Text>
<Text tone="muted" size="base"> {{ t('kycOverview.info.point3') }}</Text>
</Stack>
</Stack>
</Stack>
</template>
</div>
</template>
<script setup lang="ts">
import { GetKycRequestsRussiaDocument } from '~/composables/graphql/user/kyc-generated'
definePageMeta({
middleware: ['auth-oidc']
})
const { t } = useI18n()
const loading = ref(true)
const error = ref<string | null>(null)
const kycRequests = ref<any[]>([])
const selectCountry = (country: string) => {
if (country === 'russia') {
navigateTo('/clientarea/kyc/russia')
}
}
const getStatusVariant = (request: any) => {
if (request.approvedAt) return 'primary'
if (request.rejectedAt) return 'outline'
return 'outline'
}
const getStatusTone = (request: any) => {
if (request.approvedAt) return 'success'
if (request.rejectedAt) return 'error'
return 'warning'
}
const getStatusText = (request: any) => {
if (request.approvedAt) return t('kycOverview.list.status.approved')
if (request.rejectedAt) return t('kycOverview.list.status.rejected')
return t('kycOverview.list.status.pending')
}
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString()
}
const loadKYCStatus = async () => {
try {
loading.value = true
error.value = null
const { data, error: kycError } = await useServerQuery('kyc-requests', GetKycRequestsRussiaDocument, {}, 'user', 'kyc')
if (kycError.value) throw kycError.value
const requests = data.value?.kycRequests || []
// Сортируем по дате создания (новые первые)
kycRequests.value = [...requests].sort((a: any, b: any) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
} catch (err: any) {
error.value = t('kycOverview.errors.load_failed')
} finally {
loading.value = false
}
}
await loadKYCStatus()
</script>

View File

@@ -0,0 +1,93 @@
<template>
<Section variant="plain">
<Stack gap="6">
<Stack gap="2">
<Stack direction="row" gap="2" align="center">
<IconCircle tone="primary">🇷🇺</IconCircle>
<Heading :level="1">{{ t('kycRussia.header.title') }}</Heading>
</Stack>
<Text tone="muted" size="base">
{{ t('kycRussia.header.subtitle') }}
</Text>
</Stack>
<Card v-if="submitting" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('kycRussia.states.submitting') }}</Text>
</Stack>
</Card>
<Alert v-else-if="submitError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('kycRussia.errors.title') }}</Heading>
<Text tone="muted">{{ submitError }}</Text>
</Stack>
</Alert>
<Card v-else-if="submitSuccess" tone="muted" padding="lg">
<Stack gap="2">
<Heading :level="3" weight="semibold">{{ t('kycRussia.success.title') }}</Heading>
<Text tone="muted">
{{ t('kycRussia.success.description') }}
</Text>
<Button :as="'NuxtLink'" to="/clientarea/kyc" variant="outline">
{{ t('kycRussia.success.cta') }}
</Button>
</Stack>
</Card>
<KYCFormRussia v-else @submit="handleSubmit" />
</Stack>
</Section>
</template>
<script setup lang="ts">
import { CreateKycRequestRussiaDocument } from '~/composables/graphql/user/kyc-generated'
definePageMeta({
middleware: ['auth-oidc']
})
const { mutate } = useGraphQL()
const { t } = useI18n()
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref(false)
const handleSubmit = async (formData: any) => {
try {
submitting.value = true
submitError.value = null
const submitData = {
companyName: formData.company_name,
companyFullName: formData.company_full_name,
inn: formData.inn,
kpp: formData.kpp || '',
ogrn: formData.ogrn || '',
address: formData.address,
bankName: formData.bank_name,
bik: formData.bik,
correspondentAccount: formData.correspondent_account || '',
contactPerson: formData.contact_person,
contactEmail: formData.contact_email,
contactPhone: formData.contact_phone,
}
const result = await mutate(CreateKycRequestRussiaDocument, { input: submitData }, 'user', 'kyc')
if (result.createKycRequestRussia?.success) {
submitSuccess.value = true
setTimeout(() => navigateTo('/clientarea/kyc'), 3000)
} else {
throw new Error(t('kycRussia.errors.create_failed'))
}
} catch (err: any) {
submitError.value = err.message || t('kycRussia.errors.submit_failed')
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<LocationsContent />
</template>
<script setup>
definePageMeta({
middleware: ['auth-oidc']
})
</script>

View File

@@ -0,0 +1,281 @@
<template>
<Stack gap="6">
<!-- Header -->
<Stack direction="row" align="center" justify="between">
<Heading :level="1">{{ t('clientOfferForm.header.title') }}</Heading>
<NuxtLink :to="localePath('/clientarea/offers/new')">
<Button variant="outline">
<Icon name="lucide:arrow-left" size="16" class="mr-2" />
{{ t('clientOfferForm.actions.back') }}
</Button>
</NuxtLink>
</Stack>
<!-- Error -->
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('clientOfferForm.error.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadData">{{ t('clientOfferForm.error.retry') }}</Button>
</Stack>
</Alert>
<!-- Loading -->
<Card v-else-if="isLoading" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientOfferForm.states.loading') }}</Text>
</Stack>
</Card>
<!-- No schema -->
<Card v-else-if="!schemaId" padding="lg">
<Stack align="center" gap="4">
<IconCircle tone="warning" size="lg">
<Icon name="lucide:alert-triangle" size="24" />
</IconCircle>
<Heading :level="3" align="center">{{ t('clientOfferForm.noSchema.title') }}</Heading>
<Text tone="muted" align="center">
{{ t('clientOfferForm.noSchema.description', { name: productName }) }}
</Text>
<NuxtLink :to="localePath('/clientarea/offers/new')">
<Button variant="outline">{{ t('clientOfferForm.actions.chooseAnother') }}</Button>
</NuxtLink>
</Stack>
</Card>
<!-- Form -->
<Card v-else padding="lg">
<Stack gap="4">
<Stack gap="2">
<Heading :level="2">{{ productName }}</Heading>
<Text v-if="schemaDescription" tone="muted">{{ schemaDescription }}</Text>
</Stack>
<Stack gap="2">
<Text weight="semibold">{{ t('clientOfferForm.labels.location') }}</Text>
<select v-model="selectedAddressUuid" class="select select-bordered w-full">
<option v-if="!addresses.length" :value="null">
{{ t('clientOfferForm.labels.location_empty') }}
</option>
<option
v-for="address in addresses"
:key="address.uuid"
:value="address.uuid"
>
{{ address.name }} {{ address.address }}
</option>
</select>
</Stack>
<hr class="border-base-300" />
<!-- FormKit dynamic form -->
<FormKit
type="form"
:actions="false"
:config="formKitConfig"
@submit="handleSubmit"
>
<Stack gap="4">
<FormKitSchema :schema="formkitSchema" />
<Stack direction="row" gap="3" justify="end">
<Button
variant="outline"
type="button"
@click="navigateTo(localePath('/clientarea/offers/new'))"
>
{{ t('common.cancel') }}
</Button>
<Button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? t('clientOfferForm.actions.saving') : t('clientOfferForm.actions.save') }}
</Button>
</Stack>
</Stack>
</FormKit>
</Stack>
</Card>
<!-- Debug info -->
<Card v-if="isDev" padding="md" tone="muted">
<Stack gap="2">
<Text size="sm" weight="semibold">Debug Info</Text>
<Text size="sm" tone="muted">Product UUID: {{ productUuid }}</Text>
<Text size="sm" tone="muted">Product Name: {{ productName }}</Text>
<Text size="sm" tone="muted">Schema ID: {{ schemaId || t('clientOfferForm.debug.schema_missing') }}</Text>
<details>
<summary class="cursor-pointer text-sm text-base-content/70">FormKit Schema</summary>
<pre class="text-xs mt-2 p-2 bg-base-200 border border-base-300 rounded overflow-auto">{{ JSON.stringify(formkitSchema, null, 2) }}</pre>
</details>
</Stack>
</Card>
</Stack>
</template>
<script setup lang="ts">
import { FormKitSchema } from '@formkit/vue'
import type { FormKitSchemaNode } from '@formkit/core'
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
import { GetTeamAddressesDocument } from '~/composables/graphql/team/teams-generated'
definePageMeta({
middleware: ['auth-oidc'],
validate: (route) => {
// Exclude 'new' from the dynamic route
return route.params.uuid !== 'new'
}
})
const { t } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const { execute, mutate } = useGraphQL()
const { getSchema, getEnums, schemaToFormKit } = useTerminus()
const { activeTeamId } = useActiveTeam()
// State
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
const isSubmitting = ref(false)
const productUuid = computed(() => route.params.uuid as string)
const productName = ref<string>('')
const schemaId = ref<string | null>(null)
const schemaDescription = ref<string | null>(null)
const formkitSchema = ref<FormKitSchemaNode[]>([])
const addresses = ref<any[]>([])
const selectedAddressUuid = ref<string | null>(null)
const formKitConfig = {
classes: {
form: 'space-y-4',
label: 'text-sm font-semibold',
inner: 'w-full',
input: 'input input-bordered w-full',
textarea: 'textarea textarea-bordered w-full',
select: 'select select-bordered w-full',
help: 'text-sm text-base-content/60',
messages: 'text-error text-sm mt-1',
message: 'text-error text-sm',
},
}
const isDev = process.dev
const loadAddresses = async () => {
try {
const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
if (addressesError.value) throw addressesError.value
addresses.value = data.value?.teamAddresses || []
const defaultAddress = addresses.value.find((address: any) => address.isDefault)
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
} catch (err) {
console.error('Failed to load addresses:', err)
addresses.value = []
selectedAddressUuid.value = null
}
}
// Load data
const loadData = async () => {
try {
isLoading.value = true
hasError.value = false
// 1. Load product and get terminus_schema_id
const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange')
if (productsError.value) throw productsError.value
const products = productsData.value?.getProducts || []
const product = products.find((p: any) => p.uuid === productUuid.value)
if (!product) {
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
}
productName.value = product.name
schemaId.value = product.terminusSchemaId || null
if (!schemaId.value) {
// No schema configured
isLoading.value = false
return
}
// 2. Load schema from TerminusDB
const terminusClass = await getSchema(schemaId.value)
if (!terminusClass) {
throw new Error(t('clientOfferForm.errors.schemaNotFound', { schema: schemaId.value }))
}
// Save description
schemaDescription.value = terminusClass['@documentation']?.['@comment'] || null
// 3. Load enums and convert to FormKit schema
const enums = await getEnums()
formkitSchema.value = schemaToFormKit(terminusClass, enums)
await loadAddresses()
} catch (err: any) {
hasError.value = true
error.value = err.message || t('clientOfferForm.error.load')
console.error('Load error:', err)
} finally {
isLoading.value = false
}
}
// Handle form submission
const handleSubmit = async (data: Record<string, unknown>) => {
try {
isSubmitting.value = true
if (!activeTeamId.value) {
throw new Error(t('clientOfferForm.error.load'))
}
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
if (!selectedAddress) {
throw new Error(t('clientOfferForm.error.save'))
}
const input = {
teamUuid: activeTeamId.value,
productUuid: productUuid.value,
productName: productName.value,
categoryName: undefined,
locationUuid: selectedAddress.uuid,
locationName: selectedAddress.name,
locationCountry: '',
locationCountryCode: selectedAddress.countryCode || '',
locationLatitude: selectedAddress.latitude,
locationLongitude: selectedAddress.longitude,
quantity: data.quantity || 0,
unit: data.unit || 'ton',
pricePerUnit: data.price_per_unit || data.pricePerUnit || null,
currency: data.currency || 'USD',
description: data.description || '',
validUntil: data.valid_until || data.validUntil || null,
terminusSchemaId: schemaId.value,
terminusPayload: JSON.stringify(data),
}
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
if (!result.createOffer?.success) {
throw new Error(result.createOffer?.message || t('clientOfferForm.error.save'))
}
await navigateTo(localePath('/clientarea/offers'))
} catch (err: any) {
error.value = err.message || t('clientOfferForm.error.save')
hasError.value = true
} finally {
isSubmitting.value = false
}
}
await loadData()
</script>

View File

@@ -0,0 +1,207 @@
<template>
<Stack gap="6">
<PageHeader :title="t('clientOffersList.header.title')">
<template #actions>
<NuxtLink :to="localePath('/clientarea/offers/new')">
<Button>
<Icon name="lucide:plus" size="16" class="mr-2" />
{{ t('clientOffersList.actions.add') }}
</Button>
</NuxtLink>
</template>
</PageHeader>
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('clientOffersList.error.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadOffers">{{ t('clientOffersList.error.retry') }}</Button>
</Stack>
</Alert>
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientOffersList.states.loading') }}</Text>
</Stack>
<template v-else>
<Stack v-if="offers.length" gap="4">
<Card v-for="offer in offers" :key="offer.uuid" padding="lg">
<Stack gap="3">
<Stack direction="row" justify="between" align="center">
<Heading :level="3">{{ offer.productName || t('clientOffersList.labels.untitled') }}</Heading>
<Badge :variant="getStatusVariant(offer.status)">
{{ getStatusText(offer.status) }}
</Badge>
</Stack>
<Text v-if="offer.categoryName" tone="muted">{{ offer.categoryName }}</Text>
<Grid :cols="1" :md="4" :gap="3">
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.quantity') }}</Text>
<Text weight="semibold">{{ offer.quantity }} {{ offer.unit || t('search.units.tons_short') }}</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.price') }}</Text>
<Text weight="semibold">{{ formatPrice(offer.pricePerUnit, offer.currency) }}</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.location') }}</Text>
<Text>{{ offer.locationName || t('clientOffersList.labels.not_specified') }}</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.valid_until') }}</Text>
<Text>{{ formatDate(offer.validUntil) }}</Text>
</Stack>
</Grid>
</Stack>
</Card>
<PaginationLoadMore
:shown="offers.length"
:total="totalOffers"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
@load-more="loadMore"
/>
</Stack>
<EmptyState
v-else
icon="🏷️"
:title="t('clientOffersList.empty.title')"
:description="t('clientOffersList.empty.subtitle')"
:action-label="t('clientOffersList.actions.addOffer')"
:action-to="localePath('/clientarea/offers/new')"
action-icon="lucide:plus"
/>
</template>
</Stack>
</template>
<script setup lang="ts">
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t, locale } = useI18n()
const { activeTeamId } = useActiveTeam()
const { execute } = useGraphQL()
const PAGE_SIZE = 24
const offers = ref<any[]>([])
const totalOffers = ref(0)
const isLoadingMore = ref(false)
const {
data: offersData,
pending: offersPending,
error: loadError,
refresh: refreshOffers
} = await useServerQuery(
'client-offers-list',
GetOffersDocument,
{ teamUuid: activeTeamId.value || null, limit: PAGE_SIZE, offset: 0 },
'public',
'exchange'
)
watchEffect(() => {
if (offersData.value?.getOffers) {
offers.value = offersData.value.getOffers
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
}
})
const isLoading = computed(() => offersPending.value && offers.value.length === 0)
const canLoadMore = computed(() => offers.value.length < totalOffers.value)
const hasError = computed(() => !!loadError.value)
const error = computed(() => loadError.value?.message || t('clientOffersList.error.load'))
const getStatusVariant = (status: string) => {
const variants: Record<string, string> = {
active: 'success',
draft: 'warning',
expired: 'error',
sold: 'muted'
}
return variants[status] || 'muted'
}
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
active: t('clientOffersList.status.active'),
draft: t('clientOffersList.status.draft'),
expired: t('clientOffersList.status.expired'),
sold: t('clientOffersList.status.sold')
}
return texts[status] || status || t('clientOffersList.status.unknown')
}
const formatDate = (date: string) => {
if (!date) return t('clientOffersList.labels.not_specified')
try {
const dateObj = new Date(date)
if (isNaN(dateObj.getTime())) return t('clientOffersList.labels.invalid_date')
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(dateObj)
} catch {
return t('clientOffersList.labels.invalid_date')
}
}
const formatPrice = (price: number | string | null | undefined, currency?: string | null) => {
if (!price) return t('clientOffersList.labels.not_specified')
const num = typeof price === 'string' ? parseFloat(price) : price
const curr = currency || 'USD'
try {
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
style: 'currency',
currency: curr,
maximumFractionDigits: 0
}).format(num)
} catch {
return `${num} ${curr}`
}
}
const fetchOffers = async (offset = 0, replace = false) => {
const data = await execute(
GetOffersDocument,
{ teamUuid: activeTeamId.value || null, limit: PAGE_SIZE, offset },
'public',
'exchange'
)
const next = data?.getOffers || []
offers.value = replace ? next : offers.value.concat(next)
totalOffers.value = data?.getOffersCount ?? totalOffers.value
}
const loadOffers = async () => refreshOffers()
const loadMore = async () => {
if (isLoadingMore.value) return
isLoadingMore.value = true
try {
await fetchOffers(offers.value.length)
} finally {
isLoadingMore.value = false
}
}
watch(
() => activeTeamId.value,
async () => {
await fetchOffers(0, true)
}
)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<Stack gap="6">
<Stack direction="row" align="center" justify="between">
<Heading :level="1">{{ t('offersNew.header.title') }}</Heading>
<NuxtLink :to="localePath('/clientarea/offers')">
<Button variant="outline">
<Icon name="lucide:arrow-left" size="16" class="mr-2" />
{{ t('offersNew.actions.back') }}
</Button>
</NuxtLink>
</Stack>
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('offersNew.errors.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadProducts">{{ t('offersNew.errors.retry') }}</Button>
</Stack>
</Alert>
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('offersNew.states.loading') }}</Text>
</Stack>
<template v-else>
<Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4">
<Card
v-for="product in products"
:key="product.uuid"
padding="lg"
class="cursor-pointer hover:shadow-md transition-shadow"
@click="selectProduct(product)"
>
<Stack gap="2">
<Heading :level="3">{{ product.name }}</Heading>
<Text tone="muted">{{ product.categoryName }}</Text>
</Stack>
</Card>
</Grid>
<Stack v-else align="center" gap="3">
<IconCircle tone="warning">
<Icon name="lucide:package-x" size="24" />
</IconCircle>
<Heading :level="3">{{ t('offersNew.empty.title') }}</Heading>
<Text tone="muted">{{ t('offersNew.empty.description') }}</Text>
</Stack>
</template>
</Stack>
</template>
<script setup lang="ts">
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t } = useI18n()
const { execute } = useGraphQL()
const products = ref<any[]>([])
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
const loadProducts = async () => {
try {
isLoading.value = true
hasError.value = false
const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange')
if (productsError.value) throw productsError.value
products.value = data.value?.getProducts || []
} catch (err: any) {
hasError.value = true
error.value = err.message || t('offersNew.errors.load_failed')
products.value = []
} finally {
isLoading.value = false
}
}
const selectProduct = (product: any) => {
// Navigate to product details page
navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
}
await loadProducts()
</script>

View File

@@ -0,0 +1,243 @@
<template>
<Section variant="plain">
<Stack gap="8">
<template v-if="hasOrderError">
<div class="text-sm text-error">
{{ orderError }}
</div>
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button>
</template>
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
{{ t('ordersDetail.states.loading') }}
</div>
<template v-else>
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
</Card>
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
<Stack gap="4">
<RouteStagesList
:stages="orderStageItems"
:empty-text="t('ordersDetail.sections.stages.empty')"
/>
<div class="divider my-0"></div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" />
</Stack>
</Card>
<div class="space-y-3">
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading>
<GanttTimeline
v-if="order?.stages"
:stages="order.stages"
:showLoading="showLoading"
:showUnloading="showUnloading"
/>
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text>
</div>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
definePageMeta({
middleware: ['auth-oidc']
})
const route = useRoute()
const { t } = useI18n()
const order = ref<any>(null)
const isLoadingOrder = ref(true)
const hasOrderError = ref(false)
const orderError = ref('')
const showLoading = ref(true)
const showUnloading = ref(true)
const orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
return `${source}${destination}`
})
const orderMeta = computed(() => {
const meta: string[] = []
const orderName = order.value?.name || (route.params.id as string)
if (orderName) meta.push(`#${orderName}`)
const line = order.value?.orderLines?.[0]
if (line?.quantity) {
meta.push(`${line.quantity} ${line.unit || t('ordersDetail.labels.unit_tons')}`)
}
if (line?.productName) {
meta.push(line.productName)
}
if (order.value?.totalAmount) {
meta.push(formatPrice(order.value.totalAmount, order.value?.currency))
}
const durationDays = getOrderDuration()
if (durationDays) {
meta.push(`${durationDays} ${t('ordersDetail.labels.delivery_days')}`)
}
return meta
})
const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || [])
.filter(Boolean)
.map((stage: any) => {
if (stage.stageType === 'transport') {
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
return {
fromLat: stage.sourceLatitude,
fromLon: stage.sourceLongitude,
fromName: stage.sourceLocationName,
toLat: stage.destinationLatitude,
toLon: stage.destinationLongitude,
toName: stage.destinationLocationName,
transportType: stage.transportType
}
}
return null
})
.filter(Boolean)
if (!stages.length) return []
return [{ stages }]
})
const orderStageItems = computed<RouteStageItem[]>(() => {
return (order.value?.stages || []).map((stage: any) => {
const isTransport = stage.stageType === 'transport'
const from = isTransport ? stage.sourceLocationName : stage.locationName
const to = isTransport ? stage.destinationLocationName : stage.locationName
const meta: string[] = []
const dateRange = getStageDateRange(stage)
if (dateRange) {
meta.push(dateRange)
}
const companies = getCompaniesSummary(stage)
companies.forEach((company: any) => {
meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
)
})
return {
key: stage.uuid,
from,
to,
label: stage.name,
meta
}
})
})
const loadOrder = async () => {
try {
isLoadingOrder.value = true
hasOrderError.value = false
const orderUuid = route.params.id as string
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
if (orderErrorResp.value) throw orderErrorResp.value
order.value = data.value?.getOrder
} catch (err: any) {
hasOrderError.value = true
orderError.value = err.message || t('ordersDetail.errors.load_failed')
} finally {
isLoadingOrder.value = false
}
}
const formatPrice = (price: number, currency?: string | null) => {
if (!price) return t('ordersDetail.labels.price_zero')
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
minimumFractionDigits: 0
}).format(price)
}
const getCompaniesSummary = (stage: any) => {
const companies = []
if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({
name: stage.selectedCompany.name,
totalWeight: 0,
tripsCount: 0,
company: stage.selectedCompany
})
return companies
}
if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map()
stage.trips.forEach((trip: any) => {
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
const weight = trip.plannedWeight || 0
if (companiesMap.has(companyName)) {
const existing = companiesMap.get(companyName)
existing.totalWeight += weight
existing.tripsCount += 1
} else {
companiesMap.set(companyName, {
name: companyName,
totalWeight: weight,
tripsCount: 1,
company: trip.company
})
}
})
return Array.from(companiesMap.values())
}
return []
}
const getOrderDuration = () => {
if (!order.value?.stages?.length) return 0
let minDate: Date | null = null
let maxDate: Date | null = null
order.value.stages.forEach((stage: any) => {
stage.trips?.forEach((trip: any) => {
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
})
if (!minDate || !maxDate) return 0
const diffTime = Math.abs(maxDate.getTime() - minDate.getTime())
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
const getStageDateRange = (stage: any) => {
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
let minDate: Date | null = null
let maxDate: Date | null = null
stage.trips.forEach((trip: any) => {
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
if (!minDate || !maxDate) return t('ordersDetail.labels.dates_undefined')
const formatDate = (date: Date) => date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
if (minDate.toDateString() === maxDate.toDateString()) return formatDate(minDate)
return `${formatDate(minDate)} - ${formatDate(maxDate)}`
}
await loadOrder()
</script>

View File

@@ -0,0 +1,174 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader
:title="$t('dashboard.orders')"
:actions="[{ label: t('ordersList.actions.new_calc'), icon: 'lucide:plus', to: localePath('/clientarea') }]"
/>
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="load">{{ t('ordersList.errors.retry') }}</Button>
</Stack>
</Alert>
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('ordersList.states.loading') }}</Text>
</Stack>
<template v-else>
<template v-if="items.length">
<NuxtLink :to="localePath('/clientarea/orders/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<OrdersRoutesPreview :routes="routesForMap" :height="192" />
</ClientOnly>
</NuxtLink>
<CatalogFilters :filters="filters" v-model="selectedFilter" />
<Stack gap="4">
<Card v-for="order in filteredItems" :key="order.uuid" padding="lg" class="cursor-pointer" @click="openOrder(order)">
<Stack gap="4">
<Stack direction="row" justify="between" align="center">
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.order_label') }}</Text>
<Heading :level="3">#{{ order.name }}</Heading>
</Stack>
<div class="badge badge-outline">
{{ getOrderStartDate(order) }} {{ getOrderEndDate(order) }}
</div>
</Stack>
<div class="divider my-0"></div>
<Grid :cols="1" :md="3" :gap="3">
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.route') }}</Text>
<Text weight="semibold">{{ order.sourceLocationName }} {{ order.destinationLocationName }}</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.product') }}</Text>
<Text>
{{ order.orderLines?.[0]?.productName || t('ordersList.card.product_loading') }}
<template v-if="order.orderLines?.length > 1">
<span class="badge badge-ghost ml-2">+{{ order.orderLines.length - 1 }}</span>
</template>
</Text>
<Text tone="muted" size="sm">
{{ order.orderLines?.[0]?.quantity || 0 }} {{ order.orderLines?.[0]?.unit || t('ordersList.card.unit_tons') }}
</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.status') }}</Text>
<Badge :variant="getStatusVariant(order.status)">
{{ getStatusText(order.status) }}
</Badge>
<Text tone="muted" size="sm">{{ t('ordersList.card.stages_completed', { done: getCompletedStages(order), total: order.stages?.length || 0 }) }}</Text>
</Stack>
</Grid>
</Stack>
</Card>
</Stack>
</template>
<EmptyState
v-else
icon="📦"
:title="$t('orders.no_orders')"
:description="$t('orders.no_orders_desc')"
:action-label="$t('orders.create_new')"
:action-to="localePath('/clientarea')"
action-icon="lucide:plus"
/>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t } = useI18n()
const {
items,
filteredItems,
isLoading,
filters,
selectedFilter,
routesForMap,
load,
init,
getStatusVariant,
getStatusText
} = useTeamOrders()
const hasError = ref(false)
const error = ref('')
try {
await init()
} catch (err: any) {
hasError.value = true
error.value = err.message || t('ordersDetail.errors.load_failed')
}
const openOrder = (order: any) => {
navigateTo(localePath(`/clientarea/orders/${order.uuid}`))
}
const getOrderStartDate = (order: any) => {
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined')
return formatDate(order.createdAt)
}
const getOrderEndDate = (order: any) => {
let latestDate: Date | null = null
order.stages?.forEach((stage: any) => {
stage.trips?.forEach((trip: any) => {
const endDate = trip.actualUnloadingDate || trip.plannedUnloadingDate
if (endDate) {
const date = new Date(endDate)
if (!latestDate || date > latestDate) {
latestDate = date
}
}
})
})
if (latestDate) return formatDate((latestDate as Date).toISOString())
if (order.createdAt) {
const fallbackDate = new Date(order.createdAt)
fallbackDate.setMonth(fallbackDate.getMonth() + 1)
return formatDate(fallbackDate.toISOString())
}
return t('ordersDetail.labels.dates_undefined')
}
const getCompletedStages = (order: any) => {
if (!order.stages?.length) return 0
return order.stages.filter((stage: any) => stage.status === 'completed').length
}
const formatDate = (date: string) => {
if (!date) return t('ordersDetail.labels.dates_undefined')
try {
const dateObj = typeof date === 'string' ? new Date(date) : date
if (isNaN(dateObj.getTime())) return t('ordersDetail.labels.dates_undefined')
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(dateObj)
} catch {
return t('ordersDetail.labels.dates_undefined')
}
}
</script>

View File

@@ -0,0 +1,85 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('dashboard.orders')"
:back-link="localePath('/clientarea/orders')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="filteredItems.length"
:filters="filters"
:selected-filter="selectedFilter"
:loading="isLoading"
:empty-text="t('orders.no_orders')"
@update:selected-filter="selectedFilter = $event"
>
<template #cards>
<Card
v-for="order in filteredItems"
:key="order.uuid"
padding="small"
interactive
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
@click="selectOrder(order)"
>
<Stack gap="2">
<Stack direction="row" justify="between" align="center">
<Text weight="semibold">#{{ order.name }}</Text>
<Badge :variant="getStatusVariant(order.status)" size="sm">
{{ getStatusText(order.status) }}
</Badge>
</Stack>
<Text tone="muted" size="sm" class="truncate">
{{ order.sourceLocationName }} {{ order.destinationLocationName }}
</Text>
</Stack>
</Card>
</template>
</CatalogMapSidebar>
</template>
<ClientOnly>
<OrdersRoutesMap
ref="mapRef"
:routes="routesForMap"
:selected-order-id="selectedOrderId"
@select-order="onMapSelectOrder"
/>
</ClientOnly>
</NuxtLayout>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: ['auth-oidc']
})
const { t } = useI18n()
const localePath = useLocalePath()
const router = useRouter()
const {
filteredItems,
isLoading,
filters,
selectedFilter,
routesForMap,
init,
getStatusVariant,
getStatusText
} = useTeamOrders()
await init()
const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null)
const selectedOrderId = ref<string | null>(null)
const selectOrder = (order: any) => {
selectedOrderId.value = order.uuid
mapRef.value?.flyTo(order.uuid)
}
const onMapSelectOrder = (uuid: string) => {
selectedOrderId.value = uuid
}
</script>

View File

@@ -0,0 +1,275 @@
<template>
<Section variant="plain">
<Stack gap="6">
<PageHeader
title="Debug Tokens"
:actions="[
{ label: 'Back', icon: 'lucide:arrow-left', to: localePath('/clientarea/profile') }
]"
/>
<Alert v-if="error" variant="error">
<Text>{{ error }}</Text>
</Alert>
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" align="center" justify="between">
<Heading :level="3">JWT Tokens</Heading>
<Button variant="ghost" size="sm" @click="loadTokens" :loading="isLoading">
<Icon name="lucide:refresh-ccw" size="16" />
<span>Refresh</span>
</Button>
</Stack>
<!-- ID Token -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">ID Token</Heading>
<Button v-if="rawTokens.id" variant="ghost" size="sm" @click="copyToken(rawTokens.id)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.id || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.id) }}</pre>
</div>
</div>
<!-- Access Token: teams -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">Access Token: teams.optovia.ru</Heading>
<Button v-if="rawTokens.teams" variant="ghost" size="sm" @click="copyToken(rawTokens.teams)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.teams || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.teams) }}</pre>
</div>
</div>
<!-- Access Token: exchange -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">Access Token: exchange.optovia.ru</Heading>
<Button v-if="rawTokens.exchange" variant="ghost" size="sm" @click="copyToken(rawTokens.exchange)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.exchange || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.exchange) }}</pre>
</div>
</div>
<!-- Access Token: orders -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">Access Token: orders.optovia.ru</Heading>
<Button v-if="rawTokens.orders" variant="ghost" size="sm" @click="copyToken(rawTokens.orders)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.orders || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.orders) }}</pre>
</div>
</div>
<!-- Access Token: kyc -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">Access Token: kyc.optovia.ru</Heading>
<Button v-if="rawTokens.kyc" variant="ghost" size="sm" @click="copyToken(rawTokens.kyc)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.kyc || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.kyc) }}</pre>
</div>
</div>
<!-- Access Token: billing -->
<div class="space-y-2">
<Stack direction="row" align="center" justify="between">
<Heading :level="4">Access Token: billing.optovia.ru</Heading>
<Button v-if="rawTokens.billing" variant="ghost" size="sm" @click="copyToken(rawTokens.billing)">
<Icon name="lucide:copy" size="16" />
<span>Copy</span>
</Button>
</Stack>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
<input
type="text"
readonly
:value="rawTokens.billing || '—'"
class="input input-bordered input-sm w-full font-mono text-xs"
/>
</div>
<div class="space-y-1">
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.billing) }}</pre>
</div>
</div>
</Stack>
</Card>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth-oidc']
})
const RESOURCES = {
teams: 'https://teams.optovia.ru',
exchange: 'https://exchange.optovia.ru',
orders: 'https://orders.optovia.ru',
kyc: 'https://kyc.optovia.ru',
billing: 'https://billing.optovia.ru'
} as const
type ResourceKey = keyof typeof RESOURCES
const localePath = useLocalePath()
const auth = useAuth()
const isLoading = ref(false)
const error = ref('')
const rawTokens = ref<{ id: string | null } & Record<ResourceKey, string | null>>({
id: null,
teams: null,
exchange: null,
orders: null,
kyc: null,
billing: null
})
const decodedTokens = ref<{ id: unknown } & Record<ResourceKey, unknown>>({
id: null,
teams: null,
exchange: null,
orders: null,
kyc: null,
billing: null
})
function decodeJwt(token?: string | null) {
if (!token) return null
try {
const payload = token.split('.')[1]
if (!payload) return null
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json)
} catch {
return null
}
}
function formatJson(data: unknown) {
if (!data) return '—'
return JSON.stringify(data, null, 2)
}
async function copyToken(token: string | null) {
if (!token) return
try {
await navigator.clipboard.writeText(token)
} catch (e) {
console.error('Failed to copy:', e)
}
}
const loadTokens = async () => {
try {
isLoading.value = true
error.value = ''
// Get ID token
try {
const idToken = await auth.getIdToken()
rawTokens.value.id = idToken || null
decodedTokens.value.id = decodeJwt(idToken)
} catch (e: unknown) {
rawTokens.value.id = null
decodedTokens.value.id = { error: e instanceof Error ? e.message : 'Failed to get ID token' }
}
// Get access tokens for ALL resources
for (const [key, url] of Object.entries(RESOURCES) as [ResourceKey, string][]) {
try {
const accessToken = await auth.getAccessToken(url)
rawTokens.value[key] = accessToken || null
decodedTokens.value[key] = decodeJwt(accessToken)
} catch (e: unknown) {
rawTokens.value[key] = null
decodedTokens.value[key] = { error: e instanceof Error ? e.message : 'Failed to get access token' }
}
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Error loading tokens'
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadTokens()
})
</script>

View File

@@ -0,0 +1,157 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader
:title="$t('dashboard.profile')"
:actions="[{ label: t('clientProfile.actions.debugTokens'), icon: 'lucide:bug', to: localePath('/clientarea/profile/debug-tokens') }]"
/>
<Alert v-if="hasError" variant="error">
<Stack gap="1">
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
<Text tone="muted">{{ error }}</Text>
</Stack>
</Alert>
<Stack v-if="isLoading" align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text>
</Stack>
<template v-else>
<Card padding="lg">
<Grid :cols="1" :lg="3" :gap="8">
<GridItem :lg="2">
<Stack gap="4">
<form @submit.prevent="updateProfile">
<Stack gap="4">
<Input
v-model="profileForm.firstName"
type="text"
:label="$t('profile.first_name')"
:placeholder="$t('profile.first_name_placeholder')"
/>
<Input
v-model="profileForm.lastName"
type="text"
:label="$t('profile.last_name')"
:placeholder="$t('profile.last_name_placeholder')"
/>
<Input
v-model="profileForm.phone"
type="tel"
:label="$t('profile.phone')"
:placeholder="$t('profile.phone_placeholder')"
/>
<Button type="submit" :full-width="true" :disabled="isUpdating">
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
<template v-else>{{ $t('profile.save') }}</template>
</Button>
</Stack>
</form>
</Stack>
</GridItem>
<GridItem>
<Stack gap="6" align="center">
<Stack gap="3" align="center">
<Heading :level="3">{{ $t('profile.avatar') }}</Heading>
<UserAvatar
:userId="userData?.id"
:firstName="userData?.firstName"
:lastName="userData?.lastName"
:avatarId="userData?.avatarId"
@avatar-changed="handleAvatarChange"
/>
</Stack>
</Stack>
</GridItem>
</Grid>
</Card>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t } = useI18n()
const { mutate } = useGraphQL()
const userData = useState<{
id?: string
firstName?: string
lastName?: string
phone?: string | null
avatarId?: string | null
} | null>('me', () => null)
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
const isUpdating = ref(false)
const avatarDraftId = ref<string | null>(null)
const profileForm = reactive({
firstName: '',
lastName: '',
phone: ''
})
const syncProfileForm = () => {
if (!userData.value) {
hasError.value = true
error.value = t('clientProfile.error.load')
isLoading.value = false
return
}
hasError.value = false
error.value = ''
profileForm.firstName = userData.value.firstName || ''
profileForm.lastName = userData.value.lastName || ''
profileForm.phone = userData.value.phone || ''
avatarDraftId.value = userData.value.avatarId || null
isLoading.value = false
}
const updateProfile = async () => {
try {
isUpdating.value = true
const { UpdateUserDocument } = await import('~/composables/graphql/user/teams-generated')
const result = await mutate(UpdateUserDocument, {
userId: userData.value.id,
input: {
firstName: profileForm.firstName,
lastName: profileForm.lastName,
phone: profileForm.phone,
avatarId: avatarDraftId.value || null
},
}, 'user', 'teams')
if (result?.updateUser?.user) {
userData.value = { ...(userData.value || {}), ...result.updateUser.user }
avatarDraftId.value = userData.value.avatarId || avatarDraftId.value
}
} catch (err) {
hasError.value = true
error.value = err?.message || t('clientProfile.error.save')
} finally {
isUpdating.value = false
}
}
const handleAvatarChange = async (newAvatarId?: string) => {
if (!newAvatarId) return
// Only stage avatar change; will be saved on form submit
avatarDraftId.value = newAvatarId
}
watch(userData, () => {
syncProfileForm()
}, { immediate: true })
</script>

View File

@@ -0,0 +1,6 @@
<template>
<CalcResultContent />
</template>
<script setup>
</script>

View File

@@ -0,0 +1,196 @@
<template>
<Section variant="plain">
<Stack gap="6">
<PageHeader :title="t('clientTeam.header.title')" :actions="teamHeaderActions" />
<Alert v-if="hasError" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('clientTeam.error.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button>
</Stack>
</Alert>
<Card v-else-if="isLoading" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientTeam.loading.message') }}</Text>
</Stack>
</Card>
<!-- No team - prompt to create KYC application -->
<EmptyState
v-else-if="!currentTeam"
icon="👥"
:title="t('clientTeam.empty.title')"
:description="t('clientTeam.empty.description')"
:action-label="t('clientTeam.empty.cta')"
:action-to="localePath('/clientarea/kyc')"
action-icon="lucide:plus"
/>
<template v-else>
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" gap="4" align="start" justify="between">
<Stack direction="row" gap="3" align="center">
<IconCircle tone="neutral" size="lg">
{{ currentTeam.name?.charAt(0)?.toUpperCase() || '?' }}
</IconCircle>
<Heading :level="2" weight="semibold">{{ currentTeam.name }}</Heading>
</Stack>
</Stack>
</Stack>
</Card>
<Stack gap="3">
<Heading :level="2">{{ t('clientTeam.members.title') }}</Heading>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card
v-for="member in currentTeam?.members || []"
:key="member.user?.id"
padding="lg"
>
<Stack gap="3">
<Stack direction="row" gap="3" align="center">
<IconCircle tone="neutral">{{ getMemberInitials(member.user) }}</IconCircle>
<Stack gap="1">
<Text weight="semibold">{{ member.user?.firstName }} {{ member.user?.lastName || '—' }}</Text>
</Stack>
</Stack>
<Stack direction="row" gap="2" wrap>
<Pill variant="primary">{{ roleText(member.role) }}</Pill>
</Stack>
</Stack>
</Card>
<!-- Pending invitations -->
<Card
v-for="invitation in currentTeam?.invitations || []"
:key="invitation.uuid"
padding="lg"
class="border-dashed border-warning"
>
<Stack gap="3">
<Stack direction="row" gap="3" align="center">
<IconCircle tone="warning">
<Icon name="lucide:mail" size="16" />
</IconCircle>
<Stack gap="1">
<Text weight="semibold">{{ invitation.email }}</Text>
<Text tone="muted" size="sm">{{ t('clientTeam.invitations.pending') }}</Text>
</Stack>
</Stack>
<Stack direction="row" gap="2" wrap>
<Pill variant="outline" tone="warning">{{ roleText(invitation.role) }}</Pill>
<Pill variant="ghost" tone="muted">{{ t('clientTeam.invitations.sent') }}</Pill>
</Stack>
</Stack>
</Card>
<Card
padding="lg"
class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors"
@click="inviteMember"
>
<Stack gap="3" align="center" justify="center" class="h-full min-h-[100px]">
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center text-base-content/50">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</div>
<Text weight="semibold" tone="muted">{{ t('clientTeam.inviteCard.title') }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
import { GetTeamDocument } from '~/composables/graphql/user/teams-generated'
const { t } = useI18n()
const router = useRouter()
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const me = useState<{
teams?: Array<{ id?: string | null; name: string; logtoOrgId?: string | null } | null> | null
activeTeamId?: string | null
activeTeam?: { logtoOrgId?: string | null } | null
} | null>('me', () => null)
const { setActiveTeam } = useActiveTeam()
const userTeams = ref<any[]>([])
const currentTeam = ref<any>(null)
const isLoading = ref(true)
const hasError = ref(false)
const error = ref('')
const teamHeaderActions = computed(() => {
const actions: Array<{ label: string; icon?: string; to?: string }> = []
actions.push({ label: t('clientTeam.actions.addCompany'), icon: 'lucide:plus', to: localePath('/clientarea/kyc') })
if (userTeams.value.length > 1) {
actions.push({ label: t('clientTeam.actions.switch'), icon: 'lucide:arrow-left-right', to: localePath('/clientarea/company-switch') })
}
return actions
})
const roleText = (role?: string) => {
const map: Record<string, string> = {
OWNER: t('clientTeam.roles.owner'),
ADMIN: t('clientTeam.roles.admin'),
MANAGER: t('clientTeam.roles.manager'),
MEMBER: t('clientTeam.roles.member'),
}
return map[role || ''] || role || t('clientTeam.roles.member')
}
const getMemberInitials = (user?: any) => {
if (!user) return '??'
const first = user.firstName?.charAt(0) || ''
const last = user.lastName?.charAt(0) || ''
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
}
const loadUserTeams = async () => {
try {
isLoading.value = true
hasError.value = false
if (!me.value) {
throw new Error(t('clientTeam.error.load'))
}
userTeams.value = me.value.teams?.filter((t): t is NonNullable<typeof t> => t !== null) || []
if (me.value.activeTeamId && me.value.activeTeam) {
setActiveTeam(me.value.activeTeamId, me.value.activeTeam.logtoOrgId)
const { data: teamData } = await useServerQuery('team-page-team', GetTeamDocument, { teamId: me.value.activeTeamId }, 'user', 'teams')
currentTeam.value = teamData.value?.getTeam || null
} else if (userTeams.value.length > 0) {
const firstTeam = userTeams.value[0]
setActiveTeam(firstTeam?.id || null, firstTeam?.logtoOrgId)
currentTeam.value = firstTeam
}
// Если нет команды - currentTeam остаётся null, показываем EmptyState
} catch (err: any) {
hasError.value = true
error.value = err.message || t('clientTeam.error.load')
} finally {
isLoading.value = false
}
}
const inviteMember = () => {
router.push(localePath('/clientarea/team/invite'))
}
await loadUserTeams()
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Section variant="plain">
<Stack gap="6">
<PageHeader :title="t('clientTeam.invite.title')" />
<Card padding="lg">
<form @submit.prevent="submitInvite">
<Stack gap="4">
<div class="form-control">
<label class="label">
<span class="label-text">{{ t('clientTeam.invite.email') }}</span>
</label>
<input
v-model="inviteEmail"
type="email"
:placeholder="t('clientTeam.invite.emailPlaceholder')"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ t('clientTeam.invite.role') }}</span>
</label>
<select v-model="inviteRole" class="select select-bordered w-full">
<option value="MEMBER">{{ t('clientTeam.roles.member') }}</option>
<option value="MANAGER">{{ t('clientTeam.roles.manager') }}</option>
<option value="ADMIN">{{ t('clientTeam.roles.admin') }}</option>
</select>
</div>
<Alert v-if="inviteError" variant="error">
{{ inviteError }}
</Alert>
<Alert v-if="inviteSuccess" variant="success">
{{ t('clientTeam.invite.success') }}
</Alert>
<Stack direction="row" gap="3">
<Button variant="ghost" :to="localePath('/clientarea/team')">
{{ t('clientTeam.invite.cancel') }}
</Button>
<Button type="submit" :loading="inviteLoading">
{{ t('clientTeam.invite.submit') }}
</Button>
</Stack>
</Stack>
</form>
</Card>
</Stack>
</Section>
</template>
<script setup lang="ts">
import { InviteMemberDocument } from '~/composables/graphql/team/teams-generated'
const { t } = useI18n()
const { mutate } = useGraphQL()
const localePath = useLocalePath()
const router = useRouter()
definePageMeta({
middleware: ['auth-oidc']
})
const inviteEmail = ref('')
const inviteRole = ref('MEMBER')
const inviteLoading = ref(false)
const inviteError = ref('')
const inviteSuccess = ref(false)
const submitInvite = async () => {
if (!inviteEmail.value) return
inviteLoading.value = true
inviteError.value = ''
inviteSuccess.value = false
try {
const result = await mutate(InviteMemberDocument, {
input: {
email: inviteEmail.value,
role: inviteRole.value
}
}, 'team', 'teams')
if (result?.inviteMember?.success) {
inviteSuccess.value = true
setTimeout(() => {
router.push(localePath('/clientarea/team'))
}, 1500)
} else {
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
}
} catch (err: any) {
inviteError.value = err.message || t('clientTeam.invite.error')
} finally {
inviteLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div />
</template>
<script setup lang="ts">
// Ручное создание команды убрано — команды создаются автоматически после KYC
definePageMeta({
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
await navigateTo(localePath('/clientarea/kyc'))
</script>

5
app/pages/goods.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<Section variant="plain" paddingY="md">
<GoodsContent />
</Section>
</template>

195
app/pages/index.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<Stack gap="12">
<Section variant="hero">
<Stack gap="6">
<Heading :level="1" tone="inverse">{{ $t('search.title') }}</Heading>
<Text tone="inverse">{{ $t('search.description') }}</Text>
<Card padding="lg">
<Stack gap="6">
<!-- Search form -->
<Grid :cols="1" :md="4" :gap="4">
<Stack gap="2">
<Text tag="p" size="base" weight="semibold">{{ $t('search.product_label') }}</Text>
<FieldButton
:value="searchStore.searchForm.product"
:placeholder="$t('search.product_placeholder')"
@click="navigateTo(localePath('/goods'))"
/>
</Stack>
<Stack gap="2">
<Text tag="p" size="base" weight="semibold">{{ $t('search.quantity_label') }}</Text>
<Input
type="number"
v-model="searchStore.searchForm.quantity"
:placeholder="$t('search.quantity_placeholder')"
/>
</Stack>
<Stack gap="2">
<Text tag="p" size="base" weight="semibold">{{ $t('search.location_label') }}</Text>
<FieldButton
:value="searchStore.searchForm.location"
:placeholder="$t('search.location_placeholder')"
@click="navigateTo(localePath({ path: '/select-location', query: { mode: 'search' } }))"
/>
</Stack>
<Stack gap="2" align="stretch">
<Text tag="p" size="base" weight="semibold" tone="muted"> </Text>
<Button type="button" @click="handleSearch">
{{ searchError ? $t('search.error') : $t('search.search_button') }}
</Button>
</Stack>
</Grid>
</Stack>
</Card>
<Stack v-if="popularExamples.length" gap="2" align="center">
<Text tone="inverse" size="sm">{{ $t('search.popular_requests') }}</Text>
<Stack direction="row" gap="2" justify="center" align="center" class="flex-wrap text-center">
<button
v-for="example in popularExamples"
:key="`${example.product}-${example.location}`"
type="button"
class="badge badge-dash badge-primary text-sm"
@click="fillExample(example)"
>
{{ example.product }} {{ example.quantity }}{{ $t('search.units.tons_short') }} {{ example.location }}
</button>
</Stack>
</Stack>
</Stack>
</Section>
<Section variant="plain">
<Stack gap="8" align="center">
<Heading :level="2">{{ $t('roles.title') }}</Heading>
<Text align="center" tone="muted">{{ $t('about.description') }}</Text>
<Grid :cols="1" :lg="3" :gap="6">
<Card padding="lg">
<Stack gap="4" align="center">
<IconCircle tone="primary">🏭</IconCircle>
<Heading :level="3">{{ $t('roles.producers.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('roles.producers.description') }}</Text>
<Stack tag="ul" gap="1">
<li> {{ $t('roles.producers.benefit1') }}</li>
<li> {{ $t('roles.producers.benefit2') }}</li>
<li> {{ $t('roles.producers.benefit3') }}</li>
<li> {{ $t('roles.producers.benefit4') }}</li>
</Stack>
<Button :full-width="true" variant="outline">{{ $t('roles.producers.cta') }}</Button>
</Stack>
</Card>
<Card padding="lg">
<Stack gap="4" align="center">
<IconCircle tone="primary">🏢</IconCircle>
<Heading :level="3">{{ $t('roles.buyers.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('roles.buyers.description') }}</Text>
<Stack tag="ul" gap="1">
<li> {{ $t('roles.buyers.benefit1') }}</li>
<li> {{ $t('roles.buyers.benefit2') }}</li>
<li> {{ $t('roles.buyers.benefit3') }}</li>
<li> {{ $t('roles.buyers.benefit4') }}</li>
</Stack>
<Button :full-width="true" variant="outline">{{ $t('roles.buyers.cta') }}</Button>
</Stack>
</Card>
<Card padding="lg">
<Stack gap="4" align="center">
<IconCircle tone="primary"></IconCircle>
<Heading :level="3">{{ $t('roles.services.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('roles.services.description') }}</Text>
<Stack tag="ul" gap="1">
<li> {{ $t('roles.services.benefit1') }}</li>
<li> {{ $t('roles.services.benefit2') }}</li>
<li> {{ $t('roles.services.benefit3') }}</li>
<li> {{ $t('roles.services.benefit4') }}</li>
</Stack>
<Button :full-width="true" variant="outline">{{ $t('roles.services.cta') }}</Button>
</Stack>
</Card>
</Grid>
</Stack>
</Section>
<Section variant="plain">
<Stack gap="6" align="center">
<Heading :level="2">{{ $t('howto.title') }}</Heading>
<Grid :cols="1" :md="3" :gap="6">
<Card padding="lg">
<Stack gap="3" align="center">
<IconCircle tone="primary">🔍</IconCircle>
<Heading :level="3" weight="semibold">{{ $t('howto.step1.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('howto.step1.description') }}</Text>
</Stack>
</Card>
<Card padding="lg">
<Stack gap="3" align="center">
<IconCircle tone="primary">🤝</IconCircle>
<Heading :level="3" weight="semibold">{{ $t('howto.step2.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('howto.step2.description') }}</Text>
</Stack>
</Card>
<Card padding="lg">
<Stack gap="3" align="center">
<IconCircle tone="primary"></IconCircle>
<Heading :level="3" weight="semibold">{{ $t('howto.step3.title') }}</Heading>
<Text tone="muted" align="center">{{ $t('howto.step3.description') }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
</Section>
</Stack>
</template>
<script setup>
const { t } = useI18n()
const searchStore = useSearchStore()
const searchError = ref('')
const localePath = useLocalePath()
const popularExamples = computed(() => ([
{ product: t('search.examples.metal_sheet.product'), quantity: 120, location: t('search.examples.metal_sheet.location') },
{ product: t('search.examples.green_coffee.product'), quantity: 200, location: t('search.examples.green_coffee.location') },
{ product: t('search.examples.wheat.product'), quantity: 500, location: t('search.examples.wheat.location') },
{ product: t('search.examples.cocoa.product'), quantity: 150, location: t('search.examples.cocoa.location') },
]))
const handleSearch = () => {
const location = (searchStore.searchForm.location || '').trim()
const productText = (searchStore.searchForm.product || '').trim()
const hasProduct = !!(searchStore.searchForm.productUuid || productText)
if (!location) {
searchError.value = t('search.validation.fill_product_location')
setTimeout(() => (searchError.value = ''), 2000)
return
}
if (!hasProduct) {
navigateTo(localePath('/goods'))
return
}
const query = {
productUuid: searchStore.searchForm.productUuid || undefined,
product: productText || undefined,
quantity: searchStore.searchForm.quantity || undefined,
locationUuid: searchStore.searchForm.locationUuid || undefined,
location: searchStore.searchForm.location || undefined
}
navigateTo(localePath({ path: '/request', query }))
}
const fillExample = (example) => {
searchStore.searchForm.product = example.product
searchStore.searchForm.quantity = example.quantity
searchStore.searchForm.location = example.location
}
</script>

5
app/pages/locations.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<Section variant="plain" paddingY="md">
<LocationsContent />
</Section>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<Section variant="plain" paddingY="md">
<CalcResultContent />
</Section>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<Section variant="plain" paddingY="md">
<CalcResultContent />
</Section>
</template>

View File

@@ -0,0 +1,36 @@
<template>
<Stack gap="8">
<Section variant="hero">
<Stack gap="4">
<Stack gap="2">
<Text size="base" tone="inverse" weight="semibold" transform="uppercase">Search</Text>
<Heading :level="1" tone="inverse">{{ t('searchPage.hero.title') }}</Heading>
<Text tone="inverse" size="base">
{{ t('searchPage.hero.subtitle') }}
</Text>
</Stack>
<Stack direction="row" gap="3">
<Button :as="'NuxtLink'" :to="localePath('/catalog')" variant="ghost">
{{ t('searchPage.cta.catalog') }}
</Button>
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">
{{ t('searchPage.cta.orders') }}
</Button>
</Stack>
</Stack>
</Section>
<Section variant="plain" paddingY="md">
<Card>
<GoodsContent />
</Card>
</Section>
</Stack>
</template>
<script setup>
const localePath = useLocalePath()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,198 @@
<template>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<PageHeader :title="t('common.selectLocation')">
<template #actions>
<button class="btn btn-ghost" @click="router.back()">
<Icon name="lucide:x" size="20" />
</button>
</template>
</PageHeader>
<Stack gap="8" class="mt-6">
<!-- My addresses -->
<Stack v-if="isAuthenticated && teamAddresses?.length" gap="4">
<Heading :level="3">{{ t('profileAddresses.header.title') }}</Heading>
<Grid :cols="1" :md="2" :gap="4">
<Card
v-for="addr in teamAddresses"
:key="addr.uuid"
padding="small"
interactive
:class="{ 'ring-2 ring-primary': isSelected('address', addr.uuid) }"
@click="selectAddress(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>
<!-- Hubs -->
<Stack gap="4">
<Heading :level="3">{{ t('catalogMap.hubsTab') }}</Heading>
<NuxtLink :to="localePath('/select-location/map')" class="block h-48 rounded-lg overflow-hidden cursor-pointer">
<ClientOnly>
<MapboxGlobe
map-id="select-location-map"
:locations="itemsWithCoords"
:height="192"
/>
</ClientOnly>
</NuxtLink>
<CatalogFilters :filters="filters" v-model="selectedFilter" />
<div v-if="isLoading" class="flex items-center justify-center p-8">
<span class="loading loading-spinner loading-lg" />
</div>
<EmptyState
v-else-if="!items?.length"
:title="t('catalogMap.noHubs')"
/>
<template v-else>
<Grid :cols="1" :md="2" :gap="4">
<HubCard
v-for="hub in items"
:key="hub.uuid"
:hub="hub"
selectable
:is-selected="isSelected('hub', hub.uuid)"
@select="selectHub(hub)"
/>
</Grid>
<PaginationLoadMore
:shown="items.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
@load-more="loadMore"
/>
</template>
</Stack>
</Stack>
</div>
</template>
<script setup lang="ts">
import { useLocationStore } from '~/stores/location'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const localePath = useLocalePath()
const { isAuthenticated } = useAuth()
const locationStore = useLocationStore()
const searchStore = useSearchStore()
const isSearchMode = computed(() => route.query.mode === 'search')
// Use shared composable for hubs
const {
items,
total,
selectedFilter,
filters,
isLoading,
isLoadingMore,
itemsWithCoords,
canLoadMore,
loadMore,
init
} = useCatalogHubs()
await init()
// Load team addresses
const teamAddresses = ref<any[]>([])
if (isAuthenticated.value) {
try {
const { execute } = useGraphQL()
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
teamAddresses.value = data?.teamAddresses || []
} catch {
// Not critical
}
}
const isSelected = (type: 'address' | 'hub', uuid: string) => {
return locationStore.selectedLocation?.type === type && locationStore.selectedLocation?.uuid === uuid
}
const goToRequestIfReady = () => {
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
router.push({
path: '/request',
query: {
productUuid: searchStore.searchForm.productUuid,
product: searchStore.searchForm.product,
locationUuid: searchStore.searchForm.locationUuid,
location: searchStore.searchForm.location,
quantity: searchStore.searchForm.quantity || undefined
}
})
return true
}
return false
}
const selectHub = async (hub: any) => {
console.log('[selectHub] called', { hub, isSearchMode: isSearchMode.value })
if (isSearchMode.value) {
searchStore.setLocation(hub.name)
searchStore.setLocationUuid(hub.uuid)
if (goToRequestIfReady()) return
router.back()
return
}
try {
console.log('[selectHub] calling locationStore.select')
const success = await locationStore.select('hub', hub.uuid, hub.name, hub.latitude, hub.longitude)
console.log('[selectHub] result:', success)
if (success) {
router.back()
} else {
console.error('[selectHub] Selection failed - success=false')
}
} catch (e) {
console.error('[selectHub] Error:', e)
}
}
const selectAddress = async (addr: any) => {
console.log('[selectAddress] called', { addr, isSearchMode: isSearchMode.value })
if (isSearchMode.value) {
searchStore.setLocation(addr.address || addr.name)
searchStore.setLocationUuid(addr.uuid)
if (goToRequestIfReady()) return
router.back()
return
}
try {
console.log('[selectAddress] calling locationStore.select')
const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude, addr.longitude)
console.log('[selectAddress] result:', success)
if (success) {
router.back()
} else {
console.error('[selectAddress] Selection failed - success=false')
}
} catch (e) {
console.error('[selectAddress] Error:', e)
}
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<NuxtLayout name="map">
<template #sidebar>
<CatalogMapSidebar
:title="t('catalogMap.hubsTab')"
:back-link="localePath('/select-location')"
:back-label="t('catalogMap.actions.list_view')"
:items-count="items.length"
:filters="filters"
:selected-filter="selectedFilter"
:loading="isLoading"
:empty-text="t('catalogMap.noHubs')"
@update:selected-filter="selectedFilter = $event"
>
<template #cards>
<HubCard
v-for="hub in items"
:key="hub.uuid"
:hub="hub"
selectable
:is-selected="selectedItemId === hub.uuid"
@select="selectItem(hub)"
/>
</template>
</CatalogMapSidebar>
</template>
<CatalogMap
ref="mapRef"
map-id="select-location-fullscreen-map"
:items="itemsWithCoords"
point-color="#10b981"
@select-item="onMapSelectItem"
/>
</NuxtLayout>
</template>
<script setup lang="ts">
import { useLocationStore } from '~/stores/location'
definePageMeta({
layout: false
})
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const localePath = useLocalePath()
const locationStore = useLocationStore()
const searchStore = useSearchStore()
const isSearchMode = computed(() => route.query.mode === 'search')
const {
items,
selectedFilter,
filters,
isLoading,
itemsWithCoords,
init
} = useCatalogHubs()
await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null)
const selectItem = async (item: any) => {
selectedItemId.value = item.uuid
if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
// Selection logic
if (isSearchMode.value) {
searchStore.setLocation(item.name)
searchStore.setLocationUuid(item.uuid)
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
router.push({
path: '/request',
query: {
productUuid: searchStore.searchForm.productUuid,
product: searchStore.searchForm.product,
locationUuid: searchStore.searchForm.locationUuid,
location: searchStore.searchForm.location,
quantity: searchStore.searchForm.quantity || undefined
}
})
return
}
router.push(localePath('/select-location'))
return
}
const success = await locationStore.select('hub', item.uuid, item.name, item.latitude, item.longitude)
if (success) router.push(localePath('/select-location'))
}
const onMapSelectItem = (uuid: string) => {
const item = items.value.find(h => h.uuid === uuid)
if (item) selectItem(item)
}
</script>

290
app/pages/supplier/[id].vue Normal file
View File

@@ -0,0 +1,290 @@
<template>
<Section variant="plain">
<Stack gap="6">
<Stack gap="2">
<Stack direction="row" gap="2" wrap>
<Button :as="'NuxtLink'" :to="localePath('/')" variant="ghost">
{{ t('supplierFlow.breadcrumb.home') }}
</Button>
<Text tone="muted"></Text>
<Button :as="'NuxtLink'" :to="localePath('/search')" variant="ghost">
{{ t('supplierFlow.breadcrumb.search_results') }}
</Button>
<Text tone="muted"></Text>
<Text weight="semibold">{{ t('supplierFlow.breadcrumb.select_services') }}</Text>
</Stack>
<Heading :level="1">{{ supplier?.name }}</Heading>
<Text tone="muted">📍 {{ supplier?.location }}</Text>
</Stack>
<Card padding="lg">
<Stack direction="row" align="center" justify="between">
<Stack gap="1">
<Heading :level="3" weight="semibold">{{ supplier?.name }}</Heading>
<Text tone="muted">{{ supplier?.selectedLogistics?.route }}</Text>
</Stack>
<Stack gap="1" align="end">
<Text weight="semibold">{{ supplier?.totalPrice }}</Text>
<Text tone="muted">{{ $t('supplier.delivery_time') }}: {{ supplier?.selectedLogistics?.time }}</Text>
</Stack>
</Stack>
</Card>
<Stack gap="4">
<Stack gap="2">
<Heading :level="2">{{ t('supplierFlow.sections.logistics.title') }}</Heading>
<Text tone="muted">{{ t('supplierFlow.sections.logistics.subtitle') }}</Text>
</Stack>
<Grid :cols="1" :md="2" :gap="4">
<Card
v-for="logistics in logisticsCompanies"
:key="logistics.id"
padding="lg"
interactive
:active="selectedServices.logistics === logistics.id"
@click="selectedServices.logistics = logistics.id"
>
<Stack gap="2">
<Stack direction="row" justify="between" align="center">
<Heading :level="4" weight="semibold">{{ logistics.name }}</Heading>
<Pill variant="primary">{{ logistics.price.toLocaleString() }}</Pill>
</Stack>
<Text tone="muted">{{ logistics.description }}</Text>
<Stack direction="row" justify="between" align="center">
<Text tone="muted" size="base">{{ t('supplierFlow.labels.rating_reviews', { rating: logistics.rating, reviews: logistics.reviews }) }}</Text>
<Text tone="muted" size="base">{{ logistics.experience }}</Text>
</Stack>
</Stack>
</Card>
</Grid>
</Stack>
<Stack gap="4">
<Stack gap="2">
<Heading :level="2">{{ t('supplierFlow.sections.financing.title') }}</Heading>
<Text tone="muted">{{ t('supplierFlow.sections.financing.subtitle') }}</Text>
</Stack>
<Grid :cols="1" :md="2" :gap="4">
<Card
v-for="bank in banks"
:key="bank.id"
padding="lg"
interactive
:active="selectedServices.financing === bank.id"
@click="selectedServices.financing = bank.id"
>
<Stack gap="2">
<Stack direction="row" justify="between" align="center">
<Heading :level="4" weight="semibold">{{ bank.name }}</Heading>
<Pill variant="primary">{{ bank.rate }}%</Pill>
</Stack>
<Text tone="muted">{{ bank.terms }}</Text>
<Text tone="muted" size="base">{{ bank.period }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
<Stack gap="4">
<Stack gap="2">
<Heading :level="2">{{ t('supplierFlow.sections.insurance.title') }}</Heading>
<Text tone="muted">{{ t('supplierFlow.sections.insurance.subtitle') }}</Text>
</Stack>
<Grid :cols="1" :md="2" :gap="4">
<Card
v-for="insurance in insuranceCompanies"
:key="insurance.id"
padding="lg"
interactive
:active="selectedServices.insurance === insurance.id"
@click="selectedServices.insurance = insurance.id"
>
<Stack gap="2">
<Stack direction="row" justify="between" align="center">
<Heading :level="4" weight="semibold">{{ insurance.name }}</Heading>
<Pill variant="primary">{{ insurance.cost }}</Pill>
</Stack>
<Text tone="muted">{{ insurance.coverage }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
<Stack gap="4">
<Stack gap="2">
<Heading :level="2">{{ t('supplierFlow.sections.quality.title') }}</Heading>
<Text tone="muted">{{ t('supplierFlow.sections.quality.subtitle') }}</Text>
</Stack>
<Grid :cols="1" :md="2" :gap="4">
<Card
v-for="lab in laboratories"
:key="lab.id"
padding="lg"
interactive
:active="selectedServices.laboratory === lab.id"
@click="selectedServices.laboratory = lab.id"
>
<Stack gap="2">
<Stack direction="row" justify="between" align="center">
<Heading :level="4" weight="semibold">{{ lab.name }}</Heading>
<Pill variant="primary">{{ lab.price }}</Pill>
</Stack>
<Text tone="muted">{{ lab.tests }}</Text>
<Text tone="muted" size="base">🏆 {{ lab.certification }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
<Card padding="lg">
<Stack direction="row" align="center" justify="between">
<Stack gap="1">
<Heading :level="3" weight="semibold">{{ $t('supplier.total_summary') }}</Heading>
<Text tone="muted">{{ $t('supplier.all_services_selected') }}</Text>
</Stack>
<Stack gap="1" align="end">
<Heading :level="2">{{ totalCost }}</Heading>
<Text tone="muted">{{ $t('supplier.final_cost') }}</Text>
</Stack>
</Stack>
<Stack direction="row" gap="3" wrap>
<Button variant="outline" :as="'NuxtLink'" :to="localePath('/search')">
{{ $t('supplier.back_to_suppliers') }}
</Button>
<Button fullWidth>
{{ $t('supplier.place_order') }}
</Button>
</Stack>
</Card>
</Stack>
</Section>
</template>
<script setup>
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const selectedServices = ref({
logistics: null,
financing: null,
insurance: null,
laboratory: null
})
const logisticsCompanies = [
{
id: 1,
name: "RosLogistic",
price: 15000,
description: "National transportation network",
rating: 4.8,
reviews: 156,
experience: "15 years of experience",
verified: true
},
{
id: 2,
name: "TransService",
price: 12000,
description: "Regional carrier",
rating: 4.5,
reviews: 89,
experience: "8 years of experience",
verified: false
}
]
const banks = [
{
id: 1,
name: "VTB",
rate: 12.5,
terms: "Loan for raw materials",
period: "up to 24 months",
rating: 4.7,
reviews: 89,
verified: true
},
{
id: 2,
name: "Sberbank",
rate: 13.2,
terms: "Trade financing",
period: "up to 18 months",
rating: 4.9,
reviews: 145,
verified: true
}
]
const insuranceCompanies = [
{
id: 1,
name: "RESO",
cost: 3500,
coverage: "Cargo insurance up to 500M ₽",
rating: 4.6,
reviews: 67,
verified: true
},
{
id: 2,
name: "Ingosstrakh",
cost: 4200,
coverage: "Full transaction insurance",
rating: 4.8,
reviews: 91,
verified: true
}
]
const laboratories = [
{
id: 1,
name: "MetalTest",
price: 8000,
tests: "Chemical analysis, mechanical properties",
certification: "ISO 9001",
rating: 4.7,
reviews: 45,
verified: true
},
{
id: 2,
name: "QualityControl",
price: 6500,
tests: "Basic quality tests",
certification: "GOST R",
rating: 4.4,
reviews: 32,
verified: false
}
]
const supplier = computed(() => ({
id: route.params.id,
name: "UralMetal",
location: "Yekaterinburg",
totalPrice: 180000,
selectedLogistics: { route: "Auto delivery", time: "5-7 days" }
}))
const totalCost = computed(() => {
let total = supplier.value.totalPrice
if (selectedServices.value.logistics) {
const logistics = logisticsCompanies.find(l => l.id === selectedServices.value.logistics)
total += logistics?.price || 0
}
if (selectedServices.value.insurance) {
const insurance = insuranceCompanies.find(i => i.id === selectedServices.value.insurance)
total += insurance?.cost || 0
}
if (selectedServices.value.laboratory) {
const lab = laboratories.find(l => l.id === selectedServices.value.laboratory)
total += lab?.price || 0
}
return total
})
</script>

81
app/pages/test-gantt.vue Normal file
View File

@@ -0,0 +1,81 @@
<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<Heading :level="1">Test Timeline Chart</Heading>
<Card>
<Stack gap="4">
<Heading :level="3" weight="semibold">Hello World vue-timeline-chart</Heading>
<ClientOnly>
<Timeline
:groups="timelineGroups"
:items="timelineItems"
:viewportMin="Date.parse('2024-08-01')"
:viewportMax="Date.parse('2024-08-31')"
style="height: 400px;"
>
<template #group-label="{ group }">
<Text weight="semibold">{{ group.label }}</Text>
</template>
<template #item="{ item }">
<Pill variant="primary">{{ item.label }}</Pill>
</template>
</Timeline>
<template #fallback>
<Card tone="muted" padding="lg" style="height: 384px;">
<Stack align="center" justify="center" gap="2" fullHeight>
<Text tone="muted">Loading...</Text>
</Stack>
</Card>
</template>
</ClientOnly>
</Stack>
</Card>
<Card tone="muted" padding="lg">
<Stack gap="2">
<Heading :level="4" weight="semibold">Debug info:</Heading>
<Text tag="pre" mono size="base">{{ debugInfo }}</Text>
</Stack>
</Card>
</Stack>
</Section>
</template>
<script setup>
const timelineGroups = [
{ id: 'group1', label: 'Stage 1' },
{ id: 'group2', label: 'Stage 2' }
]
const timelineItems = [
{
id: 'item1',
group: 'group1',
start: Date.parse('2024-08-01'),
end: Date.parse('2024-08-10'),
label: 'Task 1'
},
{
id: 'item2',
group: 'group2',
start: Date.parse('2024-08-05'),
end: Date.parse('2024-08-15'),
label: 'Task 2'
}
]
const debugInfo = computed(() =>
JSON.stringify(
{
groups: timelineGroups,
items: timelineItems,
timestamp: new Date().toISOString(),
},
null,
2
)
)
</script>

1318
app/pages/test-map.vue Normal file

File diff suppressed because it is too large Load Diff