Compare commits

..

116 Commits

Author SHA1 Message Date
Ruslan Bakiev
29c34a048a fix: migrate geo GraphQL queries and frontend to camelCase
All checks were successful
Build Docker Image / build (push) Successful in 5m0s
Geo backend was migrated to camelCase but frontend .graphql files and
component code still used snake_case, causing 400 errors on all geo API calls.
2026-03-10 14:10:23 +07:00
Ruslan Bakiev
4467d20160 fix: remove overflow-hidden from header, add mx-auto wrapper like logistics
All checks were successful
Build Docker Image / build (push) Successful in 6m25s
2026-03-10 13:57:16 +07:00
Ruslan Bakiev
2e9ce856f2 fix: use exact logistics header-glass classes instead of custom liquid-header
All checks were successful
Build Docker Image / build (push) Successful in 5m42s
2026-03-10 12:01:14 +07:00
Ruslan Bakiev
1c8c81a54e fix: make header glass backdrop more visible over map backgrounds
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-03-10 11:59:27 +07:00
Ruslan Bakiev
61a37040d6 feat: step-by-step quote flow like logistics project
All checks were successful
Build Docker Image / build (push) Successful in 5m11s
New pages: /catalog/product → /catalog/destination → /catalog/quantity → /catalog/results
Each step has fullscreen map + white bottom sheet card (rounded-t-3xl).
Header capsule in quote mode now navigates between steps.
i18n keys added for step titles (en/ru).
2026-03-10 11:52:35 +07:00
Ruslan Bakiev
055d682167 feat: adopt Apple-style glassmorphism UI from logistics project
All checks were successful
Build Docker Image / build (push) Successful in 5m41s
Three-tier glass system (glass-underlay, glass-capsule, glass-chip),
pill-glass capsules with inner shine for header nav pills,
two-layer header backdrop with fade mask, solid white left panel
and juicy rounded-t-3xl bottom sheet for map interactions,
bold/black headings throughout.
2026-03-10 11:37:47 +07:00
Ruslan Bakiev
24398ad918 fix: correct Dokploy webhook URL and token
All checks were successful
Build Docker Image / build (push) Successful in 6m2s
2026-03-10 11:06:50 +07:00
Ruslan Bakiev
37c9419155 ci: trigger rebuild
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-03-10 11:01:56 +07:00
Ruslan Bakiev
fea81b43b8 remove: Sentry integration, fix connectToDevTools deprecation
All checks were successful
Build Docker Image / build (push) Successful in 5m27s
2026-03-10 10:24:15 +07:00
Ruslan Bakiev
25f946b293 Fix geo GraphQL schema mismatch: camelCase → snake_case
All checks were successful
Build Docker Image / build (push) Successful in 5m46s
All geo .graphql operations and consuming code updated to match
server schema which uses snake_case field/argument names.
Removed non-existent QuoteCalculations query, using NearestOffers instead.
2026-03-09 21:45:57 +07:00
Ruslan Bakiev
15563991df Fix SSL cert error in Dokploy webhook call
All checks were successful
Build Docker Image / build (push) Successful in 5m40s
2026-03-09 14:50:59 +07:00
Ruslan Bakiev
5982838ebd Remove build-time secrets, load NUXT_PUBLIC vars at runtime from Vault
Some checks failed
Build Docker Image / build (push) Failing after 5m36s
2026-03-09 14:41:06 +07:00
Ruslan Bakiev
84e857ffc1 Migrate from Infisical to Vault for secret loading
Some checks failed
Build Docker Image / build (push) Failing after 34s
2026-03-09 14:30:10 +07:00
Ruslan Bakiev
e4d6c9ce81 feat(ui): refresh glass header and map bottom sheets
All checks were successful
Build Docker Image / build (push) Successful in 5m23s
2026-03-08 08:56:58 +07:00
Ruslan Bakiev
4001756c3c Open info on map click without pinning
All checks were successful
Build Docker Image / build (push) Successful in 4m47s
2026-02-07 18:23:01 +07:00
Ruslan Bakiev
85913a760d Fix main navigation markup
All checks were successful
Build Docker Image / build (push) Successful in 4m53s
2026-02-07 17:39:07 +07:00
Ruslan Bakiev
bef34eeaa5 Move AI button to logo and add left chat sidebar
Some checks failed
Build Docker Image / build (push) Failing after 1m50s
2026-02-07 16:57:05 +07:00
Ruslan Bakiev
8ff44c42bc Keep view when closing select
All checks were successful
Build Docker Image / build (push) Successful in 4m59s
2026-02-07 16:40:30 +07:00
Ruslan Bakiev
3f92b3876d Show pin on hover in lists
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-07 16:37:02 +07:00
Ruslan Bakiev
a73a801a1d Make pins explicit and selection open info
All checks were successful
Build Docker Image / build (push) Successful in 5m24s
2026-02-07 13:56:36 +07:00
Ruslan Bakiev
2d54dc3283 Raise hubs/suppliers list page size to 500
All checks were successful
Build Docker Image / build (push) Successful in 4m58s
2026-02-07 13:34:07 +07:00
Ruslan Bakiev
d36409df57 Refetch hubs/suppliers on product filter
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-07 13:29:17 +07:00
Ruslan Bakiev
87d3d5b1a7 Force offers view when hub or supplier selected
All checks were successful
Build Docker Image / build (push) Successful in 4m59s
2026-02-07 13:18:36 +07:00
Ruslan Bakiev
1c033a55b4 Update geo GraphQL generated types
All checks were successful
Build Docker Image / build (push) Successful in 4m54s
2026-02-07 13:11:27 +07:00
Ruslan Bakiev
49f2c237b7 Use graph offers on map when hub filtered
All checks were successful
Build Docker Image / build (push) Successful in 5m31s
2026-02-07 13:04:22 +07:00
Ruslan Bakiev
6b9935e8e8 Align supplier map with product filter list
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-07 13:00:53 +07:00
Ruslan Bakiev
38081a5cb0 Use list data for hub map when product filtered
All checks were successful
Build Docker Image / build (push) Successful in 5m35s
2026-02-07 12:42:55 +07:00
Ruslan Bakiev
481a38b3a1 Keep select param on navigation and toggles
All checks were successful
Build Docker Image / build (push) Successful in 5m5s
2026-02-07 12:14:00 +07:00
Ruslan Bakiev
1f60062d15 Revert "Auto-open selection in Explore"
Some checks failed
Build Docker Image / build (push) Has been cancelled
This reverts commit 74dd220104.
2026-02-07 12:09:12 +07:00
Ruslan Bakiev
74dd220104 Auto-open selection in Explore
All checks were successful
Build Docker Image / build (push) Successful in 5m0s
2026-02-07 12:00:10 +07:00
Ruslan Bakiev
c0466c7234 Update geo GraphQL generated types
All checks were successful
Build Docker Image / build (push) Successful in 5m23s
2026-02-07 11:08:15 +07:00
Ruslan Bakiev
2fb34f664f Use graph-based offers and remove radius filters
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-07 11:06:00 +07:00
Ruslan Bakiev
28eff7c323 feat(catalog): pad map fit and graph hubs filter
All checks were successful
Build Docker Image / build (push) Successful in 5m4s
2026-02-07 10:18:07 +07:00
Ruslan Bakiev
589a74d75e fix(catalog): restore hover pin actions
All checks were successful
Build Docker Image / build (push) Successful in 4m56s
2026-02-07 09:55:22 +07:00
Ruslan Bakiev
1fa4a707ad fix(catalog): remove full-screen loading flash
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-07 09:50:41 +07:00
Ruslan Bakiev
f85b1504e2 fix(ui): adjust search checkbox and round toggles
All checks were successful
Build Docker Image / build (push) Successful in 4m52s
2026-02-07 09:41:58 +07:00
Ruslan Bakiev
34fc1bfab6 fix(home): keep nav static and shift hero input
All checks were successful
Build Docker Image / build (push) Successful in 5m3s
2026-02-07 09:30:27 +07:00
Ruslan Bakiev
755a92d194 feat(catalog): filter map clusters by chips
All checks were successful
Build Docker Image / build (push) Successful in 5m1s
2026-02-07 08:35:22 +07:00
Ruslan Bakiev
aa7790f45e feat(catalog): focus map during quote search
All checks were successful
Build Docker Image / build (push) Successful in 4m47s
2026-02-06 20:07:43 +07:00
Ruslan Bakiev
2d85e7187e chore(codegen): refresh geo graphql types
All checks were successful
Build Docker Image / build (push) Successful in 4m56s
2026-02-06 19:48:45 +07:00
Ruslan Bakiev
795aa0381e Fallback to nearest offers when calculations unavailable
Some checks failed
Build Docker Image / build (push) Failing after 24s
2026-02-06 19:12:48 +07:00
Ruslan Bakiev
c5d1dc87ae Clear quantity when switching to explore
Some checks failed
Build Docker Image / build (push) Failing after 20s
2026-02-06 19:10:17 +07:00
Ruslan Bakiev
2939482fc3 Add quote calculations support
Some checks failed
Build Docker Image / build (push) Failing after 23s
2026-02-06 19:07:20 +07:00
Ruslan Bakiev
1287ae9db7 Group quote results by calculation
Some checks failed
Build Docker Image / build (push) Failing after 4m9s
2026-02-06 18:44:00 +07:00
Ruslan Bakiev
87133ed37a Use geo offers for quote results
All checks were successful
Build Docker Image / build (push) Successful in 5m9s
2026-02-06 18:25:44 +07:00
Ruslan Bakiev
0453aeae07 Revert "Auto-trigger quote search on prefilled URLs"
Some checks failed
Build Docker Image / build (push) Has been cancelled
This reverts commit d877eff212.
2026-02-06 18:21:59 +07:00
Ruslan Bakiev
d877eff212 Auto-trigger quote search on prefilled URLs
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-06 18:21:09 +07:00
Ruslan Bakiev
269d801493 Resolve supplier names for offer cards
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
2026-02-06 18:09:29 +07:00
Ruslan Bakiev
85457a34d5 Make ETA and pricing more realistic
All checks were successful
Build Docker Image / build (push) Successful in 4m36s
2026-02-06 17:57:23 +07:00
Ruslan Bakiev
675f46a75e Enrich offer card origin, price, and duration
All checks were successful
Build Docker Image / build (push) Successful in 4m40s
2026-02-06 17:37:58 +07:00
Ruslan Bakiev
e4f81dba7c Redesign offer result card layout
All checks were successful
Build Docker Image / build (push) Successful in 4m53s
2026-02-06 17:07:38 +07:00
Ruslan Bakiev
b971391fd7 Add hover pin to info panel cards
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
2026-02-06 16:52:48 +07:00
Ruslan Bakiev
8c1827fab6 Adjust capsule dividers height
All checks were successful
Build Docker Image / build (push) Successful in 4m45s
2026-02-06 16:37:17 +07:00
Ruslan Bakiev
eb31b8299b Refine glass UI capsules and hub card
All checks were successful
Build Docker Image / build (push) Successful in 4m43s
2026-02-06 16:28:00 +07:00
Ruslan Bakiev
981500ec5d Soften glass gradient and round capsules
All checks were successful
Build Docker Image / build (push) Successful in 4m57s
2026-02-06 16:09:00 +07:00
Ruslan Bakiev
ca7c6fa8a5 Refine top bar glass layout
All checks were successful
Build Docker Image / build (push) Successful in 4m38s
2026-02-06 15:40:33 +07:00
Ruslan Bakiev
4585d30d53 Tweak hub distance compass styling
All checks were successful
Build Docker Image / build (push) Successful in 4m43s
2026-02-06 15:35:12 +07:00
Ruslan Bakiev
f80164c912 Pin selection items to global filters
All checks were successful
Build Docker Image / build (push) Successful in 4m29s
2026-02-06 15:30:31 +07:00
Ruslan Bakiev
f0c687c3ff Improve selection panel and hub card compass
All checks were successful
Build Docker Image / build (push) Successful in 4m44s
2026-02-06 15:21:24 +07:00
Ruslan Bakiev
fa0465fabb Auto-scope selection to current map bounds
All checks were successful
Build Docker Image / build (push) Successful in 4m36s
2026-02-06 14:37:37 +07:00
Ruslan Bakiev
161a1426e4 Sync selection list with map view toggle
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-06 14:33:54 +07:00
Ruslan Bakiev
a3e7c92915 Clear inactive clusters on view switch
All checks were successful
Build Docker Image / build (push) Successful in 4m27s
2026-02-06 14:06:37 +07:00
Ruslan Bakiev
1e761ca2a8 Drive map markers by data, not visibility
All checks were successful
Build Docker Image / build (push) Successful in 5m20s
2026-02-06 11:23:56 +07:00
Ruslan Bakiev
4bdefc9ce9 Render map points by entity type
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-06 11:19:48 +07:00
Ruslan Bakiev
fb29c2a4f6 Skip Sentry import when disabled
All checks were successful
Build Docker Image / build (push) Successful in 4m33s
2026-02-06 10:47:27 +07:00
Ruslan Bakiev
d262928a09 Disable Sentry module in low-memory builds
All checks were successful
Build Docker Image / build (push) Successful in 5m7s
2026-02-06 10:01:35 +07:00
Ruslan Bakiev
b76c7fce94 Make build Node options configurable
Some checks failed
Build Docker Image / build (push) Failing after 3m21s
2026-02-06 09:52:11 +07:00
Ruslan Bakiev
666423bcf4 Allow disabling minify to reduce build memory
Some checks failed
Build Docker Image / build (push) Failing after 3m36s
2026-02-06 09:40:40 +07:00
Ruslan Bakiev
cf081e7e67 Reduce build memory by disabling sourcemaps in CI
Some checks failed
Build Docker Image / build (push) Failing after 3m27s
2026-02-06 09:32:12 +07:00
Ruslan Bakiev
05c91ca352 Show supplier/origin in offer cards
Some checks failed
Build Docker Image / build (push) Failing after 7m43s
2026-02-05 20:21:36 +07:00
Ruslan Bakiev
adf2a7765c Render hub groups as two-column metro tiles
Some checks failed
Build Docker Image / build (push) Failing after 3m0s
2026-02-05 19:41:51 +07:00
Ruslan Bakiev
4669911162 Group hubs by rail/sea in info panel
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-02-05 19:37:51 +07:00
Ruslan Bakiev
71a27a4ab9 Improve nearest hubs layout and show distance
All checks were successful
Build Docker Image / build (push) Successful in 4m49s
2026-02-05 19:24:03 +07:00
Ruslan Bakiev
0f0b1db394 Unify offers UI to OfferResultCard and require price
All checks were successful
Build Docker Image / build (push) Successful in 5m59s
2026-02-05 19:12:39 +07:00
Ruslan Bakiev
beb02bd3fc Use offer result cards in catalog and compute routes for supplier offers
All checks were successful
Build Docker Image / build (push) Successful in 5m50s
2026-02-05 19:02:26 +07:00
Ruslan Bakiev
f1eb7bc746 Use graph-based nearest hubs
All checks were successful
Build Docker Image / build (push) Successful in 6m46s
2026-02-05 18:41:14 +07:00
Ruslan Bakiev
2fc4faaa83 Pin language toggle above hero
All checks were successful
Build Docker Image / build (push) Successful in 4m51s
2026-02-04 15:32:09 +07:00
Ruslan Bakiev
9c19d08cf5 Fix whitepaper language toggle and CTA
All checks were successful
Build Docker Image / build (push) Successful in 4m52s
2026-02-04 14:54:30 +07:00
Ruslan Bakiev
bd2a063e39 Restyle whitepaper to match site UI
All checks were successful
Build Docker Image / build (push) Successful in 4m43s
2026-02-04 14:38:23 +07:00
Ruslan Bakiev
2a8ef4b7dc Add whitepaper page
All checks were successful
Build Docker Image / build (push) Successful in 6m42s
2026-02-04 13:54:24 +07:00
Ruslan Bakiev
8a2a804c58 Add AddressDetailBottomSheet with same UX as orders
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
- Panel slides left when address is selected
- Bottom sheet slides up with address details
- Shows location, map preview, edit/delete actions
2026-01-29 21:00:18 +07:00
Ruslan Bakiev
0a63d4b0b2 Fix order detail behavior: panel hides when order selected
All checks were successful
Build Docker Image / build (push) Successful in 4m12s
- Changed show-panel to "!selectedOrderId" - panel slides left when order is clicked
- OrderDetailBottomSheet now matches KycBottomSheet structure (full screen, same z-index)
2026-01-29 20:47:24 +07:00
Ruslan Bakiev
532b9ce78d Fix OrderDetailBottomSheet backdrop to not overlap panel
All checks were successful
Build Docker Image / build (push) Successful in 4m22s
Restructured z-index layering:
- Parent container: fixed inset-0 z-40 with pointer-events-none
- Backdrop: only covers map area (lg:left-96 on desktop)
- Sheet content: z-50, positioned above backdrop
2026-01-29 20:22:22 +07:00
Ruslan Bakiev
a244589fe5 fix(orders): bottom sheet doesn't cover side panel on desktop
All checks were successful
Build Docker Image / build (push) Successful in 4m7s
2026-01-29 19:57:29 +07:00
Ruslan Bakiev
1850d255a7 feat(orders): open order details in bottom sheet (no page transition)
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Created OrderDetailBottomSheet.vue component (like KycBottomSheet)
- Click on order in list opens bottom sheet instead of navigating
- Slide-up animation with backdrop
- Click backdrop or X to close
2026-01-29 19:49:21 +07:00
Ruslan Bakiev
de3ec4c39d feat(orders): add slide-up animation for order detail bottom sheet
All checks were successful
Build Docker Image / build (push) Successful in 4m29s
2026-01-29 19:43:56 +07:00
Ruslan Bakiev
71e69a7abc i18n: add landing page translations (en/ru)
All checks were successful
Build Docker Image / build (push) Successful in 4m0s
Added/updated translations for:
- howto: step labels
- stats: suppliers desc, countries
- testimonial: quote, author, role
- roles: subtitle, updated benefits
- cta: register, demo buttons
- footer: all countries, offices, products, services, legal
2026-01-29 19:07:23 +07:00
Ruslan Bakiev
d5aa47c323 fix(orders): use side panel for list, bottom sheet for detail
All checks were successful
Build Docker Image / build (push) Successful in 3m56s
- orders/index.vue: Reverted to side panel pattern for map interaction
- orders/[id].vue: Converted to CatalogPage + bottom sheet pattern

List page uses #panel slot to interact with map markers.
Detail page uses fixed bottom sheet (70vh) with glass styling.
2026-01-29 18:56:31 +07:00
Ruslan Bakiev
d227325d1a Fix homepage: remove spacer, full-width sections with negative margin
All checks were successful
Build Docker Image / build (push) Successful in 4m5s
2026-01-29 18:51:19 +07:00
Ruslan Bakiev
bd7a1d1b4b Homepage: magazine layout with text blocks, quotes, spacing
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Added spacer after hero nav
- Section headers with decorative lines
- Mixed photo cards with text-only blocks
- Full-width testimonial/quote section
- Asymmetric grid layouts
- Dashed border stats card for variety
- Gradient CTA section at bottom
- Better visual rhythm and breathing room
2026-01-29 16:54:00 +07:00
Ruslan Bakiev
3a46cfc5dc Homepage: bento magazine layout + dark footer with offices by continent
All checks were successful
Build Docker Image / build (push) Successful in 4m6s
- Bento grid with varied card sizes (8-col hero, 4-col stats, 3-col roles)
- Stats cards with gradient backgrounds (500+ suppliers, 24/7 support)
- Dark footer with countries by continent (Europe, CIS, Asia, Americas)
- Office locations (Dubai HQ, Amsterdam, Moscow)
- Quick links for products, services, company, legal
- Security badges in bottom bar
2026-01-29 16:42:23 +07:00
Ruslan Bakiev
f4afd362eb Rewrite team/profile/orders pages with bottom sheet (not side panel)
All checks were successful
Build Docker Image / build (push) Successful in 4m9s
- All three pages now use CatalogPage + bottom sheet from bottom (70vh)
- Glass style: bg-black/40 backdrop-blur-xl rounded-t-2xl
- Drag handle at top
- Two-column grid layout for team/profile
- Orders list with search and filter
- Map visible in background
2026-01-29 16:34:58 +07:00
Ruslan Bakiev
5a780707dc Homepage Photo Glass Cards - unified visual style
All checks were successful
Build Docker Image / build (push) Successful in 4m12s
- Add 6 promo images from Unsplash (wheat, trucks, warehouse, farmer, etc.)
- Replace plain white cards with Photo Glass Cards
- Gradient overlay + backdrop blur for glass effect
- Hover animation (scale 105%)
- Colored accent icons (violet, cyan, rose)
- Matches animated hero visual style
2026-01-29 16:29:57 +07:00
Ruslan Bakiev
886415344d Glass nav fix + team/profile pages with CatalogPage layout
All checks were successful
Build Docker Image / build (push) Successful in 4m20s
- Fix glass nav on clientarea pages (add isClientArea to is-collapsed)
- Rewrite team page using CatalogPage with glass panel
- Rewrite profile page using CatalogPage with glass panel
2026-01-29 16:18:33 +07:00
Ruslan Bakiev
6ee8c12e6f Role switcher: dropdown menu with chevron icon
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
2026-01-29 16:07:41 +07:00
Ruslan Bakiev
bc037e85a4 Simplify role switcher: text + single swap icon
All checks were successful
Build Docker Image / build (push) Successful in 4m23s
2026-01-29 16:01:46 +07:00
Ruslan Bakiev
72f2e1c39d Refactor role switcher: single item with arrows on right
All checks were successful
Build Docker Image / build (push) Successful in 4m44s
- Remove separate "Кабинет" link and two role buttons
- Add single role switcher: "Я клиент <>" format
- Arrows <> shown only when user has both roles
- Click text → navigate to cabinet, click arrows → switch role
2026-01-29 15:44:52 +07:00
Ruslan Bakiev
3d5215d967 Add role switcher (Client/Seller) in navigation menu
All checks were successful
Build Docker Image / build (push) Successful in 4m20s
- Add role switcher buttons after Explore/Quote/Кабинет with separator
- Dynamic center tabs based on role: BUYER shows orders/addresses, SELLER shows offers
- Redirect to appropriate page when switching role in client area
- Add localization for roles.client and roles.seller
2026-01-29 13:53:03 +07:00
Ruslan Bakiev
33c406995f fix(catalog): align QuotePanel style with SelectionPanel/InfoPanel
All checks were successful
Build Docker Image / build (push) Successful in 5m2s
2026-01-28 09:29:17 +07:00
Ruslan Bakiev
209d81ec61 fix(clientarea): correct panel widths - orders w-1/2, addresses w-96
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-28 09:27:58 +07:00
Ruslan Bakiev
984daa7a84 refactor(clientarea): use CatalogPage with #panel slot for orders/addresses
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
- Add panelWidth and hideViewToggle props to CatalogPage
- Update orders page to use CatalogPage with narrow panel (w-50)
- Update addresses page to use CatalogPage with narrow panel (w-50)
- Remove unused ClientAreaMapPage component
2026-01-28 09:19:01 +07:00
Ruslan Bakiev
63e8d47b79 feat(clientarea): modernize orders and addresses pages with new map layout
All checks were successful
Build Docker Image / build (push) Successful in 6m13s
- Create ClientAreaMapPage component for client area pages with glass effect
- Update orders page to use new ClientAreaMapPage with filter dropdown
- Update addresses page to use new ClientAreaMapPage with add button
- Remove Profile and Team tabs from MainNavigation (already in user menu)
2026-01-28 09:11:18 +07:00
Ruslan Bakiev
f5b95c27ef fix(nav): move Cabinet to nav links next to Explore/Quote
All checks were successful
Build Docker Image / build (push) Successful in 5m19s
2026-01-28 05:30:18 +07:00
Ruslan Bakiev
8b0e1900d1 feat(nav): client area tabs in main navigation
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Add Cabinet button to header (dashboard icon)
- When in /clientarea/* show tabs instead of search input
- Tabs: Заказы | Предложения (SELLER only) | Адреса | Профиль | Команда
- Hide Explore/Quote toggle in client area
- Remove SubNavigation for clientarea (tabs moved to MainNavigation)
2026-01-28 05:28:16 +07:00
Ruslan Bakiev
45acef9b20 feat(catalog): KYC bottom sheet instead of separate page
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
- Add KycBottomSheet component with glass effect (70vh height)
- Animate sheet sliding up from bottom when opening KYC
- InfoPanel hides when KYC sheet is open
- Click outside or X button to close
- Contains all company info: реквизиты, руководство, учредители, контакты, финансы, арбитраж, ОКВЭД
2026-01-28 05:04:20 +07:00
Ruslan Bakiev
1f996d27e5 feat(kyc): comprehensive demo profile page with full business data
All checks were successful
Build Docker Image / build (push) Successful in 4m14s
- Реквизиты: ИНН, КПП, ОГРН, ОКПО, дата регистрации
- Руководство с ФИО и должностью
- Учредители с долями владения и уставным капиталом
- Контакты: адрес, телефон, email, сайт
- Финансовые показатели: выручка, прибыль, активы, сотрудники
- Арбитражные дела: истец/ответчик с суммами
- Виды деятельности (ОКВЭД) с основным и дополнительными
- История изменений в виде timeline
2026-01-27 20:35:32 +07:00
Ruslan Bakiev
02419abdd1 feat(kyc): add demo KYC profile page with mock data
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Always show 'view full profile' button with fallback to demo UUID
- KYC page shows mock data when demo UUID is used
- Add i18n translations for demo page
2026-01-27 20:16:30 +07:00
Ruslan Bakiev
7066c51505 fix(catalog): only show KYC full profile button when kycProfileUuid exists
All checks were successful
Build Docker Image / build (push) Successful in 4m6s
2026-01-27 20:10:40 +07:00
Ruslan Bakiev
88d78e9662 feat(catalog): add offers section to InfoPanel after product selection
All checks were successful
Build Docker Image / build (push) Successful in 4m33s
- Hide products section when product is selected
- Show offers section with OfferCard components
- Add cancel button to return to product list
- Wire up select-offer event to navigate to offer detail
2026-01-27 15:24:53 +07:00
Ruslan Bakiev
3f7b83bb6d feat(kyc): add KYC profile page, navigate instead of modal
All checks were successful
Build Docker Image / build (push) Successful in 4m17s
2026-01-27 12:51:08 +07:00
Ruslan Bakiev
b5534d1fd5 feat(catalog): add KYC profile modal on click
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-27 12:49:19 +07:00
Ruslan Bakiev
7f8a148aa7 feat(catalog): add KYC teaser section to supplier InfoPanel
All checks were successful
Build Docker Image / build (push) Successful in 4m6s
- Add KYC teaser info section (companyType, registrationYear, status, sourcesCount)
- Use mock data for now (backend integration TBD)
- Add 'open-kyc' emit for full profile navigation
- Add kycProfileUuid field to InfoEntity type
- Add i18n translations for both RU and EN
2026-01-27 12:30:00 +07:00
Ruslan Bakiev
f269c0daf0 Fix camera jumping when opening InfoPanel
All checks were successful
Build Docker Image / build (push) Successful in 4m17s
Replace setTimeout/debounce with event-based approach:
- Add isInfoLoading computed that tracks all info loading states
- Pass infoLoading prop through CatalogPage to CatalogMap
- Watch infoLoading transition from true->false to trigger fitBounds
- Remove setTimeout hack in favor of proper loading state detection
2026-01-27 12:25:15 +07:00
Ruslan Bakiev
497a80f0c6 Fix camera jumping - debounce fitBounds when points load
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-27 12:21:35 +07:00
Ruslan Bakiev
5aa460fd8a Fix supplier link - show name instead of 'View supplier'
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-27 12:20:06 +07:00
Ruslan Bakiev
805b6795f0 Fix InfoPanel - show supplier name as text, remove button
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Remove "View supplier" button, show name as plain text
- Add fallback translation for unknown supplier
2026-01-27 12:15:44 +07:00
84 changed files with 6203 additions and 1755 deletions

View File

@@ -26,12 +26,6 @@ jobs:
context: .
push: true
tags: gitea.dsrptlab.com/optovia/webapp/webapp:latest
build-args: |
INFISICAL_API_URL=${{ secrets.INFISICAL_API_URL }}
INFISICAL_CLIENT_ID=${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET=${{ secrets.INFISICAL_CLIENT_SECRET }}
INFISICAL_PROJECT_ID=${{ secrets.INFISICAL_PROJECT_ID }}
INFISICAL_ENV=prod
- name: Deploy to Dokploy
run: curl -X POST "https://dokploy.optovia.ru/api/deploy/0_iNAXPDx28BLZIddGTzB"
run: curl -k -X POST "https://dokploy.dsrptlab.com/api/deploy/3zjbiuDvfDQ435HvMUAG8"

View File

@@ -2,28 +2,21 @@ FROM node:22-slim AS build
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
ENV NODE_OPTIONS=--max-old-space-size=2048
ENV NUXT_SOURCEMAP=false
ENV NUXT_MINIFY=false
ENV SENTRY_ENABLED=false
ENV NUXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN corepack enable
ARG INFISICAL_API_URL
ARG INFISICAL_CLIENT_ID
ARG INFISICAL_CLIENT_SECRET
ARG INFISICAL_PROJECT_ID
ARG INFISICAL_ENV
ENV INFISICAL_API_URL=$INFISICAL_API_URL \
INFISICAL_CLIENT_ID=$INFISICAL_CLIENT_ID \
INFISICAL_CLIENT_SECRET=$INFISICAL_CLIENT_SECRET \
INFISICAL_PROJECT_ID=$INFISICAL_PROJECT_ID \
INFISICAL_ENV=$INFISICAL_ENV
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
RUN pnpm run build
FROM node:22-slim
@@ -41,4 +34,4 @@ COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs"]
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && if [ \"$SENTRY_ENABLED\" = \"false\" ] || [ ! -f ./.output/server/sentry.server.config.mjs ]; then node .output/server/index.mjs; else node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs; fi"]

View File

@@ -36,6 +36,104 @@
--noise: 0;
}
@layer components {
/* ── Three-tier glass system (Apple-style glassmorphism) ── */
/* Tier 1 — lightest underlay, large panels / sidebars */
.glass-underlay {
background: rgba(255, 255, 255, 0.34);
box-shadow:
0 16px 44px rgba(24, 20, 12, 0.11),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
/* Tier 2 — medium capsule, nav pills / search bar */
.glass-capsule {
background: rgba(255, 255, 255, 0.56);
box-shadow:
0 8px 24px rgba(24, 20, 12, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.56);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
/* Tier 3 — densest chip, small tags / badges */
.glass-chip {
background: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Legacy aliases — keep backward compat during transition */
.glass-soft {
background: rgba(255, 255, 255, 0.34);
box-shadow:
0 16px 44px rgba(24, 20, 12, 0.11),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.glass-bright {
background: rgba(255, 255, 255, 0.56);
box-shadow:
0 8px 24px rgba(24, 20, 12, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.56);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
}
/* ── Header glass: two-layer Apple-style glassmorphism ── */
.header-glass {
background: transparent;
}
/* Layer 1: frosted bar backdrop — fades to transparent at bottom */
.header-glass-backdrop {
position: absolute;
inset: 0;
height: 350%;
background: rgba(255, 255, 255, 0.06);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
pointer-events: none;
z-index: 0;
}
/* Layer 2: capsule pills — denser frosted glass with inner shine */
.pill-glass {
position: relative;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.16);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow:
0 8px 32px rgba(31, 38, 135, 0.2),
inset 0 4px 20px rgba(255, 255, 255, 0.3);
}
/* Inner shine highlight — liquid glass refraction */
.pill-glass::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(255, 255, 255, 0.1);
box-shadow:
inset -10px -8px 0 -11px rgba(255, 255, 255, 1),
inset 0 -9px 0 -8px rgba(255, 255, 255, 1);
opacity: 0.6;
filter: blur(1px) brightness(115%);
pointer-events: none;
}
@plugin "daisyui/theme" {
name: "silk";
default: false;

View File

@@ -20,12 +20,15 @@
<OfferResultCard
v-for="(option, index) in productRouteOptions"
:key="option.sourceUuid ?? index"
:supplier-name="getSupplierName(option.sourceUuid)"
:location-name="getOfferData(option.sourceUuid)?.locationName"
:product-name="productName"
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
:quantity="getOfferData(option.sourceUuid)?.quantity"
:currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit"
:stages="getRouteStages(option)"
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
@select="navigateToOffer(option.sourceUuid)"
/>
@@ -82,7 +85,7 @@ interface RoutePathType {
stages?: (RouteStage | null)[]
}
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
import type { OfferWithRouteType, RouteStageType } from '~/composables/graphql/public/geo-generated'
import type { OfferWithRoute, RouteStage } from '~/composables/graphql/public/geo-generated'
const route = useRoute()
const localePath = useLocalePath()
@@ -153,7 +156,7 @@ const fetchOffersByHub = async () => {
// Offers already include routes from backend
const offersWithRoutes = offers
.filter((offer): offer is NonNullable<OfferWithRouteType> => offer !== null)
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
.map((offer) => ({
sourceUuid: offer.uuid,
sourceName: offer.productName,
@@ -204,10 +207,12 @@ const getRouteStages = (option: ProductRouteOption) => {
const route = option.routes?.[0]
if (!route?.stages) return []
return route.stages
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
.map((stage) => ({
transportType: stage.transportType,
distanceKm: stage.distanceKm
distanceKm: stage.distanceKm,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
}))
}
@@ -226,6 +231,14 @@ const getKycProfileUuid = (offerUuid?: string | null) => {
return supplier?.kycProfileUuid || null
}
const getSupplierName = (offerUuid?: string | null) => {
if (!offerUuid) return null
const offer = offersData.value.get(offerUuid)
if (!offer?.teamUuid) return null
const supplier = suppliersData.value.get(offer.teamUuid)
return supplier?.name || null
}
// Navigate to offer detail page
const navigateToOffer = (offerUuid?: string | null) => {
if (!offerUuid) return

View File

@@ -3,7 +3,7 @@
<!-- Header with back button -->
<div class="p-4 border-b border-base-300">
<NuxtLink
:to="localePath('/catalog')"
:to="localePath('/catalog?select=product')"
class="btn btn-sm btn-ghost gap-2"
>
<Icon name="lucide:arrow-left" size="18" />
@@ -81,15 +81,20 @@
<!-- Offers Tab -->
<div v-else-if="activeTab === 'offers'" class="space-y-2">
<OfferCard
v-for="(offer, index) in offers"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:offer="offer"
selectable
:is-selected="selectedItemId === offer.uuid"
:supplier-name="offer.supplierName"
:location-name="offer.locationName || offer.locationCountry"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
@select="$emit('select', offer, 'offer')"
/>
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
<div v-if="offersWithPrice.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.offers') }}
</div>
</div>
@@ -124,6 +129,7 @@ interface Offer {
productUuid?: string | null
productName?: string | null
categoryName?: string | null
supplierName?: string | null
locationUuid?: string | null
locationName?: string | null
locationCountry?: string | null
@@ -136,7 +142,7 @@ interface Offer {
validUntil?: string | null
}
defineProps<{
const props = defineProps<{
activeTab: 'hubs' | 'suppliers' | 'offers'
hubs: Hub[]
suppliers: Supplier[]
@@ -152,4 +158,8 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
</script>

View File

@@ -104,7 +104,7 @@
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
import type { Edge } from '~/composables/graphql/public/geo-generated'
interface CurrentHub {
uuid: string
@@ -119,8 +119,8 @@ interface RouteGeometry {
}
const props = defineProps<{
autoEdges: EdgeType[]
railEdges: EdgeType[]
autoEdges: Edge[]
railEdges: Edge[]
hub: CurrentHub
railHub: CurrentHub
autoRouteGeometries: RouteGeometry[]
@@ -190,7 +190,7 @@ const buildRouteFeatureCollection = (routes: RouteGeometry[], transportType: 'au
}))
})
const buildNeighborsFeatureCollection = (edges: EdgeType[], transportType: 'auto' | 'rail') => ({
const buildNeighborsFeatureCollection = (edges: Edge[], transportType: 'auto' | 'rail') => ({
type: 'FeatureCollection' as const,
features: edges
.filter(e => e.toLatitude && e.toLongitude)

View File

@@ -66,7 +66,7 @@
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl'
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
import type { Edge } from '~/composables/graphql/public/geo-generated'
interface CurrentHub {
uuid: string
@@ -81,7 +81,7 @@ interface RouteGeometry {
}
const props = defineProps<{
edges: EdgeType[]
edges: Edge[]
currentHub: CurrentHub
routeGeometries: RouteGeometry[]
transportType: 'auto' | 'rail'

View File

@@ -0,0 +1,159 @@
<template>
<aside
class="fixed top-0 left-0 bottom-0 z-50 overflow-hidden transition-[width] duration-300"
:style="{ width: open ? width : '0px' }"
aria-label="AI assistant"
>
<div
class="h-full flex flex-col bg-base-100/80 backdrop-blur-xl border-r border-white/10 shadow-xl transition-opacity duration-200"
:class="open ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<Icon name="lucide:bot" size="16" class="text-primary" />
</div>
<div class="font-semibold text-base-content">{{ $t('aiAssistants.view.agentName') }}</div>
</div>
<button
class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content"
aria-label="Close"
@click="emit('close')"
>
<Icon name="lucide:x" size="14" />
</button>
</div>
<div ref="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-3">
<div
v-for="(message, idx) in chat"
:key="idx"
class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[90%] rounded-2xl px-3 py-2 shadow-sm"
:class="message.role === 'user' ? 'bg-primary text-primary-content' : 'bg-base-100 text-base-content border border-base-300'"
>
<Text weight="semibold" class="mb-1">
{{ message.role === 'user' ? $t('aiAssistants.view.you') : $t('aiAssistants.view.agentName') }}
</Text>
<Text :tone="message.role === 'user' ? undefined : 'muted'">
{{ message.content }}
</Text>
</div>
</div>
<div v-if="isStreaming" class="text-sm text-base-content/60">
{{ $t('aiAssistants.view.typing') }}
</div>
</div>
<div class="border-t border-base-300 bg-base-100/70 p-3">
<form class="flex items-end gap-2" @submit.prevent="handleSend">
<div class="flex-1">
<Textarea
v-model="input"
:placeholder="$t('aiAssistants.view.placeholder')"
rows="2"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<Button type="submit" size="sm" :loading="isSending" :disabled="!input.trim()">
{{ $t('aiAssistants.view.send') }}
</Button>
<Button type="button" size="sm" variant="ghost" @click="resetChat" :disabled="isSending">
{{ $t('aiAssistants.view.reset') }}
</Button>
</div>
</form>
<div class="text-xs text-error text-center mt-2" v-if="error">
{{ error }}
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
const props = defineProps<{
open: boolean
width: string
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
const runtimeConfig = useRuntimeConfig()
const agentUrl = computed(() => runtimeConfig.public.langAgentUrl || '')
const chatContainer = ref<HTMLElement | null>(null)
const chat = ref<{ role: 'user' | 'assistant', content: string }[]>([
{ role: 'assistant', content: t('aiAssistants.view.welcome') }
])
const input = ref('')
const isSending = ref(false)
const isStreaming = ref(false)
const error = ref('')
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
})
}
const handleSend = async () => {
if (!input.value.trim()) return
error.value = ''
const userMessage = input.value.trim()
chat.value.push({ role: 'user', content: userMessage })
input.value = ''
isSending.value = true
isStreaming.value = true
scrollToBottom()
try {
const body = {
input: {
messages: chat.value.map((m) => ({
type: m.role === 'assistant' ? 'ai' : 'human',
content: m.content
}))
}
}
const response = await $fetch(`${agentUrl.value}/invoke`, {
method: 'POST',
body
})
const outputMessages = (response as any)?.output?.messages || []
const last = outputMessages[outputMessages.length - 1]
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
chat.value.push({ role: 'assistant', content })
scrollToBottom()
} catch (e: unknown) {
console.error('Agent error', e)
error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
scrollToBottom()
} finally {
isSending.value = false
isStreaming.value = false
}
}
const resetChat = () => {
chat.value = [{ role: 'assistant', content: t('aiAssistants.view.welcome') }]
input.value = ''
error.value = ''
}
watch(() => props.open, (isOpen) => {
if (isOpen) scrollToBottom()
})
</script>

View File

@@ -0,0 +1,178 @@
<template>
<Transition name="address-slide">
<div
v-if="isOpen && addressUuid"
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
style="height: 72vh"
>
<!-- Backdrop (clickable to close) -->
<div
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
@click="emit('close')"
/>
<!-- Sheet content -->
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
<!-- Header with drag handle and close -->
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100/90">
<div class="flex justify-center py-2">
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
</div>
<div class="flex items-center justify-between px-6 pb-4">
<template v-if="address">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success/20 text-2xl">
{{ isoToEmoji(address.countryCode) }}
</div>
<div class="min-w-0">
<div class="truncate text-xl font-black text-base-content">{{ address.name }}</div>
<div class="truncate text-sm text-base-content/60">{{ address.address }}</div>
</div>
</div>
</template>
<template v-else>
<div class="flex items-center gap-3 flex-1">
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
<div class="flex-1">
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
</div>
</div>
</template>
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
<Icon name="lucide:x" size="20" />
</button>
</div>
</div>
<!-- Content -->
<div v-if="address" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
<!-- Location info -->
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map-pin" size="18" />
<span class="text-lg font-black">{{ t('profileAddresses.detail.location') }}</span>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-start gap-2 text-base-content/80">
<Icon name="lucide:navigation" size="14" class="mt-0.5 flex-shrink-0 text-base-content/50" />
<span>{{ address.address }}</span>
</div>
<div v-if="address.latitude && address.longitude" class="flex items-center gap-2 text-base-content/60">
<Icon name="lucide:crosshair" size="14" class="text-base-content/50" />
<span class="font-mono text-xs">{{ address.latitude.toFixed(6) }}, {{ address.longitude.toFixed(6) }}</span>
</div>
</div>
</div>
<!-- Map preview -->
<div v-if="address.latitude && address.longitude" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map" size="18" />
<span class="text-lg font-black">{{ t('profileAddresses.detail.map') }}</span>
</div>
<div class="h-48 overflow-hidden rounded-xl">
<ClientOnly>
<MapboxMap
:map-id="'address-preview-' + addressUuid"
style="width: 100%; height: 100%"
:options="{
style: 'mapbox://styles/mapbox/light-v11',
center: [address.longitude, address.latitude],
zoom: 14,
interactive: false
}"
>
<MapboxDefaultMarker
:marker-id="'address-marker'"
:lnglat="[address.longitude, address.latitude]"
color="#10b981"
/>
</MapboxMap>
</ClientOnly>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
<button class="btn btn-sm w-full btn-outline">
<Icon name="lucide:pencil" size="14" class="mr-2" />
{{ t('profileAddresses.actions.edit') }}
</button>
</NuxtLink>
<button
class="btn btn-sm bg-error/20 border-error/30 text-error hover:bg-error/30"
@click="handleDelete"
:disabled="isDeleting"
>
<Icon name="lucide:trash-2" size="14" />
</button>
</div>
</div>
<!-- Loading state -->
<div v-else class="px-6 py-4 space-y-4">
<div class="h-24 animate-pulse rounded-xl bg-base-300/70" />
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const props = defineProps<{
isOpen: boolean
addressUuid: string | null
}>()
const emit = defineEmits<{
'close': []
'deleted': []
}>()
const { t } = useI18n()
const localePath = useLocalePath()
const { items, isoToEmoji, deleteAddress } = useTeamAddresses()
const isDeleting = ref(false)
const address = computed(() => {
if (!props.addressUuid) return null
return items.value.find(a => a.uuid === props.addressUuid) || null
})
const handleDelete = async () => {
if (!props.addressUuid) return
isDeleting.value = true
const success = await deleteAddress(props.addressUuid)
isDeleting.value = false
if (success) {
emit('deleted')
emit('close')
}
}
</script>
<style scoped>
.address-slide-enter-active,
.address-slide-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.address-slide-enter-from,
.address-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.address-slide-enter-to,
.address-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>

View File

@@ -17,7 +17,7 @@
<script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds } from 'mapbox-gl'
import type { ClusterPointType } from '~/composables/graphql/public/geo-generated'
import type { ClusterPoint } from '~/composables/graphql/public/geo-generated'
interface MapItem {
uuid?: string | null
@@ -43,7 +43,8 @@ interface HoveredItem {
const props = withDefaults(defineProps<{
mapId: string
items?: MapItem[]
clusteredPoints?: ClusterPointType[]
clusteredPoints?: ClusterPoint[]
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPoint[]>>
useServerClustering?: boolean
hoveredItemId?: string | null
hoveredItem?: HoveredItem | null
@@ -51,6 +52,8 @@ const props = withDefaults(defineProps<{
entityType?: 'offer' | 'hub' | 'supplier'
initialCenter?: [number, number]
initialZoom?: number
infoLoading?: boolean
fitPaddingLeft?: number
relatedPoints?: Array<{
uuid: string
name: string
@@ -64,8 +67,11 @@ const props = withDefaults(defineProps<{
initialCenter: () => [37.64, 55.76],
initialZoom: 2,
useServerClustering: false,
infoLoading: false,
fitPaddingLeft: 0,
items: () => [],
clusteredPoints: () => [],
clusteredPointsByType: undefined,
relatedPoints: () => []
})
@@ -79,6 +85,21 @@ const { flyThroughSpace } = useMapboxFlyAnimation()
const didFitBounds = ref(false)
const mapInitialized = ref(false)
const usesTypedClusters = computed(() => {
const typed = props.clusteredPointsByType
return !!typed && Object.keys(typed).length > 0
})
const buildFitPadding = (base: number) => {
const extraLeft = Math.max(0, props.fitPaddingLeft || 0)
return {
top: base,
bottom: base,
left: base + extraLeft,
right: base
}
}
// Entity type icons - SVG data URLs with specific colors
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
const icons = {
@@ -135,6 +156,8 @@ const ENTITY_COLORS = {
offer: '#f97316' // orange
} as const
const CLUSTER_TYPES: Array<'offer' | 'hub' | 'supplier'> = ['offer', 'hub', 'supplier']
// Load all icons for related points (each type with its standard color)
const loadRelatedPointIcons = async (map: MapboxMapType) => {
const types: Array<'hub' | 'supplier' | 'offer'> = ['hub', 'supplier', 'offer']
@@ -214,6 +237,33 @@ const serverClusteredGeoJson = computed(() => ({
}))
}))
const serverClusteredGeoJsonByType = computed(() => {
const build = (points: ClusterPoint[] | undefined, type: 'offer' | 'hub' | 'supplier') => ({
type: 'FeatureCollection' as const,
features: (points || []).filter(Boolean).map(point => ({
type: 'Feature' as const,
properties: {
id: point!.id,
name: point!.name,
count: point!.count ?? 1,
expansionZoom: point!.expansionZoom,
isCluster: (point!.count ?? 1) > 1,
type
},
geometry: {
type: 'Point' as const,
coordinates: [point!.longitude ?? 0, point!.latitude ?? 0]
}
}))
})
return {
offer: build(props.clusteredPointsByType?.offer, 'offer'),
hub: build(props.clusteredPointsByType?.hub, 'hub'),
supplier: build(props.clusteredPointsByType?.supplier, 'supplier')
}
})
// Hovered point GeoJSON (separate layer on top)
const hoveredPointGeoJson = computed(() => ({
type: 'FeatureCollection' as const,
@@ -254,6 +304,13 @@ const sourceId = computed(() => `${props.mapId}-points`)
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
const relatedSourceId = computed(() => `${props.mapId}-related`)
const getServerSourceId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}`
const getServerClusterLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-clusters`
const getServerClusterCountLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-cluster-count`
const getServerPointLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-points`
const getServerPointLabelLayerId = (type: 'offer' | 'hub' | 'supplier') => `${props.mapId}-server-${type}-point-labels`
const emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds()
if (!bounds) return
@@ -277,7 +334,11 @@ const onMapCreated = (map: MapboxMapType) => {
})
if (props.useServerClustering) {
await initServerClusteringLayers(map)
if (usesTypedClusters.value) {
await initServerClusteringLayersByType(map)
} else {
await initServerClusteringLayers(map)
}
} else {
await initClientClusteringLayers(map)
}
@@ -487,7 +548,7 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
bounds.extend([item.longitude, item.latitude])
}
})
map.fitBounds(bounds, { padding: 50, maxZoom: 10 })
map.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 10 })
didFitBounds.value = true
}
}
@@ -675,15 +736,212 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
})
}
const initServerClusteringLayersByType = async (map: MapboxMapType) => {
for (const type of CLUSTER_TYPES) {
await loadEntityIcon(map, type, ENTITY_COLORS[type])
const sourceIdByType = getServerSourceId(type)
map.addSource(sourceIdByType, {
type: 'geojson',
data: serverClusteredGeoJsonByType.value[type]
})
const clusterLayerId = getServerClusterLayerId(type)
const clusterCountLayerId = getServerClusterCountLayerId(type)
const pointLayerId = getServerPointLayerId(type)
const pointLabelLayerId = getServerPointLabelLayerId(type)
map.addLayer({
id: clusterLayerId,
type: 'circle',
source: sourceIdByType,
filter: ['>', ['get', 'count'], 1],
paint: {
'circle-color': ENTITY_COLORS[type],
'circle-radius': ['step', ['get', 'count'], 20, 10, 30, 50, 40],
'circle-opacity': 0.8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
},
layout: {}
})
map.addLayer({
id: clusterCountLayerId,
type: 'symbol',
source: sourceIdByType,
filter: ['>', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'count'],
'text-size': 14
},
paint: { 'text-color': '#ffffff' }
})
map.addLayer({
id: pointLayerId,
type: 'symbol',
source: sourceIdByType,
filter: ['==', ['get', 'count'], 1],
layout: {
'icon-image': `entity-icon-${type}`,
'icon-size': 1,
'icon-allow-overlap': true
}
})
map.addLayer({
id: pointLabelLayerId,
type: 'symbol',
source: sourceIdByType,
filter: ['==', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.8],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5
}
})
map.on('click', clusterLayerId, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId] })
const feature = features[0]
if (!feature) return
const expansionZoom = feature.properties?.expansionZoom
const geometry = feature.geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: expansionZoom || map.getZoom() + 2
})
})
map.on('click', pointLayerId, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [pointLayerId] })
const feature = features[0]
if (!feature) return
const props_data = feature.properties as Record<string, any> | undefined
emit('select-item', props_data?.id, props_data)
})
map.on('mouseenter', clusterLayerId, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', clusterLayerId, () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', pointLayerId, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', pointLayerId, () => { map.getCanvas().style.cursor = '' })
}
// Hovered point layer (on top of everything)
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
map.addLayer({
id: 'hovered-point-ring',
type: 'circle',
source: hoveredSourceId.value,
paint: {
'circle-radius': 20,
'circle-color': 'transparent',
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
map.addLayer({
id: 'hovered-point-layer',
type: 'circle',
source: hoveredSourceId.value,
paint: {
'circle-radius': 14,
'circle-color': props.pointColor,
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
}
})
// Related points layer
await loadRelatedPointIcons(map)
map.addSource(relatedSourceId.value, {
type: 'geojson',
data: relatedPointsGeoJson.value
})
map.addLayer({
id: `${props.mapId}-related-points`,
type: 'symbol',
source: relatedSourceId.value,
layout: {
'icon-image': [
'match',
['get', 'type'],
'hub', 'related-icon-hub',
'supplier', 'related-icon-supplier',
'offer', 'related-icon-offer',
'related-icon-offer'
],
'icon-size': 1,
'icon-allow-overlap': true
}
})
map.addLayer({
id: `${props.mapId}-related-labels`,
type: 'symbol',
source: relatedSourceId.value,
layout: {
'text-field': ['get', 'name'],
'text-size': 11,
'text-anchor': 'top',
'text-offset': [0, 1.5]
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1
}
})
map.on('click', `${props.mapId}-related-points`, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-points`] })
const feature = features[0]
if (!feature) return
const props_data = feature.properties as Record<string, any> | undefined
emit('select-item', props_data?.uuid, props_data)
})
map.on('mouseenter', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', `${props.mapId}-related-points`, () => {
map.getCanvas().style.cursor = ''
})
}
// Update map data when items or clusteredPoints change
watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => {
if (!mapRef.value || !mapInitialized.value) return
if (usesTypedClusters.value) return
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(newData)
}
}, { deep: true })
watch(() => serverClusteredGeoJsonByType.value, (newData) => {
if (!mapRef.value || !mapInitialized.value) return
if (!usesTypedClusters.value) return
for (const type of CLUSTER_TYPES) {
const sourceIdByType = getServerSourceId(type)
const source = mapRef.value.getSource(sourceIdByType) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(newData[type])
}
}
}, { deep: true })
// Update hovered point layer when hoveredItem changes
watch(() => props.hoveredItem, () => {
if (!mapRef.value || !mapInitialized.value) return
@@ -693,27 +951,33 @@ watch(() => props.hoveredItem, () => {
}
}, { deep: true })
// Update related points layer when relatedPoints changes + fit bounds
watch(() => props.relatedPoints, (points) => {
// Update related points layer when relatedPoints changes
watch(() => props.relatedPoints, () => {
if (!mapRef.value || !mapInitialized.value) return
// Update the source data
// Update the source data immediately
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) {
source.setData(relatedPointsGeoJson.value)
}
}, { deep: true })
// Fit bounds to show all related points (Info mode)
if (points && points.length > 0) {
// no visibility toggling; layers are data-driven by query
// Fit bounds when info loading finishes (all related data loaded)
watch(() => props.infoLoading, (loading, wasLoading) => {
// Only fit bounds when loading changes from true to false (data finished loading)
if (wasLoading && !loading && props.relatedPoints && props.relatedPoints.length > 0) {
if (!mapRef.value) return
const bounds = new LngLatBounds()
points.forEach(p => {
props.relatedPoints.forEach(p => {
bounds.extend([p.longitude, p.latitude])
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: 80, maxZoom: 12 })
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
}
}
}, { deep: true })
})
// Watch for pointColor or entityType changes - update colors and icons
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
@@ -725,10 +989,12 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
// Update cluster circle colors
if (props.useServerClustering) {
if (usesTypedClusters.value) {
return
}
if (map.getLayer('server-clusters')) {
map.setPaintProperty('server-clusters', 'circle-color', newColor)
}
// Update icon reference for points
if (map.getLayer('server-points')) {
map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`)
}
@@ -749,6 +1015,7 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
// fitBounds for server clustering when first data arrives
watch(() => props.clusteredPoints, (points) => {
if (usesTypedClusters.value) return
if (!mapRef.value || !mapInitialized.value) return
if (!didFitBounds.value && points && points.length > 0) {
const bounds = new LngLatBounds()
@@ -758,12 +1025,31 @@ watch(() => props.clusteredPoints, (points) => {
}
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 })
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
didFitBounds.value = true
}
}
}, { immediate: true })
watch(() => props.clusteredPointsByType, () => {
if (!usesTypedClusters.value) return
if (!mapRef.value || !mapInitialized.value) return
if (didFitBounds.value) return
const bounds = new LngLatBounds()
CLUSTER_TYPES.forEach(type => {
const points = serverClusteredGeoJsonByType.value[type]?.features ?? []
points.forEach((p) => {
const coords = (p.geometry as GeoJSON.Point).coordinates as [number, number]
bounds.extend(coords)
})
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
didFitBounds.value = true
}
}, { deep: true, immediate: true })
// Expose flyTo method for external use (with space fly animation)
const flyTo = async (lat: number, lng: number, zoom = 8) => {
if (!mapRef.value) return

View File

@@ -37,16 +37,20 @@
<!-- Offers Tab -->
<template v-if="activeTab === 'offers'">
<OfferCard
v-for="(offer, index) in offers"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:offer="offer"
selectable
compact
:is-selected="selectedId === offer.uuid"
:supplier-name="offer.supplierName"
:location-name="offer.locationName"
:product-name="offer.productName || offer.title || undefined"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
@select="selectOffer(offer)"
/>
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
<Text v-if="offersWithPrice.length === 0" tone="muted" size="sm" class="text-center py-4">
{{ t('catalogMap.empty.offers') }}
</Text>
</template>
@@ -82,10 +86,16 @@ interface Hub {
interface Offer {
uuid?: string | null
title?: string | null
productName?: string | null
locationName?: string | null
supplierName?: string | null
status?: string | null
latitude?: number | null
longitude?: number | null
quantity?: number | string | null
pricePerUnit?: number | string | null
currency?: string | null
unit?: string | null
}
interface Supplier {
@@ -113,9 +123,13 @@ const selectedId = ref<string | null>(null)
const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
const tabs = computed(() => [
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: props.offers.length },
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: offersWithPrice.value.length },
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
])

View File

@@ -13,23 +13,30 @@
</Stack>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="(offer, index) in offers"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:offer="offer"
:supplier-name="offer.supplierName"
:location-name="offer.locationName"
:product-name="offer.title || undefined"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
/>
</Grid>
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
<Text tone="muted">
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
{{ t('common.pagination.showing', { shown: offersWithPrice.length, total: totalOffers }) }}
</Text>
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
{{ t('common.actions.load_more') }}
</Button>
</Stack>
<Stack v-if="offers.length === 0" align="center" gap="2">
<Stack v-if="offersWithPrice.length === 0" align="center" gap="2">
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</Stack>
</Stack>
@@ -46,9 +53,14 @@ interface Offer {
uuid?: string | null
title?: string | null
locationName?: string | null
supplierName?: string | null
status?: string | null
validUntil?: string | null
lines?: (OfferLine | null)[] | null
quantity?: number | string | null
pricePerUnit?: number | string | null
currency?: string | null
unit?: string | null
}
const props = defineProps<{
@@ -61,7 +73,10 @@ const props = defineProps<{
const localePath = useLocalePath()
const { t } = useI18n()
const totalOffers = computed(() => props.total ?? props.offers.length)
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
const totalOffers = computed(() => props.total ?? offersWithPrice.value.length)
const canLoadMore = computed(() => props.canLoadMore ?? false)
const loadMore = () => {
props.onLoadMore?.()

View File

@@ -16,16 +16,28 @@
]"
>
<div class="flex flex-col gap-1">
<!-- Title -->
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
<!-- Country left, distance right -->
<!-- Title + distance/compass -->
<div class="flex items-start justify-between gap-2">
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
<div class="flex items-center gap-2 text-xs text-base-content/60 whitespace-nowrap">
<Text v-if="distanceLabel" size="xs" class="text-base-content/60">{{ distanceLabel }}</Text>
<div v-if="bearing !== null" class="flex items-center gap-1">
<div class="w-6 h-6 rounded-full border border-base-content/10 bg-base-200/40 flex items-center justify-center">
<Icon
name="lucide:arrow-up"
size="12"
class="text-base-content/60"
:style="{ transform: `rotate(${bearing}deg)` }"
/>
</div>
</div>
</div>
</div>
<!-- Country -->
<div class="flex items-center justify-between">
<Text tone="muted" size="sm">
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
</Text>
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
{{ hub.distance }}
</span>
</div>
<!-- Transport icons bottom -->
<div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1">
@@ -47,12 +59,16 @@ interface Hub {
name?: string | null
country?: string | null
countryCode?: string | null
latitude?: number | null
longitude?: number | null
distance?: string
distanceKm?: number | null
transportTypes?: (string | null)[] | null
}
const props = defineProps<{
hub: Hub
origin?: { latitude: number; longitude: number } | null
selectable?: boolean
isSelected?: boolean
linkTo?: string
@@ -82,4 +98,32 @@ const countryFlag = computed(() => {
})
const hasTransport = (type: string) => props.hub.transportTypes?.some(t => t === type)
const distanceLabel = computed(() => {
if (props.hub.distance) return props.hub.distance
if (props.hub.distanceKm != null) return `${Math.round(props.hub.distanceKm)} km`
return ''
})
const toRadians = (deg: number) => (deg * Math.PI) / 180
const toDegrees = (rad: number) => (rad * 180) / Math.PI
const bearing = computed(() => {
const origin = props.origin
const lat2 = props.hub.latitude
const lon2 = props.hub.longitude
if (!origin || lat2 == null || lon2 == null) return null
const lat1 = origin.latitude
const lon1 = origin.longitude
if (lat1 == null || lon1 == null) return null
const φ1 = toRadians(lat1)
const φ2 = toRadians(lat2)
const Δλ = toRadians(lon2 - lon1)
const y = Math.sin(Δλ) * Math.cos(φ2)
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
const θ = Math.atan2(y, x)
const deg = (toDegrees(θ) + 360) % 360
return deg
})
</script>

View File

@@ -12,9 +12,20 @@
</div>
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
</div>
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
<Icon name="lucide:x" size="16" />
</button>
<div class="flex items-center gap-2">
<button
v-if="(entityType === 'hub' || entityType === 'supplier') && entity?.uuid"
class="rounded-full glass-bright border border-white/30 shadow-lg p-1.5 transition-transform hover:scale-105"
@click="emit('pin', entityType, { uuid: entity?.uuid, name: entity?.name })"
aria-label="Pin"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
<Icon name="lucide:x" size="16" />
</button>
</div>
</div>
</div>
@@ -41,25 +52,65 @@
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
</p>
<!-- Supplier for offer -->
<div v-if="entityType === 'offer'" class="text-sm text-white/70 flex items-center gap-1 mt-1">
<!-- Supplier for offer (clickable name) -->
<button
v-if="entityType === 'offer' && entity?.teamUuid"
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
@click="emit('open-info', 'supplier', entity.teamUuid)"
>
<Icon name="lucide:factory" size="14" />
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
<span v-else-if="supplierDisplayName">
{{ supplierDisplayName }}
</span>
<button
v-else-if="entity?.teamUuid"
class="text-primary hover:underline"
@click="emit('open-info', 'supplier', entity.teamUuid)"
>
{{ $t('catalog.info.viewSupplier') }}
</button>
</div>
<span v-else>{{ supplierDisplayName || $t('catalog.info.supplier') }}</span>
</button>
</div>
<!-- Products Section (for hub/supplier) -->
<section v-if="entityType === 'hub' || entityType === 'supplier'">
<!-- KYC Teaser Section (for supplier) -->
<section v-if="entityType === 'supplier' && kycTeaser" class="bg-white/5 rounded-lg p-3">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:shield-check" size="16" />
{{ $t('catalog.info.kycTeaser') }}
</h3>
<div class="flex flex-col gap-2 text-sm">
<!-- Company Type -->
<div class="flex items-center justify-between">
<span class="text-white/60">{{ $t('catalog.info.companyType') }}</span>
<span class="text-white">{{ kycTeaser.companyType }}</span>
</div>
<!-- Registration Year -->
<div class="flex items-center justify-between">
<span class="text-white/60">{{ $t('catalog.info.registrationYear') }}</span>
<span class="text-white">{{ kycTeaser.registrationYear }}</span>
</div>
<!-- Status -->
<div class="flex items-center justify-between">
<span class="text-white/60">{{ $t('catalog.info.status') }}</span>
<span :class="kycTeaser.isActive ? 'text-success' : 'text-error'">
{{ kycTeaser.isActive ? $t('catalog.info.active') : $t('catalog.info.inactive') }}
</span>
</div>
<!-- Sources Count -->
<div class="flex items-center justify-between">
<span class="text-white/60">{{ $t('catalog.info.sourcesCount') }}</span>
<span class="text-white">{{ kycTeaser.sourcesCount }}</span>
</div>
</div>
<!-- View Full Profile Button -->
<button
class="btn btn-ghost btn-xs text-primary mt-3 w-full"
@click="emit('open-kyc', kycProfileUuid)"
>
<Icon name="lucide:external-link" size="14" />
{{ $t('catalog.info.viewFullKyc') }}
</button>
</section>
<!-- Products Section (for hub/supplier) - hide when product selected -->
<section v-if="(entityType === 'hub' || entityType === 'supplier') && !selectedProduct">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:package" size="16" />
{{ productsSectionTitle }}
@@ -71,19 +122,71 @@
{{ $t('catalog.empty.noProducts') }}
</div>
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
<ProductCard
<div
v-for="(product, index) in relatedProducts"
:key="product.uuid ?? index"
:product="product"
compact
selectable
@select="onProductSelect(product)"
class="relative group"
>
<ProductCard
:product="product"
compact
selectable
@select="onProductSelect(product)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'product', product)"
aria-label="Pin product"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</div>
</section>
<!-- Offers Section (after product selected) -->
<section v-if="(entityType === 'hub' || entityType === 'supplier') && selectedProduct">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
<Icon name="lucide:shopping-bag" size="16" />
{{ $t('catalog.headers.offers') }}
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
<span v-else-if="offersWithPrice.length > 0" class="text-white/50">({{ offersWithPrice.length }})</span>
</h3>
<button
class="flex items-center gap-2 px-2 py-1 rounded-full border border-white/15 bg-white/10 text-xs text-white/80 hover:bg-white/20 transition-colors"
@click="emit('select-product', null)"
>
<Icon name="lucide:package" size="12" />
<span class="max-w-32 truncate">{{ selectedProductName }}</span>
<Icon name="lucide:x" size="12" />
</button>
</div>
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.empty.noOffers') }}
</div>
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:supplier-name="getOfferSupplierName(offer)"
:location-name="offer.country || ''"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
@select="onOfferSelect(offer)"
/>
</div>
</section>
<!-- Suppliers Section (for hub only) -->
<section v-if="entityType === 'hub'">
<section v-if="entityType === 'hub' && !selectedProduct">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:factory" size="16" />
{{ $t('catalog.info.suppliersNearby') }}
@@ -95,13 +198,25 @@
{{ $t('catalog.info.noSuppliers') }}
</div>
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
<SupplierCard
<div
v-for="(supplier, index) in relatedSuppliers"
:key="supplier.uuid ?? index"
:supplier="supplier"
selectable
@select="onSupplierSelect(supplier)"
/>
class="relative group"
>
<SupplierCard
:supplier="supplier"
selectable
@select="onSupplierSelect(supplier)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'supplier', supplier)"
aria-label="Pin supplier"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</div>
</section>
@@ -117,22 +232,75 @@
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.info.noHubs') }}
</div>
<div v-else-if="!loadingHubs" class="flex flex-col gap-2">
<HubCard
v-for="(hub, index) in relatedHubs"
:key="hub.uuid ?? index"
:hub="hub"
selectable
@select="onHubSelect(hub)"
/>
<div v-else-if="!loadingHubs" class="space-y-4">
<template v-if="railHubs.length">
<div class="grid grid-cols-2 gap-2">
<Card padding="small" class="border border-white/10 bg-white/5">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
<Icon name="lucide:train-front" size="16" class="text-white/80" />
</div>
<div class="text-sm text-white/80">{{ $t('catalog.info.railHubs') }}</div>
</div>
</Card>
<div
v-for="(hub, index) in railHubs"
:key="hub.uuid ?? index"
class="relative group"
>
<HubCard
:hub="hub"
:origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'hub', hub)"
aria-label="Pin hub"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</div>
</template>
<template v-if="seaHubs.length">
<div class="grid grid-cols-2 gap-2">
<Card padding="small" class="border border-white/10 bg-white/5">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
<Icon name="lucide:ship" size="16" class="text-white/80" />
</div>
<div class="text-sm text-white/80">{{ $t('catalog.info.seaHubs') }}</div>
</div>
</Card>
<div
v-for="(hub, index) in seaHubs"
:key="hub.uuid ?? index"
class="relative group"
>
<HubCard
:hub="hub"
:origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'hub', hub)"
aria-label="Pin hub"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</div>
</template>
</div>
</section>
<!-- Add to filter button -->
<button class="btn btn-primary btn-sm mt-2" @click="emit('add-to-filter')">
<Icon name="lucide:filter-plus" size="16" />
{{ $t('catalog.info.addToFilter') }}
</button>
</div>
</div>
</div>
@@ -147,6 +315,7 @@ import type {
InfoSupplierItem,
InfoOfferItem
} from '~/composables/useCatalogInfo'
import type { RouteStage } from '~/composables/graphql/public/geo-generated'
const props = defineProps<{
entityType: InfoEntityType
@@ -167,10 +336,12 @@ const props = defineProps<{
const emit = defineEmits<{
'close': []
'add-to-filter': []
'open-info': [type: InfoEntityType, uuid: string]
'select-product': [uuid: string | null]
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
'update:current-tab': [tab: string]
'open-kyc': [uuid: string | undefined]
'pin': [type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }]
}>()
const { t } = useI18n()
@@ -180,6 +351,37 @@ const { entityColors } = useCatalogSearch()
const relatedProducts = computed(() => props.relatedProducts ?? [])
const relatedHubs = computed(() => props.relatedHubs ?? [])
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
const relatedOffers = computed(() => props.relatedOffers ?? [])
const offersWithPrice = computed(() =>
relatedOffers.value.filter(o => o?.pricePerUnit != null)
)
const suppliersByUuid = computed(() => {
const map = new Map<string, string>()
relatedSuppliers.value.forEach(supplier => {
if (supplier?.uuid && supplier?.name) {
map.set(supplier.uuid, supplier.name)
}
if (supplier?.teamUuid && supplier?.name) {
map.set(supplier.teamUuid, supplier.name)
}
})
return map
})
const getOfferSupplierName = (offer: InfoOfferItem) => {
if (offer.supplierName) return offer.supplierName
if (offer.supplierUuid && suppliersByUuid.value.has(offer.supplierUuid)) {
return suppliersByUuid.value.get(offer.supplierUuid)
}
return null
}
const selectedProductName = computed(() => {
if (!props.selectedProduct) return ''
const match = relatedProducts.value.find(p => p.uuid === props.selectedProduct)
return match?.name || props.selectedProduct.slice(0, 8) + '...'
})
// Entity name
const entityName = computed(() => {
@@ -193,6 +395,13 @@ const entityLocation = computed(() => {
return parts.length > 0 ? parts.join(', ') : null
})
const originCoords = computed(() => {
const lat = props.entity?.locationLatitude ?? props.entity?.latitude
const lon = props.entity?.locationLongitude ?? props.entity?.longitude
if (lat == null || lon == null) return null
return { latitude: Number(lat), longitude: Number(lon) }
})
// Products section title based on entity type
const productsSectionTitle = computed(() => {
return props.entityType === 'hub'
@@ -231,11 +440,43 @@ const formatPrice = (price: number | string) => {
return new Intl.NumberFormat('ru-RU').format(num)
}
const railHubs = computed(() =>
relatedHubs.value.filter(h => h.transportTypes?.includes('rail'))
)
const seaHubs = computed(() =>
relatedHubs.value.filter(h => h.transportTypes?.includes('sea'))
)
// Mock KYC teaser data (will be replaced with real data later)
const kycTeaser = computed(() => {
if (props.entityType !== 'supplier') return null
// Mock data for now
return {
companyType: 'ООО',
registrationYear: 2018,
isActive: true,
sourcesCount: 3
}
})
// KYC Profile UUID - use real if available, otherwise mock for demo
const MOCK_KYC_UUID = 'demo-kyc-profile'
const kycProfileUuid = computed(() => {
return props.entity?.kycProfileUuid || MOCK_KYC_UUID
})
// Handlers for selecting related items
const onProductSelect = (product: InfoProductItem) => {
emit('select-product', product.uuid)
}
const onOfferSelect = (offer: InfoOfferItem) => {
if (offer.uuid) {
emit('select-offer', { uuid: offer.uuid, productUuid: offer.productUuid })
}
}
const onHubSelect = (hub: InfoHubItem) => {
if (hub.uuid) {
emit('open-info', 'hub', hub.uuid)
@@ -247,4 +488,17 @@ const onSupplierSelect = (supplier: InfoSupplierItem) => {
emit('open-info', 'supplier', supplier.uuid)
}
}
const getOfferStages = (offer: InfoOfferItem) => {
const route = offer.routes?.[0]
if (!route?.stages) return []
return route.stages
.filter((stage): stage is NonNullable<RouteStage> => stage !== null)
.map(stage => ({
transportType: stage.transportType,
distanceKm: stage.distanceKm,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
}))
}
</script>

View File

@@ -0,0 +1,304 @@
<template>
<Transition name="kyc-slide">
<div
v-if="isOpen"
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
style="height: 70vh"
>
<!-- Backdrop (clickable to close) -->
<div
class="absolute inset-0 -top-[30vh] bg-black/30"
@click="emit('close')"
/>
<!-- Sheet content -->
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
<!-- Header with drag handle and close -->
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
<div class="flex justify-center py-2">
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
</div>
<div class="flex items-center justify-between px-6 pb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/20 rounded-xl flex items-center justify-center">
<Icon name="lucide:building-2" size="24" class="text-primary" />
</div>
<div>
<Text weight="bold" size="lg" class="text-white">{{ companyName }}</Text>
<div class="flex items-center gap-2 mt-0.5">
<span class="badge badge-success badge-sm">{{ $t('catalog.info.active') }}</span>
<span class="badge badge-outline badge-sm text-white/60">{{ companyType }}</span>
</div>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white" @click="emit('close')">
<Icon name="lucide:x" size="20" />
</button>
</div>
</div>
<!-- Scrollable content -->
<div class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Left Column -->
<div class="flex flex-col gap-4">
<!-- Реквизиты -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:file-text" size="18" />
Реквизиты
</Text>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<Text tone="muted" size="xs" class="text-white/50">ИНН</Text>
<Text class="text-white font-mono">{{ inn }}</Text>
</div>
<div>
<Text tone="muted" size="xs" class="text-white/50">КПП</Text>
<Text class="text-white font-mono">{{ kpp }}</Text>
</div>
<div>
<Text tone="muted" size="xs" class="text-white/50">ОГРН</Text>
<Text class="text-white font-mono">{{ ogrn }}</Text>
</div>
<div>
<Text tone="muted" size="xs" class="text-white/50">Год регистрации</Text>
<Text class="text-white">{{ registrationYear }}</Text>
</div>
</div>
</div>
<!-- Руководство -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:user-cog" size="18" />
Руководство
</Text>
<div class="flex items-center gap-3 p-2 bg-white/5 rounded-lg">
<div class="avatar placeholder">
<div class="w-9 h-9 rounded-full bg-primary text-primary-content text-sm">
<span>{{ directorInitials }}</span>
</div>
</div>
<div>
<Text weight="medium" size="sm" class="text-white">{{ directorName }}</Text>
<Text size="xs" class="text-white/50">Генеральный директор</Text>
</div>
</div>
</div>
<!-- Учредители -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:users" size="18" />
Учредители
</Text>
<div class="space-y-2">
<div
v-for="(founder, i) in founders"
:key="i"
class="flex items-center justify-between p-2 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-2">
<div class="avatar placeholder">
<div class="w-8 h-8 rounded-full bg-secondary text-secondary-content text-xs">
<span>{{ founder.initials }}</span>
</div>
</div>
<div>
<Text size="sm" class="text-white">{{ founder.name }}</Text>
<Text size="xs" class="text-white/50">Физ. лицо</Text>
</div>
</div>
<span class="badge badge-primary badge-sm">{{ founder.share }}%</span>
</div>
</div>
<div class="mt-3 pt-3 border-t border-white/10 flex justify-between">
<Text size="xs" class="text-white/50">Уставный капитал</Text>
<Text weight="semibold" size="sm" class="text-white">{{ authorizedCapital }}</Text>
</div>
</div>
</div>
<!-- Right Column -->
<div class="flex flex-col gap-4">
<!-- Контакты -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:contact" size="18" />
Контакты
</Text>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2 text-white/80">
<Icon name="lucide:map-pin" size="14" class="text-white/50" />
<span>{{ address }}</span>
</div>
<div class="flex items-center gap-2 text-white/80">
<Icon name="lucide:phone" size="14" class="text-white/50" />
<span>{{ phone }}</span>
</div>
<div class="flex items-center gap-2 text-white/80">
<Icon name="lucide:mail" size="14" class="text-white/50" />
<span>{{ email }}</span>
</div>
</div>
</div>
<!-- Финансы -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:bar-chart-3" size="18" />
Финансы (2024)
</Text>
<div class="space-y-3">
<div>
<div class="flex justify-between mb-1">
<Text size="xs" class="text-white/50">Выручка</Text>
<Text size="xs" class="text-success"> 15%</Text>
</div>
<Text weight="bold" class="text-white">{{ revenue }}</Text>
</div>
<div>
<div class="flex justify-between mb-1">
<Text size="xs" class="text-white/50">Чистая прибыль</Text>
<Text size="xs" class="text-success"> 23%</Text>
</div>
<Text weight="bold" class="text-white">{{ profit }}</Text>
</div>
<div class="pt-2 border-t border-white/10 flex justify-between">
<Text size="xs" class="text-white/50">Сотрудников</Text>
<Text weight="medium" size="sm" class="text-white">{{ employees }}</Text>
</div>
</div>
</div>
<!-- Арбитраж -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:scale" size="18" />
Арбитражные дела
</Text>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="badge badge-warning badge-xs">Истец</span>
<Text class="text-white/80">{{ arbitration.plaintiff.count }} дела</Text>
</div>
<Text class="text-white">{{ arbitration.plaintiff.amount }}</Text>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="badge badge-error badge-xs">Ответчик</span>
<Text class="text-white/80">{{ arbitration.defendant.count }} дело</Text>
</div>
<Text class="text-white">{{ arbitration.defendant.amount }}</Text>
</div>
</div>
</div>
</div>
</div>
<!-- ОКВЭД (full width) -->
<div class="mt-4 bg-white/5 rounded-xl p-4 border border-white/10">
<Text weight="semibold" class="text-white mb-3 flex items-center gap-2">
<Icon name="lucide:briefcase" size="18" />
Виды деятельности (ОКВЭД)
</Text>
<div class="space-y-2">
<div class="flex items-start gap-2 p-2 bg-primary/10 rounded-lg border border-primary/20">
<span class="badge badge-primary badge-xs mt-0.5">Осн.</span>
<Text size="sm" class="text-white">{{ mainActivity }}</Text>
</div>
<div
v-for="(activity, i) in additionalActivities"
:key="i"
class="flex items-start gap-2 p-2 bg-white/5 rounded-lg"
>
<span class="badge badge-ghost badge-xs mt-0.5 text-white/50">Доп.</span>
<Text size="sm" class="text-white/80">{{ activity }}</Text>
</div>
</div>
</div>
<!-- Sources footer -->
<div class="mt-4 flex items-center justify-between text-xs text-white/40 px-1">
<span class="flex items-center gap-1">
<Icon name="lucide:database" size="12" />
Источники: ЕГРЮЛ, ФНС, Росстат
</span>
<span>Обновлено: {{ lastUpdated }}</span>
</div>
<!-- Demo notice -->
<div class="mt-4 alert bg-info/20 border border-info/30 text-info text-sm">
<Icon name="lucide:info" size="16" />
<span>{{ $t('kyc.demo.notice') }}</span>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const props = defineProps<{
isOpen: boolean
uuid: string | null
}>()
const emit = defineEmits<{
'close': []
}>()
// Demo data (will be replaced with real data from API)
const isDemo = computed(() => props.uuid === 'demo-kyc-profile')
const companyName = computed(() => isDemo.value ? 'ООО "АГРОТОРГ ПЛЮС"' : 'Загрузка...')
const companyType = computed(() => 'ООО')
const inn = computed(() => '7707456789')
const kpp = computed(() => '770701001')
const ogrn = computed(() => '1157746123456')
const registrationYear = computed(() => '2015')
const directorName = computed(() => 'Петров Сергей Александрович')
const directorInitials = computed(() => 'ПС')
const founders = computed(() => [
{ name: 'Петров Сергей Александрович', initials: 'ПС', share: 60 },
{ name: 'Иванова Анна Петровна', initials: 'ИА', share: 40 }
])
const authorizedCapital = computed(() => '500 000 ₽')
const address = computed(() => 'г. Москва, ул. Складская, д. 15, оф. 301')
const phone = computed(() => '+7 (495) 123-45-67')
const email = computed(() => 'info@agrotorg-plus.ru')
const revenue = computed(() => '245 800 000 ₽')
const profit = computed(() => '18 450 000 ₽')
const employees = computed(() => '47 человек')
const arbitration = computed(() => ({
plaintiff: { count: 3, amount: '1 250 000 ₽' },
defendant: { count: 1, amount: '320 000 ₽' }
}))
const mainActivity = computed(() => '46.21 - Торговля оптовая зерном, семенами и кормами')
const additionalActivities = computed(() => [
'46.11 - Деятельность агентов по оптовой торговле',
'52.10 - Деятельность по складированию и хранению'
])
const lastUpdated = computed(() => new Date().toLocaleDateString('ru-RU'))
</script>
<style scoped>
.kyc-slide-enter-active,
.kyc-slide-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.kyc-slide-enter-from,
.kyc-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.kyc-slide-enter-to,
.kyc-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>

View File

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

View File

@@ -1,58 +1,116 @@
<template>
<Card padding="md" interactive @click="$emit('select')">
<!-- Header: Location + Price -->
<div class="flex items-start justify-between mb-3">
<div>
<Text weight="semibold">{{ locationName || 'Локация' }}</Text>
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text>
<Card padding="md" interactive :class="groupClass" @click="$emit('select')">
<!-- Header: Supplier + Price -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
<Icon name="lucide:factory" size="14" class="text-white" />
</div>
<Text weight="semibold">{{ supplierDisplay }}</Text>
</div>
<div class="flex items-center gap-2 text-sm text-base-content/70">
<Icon name="lucide:map-pin" size="14" class="text-base-content/60" />
<span>{{ originDisplay }}</span>
</div>
<div v-if="productName" class="flex items-center gap-2 text-sm text-base-content/70">
<Icon name="lucide:package" size="14" class="text-base-content/60" />
<span>{{ productName }}</span>
</div>
<div v-if="quantityDisplay" class="flex items-center gap-2 text-sm text-base-content/70">
<Icon name="lucide:scale" size="14" class="text-base-content/60" />
<span>{{ quantityDisplay }}</span>
</div>
</div>
<div class="text-right">
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
{{ priceDisplay }}
</Text>
<Text v-if="durationDisplay" size="xs" class="text-base-content/60">
{{ t('catalogOfferCard.labels.duration_label') }} {{ durationDisplay }}
</Text>
</div>
<Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
{{ priceDisplay }}
</Text>
</div>
<!-- Supplier info -->
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
<!-- Route stepper -->
<RouteStepper
v-if="stages.length > 0"
:stages="stages"
:start-name="startName"
:end-name="endName"
/>
<!-- Route lines -->
<div v-if="routeRows.length" class="mt-3 pt-2 border-t border-base-200/60">
<div v-for="(row, index) in routeRows" :key="index" class="flex items-center gap-2 text-sm text-base-content/70">
<Icon :name="row.icon" size="14" class="text-base-content/60" />
<span>{{ row.distanceLabel }}</span>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import type { RouteStage } from './RouteStepper.vue'
interface RouteStage {
transportType?: string | null
distanceKm?: number | null
travelTimeSeconds?: number | null
fromName?: string | null
}
const props = withDefaults(defineProps<{
locationName?: string
supplierName?: string
productName?: string
pricePerUnit?: number | null
quantity?: number | string | null
currency?: string | null
unit?: string | null
stages?: RouteStage[]
startName?: string
endName?: string
totalTimeSeconds?: number | null
kycProfileUuid?: string | null
grouped?: boolean
}>(), {
stages: () => []
stages: () => [],
grouped: false
})
defineEmits<{
select: []
}>()
const { t } = useI18n()
const supplierDisplay = computed(() => {
return props.supplierName || t('catalogOfferCard.labels.supplier_unknown')
})
const originDisplay = computed(() => {
const fromStage = props.stages?.find(stage => stage?.fromName)?.fromName
return props.locationName || fromStage || t('catalogOfferCard.labels.origin_unknown')
})
const priceDisplay = computed(() => {
if (!props.pricePerUnit) return null
if (props.pricePerUnit == null) return null
const currSymbol = getCurrencySymbol(props.currency)
const unitName = getUnitName(props.unit)
const formattedPrice = props.pricePerUnit.toLocaleString()
const basePrice = Number(props.pricePerUnit)
const totalPrice = basePrice + (logisticsCost.value ?? 0)
const formattedPrice = totalPrice.toLocaleString()
return `${currSymbol}${formattedPrice}/${unitName}`
})
const quantityDisplay = computed(() => {
if (props.quantity == null || props.quantity === '') return null
const quantityValue = Number(props.quantity)
if (Number.isNaN(quantityValue)) return null
const formattedQuantity = quantityValue.toLocaleString()
const unitName = getUnitName(props.unit)
return t('catalogOfferCard.labels.quantity_with_unit', {
quantity: formattedQuantity,
unit: unitName
})
})
const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) {
case 'USD': return '$'
@@ -66,14 +124,104 @@ const getCurrencySymbol = (currency?: string | null) => {
const getUnitName = (unit?: string | null) => {
switch (unit?.toLowerCase()) {
case 'т':
case 't':
case 'ton':
case 'tonne':
return 'тонна'
return t('catalogOfferCard.labels.default_unit')
case 'кг':
case 'kg':
return 'кг'
return t('catalogOfferCard.labels.unit_kg')
default:
return 'тонна'
return t('catalogOfferCard.labels.default_unit')
}
}
const formatDistance = (km?: number | null) => {
if (km == null) return null
const formatted = Math.round(km).toLocaleString()
return t('catalogOfferCard.labels.distance_km', { km: formatted })
}
const formatDurationDays = (days?: number | null) => {
if (!days) return null
const rounded = Math.max(1, Math.ceil(days))
return t('catalogOfferCard.labels.duration_days', { days: rounded })
}
const getTransportIcon = (type?: string | null) => {
switch (type) {
case 'rail':
return 'lucide:train-front'
case 'sea':
return 'lucide:ship'
case 'road':
case 'auto':
default:
return 'lucide:truck'
}
}
const getTransportRate = (type?: string | null) => {
switch (type) {
case 'rail':
return 0.12
case 'sea':
return 0.06
case 'road':
case 'auto':
default:
return 0.22
}
}
const getTransportSpeedPerDay = (type?: string | null) => {
switch (type) {
case 'rail':
return 900
case 'sea':
return 800
case 'road':
case 'auto':
default:
return 600
}
}
const logisticsCost = computed(() => {
if (!props.stages?.length) return null
return props.stages.reduce((sum, stage) => {
const km = stage?.distanceKm
if (km == null) return sum
return sum + km * getTransportRate(stage?.transportType) + 40
}, 0)
})
const totalDurationDays = computed(() => {
if (!props.stages?.length) return null
const stageDays = props.stages.reduce((sum, stage) => {
const km = stage?.distanceKm
if (km == null) return sum
return sum + km / getTransportSpeedPerDay(stage?.transportType)
}, 0)
const transfers = Math.max(0, props.stages.length - 1) * 0.5
const buffer = 1
return stageDays + transfers + buffer
})
const durationDisplay = computed(() => formatDurationDays(totalDurationDays.value))
const groupClass = computed(() => {
if (!props.grouped) return ''
return 'rounded-none shadow-none hover:shadow-none'
})
const routeRows = computed(() =>
(props.stages || [])
.filter(stage => stage?.distanceKm != null)
.map(stage => ({
icon: getTransportIcon(stage?.transportType),
distanceLabel: formatDistance(stage?.distanceKm)
}))
.filter(row => !!row.distanceLabel)
)
</script>

View File

@@ -0,0 +1,391 @@
<template>
<Transition name="order-slide">
<div
v-if="isOpen && orderUuid"
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
style="height: 72vh"
>
<!-- Backdrop (clickable to close) -->
<div
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
@click="emit('close')"
/>
<!-- Sheet content -->
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
<!-- Header with drag handle and close -->
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100/90">
<div class="flex justify-center py-2">
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
</div>
<div class="flex items-center justify-between px-6 pb-4">
<template v-if="hasOrderError">
<div class="flex-1">
<div class="font-black text-base-content">{{ t('common.error') }}</div>
<div class="text-sm text-base-content/60">{{ orderError }}</div>
</div>
</template>
<template v-else-if="!isLoadingOrder && order">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Icon name="lucide:package" size="24" class="text-primary" />
</div>
<div class="min-w-0">
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
<span v-if="order.status" class="badge badge-outline badge-sm">{{ order.status }}</span>
</div>
</div>
</div>
</template>
<template v-else>
<div class="flex items-center gap-3 flex-1">
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
<div class="flex-1">
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
</div>
</div>
</template>
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
<Icon name="lucide:x" size="20" />
</button>
</div>
</div>
<!-- Error state -->
<div v-if="hasOrderError" class="px-6 py-8 text-center">
<div class="mb-4 text-base-content/70">{{ orderError }}</div>
<button class="btn btn-sm btn-outline" @click="loadOrder">
{{ t('ordersDetail.errors.retry') }}
</button>
</div>
<!-- Scrollable content -->
<div v-else-if="order" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
<!-- Order meta -->
<div class="flex flex-wrap gap-2 text-sm">
<span v-for="(meta, idx) in orderMeta" :key="idx" class="rounded-full border border-base-300 bg-base-200 px-3 py-1 text-base-content/70">
{{ meta }}
</span>
</div>
<!-- Route stages -->
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:route" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
</div>
<div class="space-y-3">
<div
v-for="(stage, idx) in orderStageItems"
:key="stage.key || idx"
class="flex gap-3"
>
<div class="flex flex-col items-center">
<div class="h-3 w-3 rounded-full bg-primary" />
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
</div>
<div class="flex-1 pb-3">
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
{{ stage.to }}
</div>
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
{{ stage.meta.join(' · ') }}
</div>
</div>
</div>
</div>
</div>
<!-- Timeline -->
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:calendar" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
</div>
<GanttTimeline
:stages="order.stages"
:showLoading="true"
:showUnloading="true"
/>
</div>
<!-- Map preview (small) -->
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
</div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
</div>
</div>
<!-- Loading state -->
<div v-else class="px-6 py-4 space-y-4">
<div class="h-20 animate-pulse rounded-xl bg-base-300/70" />
<div class="h-32 animate-pulse rounded-xl bg-base-300/70" />
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
// Types from GraphQL
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
type CompanyType = NonNullable<StageType['selectedCompany']>
const props = defineProps<{
isOpen: boolean
orderUuid: string | null
}>()
const emit = defineEmits<{
'close': []
}>()
const { t } = useI18n()
const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(false)
const hasOrderError = ref(false)
const orderError = ref('')
// Load order when uuid changes
watch(() => props.orderUuid, async (uuid) => {
if (uuid && props.isOpen) {
await loadOrder()
}
}, { immediate: true })
// Also load when opening
watch(() => props.isOpen, async (open) => {
if (open && props.orderUuid) {
await loadOrder()
} else if (!open) {
// Reset state when closing
order.value = null
hasOrderError.value = false
orderError.value = ''
}
})
const orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
return `${source}${destination}`
})
const orderMeta = computed(() => {
const meta: string[] = []
const line = order.value?.orderLines?.[0]
if (line?.quantity) {
meta.push(`${line.quantity} ${line.unit || t('ordersDetail.labels.unit_tons')}`)
}
if (line?.productName) {
meta.push(line.productName)
}
if (order.value?.totalAmount) {
meta.push(formatPrice(order.value.totalAmount, order.value?.currency))
}
const durationDays = getOrderDuration()
if (durationDays) {
meta.push(`${durationDays} ${t('ordersDetail.labels.delivery_days')}`)
}
return meta
})
const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
if (stage.stageType === 'transport') {
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
return {
fromLat: stage.sourceLatitude,
fromLon: stage.sourceLongitude,
fromName: stage.sourceLocationName,
toLat: stage.destinationLatitude,
toLon: stage.destinationLongitude,
toName: stage.destinationLocationName,
transportType: stage.transportType
}
}
return null
})
.filter(Boolean)
if (!stages.length) return []
return [{ stages }]
})
// Company summary type
interface CompanySummary {
name: string | null | undefined
totalWeight: number
tripsCount: number
company: CompanyType | null | undefined
}
const orderStageItems = computed<RouteStageItem[]>(() => {
return (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
const isTransport = stage.stageType === 'transport'
const from = isTransport ? stage.sourceLocationName : stage.locationName
const to = isTransport ? stage.destinationLocationName : stage.locationName
const meta: string[] = []
const dateRange = getStageDateRange(stage)
if (dateRange) {
meta.push(dateRange)
}
const companies = getCompaniesSummary(stage)
companies.forEach((company: CompanySummary) => {
meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
)
})
return {
key: stage.uuid ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
label: stage.name ?? undefined,
meta
}
})
})
const loadOrder = async () => {
if (!props.orderUuid) return
try {
isLoadingOrder.value = true
hasOrderError.value = false
const { data, error: orderErrorResp } = await useServerQuery('order-detail-sheet', GetOrderDocument, { orderUuid: props.orderUuid }, 'team', 'orders')
if (orderErrorResp.value) throw orderErrorResp.value
order.value = data.value?.getOrder ?? null
} catch (err: unknown) {
hasOrderError.value = true
orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
} finally {
isLoadingOrder.value = false
}
}
const formatPrice = (price: number, currency?: string | null) => {
if (!price) return t('ordersDetail.labels.price_zero')
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
minimumFractionDigits: 0
}).format(price)
}
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
const companies: CompanySummary[] = []
if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({
name: stage.selectedCompany.name,
totalWeight: 0,
tripsCount: 0,
company: stage.selectedCompany
})
return companies
}
if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map<string, CompanySummary>()
stage.trips.forEach((trip) => {
if (!trip) return
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
const weight = trip.plannedWeight || 0
if (companiesMap.has(companyName)) {
const existing = companiesMap.get(companyName)!
existing.totalWeight += weight
existing.tripsCount += 1
} else {
companiesMap.set(companyName, {
name: companyName,
totalWeight: weight,
tripsCount: 1,
company: trip.company
})
}
})
return Array.from(companiesMap.values())
}
return []
}
const getOrderDuration = () => {
if (!order.value?.stages?.length) return 0
let minDate: Date | null = null
let maxDate: Date | null = null
order.value.stages.forEach((stage) => {
if (!stage) return
stage.trips?.forEach((trip) => {
if (!trip) return
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
})
if (!minDate || !maxDate) return 0
const diffTime = Math.abs((maxDate as Date).getTime() - (minDate as Date).getTime())
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
const getStageDateRange = (stage: StageType) => {
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
let minDate: Date | null = null
let maxDate: Date | null = null
stage.trips.forEach((trip) => {
if (!trip) return
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
if (!minDate || !maxDate) return t('ordersDetail.labels.dates_undefined')
const formatDate = (date: Date) => date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
if ((minDate as Date).toDateString() === (maxDate as Date).toDateString()) return formatDate(minDate as Date)
return `${formatDate(minDate as Date)} - ${formatDate(maxDate as Date)}`
}
</script>
<style scoped>
.order-slide-enter-active,
.order-slide-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.order-slide-enter-from,
.order-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.order-slide-enter-to,
.order-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>

View File

@@ -1,50 +1,148 @@
<template>
<MapPanel>
<template #header>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex-shrink-0 p-4 border-b border-white/10">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-base text-base-content">{{ $t('catalog.headers.offers') }}</h3>
<span class="badge badge-neutral">{{ offers.length }}</span>
<h3 class="font-semibold text-base text-white">{{ $t('catalog.headers.offers') }}</h3>
<span class="badge badge-neutral">{{ totalOffers }}</span>
</div>
</template>
<!-- Content -->
<div v-if="loading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<div v-else-if="offers.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noOffers') }}</p>
</div>
<!-- Content (scrollable) -->
<div class="flex-1 overflow-y-auto p-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md text-white" />
</div>
<div v-else class="flex flex-col gap-3">
<div v-else-if="offersWithPrice.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noOffers') }}</p>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="group in offerGroups"
:key="group.id"
class="flex flex-col gap-0"
>
<div
v-if="group.offers.length > 1"
class="rounded-2xl overflow-hidden border border-base-200/60 divide-y divide-base-200/60"
>
<div
v-for="offer in offers"
v-for="offer in group.offers"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
>
<slot name="offer-card" :offer="offer">
<OfferCard :offer="offer" linkable />
</slot>
<OfferResultCard
grouped
:supplier-name="offer.supplierName"
:location-name="offer.country || ''"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
</MapPanel>
<div
v-else
v-for="offer in group.offers"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
>
<OfferResultCard
:supplier-name="offer.supplierName"
:location-name="offer.country || ''"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Offer {
uuid: string
name?: string | null
productName?: string | null
productUuid?: string | null
supplierName?: string | null
supplierUuid?: string | null
quantity?: number | string | null
unit?: string | null
pricePerUnit?: number | string | null
currency?: string | null
country?: string | null
countryCode?: string | null
routes?: Array<{
totalTimeSeconds?: number | null
stages?: Array<{
transportType?: string | null
distanceKm?: number | null
travelTimeSeconds?: number | null
fromName?: string | null
} | null> | null
} | null> | null
}
defineProps<{
loading: boolean
interface OfferGroup {
id: string
offers: Offer[]
}>()
}
const emit = defineEmits<{
'select-offer': [offer: Offer]
}>()
const props = defineProps<{
loading: boolean
offers: Offer[]
calculations?: OfferGroup[]
}>()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
const totalOffers = computed(() => {
if (props.calculations?.length) {
return props.calculations.reduce((sum, calc) => sum + (calc.offers?.length || 0), 0)
}
return props.offers.length
})
const offerGroups = computed<OfferGroup[]>(() => {
if (props.calculations?.length) return props.calculations
return offersWithPrice.value.map(offer => ({
id: offer.uuid,
offers: [offer]
}))
})
const getOfferStages = (offer: Offer) => {
const route = offer.routes?.[0]
if (!route?.stages) return []
return route.stages
.filter((stage): stage is NonNullable<typeof stage> => stage !== null)
.map((stage) => ({
transportType: stage.transportType,
distanceKm: stage.distanceKm,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
}))
}
</script>

View File

@@ -8,12 +8,6 @@
<Icon name="lucide:x" size="16" />
</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
/>
</div>
<!-- Content (scrollable) -->
@@ -22,7 +16,7 @@
<span class="loading loading-spinner loading-md text-white" />
</div>
<div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60">
<div v-else-if="items.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noResults') }}</p>
</div>
@@ -31,8 +25,9 @@
<!-- Products -->
<template v-if="selectMode === 'product'">
<div
v-for="(item, index) in filteredItems"
v-for="(item, index) in items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
@@ -42,14 +37,23 @@
compact
@select="onSelect(item)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'product', item)"
aria-label="Pin product"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</template>
<!-- Hubs -->
<template v-else-if="selectMode === 'hub'">
<div
v-for="(item, index) in filteredItems"
v-for="(item, index) in items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
@@ -58,14 +62,23 @@
selectable
@select="onSelect(item)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'hub', item)"
aria-label="Pin hub"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</template>
<!-- Suppliers -->
<template v-else-if="selectMode === 'supplier'">
<div
v-for="(item, index) in filteredItems"
v-for="(item, index) in items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
@@ -74,12 +87,20 @@
selectable
@select="onSelect(item)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'supplier', item)"
aria-label="Pin supplier"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</template>
<!-- Infinite scroll sentinel -->
<div
v-if="hasMore && !searchQuery"
v-if="hasMore"
ref="loadMoreSentinel"
class="flex items-center justify-center py-4"
>
@@ -114,11 +135,11 @@ const emit = defineEmits<{
'close': []
'load-more': []
'hover': [uuid: string | null]
'pin': [type: 'product' | 'hub' | 'supplier', item: Item]
}>()
const { t } = useI18n()
const searchQuery = ref('')
const loadMoreSentinel = ref<HTMLElement | null>(null)
// Infinite scroll using IntersectionObserver
@@ -128,7 +149,7 @@ onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting && props.hasMore && !props.loadingMore && !searchQuery.value) {
if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
emit('load-more')
}
},
@@ -158,15 +179,6 @@ const title = computed(() => {
}
})
const searchPlaceholder = computed(() => {
switch (props.selectMode) {
case 'product': return t('catalog.search.searchProducts')
case 'hub': return t('catalog.search.searchHubs')
case 'supplier': return t('catalog.search.searchSuppliers')
default: return t('catalog.search.placeholder')
}
})
const items = computed(() => {
switch (props.selectMode) {
case 'product': return props.products || []
@@ -176,16 +188,6 @@ const items = computed(() => {
}
})
const filteredItems = computed(() => {
if (!searchQuery.value.trim()) return items.value
const query = searchQuery.value.toLowerCase()
return items.value.filter(item =>
item.name?.toLowerCase().includes(query) ||
item.country?.toLowerCase().includes(query)
)
})
// Select item and emit
const onSelect = (item: Item) => {
if (props.selectMode && item.uuid) {

View File

@@ -1,86 +1,177 @@
<template>
<header
class="shadow-lg"
:class="headerClasses"
class="relative"
:style="{ height: `${height}px` }"
>
<!-- Single row: Logo + Search + Icons -->
<div class="flex items-stretch h-full px-4 lg:px-6 gap-4">
<!-- Left: Logo + Nav links (top aligned) -->
<div class="flex items-start gap-6 flex-shrink-0 pt-4">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink>
<div class="relative mx-auto max-w-[2200px] px-3 py-2 md:px-4">
<div
class="flex items-center gap-2"
:class="isHeroLayout ? 'items-start' : ''"
:style="rowStyle"
>
<!-- Left: Logo + AI button + Nav links (top aligned) -->
<div class="flex items-center flex-shrink-0 rounded-full pill-glass">
<div class="flex items-center gap-2 px-4 py-2">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-black text-xl tracking-tight" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink>
<button
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="[
useWhiteText
? (chatOpen ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10')
: (chatOpen ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')
]"
aria-label="Toggle AI assistant"
@click="$emit('toggle-chat')"
>
<Icon name="lucide:bot" size="18" />
</button>
</div>
<!-- Service nav links -->
<nav v-if="showModeToggle" class="flex items-center gap-1">
<button
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
:class="showActiveMode && catalogMode === 'explore'
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'explore')"
>
{{ $t('catalog.modes.explore') }}
</button>
<button
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
:class="showActiveMode && catalogMode === 'quote'
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'quote')"
>
{{ $t('catalog.modes.quote') }}
</button>
</nav>
<div v-if="showModeToggle" class="w-px h-6 bg-white/20 self-center" />
<div v-if="showModeToggle" class="flex items-center px-3 py-2">
<nav class="flex items-center gap-1">
<button
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="showActiveMode && catalogMode === 'explore' && !isClientArea
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'explore')"
>
{{ $t('catalog.modes.explore') }}
</button>
<NuxtLink
:to="localePath('/catalog/product')"
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="isQuoteStepPage
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
>
{{ $t('catalog.modes.quote') }}
</NuxtLink>
<!-- Role switcher: Я клиент + dropdown -->
<div v-if="loggedIn" class="flex items-center">
<NuxtLink
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
:class="isClientArea
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
>
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
</NuxtLink>
<!-- Dropdown для переключения роли (если есть обе роли) -->
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
<button
tabindex="0"
class="p-1 ml-0.5 transition-colors"
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
>
<Icon name="lucide:chevron-down" size="14" />
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
<li>
<a
:class="{ active: currentRole === 'BUYER' }"
@click="$emit('switch-role', 'BUYER')"
>
{{ $t('cabinetNav.roles.client') }}
</a>
</li>
<li>
<a
:class="{ active: currentRole === 'SELLER' }"
@click="$emit('switch-role', 'SELLER')"
>
{{ $t('cabinetNav.roles.seller') }}
</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
<!-- Center: Search input (vertically centered) -->
<div class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 justify-center">
<!-- Center: Search input OR Client Area tabs (vertically centered) -->
<div
class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 transition-all"
:class="isHeroLayout ? 'justify-start' : 'justify-center'"
:style="centerStyle"
>
<!-- Hero slot for home page title -->
<slot name="hero" />
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
<template v-if="catalogMode === 'quote'">
<div class="flex items-center w-full rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg divide-x divide-base-300/30">
<!-- Client Area tabs -->
<template v-if="isClientArea">
<div class="flex items-center gap-1 rounded-full pill-glass p-1">
<!-- BUYER tabs -->
<template v-if="currentRole !== 'SELLER'">
<NuxtLink
:to="localePath('/clientarea/orders')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/orders') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.orders') }}
</NuxtLink>
<NuxtLink
:to="localePath('/clientarea/addresses')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/addresses') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.addresses') }}
</NuxtLink>
</template>
<!-- SELLER tabs -->
<template v-else>
<NuxtLink
:to="localePath('/clientarea/offers')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/offers') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.myOffers') }}
</NuxtLink>
</template>
</div>
</template>
<!-- Quote mode: Step-based capsule navigation (like logistics) -->
<template v-else-if="catalogMode === 'quote'">
<div class="flex items-center w-full rounded-full pill-glass overflow-hidden">
<!-- Product segment -->
<button
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 rounded-l-full transition-colors min-w-0"
@click="$emit('edit-token', 'product')"
<NuxtLink
:to="localePath('/catalog/product')"
class="flex-1 px-4 py-2 text-left hover:bg-white/10 rounded-l-full transition-colors min-w-0"
>
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.product') }}</div>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.product') }}</span>
<div class="font-medium truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</div>
</button>
</NuxtLink>
<div class="w-px h-8 bg-base-300/40 self-center" />
<!-- Hub segment -->
<button
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 transition-colors min-w-0"
@click="$emit('edit-token', 'hub')"
<NuxtLink
:to="localePath('/catalog/destination')"
class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
>
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.hub') }}</div>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.hub') }}</span>
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
</button>
<!-- Quantity segment (inline input) -->
<div class="flex-1 px-4 py-2 min-w-0">
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div>
<div class="flex items-center gap-1">
<input
v-model="localQuantity"
type="number"
min="0"
step="0.1"
placeholder="—"
class="w-16 font-medium bg-transparent outline-none text-base-content [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@blur="$emit('update-quantity', localQuantity)"
@keyup.enter="$emit('update-quantity', localQuantity)"
/>
<span v-if="localQuantity" class="text-base-content/60 text-sm">{{ $t('units.t') }}</span>
</div>
</div>
<!-- Search button inside -->
</NuxtLink>
<div class="w-px h-8 bg-base-300/40 self-center" />
<!-- Quantity segment -->
<NuxtLink
:to="localePath('/catalog/quantity')"
class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.quantity') }}</span>
<div class="font-medium truncate text-base-content">{{ quantity || '—' }} {{ quantity ? $t('units.t') : '' }}</div>
</NuxtLink>
<!-- Search button -->
<button
class="btn btn-primary btn-circle m-1"
:disabled="!canSearch"
@click="$emit('search')"
@click="navigateToSearch"
>
<Icon name="lucide:search" size="18" />
</button>
@@ -91,7 +182,7 @@
<template v-else>
<!-- Big pill input -->
<div
class="flex items-center gap-3 w-full px-5 py-3 rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
class="flex items-center gap-3 w-full px-5 py-3 rounded-full pill-glass focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
@click="focusInput"
>
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
@@ -135,52 +226,47 @@
</template>
</div>
<!-- Right: AI + Globe + Team + User (top aligned like logo) -->
<div class="flex items-start gap-1 flex-shrink-0 pt-4">
<!-- AI Assistant button -->
<NuxtLink
:to="localePath('/clientarea/ai')"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:bot" size="18" />
</NuxtLink>
<!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:globe" size="18" />
</button>
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
<div class="flex gap-2 mb-4">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="btn btn-sm"
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<!-- Right: Globe + Team + User (top aligned like logo) -->
<div class="flex items-center flex-shrink-0 rounded-full pill-glass">
<div class="w-px h-6 bg-white/20 self-center" />
<div class="flex items-center px-2 py-2">
<!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end">
<button
class="btn btn-sm btn-ghost w-full justify-start"
@click="$emit('toggle-theme')"
tabindex="0"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
<Icon name="lucide:globe" size="18" />
</button>
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
<div class="flex gap-2 mb-4">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="btn btn-sm"
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<button
class="btn btn-sm btn-ghost w-full justify-start"
@click="$emit('toggle-theme')"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
</button>
</div>
</div>
</div>
<!-- Team dropdown -->
<template v-if="loggedIn && userData?.teams?.length">
<div v-if="loggedIn && userData?.teams?.length" class="w-px h-6 bg-white/20 self-center" />
<div v-if="loggedIn && userData?.teams?.length" class="flex items-center px-2 py-2">
<div class="dropdown dropdown-end">
<button
tabindex="0"
@@ -212,10 +298,11 @@
</li>
</ul>
</div>
</template>
</div>
<!-- User menu -->
<template v-if="sessionChecked">
<div v-if="sessionChecked" class="w-px h-6 bg-white/20 self-center" />
<div v-if="sessionChecked" class="flex items-center px-2 py-2">
<template v-if="loggedIn">
<div class="dropdown dropdown-end">
<div
@@ -265,9 +352,10 @@
{{ $t('auth.login') }}
</button>
</template>
</template>
</div>
</div>
</div>
</div>
</header>
</template>
@@ -292,6 +380,9 @@ const props = withDefaults(defineProps<{
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null
isSeller?: boolean
// Role switching props
hasMultipleRoles?: boolean
currentRole?: string
// Search props
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
availableChips?: Array<{ type: string; label: string }>
@@ -310,17 +401,26 @@ const props = withDefaults(defineProps<{
isCollapsed?: boolean
// Home page flag for transparent background
isHomePage?: boolean
// Client area flag - shows cabinet tabs instead of search
isClientArea?: boolean
// AI chat sidebar state
chatOpen?: boolean
// Dynamic height for hero effect
height?: number
// Collapse progress for hero layout
collapseProgress?: number
}>(), {
height: 100
height: 100,
collapseProgress: 1
})
defineEmits([
'toggle-chat',
'toggle-theme',
'sign-out',
'sign-in',
'switch-team',
'switch-role',
// Search events
'start-select',
'cancel-select',
@@ -334,9 +434,34 @@ defineEmits([
])
const localePath = useLocalePath()
const route = useRoute()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const { t } = useI18n()
const { chatOpen } = toRefs(props)
const router = useRouter()
// Check if we're on a quote step page
const isQuoteStepPage = computed(() => {
const path = route.path
return path.includes('/catalog/product') ||
path.includes('/catalog/destination') ||
path.includes('/catalog/quantity') ||
path.includes('/catalog/results')
})
// Navigate to search results (quote mode step flow)
const navigateToSearch = () => {
router.push(localePath('/catalog/product'))
}
// Check if client area tab is active
const isClientAreaTabActive = (path: string) => {
const currentPath = route.path
const localizedPath = localePath(path)
return currentPath === localizedPath || currentPath.startsWith(localizedPath + '/')
}
const inputRef = ref<HTMLInputElement>()
const localSearchQuery = ref(props.searchQuery || '')
@@ -389,21 +514,26 @@ const getTokenIcon = (type: string) => {
return icons[type] || 'lucide:tag'
}
// Header background classes
const headerClasses = computed(() => {
if (props.isCollapsed) {
// Glass style when collapsed
return 'bg-black/30 backdrop-blur-md border-b border-white/10'
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
const topRowHeight = 100
const rowStyle = computed(() => {
if (isHeroLayout.value) {
return { height: `${topRowHeight}px` }
}
if (props.isHomePage) {
// Transparent on home page (animation visible behind)
return 'bg-transparent'
}
// White on other pages
return 'bg-base-100 border-b border-base-300'
return { height: `${props.height}px` }
})
const centerStyle = computed(() => {
if (!isHeroLayout.value) return {}
const heroHeight = props.height || topRowHeight
const minTop = 0
const maxTop = Math.max(120, Math.round(heroHeight * 0.42))
const progress = Math.min(1, Math.max(0, props.collapseProgress || 0))
const top = Math.round(maxTop - (maxTop - minTop) * progress)
return { marginTop: `${top}px` }
})
// Use white text on dark backgrounds (collapsed or home page with animation)
const useWhiteText = computed(() => props.isCollapsed || props.isHomePage)
</script>

View File

@@ -1,15 +1,5 @@
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Loading state -->
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-base-100/80">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ $t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</div>
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
@@ -17,13 +7,16 @@
ref="mapRef"
:map-id="mapId"
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
:clustered-points="isInfoMode ? [] : (useServerClustering ? clusteredNodes : [])"
:clustered-points="isInfoMode ? [] : (useServerClustering && !useTypedClusters ? clusteredNodes : [])"
:clustered-points-by-type="isInfoMode ? undefined : (useServerClustering && useTypedClusters ? clusteredPointsByType : undefined)"
:use-server-clustering="useServerClustering && !isInfoMode"
:point-color="activePointColor"
:entity-type="activeEntityType"
:hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
:related-points="relatedPoints"
:info-loading="infoLoading"
:fit-padding-left="fitPaddingLeft"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
@@ -32,17 +25,17 @@
<!-- View mode loading indicator -->
<div
v-if="clusterLoading"
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-black/50 backdrop-blur-md rounded-full px-4 py-2 border border-white/20"
v-if="clusterLoading || loading"
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 pill-glass rounded-full px-4 py-2"
>
<span class="loading loading-spinner loading-sm text-white" />
<span class="text-white text-sm">{{ $t('common.loading') }}</span>
<span class="loading loading-spinner loading-sm text-base-content" />
<span class="text-base-content text-sm font-medium">{{ $t('common.loading') }}</span>
</div>
<!-- List button (LEFT, opens panel) - hide when panel is open -->
<button
v-if="!isPanelOpen"
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 text-white text-sm hover:bg-black/40 transition-colors"
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 pill-glass rounded-full px-3 py-1.5 text-base-content text-sm hover:bg-white/20 transition-colors"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
@@ -52,7 +45,7 @@
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
<label
v-if="selectMode !== null"
class="absolute top-[116px] left-[420px] z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 cursor-pointer text-white text-sm hover:bg-black/40 transition-colors"
class="absolute top-[116px] left-[calc(1rem+32rem+1rem)] z-20 hidden lg:flex items-center gap-2 pill-glass rounded-full px-3 py-1.5 cursor-pointer text-base-content text-sm hover:bg-white/20 transition-colors"
>
<input
type="checkbox"
@@ -64,13 +57,14 @@
</label>
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode -->
<div v-if="!isInfoMode" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
<div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
<!-- View mode toggle -->
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
<div class="flex gap-1 pill-glass rounded-full p-1">
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
:class="mapViewMode === 'offers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
v-if="showOffersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'offers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('offers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
@@ -79,8 +73,9 @@
{{ $t('catalog.views.offers') }}
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
v-if="showHubsToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('hubs')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
@@ -89,8 +84,9 @@
{{ $t('catalog.views.hubs') }}
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
v-if="showSuppliersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('suppliers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
@@ -105,9 +101,10 @@
<Transition name="slide-left">
<div
v-if="isPanelOpen"
class="absolute top-[116px] left-4 bottom-4 z-30 w-96 max-w-[calc(100vw-2rem)] hidden lg:block"
class="absolute top-[116px] left-4 bottom-4 z-30 max-w-[calc(100vw-2rem)] hidden lg:block"
:class="panelWidth"
>
<div class="bg-black/50 backdrop-blur-md rounded-xl shadow-lg border border-white/10 h-full flex flex-col text-white">
<div class="bg-white/90 backdrop-blur-[14px] border border-white/50 rounded-[1.1rem] shadow-2xl h-full flex flex-col text-base-content">
<slot name="panel" />
</div>
</div>
@@ -119,17 +116,18 @@
<div class="flex justify-between px-4 mb-2">
<!-- List button (mobile) -->
<button
class="flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-2 border border-white/10 text-white text-sm"
class="flex items-center gap-2 pill-glass rounded-full px-3 py-2 text-base-content text-sm font-medium"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
<span>{{ $t('catalog.list') }}</span>
</button>
<!-- Mobile view toggle - hide in info mode -->
<div v-if="!isInfoMode" class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 pill-glass rounded-full p-1">
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
v-if="showOffersToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'offers' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('offers')"
>
@@ -138,7 +136,8 @@
</span>
</button>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
v-if="showHubsToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('hubs')"
>
@@ -147,7 +146,8 @@
</span>
</button>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
v-if="showSuppliersToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('suppliers')"
>
@@ -162,14 +162,14 @@
<Transition name="slide-up">
<div
v-if="isPanelOpen"
class="bg-black/50 backdrop-blur-md rounded-t-xl shadow-lg border border-white/10 transition-all duration-300 text-white h-[60vh]"
class="bg-white rounded-t-3xl shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 text-base-content h-[60vh]"
>
<!-- Drag handle / close -->
<div
class="flex justify-center py-2 cursor-pointer"
@click="closePanel"
>
<div class="w-10 h-1 bg-white/30 rounded-full" />
<div class="w-10 h-1 bg-base-300 rounded-full" />
</div>
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
@@ -189,6 +189,34 @@ const { mapViewMode, setMapViewMode, selectMode, startSelect, cancelSelect } = u
// Panel is open when selectMode is set OR when showPanel prop is true (info/quote)
const isPanelOpen = computed(() => props.showPanel || selectMode.value !== null)
const isDesktop = ref(false)
onMounted(() => {
const media = window.matchMedia('(min-width: 1024px)')
const update = () => {
isDesktop.value = media.matches
}
update()
media.addEventListener('change', update)
onUnmounted(() => {
media.removeEventListener('change', update)
})
})
const panelWidthPx = computed(() => {
const match = props.panelWidth.match(/w-\[(\d+(?:\.\d+)?)rem\]/)
if (match) return Number(match[1]) * 16
if (props.panelWidth === 'w-96') return 24 * 16
if (props.panelWidth === 'w-80') return 20 * 16
return 0
})
const fitPaddingLeft = computed(() => {
if (!isPanelOpen.value || !isDesktop.value || panelWidthPx.value === 0) return 0
const leftInset = 16
const rightInset = 16
return leftInset + panelWidthPx.value + rightInset
})
// Open panel based on current mapViewMode
const openPanel = () => {
const newSelectMode = mapViewMode.value === 'hubs' ? 'hub'
@@ -244,12 +272,23 @@ const props = withDefaults(defineProps<{
loading?: boolean
useServerClustering?: boolean
clusterNodeType?: string
useTypedClusters?: boolean
mapId?: string
pointColor?: string
hoveredId?: string
items?: MapItem[]
showPanel?: boolean
filterByBounds?: boolean
infoLoading?: boolean
forceInfoMode?: boolean
panelWidth?: string
hideViewToggle?: boolean
showOffersToggle?: boolean
showHubsToggle?: boolean
showSuppliersToggle?: boolean
clusterProductUuid?: string
clusterHubUuid?: string
clusterSupplierUuid?: string
relatedPoints?: Array<{
uuid: string
name: string
@@ -261,11 +300,22 @@ const props = withDefaults(defineProps<{
loading: false,
useServerClustering: true,
clusterNodeType: 'offer',
useTypedClusters: false,
mapId: 'catalog-map',
pointColor: '#f97316',
items: () => [],
showPanel: false,
filterByBounds: false,
infoLoading: false,
forceInfoMode: false,
panelWidth: 'w-96',
hideViewToggle: false,
showOffersToggle: true,
showHubsToggle: true,
showSuppliersToggle: true,
clusterProductUuid: undefined,
clusterHubUuid: undefined,
clusterSupplierUuid: undefined,
relatedPoints: () => []
})
@@ -276,11 +326,73 @@ const emit = defineEmits<{
'update:filter-by-bounds': [value: boolean]
}>()
// Server-side clustering - use computed node type based on view mode
const { clusteredNodes, fetchClusters, loading: clusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
const clusterProductUuid = computed(() => props.clusterProductUuid ?? undefined)
const clusterHubUuid = computed(() => props.clusterHubUuid ?? undefined)
const clusterSupplierUuid = computed(() => props.clusterSupplierUuid ?? undefined)
// Server-side clustering (single-type mode)
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(
undefined,
activeClusterNodeType,
)
// Server-side clustering (typed mode)
const offerClusters = useClusteredNodes(undefined, ref('offer'))
const hubClusters = useClusteredNodes(undefined, ref('logistics'))
const supplierClusters = useClusteredNodes(undefined, ref('supplier'))
const clusteredPointsByType = computed(() => ({
offer: offerClusters.clusteredNodes.value,
hub: hubClusters.clusteredNodes.value,
supplier: supplierClusters.clusteredNodes.value
}))
const activeClusterType = computed<'offer' | 'hub' | 'supplier'>(() => {
if (mapViewMode.value === 'hubs') return 'hub'
if (mapViewMode.value === 'suppliers') return 'supplier'
return 'offer'
})
const clusterLoading = computed(() => {
if (!useTypedClusters.value) return singleClusterLoading.value
if (activeClusterType.value === 'hub') return hubClusters.loading.value
if (activeClusterType.value === 'supplier') return supplierClusters.loading.value
return offerClusters.loading.value
})
const clearInactiveClusters = (active: 'offer' | 'hub' | 'supplier') => {
if (active !== 'offer') offerClusters.clearNodes()
if (active !== 'hub') hubClusters.clearNodes()
if (active !== 'supplier') supplierClusters.clearNodes()
}
const fetchActiveClusters = async () => {
if (!currentBounds.value) return
clearInactiveClusters(activeClusterType.value)
if (activeClusterType.value === 'hub') {
await hubClusters.fetchClusters(currentBounds.value)
return
}
if (activeClusterType.value === 'supplier') {
await supplierClusters.fetchClusters(currentBounds.value)
return
}
await offerClusters.fetchClusters(currentBounds.value)
}
// Refetch clusters when view mode changes
watch(mapViewMode, async () => {
if (!props.useServerClustering) return
if (isInfoMode.value) return
if (useTypedClusters.value) {
clearInactiveClusters(activeClusterType.value)
if (currentBounds.value) {
await fetchActiveClusters()
}
return
}
// Clear old data first
clearNodes()
// Refetch with current bounds if available
@@ -289,6 +401,17 @@ watch(mapViewMode, async () => {
}
})
watch([clusterProductUuid, clusterHubUuid, clusterSupplierUuid], async () => {
if (!props.useServerClustering) return
if (isInfoMode.value) return
if (!currentBounds.value) return
if (useTypedClusters.value) {
await fetchActiveClusters()
return
}
await fetchClusters(currentBounds.value)
})
// Map refs
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
@@ -299,7 +422,7 @@ const selectedMapItem = ref<MapItem | null>(null)
const mobilePanelExpanded = ref(false)
// Info mode - when relatedPoints are present, hide clusters and show only related points
const isInfoMode = computed(() => props.relatedPoints && props.relatedPoints.length > 0)
const isInfoMode = computed(() => props.forceInfoMode || (props.relatedPoints && props.relatedPoints.length > 0))
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
@@ -330,7 +453,11 @@ const onBoundsChange = (bounds: MapBounds) => {
emit('bounds-change', bounds)
// Don't fetch clusters when in info mode
if (props.useServerClustering && !isInfoMode.value) {
fetchClusters(bounds)
if (useTypedClusters.value) {
fetchActiveClusters()
} else {
fetchClusters(bounds)
}
}
}

View File

@@ -13,96 +13,61 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
Date: { input: string; output: string; }
DateTime: { input: string; output: string; }
Decimal: { input: string; output: string; }
};
export type OfferType = {
__typename?: 'OfferType';
categoryName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
export type Offer = {
__typename?: 'Offer';
categoryName?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['String']['output'];
currency: Scalars['String']['output'];
description: Scalars['String']['output'];
id: Scalars['ID']['output'];
locationCountry: Scalars['String']['output'];
locationCountryCode: Scalars['String']['output'];
description?: Maybe<Scalars['String']['output']>;
locationCountry?: Maybe<Scalars['String']['output']>;
locationCountryCode?: Maybe<Scalars['String']['output']>;
locationLatitude?: Maybe<Scalars['Float']['output']>;
locationLongitude?: Maybe<Scalars['Float']['output']>;
locationName: Scalars['String']['output'];
locationUuid: Scalars['String']['output'];
pricePerUnit?: Maybe<Scalars['Decimal']['output']>;
locationName?: Maybe<Scalars['String']['output']>;
locationUuid?: Maybe<Scalars['String']['output']>;
pricePerUnit: Scalars['Float']['output'];
productName: Scalars['String']['output'];
productUuid: Scalars['String']['output'];
quantity: Scalars['Decimal']['output'];
status: OffersOfferStatusChoices;
quantity: Scalars['Float']['output'];
status: Scalars['String']['output'];
teamUuid: Scalars['String']['output'];
terminusDocumentId: Scalars['String']['output'];
terminusSchemaId: Scalars['String']['output'];
unit: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
updatedAt: Scalars['String']['output'];
uuid: Scalars['String']['output'];
validUntil?: Maybe<Scalars['Date']['output']>;
workflowError: Scalars['String']['output'];
workflowStatus: OffersOfferWorkflowStatusChoices;
validUntil?: Maybe<Scalars['String']['output']>;
};
/** An enumeration. */
export enum OffersOfferStatusChoices {
/** Активно */
Active = 'ACTIVE',
/** Отменено */
Cancelled = 'CANCELLED',
/** Закрыто */
Closed = 'CLOSED',
/** Черновик */
Draft = 'DRAFT'
}
/** An enumeration. */
export enum OffersOfferWorkflowStatusChoices {
/** Активен */
Active = 'ACTIVE',
/** Ошибка */
Error = 'ERROR',
/** Ожидает обработки */
Pending = 'PENDING'
}
export type Product = {
__typename?: 'Product';
categoryId?: Maybe<Scalars['Int']['output']>;
categoryId?: Maybe<Scalars['String']['output']>;
categoryName?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
terminusSchemaId?: Maybe<Scalars['String']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
/** Public schema - no authentication required */
export type PublicQuery = {
__typename?: 'PublicQuery';
/** Get products that have active offers */
export type Query = {
__typename?: 'Query';
getAvailableProducts?: Maybe<Array<Maybe<Product>>>;
getOffer?: Maybe<OfferType>;
getOffers?: Maybe<Array<Maybe<OfferType>>>;
getOffer?: Maybe<Offer>;
getOffers?: Maybe<Array<Maybe<Offer>>>;
getOffersCount?: Maybe<Scalars['Int']['output']>;
getProducts?: Maybe<Array<Maybe<Product>>>;
getSupplierProfile?: Maybe<SupplierProfileType>;
/** Get supplier profile by team UUID */
getSupplierProfileByTeam?: Maybe<SupplierProfileType>;
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfileType>>>;
getSupplierProfile?: Maybe<SupplierProfile>;
getSupplierProfileByTeam?: Maybe<SupplierProfile>;
getSupplierProfiles?: Maybe<Array<Maybe<SupplierProfile>>>;
getSupplierProfilesCount?: Maybe<Scalars['Int']['output']>;
};
/** Public schema - no authentication required */
export type PublicQueryGetOfferArgs = {
export type QueryGetOfferArgs = {
uuid: Scalars['String']['input'];
};
/** Public schema - no authentication required */
export type PublicQueryGetOffersArgs = {
export type QueryGetOffersArgs = {
categoryName?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
locationUuid?: InputMaybe<Scalars['String']['input']>;
@@ -113,8 +78,7 @@ export type PublicQueryGetOffersArgs = {
};
/** Public schema - no authentication required */
export type PublicQueryGetOffersCountArgs = {
export type QueryGetOffersCountArgs = {
categoryName?: InputMaybe<Scalars['String']['input']>;
locationUuid?: InputMaybe<Scalars['String']['input']>;
productUuid?: InputMaybe<Scalars['String']['input']>;
@@ -123,20 +87,17 @@ export type PublicQueryGetOffersCountArgs = {
};
/** Public schema - no authentication required */
export type PublicQueryGetSupplierProfileArgs = {
export type QueryGetSupplierProfileArgs = {
uuid: Scalars['String']['input'];
};
/** Public schema - no authentication required */
export type PublicQueryGetSupplierProfileByTeamArgs = {
export type QueryGetSupplierProfileByTeamArgs = {
teamUuid: Scalars['String']['input'];
};
/** Public schema - no authentication required */
export type PublicQueryGetSupplierProfilesArgs = {
export type QueryGetSupplierProfilesArgs = {
country?: InputMaybe<Scalars['String']['input']>;
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -144,51 +105,46 @@ export type PublicQueryGetSupplierProfilesArgs = {
};
/** Public schema - no authentication required */
export type PublicQueryGetSupplierProfilesCountArgs = {
export type QueryGetSupplierProfilesCountArgs = {
country?: InputMaybe<Scalars['String']['input']>;
isVerified?: InputMaybe<Scalars['Boolean']['input']>;
};
/** Профиль поставщика на бирже */
export type SupplierProfileType = {
__typename?: 'SupplierProfileType';
country: Scalars['String']['output'];
export type SupplierProfile = {
__typename?: 'SupplierProfile';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
description: Scalars['String']['output'];
id: Scalars['ID']['output'];
description?: Maybe<Scalars['String']['output']>;
isActive: Scalars['Boolean']['output'];
isVerified: Scalars['Boolean']['output'];
kycProfileUuid: Scalars['String']['output'];
kycProfileUuid?: Maybe<Scalars['String']['output']>;
latitude?: Maybe<Scalars['Float']['output']>;
logoUrl: Scalars['String']['output'];
logoUrl?: Maybe<Scalars['String']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
name: Scalars['String']['output'];
offersCount?: Maybe<Scalars['Int']['output']>;
teamUuid: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
uuid: Scalars['String']['output'];
};
export type GetAvailableProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetAvailableProductsQueryResult = { __typename?: 'PublicQuery', getAvailableProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetAvailableProductsQueryResult = { __typename?: 'Query', getAvailableProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetLocationOffersQueryVariables = Exact<{
locationUuid: Scalars['String']['input'];
}>;
export type GetLocationOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetLocationOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetOfferQueryVariables = Exact<{
uuid: Scalars['String']['input'];
}>;
export type GetOfferQueryResult = { __typename?: 'PublicQuery', getOffer?: { __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
export type GetOfferQueryResult = { __typename?: 'Query', getOffer?: { __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null };
export type GetOffersQueryVariables = Exact<{
productUuid?: InputMaybe<Scalars['String']['input']>;
@@ -200,47 +156,47 @@ export type GetOffersQueryVariables = Exact<{
}>;
export type GetOffersQueryResult = { __typename?: 'PublicQuery', getOffersCount?: number | null, getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetOffersQueryResult = { __typename?: 'Query', getOffersCount?: number | null, getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetProductQueryVariables = Exact<{
uuid: Scalars['String']['input'];
}>;
export type GetProductQueryResult = { __typename?: 'PublicQuery', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetProductQueryResult = { __typename?: 'Query', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetProductOffersQueryVariables = Exact<{
productUuid: Scalars['String']['input'];
}>;
export type GetProductOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetProductOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetProductsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetProductsQueryResult = { __typename?: 'PublicQuery', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: number | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetProductsQueryResult = { __typename?: 'Query', getProducts?: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, categoryId?: string | null, categoryName?: string | null, terminusSchemaId?: string | null } | null> | null };
export type GetSupplierOffersQueryVariables = Exact<{
teamUuid: Scalars['String']['input'];
}>;
export type GetSupplierOffersQueryResult = { __typename?: 'PublicQuery', getOffers?: Array<{ __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: string, unit: string, pricePerUnit?: string | null, currency: string, description: string, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetSupplierOffersQueryResult = { __typename?: 'Query', getOffers?: Array<{ __typename?: 'Offer', uuid: string, teamUuid: string, status: string, locationUuid?: string | null, locationName?: string | null, locationCountry?: string | null, locationCountryCode?: string | null, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName?: string | null, quantity: number, unit: string, pricePerUnit: number, currency: string, description?: string | null, validUntil?: string | null, createdAt: string, updatedAt: string } | null> | null };
export type GetSupplierProfileQueryVariables = Exact<{
uuid: Scalars['String']['input'];
}>;
export type GetSupplierProfileQueryResult = { __typename?: 'PublicQuery', getSupplierProfile?: { __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, kycProfileUuid: string, name: string, description: string, country: string, logoUrl: string, isVerified: boolean, isActive: boolean, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null };
export type GetSupplierProfileQueryResult = { __typename?: 'Query', getSupplierProfile?: { __typename?: 'SupplierProfile', uuid: string, teamUuid: string, kycProfileUuid?: string | null, name: string, description?: string | null, country?: string | null, logoUrl?: string | null, isVerified: boolean, isActive: boolean, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null };
export type GetSupplierProfileByTeamQueryVariables = Exact<{
teamUuid: Scalars['String']['input'];
}>;
export type GetSupplierProfileByTeamQueryResult = { __typename?: 'PublicQuery', getSupplierProfileByTeam?: { __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, kycProfileUuid: string, name: string, description: string, country: string, logoUrl: string, isVerified: boolean, isActive: boolean, offersCount?: number | null } | null };
export type GetSupplierProfileByTeamQueryResult = { __typename?: 'Query', getSupplierProfileByTeam?: { __typename?: 'SupplierProfile', uuid: string, teamUuid: string, kycProfileUuid?: string | null, name: string, description?: string | null, country?: string | null, logoUrl?: string | null, isVerified: boolean, isActive: boolean, offersCount?: number | null } | null };
export type GetSupplierProfilesQueryVariables = Exact<{
country?: InputMaybe<Scalars['String']['input']>;
@@ -249,7 +205,7 @@ export type GetSupplierProfilesQueryVariables = Exact<{
}>;
export type GetSupplierProfilesQueryResult = { __typename?: 'PublicQuery', getSupplierProfilesCount?: number | null, getSupplierProfiles?: Array<{ __typename?: 'SupplierProfileType', uuid: string, teamUuid: string, name: string, description: string, country: string, countryCode?: string | null, logoUrl: string, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null> | null };
export type GetSupplierProfilesQueryResult = { __typename?: 'Query', getSupplierProfilesCount?: number | null, getSupplierProfiles?: Array<{ __typename?: 'SupplierProfile', uuid: string, teamUuid: string, name: string, description?: string | null, country?: string | null, countryCode?: string | null, logoUrl?: string | null, offersCount?: number | null, latitude?: number | null, longitude?: number | null } | null> | null };
export const GetAvailableProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAvailableProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAvailableProducts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"categoryId"}},{"kind":"Field","name":{"kind":"Name","value":"categoryName"}},{"kind":"Field","name":{"kind":"Name","value":"terminusSchemaId"}}]}}]}}]} as unknown as DocumentNode<GetAvailableProductsQueryResult, GetAvailableProductsQueryVariables>;

View File

@@ -13,27 +13,21 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
JSON: { input: Record<string, unknown>; output: Record<string, unknown>; }
};
/** Cluster or individual point for map display. */
export type ClusterPointType = {
__typename?: 'ClusterPointType';
/** 1 for single point, >1 for cluster */
export type ClusterPoint = {
__typename?: 'ClusterPoint';
count?: Maybe<Scalars['Int']['output']>;
/** Zoom level to expand cluster */
expansionZoom?: Maybe<Scalars['Int']['output']>;
/** UUID for points, 'cluster-N' for clusters */
id?: Maybe<Scalars['String']['output']>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
/** Node name (only for single points) */
name?: Maybe<Scalars['String']['output']>;
};
/** Edge between two nodes (route). */
export type EdgeType = {
__typename?: 'EdgeType';
export type Edge = {
__typename?: 'Edge';
distanceKm?: Maybe<Scalars['Float']['output']>;
toLatitude?: Maybe<Scalars['Float']['output']>;
toLongitude?: Maybe<Scalars['Float']['output']>;
@@ -43,22 +37,12 @@ export type EdgeType = {
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Auto + rail edges for a node, rail uses nearest rail node. */
export type NodeConnectionsType = {
__typename?: 'NodeConnectionsType';
autoEdges?: Maybe<Array<Maybe<EdgeType>>>;
hub?: Maybe<NodeType>;
railEdges?: Maybe<Array<Maybe<EdgeType>>>;
railNode?: Maybe<NodeType>;
};
/** Logistics node with edges to neighbors. */
export type NodeType = {
__typename?: 'NodeType';
export type Node = {
__typename?: 'Node';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
distanceKm?: Maybe<Scalars['Float']['output']>;
edges?: Maybe<Array<Maybe<EdgeType>>>;
edges?: Maybe<Array<Maybe<Edge>>>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
name?: Maybe<Scalars['String']['output']>;
@@ -67,9 +51,16 @@ export type NodeType = {
uuid?: Maybe<Scalars['String']['output']>;
};
/** Offer node with location and product info. */
export type OfferNodeType = {
__typename?: 'OfferNodeType';
export type NodeConnections = {
__typename?: 'NodeConnections';
autoEdges?: Maybe<Array<Maybe<Edge>>>;
hub?: Maybe<Node>;
railEdges?: Maybe<Array<Maybe<Edge>>>;
railNode?: Maybe<Node>;
};
export type OfferNode = {
__typename?: 'OfferNode';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
currency?: Maybe<Scalars['String']['output']>;
@@ -86,9 +77,8 @@ export type OfferNodeType = {
uuid?: Maybe<Scalars['String']['output']>;
};
/** Offer with route information to destination. */
export type OfferWithRouteType = {
__typename?: 'OfferWithRouteType';
export type OfferWithRoute = {
__typename?: 'OfferWithRoute';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
currency?: Maybe<Scalars['String']['output']>;
@@ -99,94 +89,62 @@ export type OfferWithRouteType = {
productName?: Maybe<Scalars['String']['output']>;
productUuid?: Maybe<Scalars['String']['output']>;
quantity?: Maybe<Scalars['String']['output']>;
routes?: Maybe<Array<Maybe<RoutePathType>>>;
routes?: Maybe<Array<Maybe<RoutePath>>>;
supplierName?: Maybe<Scalars['String']['output']>;
supplierUuid?: Maybe<Scalars['String']['output']>;
unit?: Maybe<Scalars['String']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
/** Route options for a product source to the destination. */
export type ProductRouteOptionType = {
__typename?: 'ProductRouteOptionType';
export type Product = {
__typename?: 'Product';
name?: Maybe<Scalars['String']['output']>;
offersCount?: Maybe<Scalars['Int']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
export type ProductRouteOption = {
__typename?: 'ProductRouteOption';
distanceKm?: Maybe<Scalars['Float']['output']>;
routes?: Maybe<Array<Maybe<RoutePathType>>>;
routes?: Maybe<Array<Maybe<RoutePath>>>;
sourceLat?: Maybe<Scalars['Float']['output']>;
sourceLon?: Maybe<Scalars['Float']['output']>;
sourceName?: Maybe<Scalars['String']['output']>;
sourceUuid?: Maybe<Scalars['String']['output']>;
};
/** Unique product from offers. */
export type ProductType = {
__typename?: 'ProductType';
name?: Maybe<Scalars['String']['output']>;
/** Number of offers for this product */
offersCount?: Maybe<Scalars['Int']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
/** Root query. */
export type Query = {
__typename?: 'Query';
/** Get auto route between two points via GraphHopper */
autoRoute?: Maybe<RouteType>;
/** Get clustered nodes for map display (server-side clustering) */
clusteredNodes?: Maybe<Array<Maybe<ClusterPointType>>>;
/** List of countries that have logistics hubs */
hubCountries?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Get hubs where a product is available nearby */
hubsForProduct?: Maybe<Array<Maybe<NodeType>>>;
/** Get paginated list of logistics hubs */
hubsList?: Maybe<Array<Maybe<NodeType>>>;
/** Get nearest hubs to an offer location */
hubsNearOffer?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest hubs to coordinates (optionally filtered by product) */
nearestHubs?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest logistics nodes to given coordinates */
nearestNodes?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest offers to coordinates with optional routes to hub */
nearestOffers?: Maybe<Array<Maybe<OfferWithRouteType>>>;
/** Find nearest suppliers to coordinates (optionally filtered by product) */
nearestSuppliers?: Maybe<Array<Maybe<SupplierType>>>;
/** Get node by UUID with all edges to neighbors */
node?: Maybe<NodeType>;
/** Get auto + rail edges for a node (rail uses nearest rail node) */
nodeConnections?: Maybe<NodeConnectionsType>;
/** Get all nodes (without edges for performance) */
nodes?: Maybe<Array<Maybe<NodeType>>>;
/** Get total count of nodes (with optional transport/country/bounds filter) */
nodesCount?: Maybe<Scalars['Int']['output']>;
/** Get route from a specific offer to hub */
offerToHub?: Maybe<ProductRouteOptionType>;
/** Get offers for a product with routes to hub (auto → rail* → auto) */
offersByHub?: Maybe<Array<Maybe<ProductRouteOptionType>>>;
/** Get all offers for a product */
offersByProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
/** Get offers from a supplier for a specific product */
offersBySupplierProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
/** Get unique products from all offers */
products?: Maybe<Array<Maybe<ProductType>>>;
/** Get products offered by a supplier */
productsBySupplier?: Maybe<Array<Maybe<ProductType>>>;
/** Get paginated list of products from graph */
productsList?: Maybe<Array<Maybe<ProductType>>>;
/** Get products available near a hub */
productsNearHub?: Maybe<Array<Maybe<ProductType>>>;
/** Get rail route between two points via OpenRailRouting */
railRoute?: Maybe<RouteType>;
/** Get route from offer to target coordinates (finds nearest hub to coordinate) */
routeToCoordinate?: Maybe<ProductRouteOptionType>;
/** Get unique suppliers from all offers */
suppliers?: Maybe<Array<Maybe<SupplierType>>>;
/** Get suppliers that offer a specific product */
suppliersForProduct?: Maybe<Array<Maybe<SupplierType>>>;
/** Get paginated list of suppliers from graph */
suppliersList?: Maybe<Array<Maybe<SupplierType>>>;
autoRoute?: Maybe<Route>;
clusteredNodes: Array<ClusterPoint>;
hubCountries: Array<Scalars['String']['output']>;
hubsForProduct: Array<Node>;
hubsList: Array<Node>;
hubsNearOffer: Array<Node>;
nearestHubs: Array<Node>;
nearestNodes: Array<Node>;
nearestOffers: Array<OfferWithRoute>;
nearestSuppliers: Array<Supplier>;
node?: Maybe<Node>;
nodeConnections?: Maybe<NodeConnections>;
nodes: Array<Node>;
nodesCount: Scalars['Int']['output'];
offerToHub?: Maybe<ProductRouteOption>;
offersByHub: Array<ProductRouteOption>;
offersByProduct: Array<OfferNode>;
offersBySupplierProduct: Array<OfferNode>;
products: Array<Product>;
productsBySupplier: Array<Product>;
productsList: Array<Product>;
productsNearHub: Array<Product>;
railRoute?: Maybe<Route>;
routeToCoordinate?: Maybe<ProductRouteOption>;
suppliers: Array<Supplier>;
suppliersForProduct: Array<Supplier>;
suppliersList: Array<Supplier>;
};
/** Root query. */
export type QueryAutoRouteArgs = {
fromLat: Scalars['Float']['input'];
fromLon: Scalars['Float']['input'];
@@ -195,7 +153,6 @@ export type QueryAutoRouteArgs = {
};
/** Root query. */
export type QueryClusteredNodesArgs = {
east: Scalars['Float']['input'];
nodeType?: InputMaybe<Scalars['String']['input']>;
@@ -207,14 +164,12 @@ export type QueryClusteredNodesArgs = {
};
/** Root query. */
export type QueryHubsForProductArgs = {
productUuid: Scalars['String']['input'];
radiusKm?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryHubsListArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -227,14 +182,12 @@ export type QueryHubsListArgs = {
};
/** Root query. */
export type QueryHubsNearOfferArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
offerUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryNearestHubsArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -244,7 +197,6 @@ export type QueryNearestHubsArgs = {
};
/** Root query. */
export type QueryNearestNodesArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -252,7 +204,6 @@ export type QueryNearestNodesArgs = {
};
/** Root query. */
export type QueryNearestOffersArgs = {
hubUuid?: InputMaybe<Scalars['String']['input']>;
lat: Scalars['Float']['input'];
@@ -263,7 +214,6 @@ export type QueryNearestOffersArgs = {
};
/** Root query. */
export type QueryNearestSuppliersArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -273,13 +223,11 @@ export type QueryNearestSuppliersArgs = {
};
/** Root query. */
export type QueryNodeArgs = {
uuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryNodeConnectionsArgs = {
limitAuto?: InputMaybe<Scalars['Int']['input']>;
limitRail?: InputMaybe<Scalars['Int']['input']>;
@@ -287,7 +235,6 @@ export type QueryNodeConnectionsArgs = {
};
/** Root query. */
export type QueryNodesArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -301,7 +248,6 @@ export type QueryNodesArgs = {
};
/** Root query. */
export type QueryNodesCountArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -312,14 +258,12 @@ export type QueryNodesCountArgs = {
};
/** Root query. */
export type QueryOfferToHubArgs = {
hubUuid: Scalars['String']['input'];
offerUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryOffersByHubArgs = {
hubUuid: Scalars['String']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -327,26 +271,22 @@ export type QueryOffersByHubArgs = {
};
/** Root query. */
export type QueryOffersByProductArgs = {
productUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryOffersBySupplierProductArgs = {
productUuid: Scalars['String']['input'];
supplierUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryProductsBySupplierArgs = {
supplierUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryProductsListArgs = {
east?: InputMaybe<Scalars['Float']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -357,14 +297,12 @@ export type QueryProductsListArgs = {
};
/** Root query. */
export type QueryProductsNearHubArgs = {
hubUuid: Scalars['String']['input'];
radiusKm?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryRailRouteArgs = {
fromLat: Scalars['Float']['input'];
fromLon: Scalars['Float']['input'];
@@ -373,7 +311,6 @@ export type QueryRailRouteArgs = {
};
/** Root query. */
export type QueryRouteToCoordinateArgs = {
lat: Scalars['Float']['input'];
lon: Scalars['Float']['input'];
@@ -381,13 +318,11 @@ export type QueryRouteToCoordinateArgs = {
};
/** Root query. */
export type QuerySuppliersForProductArgs = {
productUuid: Scalars['String']['input'];
};
/** Root query. */
export type QuerySuppliersListArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -398,17 +333,21 @@ export type QuerySuppliersListArgs = {
west?: InputMaybe<Scalars['Float']['input']>;
};
/** Complete route through graph with multiple stages. */
export type RoutePathType = {
__typename?: 'RoutePathType';
stages?: Maybe<Array<Maybe<RouteStageType>>>;
export type Route = {
__typename?: 'Route';
distanceKm?: Maybe<Scalars['Float']['output']>;
geometry?: Maybe<Scalars['JSON']['output']>;
};
export type RoutePath = {
__typename?: 'RoutePath';
stages?: Maybe<Array<Maybe<RouteStage>>>;
totalDistanceKm?: Maybe<Scalars['Float']['output']>;
totalTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Single stage in a multi-hop route. */
export type RouteStageType = {
__typename?: 'RouteStageType';
export type RouteStage = {
__typename?: 'RouteStage';
distanceKm?: Maybe<Scalars['Float']['output']>;
fromLat?: Maybe<Scalars['Float']['output']>;
fromLon?: Maybe<Scalars['Float']['output']>;
@@ -422,17 +361,8 @@ export type RouteStageType = {
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Route between two points with geometry. */
export type RouteType = {
__typename?: 'RouteType';
distanceKm?: Maybe<Scalars['Float']['output']>;
/** GeoJSON LineString coordinates */
geometry?: Maybe<Scalars['JSONString']['output']>;
};
/** Unique supplier from offers. */
export type SupplierType = {
__typename?: 'SupplierType';
export type Supplier = {
__typename?: 'Supplier';
distanceKm?: Maybe<Scalars['Float']['output']>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
@@ -448,7 +378,7 @@ export type GetAutoRouteQueryVariables = Exact<{
}>;
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
export type GetAutoRouteQueryResult = { __typename?: 'Query', autoRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
export type GetClusteredNodesQueryVariables = Exact<{
west: Scalars['Float']['input'];
@@ -461,19 +391,19 @@ export type GetClusteredNodesQueryVariables = Exact<{
}>;
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes?: Array<{ __typename?: 'ClusterPointType', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null } | null> | null };
export type GetClusteredNodesQueryResult = { __typename?: 'Query', clusteredNodes: Array<{ __typename?: 'ClusterPoint', id?: string | null, latitude?: number | null, longitude?: number | null, count?: number | null, expansionZoom?: number | null, name?: string | null }> };
export type GetHubCountriesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries?: Array<string | null> | null };
export type GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries: Array<string> };
export type GetNodeQueryVariables = Exact<{
uuid: Scalars['String']['input'];
}>;
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
export type GetRailRouteQueryVariables = Exact<{
fromLat: Scalars['Float']['input'];
@@ -483,7 +413,7 @@ export type GetRailRouteQueryVariables = Exact<{
}>;
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'RouteType', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
export type GetRailRouteQueryResult = { __typename?: 'Query', railRoute?: { __typename?: 'Route', distanceKm?: number | null, geometry?: Record<string, unknown> | null } | null };
export type HubsListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -497,7 +427,7 @@ export type HubsListQueryVariables = Exact<{
}>;
export type HubsListQueryResult = { __typename?: 'Query', hubsList?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null> | null };
export type HubsListQueryResult = { __typename?: 'Query', hubsList: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
export type NearestHubsQueryVariables = Exact<{
lat: Scalars['Float']['input'];
@@ -508,7 +438,7 @@ export type NearestHubsQueryVariables = Exact<{
}>;
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs?: Array<{ __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null> | null };
export type NearestHubsQueryResult = { __typename?: 'Query', nearestHubs: Array<{ __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null }> };
export type NearestOffersQueryVariables = Exact<{
lat: Scalars['Float']['input'];
@@ -520,7 +450,7 @@ export type NearestOffersQueryVariables = Exact<{
}>;
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers?: Array<{ __typename?: 'OfferWithRouteType', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePathType', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStageType', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null } | null> | null };
export type NearestOffersQueryResult = { __typename?: 'Query', nearestOffers: Array<{ __typename?: 'OfferWithRoute', uuid?: string | null, productUuid?: string | null, productName?: string | null, supplierUuid?: string | null, supplierName?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, pricePerUnit?: string | null, currency?: string | null, quantity?: string | null, unit?: string | null, distanceKm?: number | null, routes?: Array<{ __typename?: 'RoutePath', totalDistanceKm?: number | null, totalTimeSeconds?: number | null, stages?: Array<{ __typename?: 'RouteStage', fromUuid?: string | null, fromName?: string | null, fromLat?: number | null, fromLon?: number | null, toUuid?: string | null, toName?: string | null, toLat?: number | null, toLon?: number | null, distanceKm?: number | null, travelTimeSeconds?: number | null, transportType?: string | null } | null> | null } | null> | null }> };
export type NearestSuppliersQueryVariables = Exact<{
lat: Scalars['Float']['input'];
@@ -531,7 +461,7 @@ export type NearestSuppliersQueryVariables = Exact<{
}>;
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers?: Array<{ __typename?: 'SupplierType', uuid?: string | null } | null> | null };
export type NearestSuppliersQueryResult = { __typename?: 'Query', nearestSuppliers: Array<{ __typename?: 'Supplier', uuid?: string | null }> };
export type ProductsListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -543,7 +473,7 @@ export type ProductsListQueryVariables = Exact<{
}>;
export type ProductsListQueryResult = { __typename?: 'Query', productsList?: Array<{ __typename?: 'ProductType', uuid?: string | null, name?: string | null, offersCount?: number | null } | null> | null };
export type ProductsListQueryResult = { __typename?: 'Query', productsList: Array<{ __typename?: 'Product', uuid?: string | null, name?: string | null, offersCount?: number | null }> };
export type SuppliersListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -556,7 +486,7 @@ export type SuppliersListQueryVariables = Exact<{
}>;
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList?: Array<{ __typename?: 'SupplierType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null } | null> | null };
export type SuppliersListQueryResult = { __typename?: 'Query', suppliersList: Array<{ __typename?: 'Supplier', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null }> };
export const GetAutoRouteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAutoRoute"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoRoute"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromLon"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLat"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLat"}}},{"kind":"Argument","name":{"kind":"Name","value":"toLon"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toLon"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"distanceKm"}},{"kind":"Field","name":{"kind":"Name","value":"geometry"}}]}}]}}]} as unknown as DocumentNode<GetAutoRouteQueryResult, GetAutoRouteQueryVariables>;

View File

@@ -13,12 +13,10 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
DateTime: { input: string; output: string; }
};
/** Full company data (requires auth). */
export type CompanyFullType = {
__typename?: 'CompanyFullType';
export type CompanyFull = {
__typename?: 'CompanyFull';
activities?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
address?: Maybe<Scalars['String']['output']>;
capital?: Maybe<Scalars['String']['output']>;
@@ -26,45 +24,35 @@ export type CompanyFullType = {
director?: Maybe<Scalars['String']['output']>;
inn?: Maybe<Scalars['String']['output']>;
isActive?: Maybe<Scalars['Boolean']['output']>;
lastUpdated?: Maybe<Scalars['DateTime']['output']>;
lastUpdated?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
ogrn?: Maybe<Scalars['String']['output']>;
registrationYear?: Maybe<Scalars['Int']['output']>;
sources?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
};
/** Public company data (teaser). */
export type CompanyTeaserType = {
__typename?: 'CompanyTeaserType';
/** Company type: ООО, АО, ИП, etc. */
export type CompanyTeaser = {
__typename?: 'CompanyTeaser';
companyType?: Maybe<Scalars['String']['output']>;
/** Is company active */
isActive?: Maybe<Scalars['Boolean']['output']>;
/** Year of registration */
registrationYear?: Maybe<Scalars['Int']['output']>;
/** Number of data sources */
sourcesCount?: Maybe<Scalars['Int']['output']>;
};
/** Public queries - no authentication required. */
export type PublicQuery = {
__typename?: 'PublicQuery';
health?: Maybe<Scalars['String']['output']>;
/** Get full KYC profile data by UUID (requires auth) */
kycProfileFull?: Maybe<CompanyFullType>;
/** Get public KYC profile teaser data by UUID */
kycProfileTeaser?: Maybe<CompanyTeaserType>;
export type Query = {
__typename?: 'Query';
health: Scalars['String']['output'];
kycProfileFull?: Maybe<CompanyFull>;
kycProfileTeaser?: Maybe<CompanyTeaser>;
};
/** Public queries - no authentication required. */
export type PublicQueryKycProfileFullArgs = {
export type QueryKycProfileFullArgs = {
profileUuid: Scalars['String']['input'];
};
/** Public queries - no authentication required. */
export type PublicQueryKycProfileTeaserArgs = {
export type QueryKycProfileTeaserArgs = {
profileUuid: Scalars['String']['input'];
};
@@ -73,14 +61,14 @@ export type GetKycProfileFullQueryVariables = Exact<{
}>;
export type GetKycProfileFullQueryResult = { __typename?: 'PublicQuery', kycProfileFull?: { __typename?: 'CompanyFullType', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
export type GetKycProfileFullQueryResult = { __typename?: 'Query', kycProfileFull?: { __typename?: 'CompanyFull', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
export type GetKycProfileTeaserQueryVariables = Exact<{
profileUuid: Scalars['String']['input'];
}>;
export type GetKycProfileTeaserQueryResult = { __typename?: 'PublicQuery', kycProfileTeaser?: { __typename?: 'CompanyTeaserType', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
export type GetKycProfileTeaserQueryResult = { __typename?: 'Query', kycProfileTeaser?: { __typename?: 'CompanyTeaser', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
export const GetKycProfileFullDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetKycProfileFull"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kycProfileFull"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"profileUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inn"}},{"kind":"Field","name":{"kind":"Name","value":"ogrn"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"companyType"}},{"kind":"Field","name":{"kind":"Name","value":"registrationYear"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"address"}},{"kind":"Field","name":{"kind":"Name","value":"director"}},{"kind":"Field","name":{"kind":"Name","value":"capital"}},{"kind":"Field","name":{"kind":"Name","value":"activities"}},{"kind":"Field","name":{"kind":"Name","value":"sources"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}}]}}]}}]} as unknown as DocumentNode<GetKycProfileFullQueryResult, GetKycProfileFullQueryVariables>;

View File

@@ -1,7 +1,7 @@
import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated'
import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24
const PAGE_SIZE = 500
// Type from codegen - exported for use in pages
export type CatalogHubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
@@ -61,16 +61,15 @@ export function useCatalogHubs() {
const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true
try {
// If filtering by product, use nearestHubs with global search
// (center point 0,0 with very large radius to cover entire globe)
// If filtering by product, use nearestHubs (graph-based)
if (filterProductUuid.value) {
const data = await execute(
NearestHubsDocument,
{
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
useGraph: true,
limit: 500 // Increased limit for global search
},
'public',
@@ -147,9 +146,7 @@ export function useCatalogHubs() {
const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true)
}
fetchPage(0, true)
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {

View File

@@ -15,7 +15,8 @@ import type {
} from '~/composables/graphql/public/exchange-generated'
import {
GetOfferDocument,
GetSupplierProfileDocument
GetSupplierProfileDocument,
GetSupplierOffersDocument
} from '~/composables/graphql/public/exchange-generated'
// Types from codegen
@@ -63,6 +64,8 @@ export interface InfoEntity {
unit?: string | null
// Enriched field from supplier profile
supplierName?: string | null
// KYC profile reference
kycProfileUuid?: string | null
}
// Helper to get coordinates from entity (handles both node and offer patterns)
@@ -123,7 +126,8 @@ export function useCatalogInfo() {
{
lat: coords.lat,
lon: coords.lon,
radius: 500
hubUuid: uuid,
limit: 500
},
'public',
'geo'
@@ -222,21 +226,16 @@ export function useCatalogInfo() {
isLoadingProducts.value = true
isLoadingHubs.value = true
// Load products (offers grouped by product)
// Load products from supplier offers (no geo radius)
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
GetSupplierOffersDocument,
{ teamUuid: uuid },
'public',
'geo'
'exchange'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, InfoProductItem>()
offersData?.nearestOffers?.forEach(offer => {
if (!offer || !offer.productUuid || !offer.productName) return
offersData?.getOffers?.forEach(offer => {
if (!offer?.productUuid || !offer.productName) return
const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
@@ -259,7 +258,7 @@ export function useCatalogInfo() {
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
sourceUuid: entity.value.uuid,
limit: 12
},
'public',
@@ -309,7 +308,7 @@ export function useCatalogInfo() {
{
lat: coords.lat,
lon: coords.lon,
radius: 1000,
sourceUuid: entity.value?.uuid ?? null,
limit: 12
},
'public',
@@ -368,7 +367,6 @@ export function useCatalogInfo() {
lon: hub.longitude,
productUuid,
hubUuid, // Pass hubUuid to get routes calculated on backend
radius: 500,
limit: 12
},
'public',
@@ -427,6 +425,28 @@ export function useCatalogInfo() {
isLoadingHubs.value = true
try {
let hubUuid: string | null = relatedHubs.value?.[0]?.uuid ?? null
if (!hubUuid && supplier.uuid) {
const hubsData = await execute(
NearestHubsDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
sourceUuid: supplier.uuid,
limit: 1
},
'public',
'geo'
)
const hub = (hubsData?.nearestHubs || []).find((h): h is HubItem => h !== null)
if (hub?.uuid) {
hubUuid = hub.uuid
if (!relatedHubs.value.length) {
relatedHubs.value = [hub]
}
}
}
// Find offers near supplier for this product
const offersData = await execute(
NearestOffersDocument,
@@ -434,46 +454,19 @@ export function useCatalogInfo() {
lat: supplier.latitude,
lon: supplier.longitude,
productUuid,
radius: 500,
...(hubUuid ? { hubUuid } : {}),
limit: 12
},
'public',
'geo'
)
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => {
if (!o) return false
if (!supplier.uuid) return true
return o.supplierUuid === supplier.uuid
})
isLoadingOffers.value = false
// Load hubs near each offer and aggregate (limit to 12)
const allHubs = new Map<string, HubItem>()
for (const offer of relatedOffers.value.slice(0, 3)) {
// Check first 3 offers
if (!offer.latitude || !offer.longitude) continue
try {
const hubsData = await execute(
NearestHubsDocument,
{
lat: offer.latitude,
lon: offer.longitude,
radius: 1000,
limit: 5
},
'public',
'geo'
)
hubsData?.nearestHubs?.forEach(hub => {
if (hub && hub.uuid && !allHubs.has(hub.uuid)) {
allHubs.set(hub.uuid, hub)
}
})
} catch (e) {
console.warn('Error loading hubs for offer:', offer.uuid, e)
}
if (allHubs.size >= 12) break
}
relatedHubs.value = Array.from(allHubs.values()).slice(0, 12)
} finally {
isLoadingOffers.value = false
isLoadingHubs.value = false

View File

@@ -5,7 +5,7 @@ import {
NearestOffersDocument
} from '~/composables/graphql/public/geo-generated'
import {
GetSupplierProfileDocument
GetSupplierOffersDocument
} from '~/composables/graphql/public/exchange-generated'
// Type from codegen
@@ -43,46 +43,26 @@ export function useCatalogProducts() {
let data
if (filterSupplierUuid.value) {
// Products from specific supplier - get supplier coordinates first
const supplierData = await execute(
GetSupplierProfileDocument,
{ uuid: filterSupplierUuid.value },
// Products from specific supplier - get offers directly (no geo radius)
const offersData = await execute(
GetSupplierOffersDocument,
{ teamUuid: filterSupplierUuid.value },
'public',
'exchange'
)
const supplier = supplierData?.getSupplierProfile
if (!supplier?.latitude || !supplier?.longitude) {
console.warn('Supplier has no coordinates')
items.value = []
} else {
// Get offers near supplier and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
radius: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, AggregatedProduct>()
offersData?.nearestOffers?.forEach((offer) => {
if (!offer?.productUuid) return
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
})
items.value = Array.from(productsMap.values()) as ProductItem[]
}
const productsMap = new Map<string, AggregatedProduct>()
offersData?.getOffers?.forEach((offer) => {
if (!offer?.productUuid) return
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
})
items.value = Array.from(productsMap.values()) as ProductItem[]
} else if (filterHubUuid.value) {
// Products near hub - get hub coordinates first
const hubData = await execute(
@@ -97,13 +77,14 @@ export function useCatalogProducts() {
console.warn('Hub has no coordinates')
items.value = []
} else {
// Get offers near hub and group by product
// Get offers by graph from hub and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
radius: 500
hubUuid: filterHubUuid.value,
limit: 500
},
'public',
'geo'

View File

@@ -94,7 +94,8 @@ export function useCatalogSearch() {
})
// Filter by bounds checkbox state from URL
const filterByBounds = computed(() => route.query.bounds !== undefined)
// Use explicit flag so bounds don't auto-enable filtering.
const filterByBounds = computed(() => route.query.boundsFilter === '1')
// Get label for a filter (from cache or fallback to ID)
const getLabel = (type: string, id: string | undefined): string | null => {
@@ -220,19 +221,26 @@ export function useCatalogSearch() {
}
const startSelect = (type: SelectMode) => {
if (!selectMode.value) {
lastViewMode.value = mapViewMode.value
}
updateQuery({ select: type })
}
const cancelSelect = () => {
updateQuery({ select: null })
updateQuery({
select: null
})
}
const selectItem = (type: string, id: string, label: string) => {
setLabel(type, id, label)
const forcedView = (type === 'hub' || type === 'supplier') ? null : (lastViewMode.value === 'offers' ? null : lastViewMode.value)
updateQuery({
[type]: id,
select: null, // Exit selection mode
info: null // Exit info mode
info: null, // Exit info mode
view: forcedView
})
}
@@ -253,7 +261,7 @@ export function useCatalogSearch() {
const setBoundsInUrl = (bounds: { west: number; south: number; east: number; north: number } | null) => {
if (bounds) {
const boundsStr = `${bounds.west.toFixed(4)},${bounds.south.toFixed(4)},${bounds.east.toFixed(4)},${bounds.north.toFixed(4)}`
updateQuery({ bounds: boundsStr })
updateQuery({ bounds: boundsStr, boundsFilter: '1' })
} else {
updateQuery({ bounds: null })
}
@@ -261,7 +269,12 @@ export function useCatalogSearch() {
// Clear bounds from URL
const clearBoundsFromUrl = () => {
updateQuery({ bounds: null })
updateQuery({ bounds: null, boundsFilter: null })
}
// Explicitly enable/disable bounds filter flag in URL
const setBoundsFilterEnabled = (enabled: boolean) => {
updateQuery({ boundsFilter: enabled ? '1' : null })
}
const openInfo = (type: InfoEntityType, uuid: string) => {
@@ -299,8 +312,15 @@ export function useCatalogSearch() {
}
return 'offers' // default
})
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
const setMapViewMode = (mode: MapViewMode) => {
updateQuery({ view: mode === 'offers' ? null : mode })
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
: mode === 'suppliers' ? 'supplier'
: 'product'
updateQuery({
view: mode === 'offers' ? null : mode,
select: newSelectMode
})
}
// Drawer state for list view
@@ -350,7 +370,15 @@ export function useCatalogSearch() {
})
const setCatalogMode = (newMode: CatalogMode) => {
updateQuery({ mode: newMode })
const defaultSelect: SelectMode = selectMode.value
|| (mapViewMode.value === 'hubs' ? 'hub'
: mapViewMode.value === 'suppliers' ? 'supplier'
: 'product')
if (newMode === 'explore') {
updateQuery({ mode: newMode, qty: null, select: defaultSelect })
} else {
updateQuery({ mode: newMode, select: defaultSelect })
}
}
// Can search for offers (product + hub or product + supplier required)
@@ -404,6 +432,7 @@ export function useCatalogSearch() {
setQuantity,
setBoundsInUrl,
clearBoundsFromUrl,
setBoundsFilterEnabled,
openInfo,
closeInfo,
setInfoTab,

View File

@@ -1,7 +1,7 @@
import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated'
import { SuppliersListDocument, NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24
const PAGE_SIZE = 500
// Types from codegen
type SupplierItem = NonNullable<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
@@ -28,15 +28,13 @@ export function useCatalogSuppliers() {
const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true
try {
// If filtering by product, use nearestSuppliers with global search
// (center point 0,0 with very large radius to cover entire globe)
// If filtering by product, use nearestSuppliers (product-only list)
if (filterProductUuid.value) {
const data = await execute(
NearestSuppliersDocument,
{
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
limit: 500 // Increased limit for global search
},
@@ -100,9 +98,7 @@ export function useCatalogSuppliers() {
const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true)
}
fetchPage(0, true)
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {

View File

@@ -1,5 +1,5 @@
import { GetClusteredNodesDocument } from './graphql/public/geo-generated'
import type { ClusterPointType } from './graphql/public/geo-generated'
import type { ClusterPoint } from './graphql/public/geo-generated'
export interface MapBounds {
west: number
@@ -11,11 +11,11 @@ export interface MapBounds {
export function useClusteredNodes(
transportType?: Ref<string | undefined>,
nodeType?: Ref<string | undefined>
nodeType?: Ref<string | undefined>,
) {
const { client } = useApolloClient('publicGeo')
const clusteredNodes = ref<ClusterPointType[]>([])
const clusteredNodes = ref<ClusterPoint[]>([])
const loading = ref(false)
const fetchClusters = async (bounds: MapBounds) => {
@@ -30,12 +30,12 @@ export function useClusteredNodes(
north: bounds.north,
zoom: Math.floor(bounds.zoom),
transportType: transportType?.value,
nodeType: nodeType?.value
nodeType: nodeType?.value,
},
fetchPolicy: 'network-only'
})
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPointType[]
clusteredNodes.value = (data?.clusteredNodes ?? []).filter(Boolean) as ClusterPoint[]
} catch (error) {
console.error('Failed to fetch clustered nodes:', error)
clusteredNodes.value = []

View File

@@ -1,7 +1,15 @@
<template>
<div class="min-h-screen flex flex-col bg-base-300">
<!-- Fixed Header Container -->
<div class="fixed top-0 left-0 right-0 z-40" :style="headerContainerStyle">
<AiChatSidebar
:open="isChatOpen"
:width="chatWidth"
@close="isChatOpen = false"
/>
<div class="flex-1 flex flex-col" :style="contentStyle">
<!-- Fixed Header Container -->
<div class="header-glass fixed inset-x-0 top-0 z-50 border-0" :style="headerContainerStyle">
<div class="header-glass-backdrop" aria-hidden="true" />
<!-- Animated background for home page -->
<HeroBackground v-if="isHomePage" :collapse-progress="collapseProgress" />
@@ -9,6 +17,7 @@
<MainNavigation
class="relative z-10"
:height="isHomePage ? heroHeight : 100"
:collapse-progress="isHomePage ? collapseProgress : 1"
:session-checked="sessionChecked"
:logged-in="isLoggedIn"
:user-avatar-svg="userAvatarSvg"
@@ -17,6 +26,8 @@
:theme="theme"
:user-data="userData"
:is-seller="isSeller"
:has-multiple-roles="hasMultipleRoles"
:current-role="currentRole"
:active-tokens="activeTokens"
:available-chips="availableChips"
:select-mode="selectMode"
@@ -28,13 +39,17 @@
:can-search="canSearch"
:show-mode-toggle="true"
:show-active-mode="isCatalogSection"
:is-collapsed="isHomePage ? heroIsCollapsed : isCatalogSection"
:is-collapsed="isHomePage ? heroIsCollapsed : (isCatalogSection || isClientArea)"
:is-home-page="isHomePage"
:is-client-area="isClientArea"
:chat-open="isChatOpen"
@toggle-theme="toggleTheme"
@toggle-chat="isChatOpen = !isChatOpen"
@set-catalog-mode="setCatalogMode"
@sign-out="onClickSignOut"
@sign-in="signIn()"
@switch-team="switchToTeam"
@switch-role="switchToRole"
@start-select="startSelect"
@cancel-select="cancelSelect"
@edit-token="editFilter"
@@ -46,7 +61,7 @@
<!-- Hero content for home page -->
<template v-if="isHomePage && collapseProgress < 1" #hero>
<h1
class="text-3xl lg:text-4xl font-bold text-white mb-4"
class="text-3xl lg:text-5xl font-black tracking-tight text-white mb-4"
:style="{ opacity: 1 - collapseProgress }"
>
{{ $t('hero.tagline', 'Make trade easy') }}
@@ -54,17 +69,18 @@
</template>
</MainNavigation>
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home sections -->
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home/non-clientarea sections -->
<SubNavigation
v-if="!isHomePage && !isCatalogSection"
v-if="!isHomePage && !isCatalogSection && !isClientArea"
:section="currentSection"
/>
</div>
</div>
<!-- Page content - padding-top compensates for fixed header -->
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
<slot />
</main>
<!-- Page content - padding-top compensates for fixed header -->
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
<slot />
</main>
</div>
</div>
</template>
@@ -78,6 +94,14 @@ const localePath = useLocalePath()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const isChatOpen = useState('ai-chat-open', () => false)
const chatWidth = computed(() => (isChatOpen.value ? 'clamp(240px, 15vw, 360px)' : '0px'))
const contentStyle = computed(() => ({
transform: isChatOpen.value ? `translateX(${chatWidth.value})` : 'translateX(0)',
width: isChatOpen.value ? `calc(100% - ${chatWidth.value})` : '100%',
transition: 'transform 250ms ease, width 250ms ease'
}))
// Catalog search state
const {
selectMode,
@@ -128,7 +152,7 @@ const userData = useState<{
avatarId?: string
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: SelectedLocation | null }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string; teamType?: string }>
} | null>('me', () => null)
const sessionChecked = ref(false)
@@ -139,6 +163,20 @@ const isSeller = computed(() => {
return userData.value?.activeTeam?.teamType === 'SELLER'
})
// Role switching support
const buyerTeam = computed(() =>
userData.value?.teams?.find(t => t?.teamType === 'BUYER')
)
const sellerTeam = computed(() =>
userData.value?.teams?.find(t => t?.teamType === 'SELLER')
)
const hasBuyerTeam = computed(() => !!buyerTeam.value)
const hasSellerTeam = computed(() => !!sellerTeam.value)
const hasMultipleRoles = computed(() => hasBuyerTeam.value && hasSellerTeam.value)
const currentRole = computed(() =>
userData.value?.activeTeam?.teamType || 'BUYER'
)
const isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
const userName = computed(() => {
@@ -174,8 +212,13 @@ const isCatalogSection = computed(() => {
route.path.startsWith('/ru/catalog')
})
// Client area detection (cabinet tabs in MainNavigation, no SubNav needed)
const isClientArea = computed(() => {
return route.path.includes('/clientarea')
})
// Collapsible header logic - only for pages with SubNav
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value)
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value && !isClientArea.value)
const canCollapse = computed(() => hasSubNav.value)
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
@@ -193,6 +236,7 @@ const headerContainerStyle = computed(() => {
const mainStyle = computed(() => {
if (isCatalogSection.value) return { paddingTop: '0' }
if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` }
if (isClientArea.value) return { paddingTop: '116px' } // Header only, no SubNav
return { paddingTop: '154px' }
})
@@ -245,7 +289,7 @@ watch(userData, () => {
await fetchSession().catch(() => {})
sessionChecked.value = true
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string }) => {
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string; teamType?: string }) => {
if (!team?.id) return
try {
@@ -264,6 +308,20 @@ const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: str
}
}
const switchToRole = async (role: 'BUYER' | 'SELLER') => {
const targetTeam = role === 'SELLER' ? sellerTeam.value : buyerTeam.value
if (targetTeam?.id) {
await switchToTeam(targetTeam)
// Redirect to appropriate page when in client area
if (isClientArea.value) {
const targetPath = role === 'SELLER'
? '/clientarea/offers'
: '/clientarea/orders'
await navigateTo(localePath(targetPath))
}
}
}
const onClickSignOut = () => {
signOut(siteUrl)
}
@@ -294,9 +352,10 @@ const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
const onSearch = () => {
// Navigate to catalog page if not there
if (!route.path.includes('/catalog')) {
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote' } })
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote', select: 'product' } })
}
// Trigger search by incrementing the counter (page watches this)
searchTrigger.value++
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="step-hub-map"
:items="hubMapItems"
:use-server-clustering="false"
point-color="#22c55e"
entity-type="hub"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
<!-- Bottom sheet card -->
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
<article
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
style="max-height: 60vh"
>
<!-- Header -->
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
<div class="flex justify-center mb-4">
<div class="w-10 h-1 bg-base-300 rounded-full" />
</div>
<div class="flex items-center justify-between mb-1">
<div>
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 2 }) }}</p>
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectDestination') }}</h2>
</div>
<span class="badge badge-neutral">{{ hubs.length }}</span>
</div>
<!-- Selected product chip -->
<div v-if="productName" class="flex items-center gap-2 mt-2 mb-1">
<span class="badge badge-warning gap-1">
<Icon name="lucide:package" size="12" />
{{ productName }}
</span>
</div>
<!-- Search input -->
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
<Icon name="lucide:search" size="16" class="text-base-content/40" />
<input
v-model="searchQuery"
type="text"
:placeholder="$t('catalog.search.searchHubs')"
class="grow bg-transparent"
/>
</label>
</div>
<!-- Hub list -->
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<div v-else-if="filteredHubs.length === 0" class="text-center py-8 text-base-content/50">
<Icon name="lucide:warehouse" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noHubs') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<button
v-for="hub in filteredHubs"
:key="hub.uuid"
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
@click="selectHub(hub)"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-green-400 to-green-600 shadow-lg">
<Icon name="lucide:warehouse" size="20" class="text-white" />
</div>
<div class="flex-1 min-w-0">
<span class="text-base font-bold text-base-content block truncate">{{ hub.name || hub.uuid }}</span>
<span v-if="hub.country" class="text-sm text-base-content/50">{{ hub.country }}</span>
</div>
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
</button>
</div>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
definePageMeta({ layout: 'topnav' })
const { t } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const route = useRoute()
const mapRef = ref(null)
const searchQuery = ref('')
// Get product from query
const productUuid = computed(() => route.query.product as string | undefined)
const productName = computed(() => route.query.productName as string | undefined)
// Load hubs (filtered by product if available)
const { items: hubs, isLoading, init: initHubs, setProductFilter } = useCatalogHubs()
const onBoundsChange = (_bounds: MapBounds) => {
// No clustering needed — showing hub items directly
}
// Hub items for map
const hubMapItems = computed(() =>
hubs.value
.filter(h => h.latitude != null && h.longitude != null)
.map(h => ({
uuid: h.uuid || '',
name: h.name || '',
latitude: Number(h.latitude),
longitude: Number(h.longitude),
country: h.country || undefined,
}))
)
// Map click → select hub
const onMapSelect = (uuid: string) => {
const hub = hubs.value.find(h => h.uuid === uuid)
if (hub) selectHub(hub)
}
// Filter hubs by search
const filteredHubs = computed(() => {
if (!searchQuery.value.trim()) return hubs.value
const q = searchQuery.value.toLowerCase().trim()
return hubs.value.filter(h =>
(h.name || '').toLowerCase().includes(q) ||
(h.country || '').toLowerCase().includes(q)
)
})
// Select hub → navigate to quantity step
const selectHub = (hub: { uuid?: string | null; name?: string | null }) => {
if (!hub.uuid) return
const query: Record<string, string> = {
...route.query as Record<string, string>,
hub: hub.uuid,
}
if (hub.name) query.hubName = hub.name
router.push({
path: localePath('/catalog/quantity'),
query,
})
}
// Init
onMounted(() => {
if (productUuid.value) {
setProductFilter(productUuid.value)
}
initHubs()
})
useHead(() => ({
title: t('catalog.steps.selectDestination')
}))
</script>

View File

@@ -44,7 +44,7 @@
</template>
<script setup lang="ts">
import { GetNodeDocument, NearestOffersDocument, type OfferWithRouteType, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
import { GetNodeDocument, NearestOffersDocument, type OfferWithRoute, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
type Hub = NonNullable<GetNodeQueryResult['node']>

View File

@@ -1,77 +1,101 @@
<template>
<CatalogPage
ref="catalogPageRef"
:loading="isLoading"
:use-server-clustering="true"
:cluster-node-type="clusterNodeType"
map-id="unified-catalog-map"
:point-color="mapPointColor"
:items="currentSelectionItems"
:hovered-id="hoveredItemId ?? undefined"
:show-panel="showPanel"
:filter-by-bounds="filterByBounds"
:related-points="relatedPoints"
@select="onMapSelect"
@bounds-change="onBoundsChange"
@update:filter-by-bounds="$event ? setBoundsInUrl(currentMapBounds) : clearBoundsFromUrl()"
>
<!-- Panel slot - shows selection list OR info OR quote results -->
<template #panel>
<!-- Selection mode: show list for picking product/hub/supplier -->
<SelectionPanel
v-if="selectMode"
:select-mode="selectMode"
:products="filteredProducts"
:hubs="filteredHubs"
:suppliers="filteredSuppliers"
:loading="selectionLoading"
:loading-more="selectionLoadingMore"
:has-more="selectionHasMore && !filterByBounds"
@select="onSelectItem"
@close="onClosePanel"
@load-more="onLoadMore"
@hover="onHoverItem"
/>
<div>
<CatalogPage
ref="catalogPageRef"
:loading="isLoading"
:use-server-clustering="useServerClustering"
:use-typed-clusters="useServerClustering"
:cluster-node-type="clusterNodeType"
panel-width="w-[32rem]"
map-id="unified-catalog-map"
:point-color="mapPointColor"
:items="mapItems"
:hovered-id="hoveredItemId ?? undefined"
:show-panel="showPanel && !kycSheetUuid"
:filter-by-bounds="filterByBounds"
:related-points="relatedPoints"
:info-loading="mapInfoLoading"
:force-info-mode="forceInfoMode"
:hide-view-toggle="hideViewToggle"
:show-offers-toggle="showOffersToggle"
:show-hubs-toggle="showHubsToggle"
:show-suppliers-toggle="showSuppliersToggle"
:cluster-product-uuid="clusterProductUuid"
:cluster-hub-uuid="clusterHubUuid"
:cluster-supplier-uuid="clusterSupplierUuid"
@select="onMapSelect"
@bounds-change="onBoundsChange"
@update:filter-by-bounds="onToggleBoundsFilter"
>
<!-- Panel slot - shows selection list OR info OR quote results -->
<template #panel>
<!-- Selection mode: show list for picking product/hub/supplier -->
<SelectionPanel
v-if="selectMode"
:select-mode="selectMode"
:products="filteredProducts"
:hubs="filteredHubs"
:suppliers="filteredSuppliers"
:loading="selectionLoading"
:loading-more="selectionLoadingMore"
:has-more="selectionHasMore && !filterByBounds"
@select="onSelectItem"
@pin="onPinItem"
@close="onClosePanel"
@load-more="onLoadMore"
@hover="onHoverItem"
/>
<!-- Info mode: show detailed info about selected entity -->
<InfoPanel
v-else-if="infoId"
:entity-type="infoId.type"
:entity-id="infoId.uuid"
:entity="entity"
:related-products="relatedProducts"
:related-hubs="relatedHubs"
:related-suppliers="relatedSuppliers"
:related-offers="relatedOffers"
:selected-product="infoProduct ?? null"
:loading="infoLoading"
:loading-products="isLoadingProducts"
:loading-hubs="isLoadingHubs"
:loading-suppliers="isLoadingSuppliers"
:loading-offers="isLoadingOffers"
@close="onInfoClose"
@add-to-filter="onInfoAddToFilter"
@open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct"
/>
<!-- Info mode: show detailed info about selected entity -->
<InfoPanel
v-else-if="infoId"
:entity-type="infoId.type"
:entity-id="infoId.uuid"
:entity="entity"
:related-products="relatedProducts"
:related-hubs="relatedHubs"
:related-suppliers="relatedSuppliers"
:related-offers="relatedOffers"
:selected-product="infoProduct ?? null"
:loading="infoLoading"
:loading-products="isLoadingProducts"
:loading-hubs="isLoadingHubs"
:loading-suppliers="isLoadingSuppliers"
:loading-offers="isLoadingOffers"
@close="onInfoClose"
@open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct"
@select-offer="onSelectOffer"
@open-kyc="onOpenKyc"
@pin="onPinItem"
/>
<!-- Quote results: show offers after search -->
<QuotePanel
v-else-if="showQuoteResults"
:loading="offersLoading"
:offers="offers"
@select-offer="onSelectOffer"
/>
</template>
</CatalogPage>
<!-- Quote results: show offers after search -->
<QuotePanel
v-else-if="showQuoteResults"
:loading="offersLoading"
:offers="offers"
:calculations="quoteCalculations"
@select-offer="onSelectOffer"
/>
</template>
</CatalogPage>
<!-- KYC Bottom Sheet (overlays everything) -->
<KycBottomSheet
:is-open="!!kycSheetUuid"
:uuid="kycSheetUuid"
@close="onCloseKycSheet"
/>
</div>
</template>
<script setup lang="ts">
import { GetOffersDocument, GetOfferDocument, type GetOffersQueryVariables, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
import { GetOffersDocument, type GetOffersQueryVariables } from '~/composables/graphql/public/exchange-generated'
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
// Offer type from search results
type OfferResult = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
definePageMeta({
layout: 'topnav'
@@ -87,6 +111,7 @@ const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null
// Current map bounds (local state, updated when map moves)
const currentMapBounds = ref<MapBounds | null>(null)
const selectionBoundsBackup = ref<{ hadBounds: boolean; bounds: { west: number; south: number; east: number; north: number } | null } | null>(null)
// Hovered item for map highlight
const hoveredItemId = ref<string | null>(null)
@@ -112,6 +137,7 @@ const toMapItems = <T extends { uuid?: string | null; name?: string | null; lati
// Current selection items for hover highlighting on map
const currentSelectionItems = computed((): MapItemWithCoords[] => {
if (showQuoteResults.value) return []
if (selectMode.value === 'product') return [] // Products don't have coordinates
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
@@ -148,7 +174,8 @@ const {
urlBounds,
filterByBounds,
setBoundsInUrl,
clearBoundsFromUrl
clearBoundsFromUrl,
setBoundsFilterEnabled
} = useCatalogSearch()
// Info panel composable
@@ -222,8 +249,56 @@ const onLoadMore = () => {
if (selectMode.value === 'supplier') loadMoreSuppliers()
}
const getSelectionBounds = () => {
const bounds = currentMapBounds.value ?? catalogPageRef.value?.currentBounds?.value ?? null
if (!bounds) return null
return { west: bounds.west, south: bounds.south, east: bounds.east, north: bounds.north }
}
const onToggleBoundsFilter = (enabled: boolean) => {
if (enabled) {
setBoundsFilterEnabled(true)
const bounds = getSelectionBounds()
if (bounds) {
setBoundsInUrl(bounds)
}
} else {
clearBoundsFromUrl()
}
}
const applySelectionBounds = () => {
if (!filterByBounds.value) return
if (!selectionBoundsBackup.value) {
selectionBoundsBackup.value = {
hadBounds: !!urlBounds.value,
bounds: urlBounds.value
}
}
const bounds = getSelectionBounds()
if (bounds) {
setBoundsInUrl(bounds)
}
}
const restoreSelectionBounds = () => {
const prev = selectionBoundsBackup.value
if (!prev) return
if (prev.hadBounds && prev.bounds) {
setBoundsInUrl(prev.bounds)
} else {
clearBoundsFromUrl()
}
selectionBoundsBackup.value = null
}
// Initialize data and sync map view when selectMode changes
watch(selectMode, async (mode) => {
if (mode) {
applySelectionBounds()
} else {
restoreSelectionBounds()
}
if (mode === 'product') {
await initProducts()
setMapViewMode('offers')
@@ -255,6 +330,16 @@ watch(productId, (newProductId) => {
setSupplierProductFilter(newProductId || null)
}, { immediate: true })
// If a filter locks a view type, switch away from that view
watch([hubId, supplierId], ([newHubId, newSupplierId]) => {
if (newHubId && mapViewMode.value === 'hubs') {
setMapViewMode('offers')
}
if (newSupplierId && mapViewMode.value === 'suppliers') {
setMapViewMode('offers')
}
}, { immediate: true })
// Apply bounds filter when "filter by map bounds" is enabled
// Only watch URL bounds - currentMapBounds changes too often (every map move)
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
@@ -286,7 +371,7 @@ watch(infoProduct, async (productUuid) => {
})
// Related points for Info mode (shown on map) - show current entity + all related entities
const relatedPoints = computed(() => {
const infoRelatedPoints = computed(() => {
if (!infoId.value) return []
const points: Array<{
@@ -340,11 +425,60 @@ const relatedPoints = computed(() => {
return points
})
// Related points for Quote mode (shown on map)
const searchHubPoint = ref<MapItemWithCoords | null>(null)
const searchOfferPoints = computed(() =>
offers.value
.filter((offer) => offer.latitude != null && offer.longitude != null)
.map((offer) => ({
uuid: offer.uuid,
name: offer.productName || '',
latitude: Number(offer.latitude),
longitude: Number(offer.longitude),
type: 'offer' as const
}))
)
const searchRelatedPoints = computed(() => {
const points: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}> = []
if (searchHubPoint.value) {
points.push({
uuid: searchHubPoint.value.uuid,
name: searchHubPoint.value.name,
latitude: searchHubPoint.value.latitude,
longitude: searchHubPoint.value.longitude,
type: 'hub'
})
}
searchOfferPoints.value.forEach((point) => points.push(point))
return points
})
const relatedPoints = computed(() => {
if (infoId.value) return infoRelatedPoints.value
if (showQuoteResults.value) return searchRelatedPoints.value
return []
})
// Offers data for quote results
const offers = ref<OfferResult[]>([])
const offers = ref<NearestOffer[]>([])
const quoteCalculations = ref<{ offers: NearestOffer[] }[]>([])
const buildCalculationsFromOffers = (list: NearestOffer[]) =>
list.map((offer) => ({ offers: [offer] }))
const offersLoading = ref(false)
const showQuoteResults = ref(false)
// Watch for search trigger from topnav
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
watch(searchTrigger, () => {
@@ -354,7 +488,98 @@ watch(searchTrigger, () => {
})
// Loading state
const isLoading = computed(() => offersLoading.value || selectionLoading.value)
const isLoading = computed(() => offersLoading.value || selectionLoading.value || exploreOffersLoading.value)
// Info loading state for map fitBounds (true while any info data is still loading)
const isInfoLoading = computed(() =>
infoLoading.value || isLoadingProducts.value || isLoadingHubs.value || isLoadingSuppliers.value || isLoadingOffers.value
)
const mapInfoLoading = computed(() =>
isInfoLoading.value || (showQuoteResults.value && offersLoading.value)
)
const forceInfoMode = computed(() => showQuoteResults.value)
const hideViewToggle = computed(() => showQuoteResults.value)
const showOffersToggle = computed(() => true)
const showHubsToggle = computed(() => !hubId.value)
const showSuppliersToggle = computed(() => !supplierId.value)
const clusterProductUuid = computed(() => productId.value || undefined)
const clusterHubUuid = computed(() => hubId.value || undefined)
const clusterSupplierUuid = computed(() => supplierId.value || undefined)
// When a product filter is active and we're viewing hubs, use the same list data on the map
// to avoid mismatch between graph-filtered list and clustered map results.
const useServerClustering = computed(() => {
if (productId.value && (mapViewMode.value === 'hubs' || mapViewMode.value === 'suppliers')) return false
if (hubId.value && mapViewMode.value === 'offers') return false
return true
})
// Offers for Explore map when hub filter is active (graph-based)
const exploreOffers = ref<NearestOffer[]>([])
const exploreOffersLoading = ref(false)
const shouldLoadExploreOffers = computed(() =>
catalogMode.value === 'explore' && mapViewMode.value === 'offers' && !!hubId.value
)
const loadExploreOffers = async () => {
if (!hubId.value) return
exploreOffersLoading.value = true
try {
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
const hub = hubData?.node
if (!hub?.latitude || !hub?.longitude) {
exploreOffers.value = []
return
}
const geoData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
hubUuid: hubId.value,
productUuid: productId.value || null,
limit: 500
},
'public',
'geo'
)
exploreOffers.value = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
} finally {
exploreOffersLoading.value = false
}
}
watch([shouldLoadExploreOffers, hubId, productId], ([enabled]) => {
if (!enabled) {
exploreOffers.value = []
exploreOffersLoading.value = false
return
}
loadExploreOffers()
}, { immediate: true })
const mapItems = computed((): MapItemWithCoords[] => {
if (!useServerClustering.value) {
if (mapViewMode.value === 'offers') {
return exploreOffers.value
.filter((offer) => offer.uuid && offer.latitude != null && offer.longitude != null)
.map((offer) => ({
uuid: offer.uuid,
name: offer.productName || '',
latitude: Number(offer.latitude),
longitude: Number(offer.longitude)
}))
}
if (mapViewMode.value === 'hubs') return toMapItems(filteredHubs.value)
if (mapViewMode.value === 'suppliers') return toMapItems(filteredSuppliers.value)
}
return currentSelectionItems.value
})
// Show panel when selecting OR when showing info OR when showing quote results
const showPanel = computed(() => {
@@ -385,60 +610,44 @@ interface MapSelectItem {
}
// Handle map item selection
const onMapSelect = async (item: MapSelectItem) => {
const onMapSelect = (item: MapSelectItem) => {
// Get uuid from item - clusters use 'id', regular items use 'uuid'
const itemId = item.uuid || item.id
if (!itemId || itemId.startsWith('cluster-')) return
const itemName = item.name || itemId.slice(0, 8) + '...'
// If in selection mode, use map click to fill the selector
if (selectMode.value) {
// For hubs selection - click on hub fills hub selector
if (selectMode.value === 'hub' && mapViewMode.value === 'hubs') {
selectItem('hub', itemId, itemName)
showQuoteResults.value = false
offers.value = []
return
}
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
// For supplier selection - click on supplier fills supplier selector
if (selectMode.value === 'supplier' && mapViewMode.value === 'suppliers') {
selectItem('supplier', itemId, itemName)
showQuoteResults.value = false
offers.value = []
return
}
// For product selection viewing offers - fetch offer to get productUuid
if (selectMode.value === 'product' && mapViewMode.value === 'offers') {
// Fetch offer details to get productUuid (not available in cluster data)
const data = await execute(GetOfferDocument, { uuid: itemId }, 'public', 'exchange')
const offer = data?.getOffer
if (offer?.productUuid) {
selectItem('product', offer.productUuid, offer.productName || itemName)
showQuoteResults.value = false
offers.value = []
}
return
}
}
// NEW: Default behavior - open Info directly
// Default behavior - open Info directly
let infoType: 'hub' | 'supplier' | 'offer'
if (mapViewMode.value === 'hubs') infoType = 'hub'
else if (mapViewMode.value === 'suppliers') infoType = 'supplier'
if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
else infoType = 'offer'
openInfo(infoType, itemId)
setLabel(infoType, itemId, itemName)
}
// Handle selection from SelectionPanel - add to filter (show badge in search)
// Handle selection from SelectionPanel - open info card (pin only via pin button)
const onSelectItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
if (item.uuid && item.name) {
selectItem(type, item.uuid, item.name)
if (!item.uuid) return
if (type === 'hub' || type === 'supplier') {
if (item.name) {
setLabel(type, item.uuid, item.name)
}
openInfo(type, item.uuid)
return
}
if (type === 'product') {
router.push(localePath(`/catalog/products/${item.uuid}`))
}
}
const onPinItem = (type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }) => {
if (!item.uuid) return
const label = item.name || item.uuid.slice(0, 8) + '...'
selectItem(type, item.uuid, label)
}
// Close panel (cancel select mode)
@@ -452,30 +661,6 @@ const onInfoClose = () => {
clearInfo()
}
const onInfoAddToFilter = () => {
if (!infoId.value || !entity.value) return
const { type, uuid } = infoId.value
// For offers, add the product AND hub to filter
if (type === 'offer') {
if (entity.value.productUuid) {
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
selectItem('product', entity.value.productUuid, productName)
}
// Also add hub (location) to filter if available
if (entity.value.locationUuid) {
const hubName = entity.value.locationName || entity.value.locationUuid.slice(0, 8) + '...'
selectItem('hub', entity.value.locationUuid, hubName)
}
} else {
// For hubs and suppliers, add directly
const name = entity.value.name || uuid.slice(0, 8) + '...'
selectItem(type, uuid, name)
}
closeInfo()
clearInfo()
}
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
openInfo(type, uuid)
@@ -486,32 +671,102 @@ const onInfoSelectProduct = (uuid: string | null) => {
setInfoProduct(uuid)
}
// KYC Bottom Sheet state
const kycSheetUuid = ref<string | null>(null)
// Handle KYC profile open - show bottom sheet instead of navigating
const onOpenKyc = (uuid: string | undefined) => {
if (!uuid) return
kycSheetUuid.value = uuid
}
// Close KYC bottom sheet
const onCloseKycSheet = () => {
kycSheetUuid.value = null
}
// Search for offers
const onSearch = async () => {
if (!canSearch.value) return
offersLoading.value = true
showQuoteResults.value = true
searchHubPoint.value = null
try {
const vars: GetOffersQueryVariables = {}
if (productId.value) vars.productUuid = productId.value
if (supplierId.value) vars.teamUuid = supplierId.value
if (hubId.value) vars.locationUuid = hubId.value
// Prefer geo-based offers with routes when hub + product are selected
if (hubId.value && productId.value) {
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
const hub = hubData?.node
if (hub?.latitude != null && hub?.longitude != null) {
searchHubPoint.value = {
uuid: hub.uuid,
name: hub.name || hub.uuid,
latitude: Number(hub.latitude),
longitude: Number(hub.longitude)
}
const geoData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productId.value,
hubUuid: hubId.value,
limit: 12
},
'public',
'geo'
)
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
offers.value = (data?.getOffers || []).filter((o): o is OfferResult => o !== null)
let nearest = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
if (supplierId.value) {
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
}
// Update labels from response
const first = offers.value[0]
if (first) {
if (productId.value && first.productName) {
setLabel('product', productId.value, first.productName)
offers.value = nearest
quoteCalculations.value = buildCalculationsFromOffers(nearest)
const first = offers.value[0]
if (first?.productName) {
setLabel('product', productId.value, first.productName)
}
} else {
offers.value = []
quoteCalculations.value = []
}
if (hubId.value && first.locationName) {
setLabel('hub', hubId.value, first.locationName)
} else {
searchHubPoint.value = null
const vars: GetOffersQueryVariables = {}
if (productId.value) vars.productUuid = productId.value
if (supplierId.value) vars.teamUuid = supplierId.value
if (hubId.value) vars.locationUuid = hubId.value
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
const exchangeOffers = (data?.getOffers || []).filter((o): o is NonNullable<typeof o> => o !== null)
offers.value = exchangeOffers.map((offer) => ({
uuid: offer.uuid,
productUuid: offer.productUuid,
productName: offer.productName,
teamUuid: offer.teamUuid,
quantity: offer.quantity,
unit: offer.unit,
pricePerUnit: offer.pricePerUnit,
currency: offer.currency,
locationName: offer.locationName,
locationCountry: offer.locationCountry
}))
quoteCalculations.value = buildCalculationsFromOffers(offers.value)
// Update labels from response
const first = offers.value[0]
if (first) {
if (productId.value && first.productName) {
setLabel('product', productId.value, first.productName)
}
if (hubId.value && first.locationName) {
setLabel('hub', hubId.value, first.locationName)
}
}
// Note: teamName not included in GetOffers query, supplier label cannot be updated from offer
}
} finally {
offersLoading.value = false
@@ -520,8 +775,9 @@ const onSearch = async () => {
// Select offer - navigate to detail page
const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
if (offer.uuid && offer.productUuid) {
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`))
const productUuid = offer.productUuid
if (offer.uuid && productUuid) {
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
}
}

View File

@@ -0,0 +1,146 @@
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="step-product-map"
:items="[]"
:clustered-points="clusteredNodes"
:use-server-clustering="true"
point-color="#f97316"
entity-type="offer"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
<!-- Bottom sheet card -->
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
<article
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
style="max-height: 60vh"
>
<!-- Header -->
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
<!-- Drag handle -->
<div class="flex justify-center mb-4">
<div class="w-10 h-1 bg-base-300 rounded-full" />
</div>
<div class="flex items-center justify-between mb-1">
<div>
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 1 }) }}</p>
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.steps.selectProduct') }}</h2>
</div>
<span class="badge badge-neutral">{{ products.length }}</span>
</div>
<!-- Search input -->
<label class="input input-bordered w-full mt-3 rounded-full flex items-center gap-2">
<Icon name="lucide:search" size="16" class="text-base-content/40" />
<input
v-model="searchQuery"
type="text"
:placeholder="$t('catalog.search.searchProducts')"
class="grow bg-transparent"
/>
</label>
</div>
<!-- Product list -->
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<div v-else-if="filteredProducts.length === 0" class="text-center py-8 text-base-content/50">
<Icon name="lucide:package-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noProducts') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<button
v-for="product in filteredProducts"
:key="product.uuid"
class="flex items-center gap-4 rounded-2xl p-4 text-left transition-all hover:bg-base-200/60 active:scale-[0.98] group"
@click="selectProduct(product)"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 shadow-lg">
<Icon name="lucide:package" size="20" class="text-white" />
</div>
<div class="flex-1 min-w-0">
<span class="text-base font-bold text-base-content block truncate">{{ product.name || product.uuid }}</span>
<span v-if="product.offersCount" class="text-sm text-base-content/50">{{ product.offersCount }} {{ $t('catalog.offers') }}</span>
</div>
<Icon name="lucide:chevron-right" size="18" class="text-base-content/30 group-hover:text-base-content/60 transition-colors" />
</button>
</div>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
definePageMeta({ layout: 'topnav' })
const { t } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const route = useRoute()
const mapRef = ref(null)
const searchQuery = ref('')
// Load products
const { items: products, isLoading, init: initProducts } = useCatalogProducts()
// Clustering for map background
const { clusteredNodes, fetchClusters } = useClusteredNodes(undefined, ref('offer'))
const onBoundsChange = (bounds: MapBounds) => {
fetchClusters(bounds)
}
const onMapSelect = (uuid: string) => {
// Map click — ignore for product step
}
// Filter products by search
const filteredProducts = computed(() => {
if (!searchQuery.value.trim()) return products.value
const q = searchQuery.value.toLowerCase().trim()
return products.value.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.uuid || '').toLowerCase().includes(q)
)
})
// Select product → navigate to hub step
const selectProduct = (product: { uuid: string; name?: string | null }) => {
const query: Record<string, string> = {
...route.query as Record<string, string>,
product: product.uuid,
}
if (product.name) query.productName = product.name
router.push({
path: localePath('/catalog/destination'),
query,
})
}
// Init
onMounted(() => {
initProducts()
})
useHead(() => ({
title: t('catalog.steps.selectProduct')
}))
</script>

View File

@@ -17,7 +17,7 @@
</IconCircle>
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
<Button @click="navigateTo(localePath('/catalog?select=product'))">
{{ t('catalogProduct.actions.back_to_catalog') }}
</Button>
</Stack>

View File

@@ -0,0 +1,150 @@
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="step-quantity-map"
:items="mapPoints"
:use-server-clustering="false"
point-color="#22c55e"
entity-type="hub"
:related-points="relatedPoints"
:info-loading="false"
@bounds-change="() => {}"
/>
</ClientOnly>
</div>
<!-- Bottom sheet card -->
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
<article
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
style="max-height: 60vh"
>
<div class="shrink-0 p-5 md:px-7 md:pt-7">
<div class="flex justify-center mb-4">
<div class="w-10 h-1 bg-base-300 rounded-full" />
</div>
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.step', { n: 3 }) }}</p>
<h2 class="text-2xl font-black tracking-tight text-base-content mb-4">{{ $t('catalog.steps.setQuantity') }}</h2>
<!-- Selected product + hub chips -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<span v-if="productName" class="badge badge-warning gap-1">
<Icon name="lucide:package" size="12" />
{{ productName }}
</span>
<Icon name="lucide:arrow-right" size="14" class="text-base-content/30" />
<span v-if="hubName" class="badge badge-success gap-1">
<Icon name="lucide:warehouse" size="12" />
{{ hubName }}
</span>
</div>
<!-- Quantity input -->
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text font-bold">{{ $t('catalog.filters.quantity') }}</span>
</label>
<label class="input input-bordered rounded-xl flex items-center gap-2">
<input
v-model="qty"
type="number"
min="0"
step="0.1"
placeholder="100"
class="grow bg-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span class="text-base-content/50 text-sm">{{ $t('units.t') }}</span>
</label>
</div>
<!-- Search button -->
<button
class="btn btn-primary w-full mt-6 rounded-full text-base font-bold"
:disabled="!canSearch"
@click="goSearch"
>
<Icon name="lucide:search" size="18" />
{{ $t('catalog.quote.findOffers') }}
</button>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { GetNodeDocument } from '~/composables/graphql/public/geo-generated'
definePageMeta({ layout: 'topnav' })
const { t } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const route = useRoute()
const { execute } = useGraphQL()
const mapRef = ref(null)
const qty = ref('100')
const productUuid = computed(() => route.query.product as string | undefined)
const productName = computed(() => route.query.productName as string | undefined)
const hubUuid = computed(() => route.query.hub as string | undefined)
const hubName = computed(() => route.query.hubName as string | undefined)
const canSearch = computed(() => !!(productUuid.value && hubUuid.value))
// Load hub coordinates for map
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
const loadHubPoint = async () => {
if (!hubUuid.value) return
const data = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
const node = data?.node
if (node?.latitude != null && node?.longitude != null) {
hubPoint.value = {
uuid: node.uuid,
name: node.name || hubName.value || '',
latitude: Number(node.latitude),
longitude: Number(node.longitude),
}
}
}
const mapPoints = computed(() => hubPoint.value ? [hubPoint.value] : [])
const relatedPoints = computed(() => {
if (!hubPoint.value) return []
return [{
uuid: hubPoint.value.uuid,
name: hubPoint.value.name,
latitude: hubPoint.value.latitude,
longitude: hubPoint.value.longitude,
type: 'hub' as const,
}]
})
const goSearch = () => {
const query: Record<string, string> = {
...route.query as Record<string, string>,
}
if (qty.value) query.qty = qty.value
router.push({
path: localePath('/catalog/results'),
query,
})
}
onMounted(() => {
loadHubPoint()
})
useHead(() => ({
title: t('catalog.steps.setQuantity')
}))
</script>

View File

@@ -0,0 +1,242 @@
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="step-results-map"
:items="[]"
:use-server-clustering="false"
point-color="#f97316"
entity-type="offer"
:related-points="relatedPoints"
:info-loading="offersLoading"
@select-item="onMapSelect"
@bounds-change="() => {}"
/>
</ClientOnly>
</div>
<!-- Bottom sheet card -->
<div class="fixed inset-x-0 bottom-0 z-10 flex flex-col items-center pointer-events-none">
<article
class="w-full max-w-[980px] rounded-t-3xl bg-white shadow-[0_-8px_40px_rgba(0,0,0,0.12)] pointer-events-auto flex flex-col"
style="max-height: 60vh"
>
<!-- Header -->
<div class="shrink-0 p-5 pb-0 md:px-7 md:pt-7">
<div class="flex justify-center mb-4">
<div class="w-10 h-1 bg-base-300 rounded-full" />
</div>
<div class="flex items-center justify-between mb-1">
<div>
<p class="text-xs font-bold uppercase tracking-wider text-base-content/50">{{ $t('catalog.steps.results') }}</p>
<h2 class="text-2xl font-black tracking-tight text-base-content">{{ $t('catalog.headers.offers') }}</h2>
</div>
<span v-if="!offersLoading" class="badge badge-neutral">{{ offers.length }}</span>
</div>
<!-- Selected filters summary -->
<div class="flex flex-wrap items-center gap-2 mt-2">
<span v-if="productName" class="badge badge-warning gap-1">
<Icon name="lucide:package" size="12" />
{{ productName }}
</span>
<Icon name="lucide:arrow-right" size="14" class="text-base-content/30" />
<span v-if="hubName" class="badge badge-success gap-1">
<Icon name="lucide:warehouse" size="12" />
{{ hubName }}
</span>
<span v-if="qty" class="badge badge-info gap-1">
{{ qty }} {{ $t('units.t') }}
</span>
</div>
</div>
<!-- Offers list -->
<div class="min-h-0 flex-1 overflow-y-auto p-5 md:px-7">
<div v-if="offersLoading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<div v-else-if="offers.length === 0" class="text-center py-8 text-base-content/50">
<Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noOffers') }}</p>
<button class="btn btn-ghost btn-sm mt-3" @click="goBack">
<Icon name="lucide:arrow-left" size="16" />
{{ $t('common.back') }}
</button>
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="offer in offers"
:key="offer.uuid"
class="cursor-pointer"
@click="onSelectOffer(offer)"
>
<OfferResultCard
:supplier-name="offer.supplierName"
:location-name="offer.country || ''"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
</div>
<!-- New search button -->
<div class="shrink-0 p-5 pt-0 md:px-7">
<button class="btn btn-outline btn-sm w-full rounded-full" @click="goBack">
<Icon name="lucide:refresh-cw" size="14" />
{{ $t('catalog.steps.newSearch') }}
</button>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
definePageMeta({ layout: 'topnav' })
const { t } = useI18n()
const router = useRouter()
const localePath = useLocalePath()
const route = useRoute()
const { execute } = useGraphQL()
const mapRef = ref(null)
const productUuid = computed(() => route.query.product as string | undefined)
const productName = computed(() => route.query.productName as string | undefined)
const hubUuid = computed(() => route.query.hub as string | undefined)
const hubName = computed(() => route.query.hubName as string | undefined)
const qty = computed(() => route.query.qty as string | undefined)
// Offers data
const offers = ref<NearestOffer[]>([])
const offersLoading = ref(false)
// Hub point for map
const hubPoint = ref<{ uuid: string; name: string; latitude: number; longitude: number } | null>(null)
// Related points for map (hub + offer locations)
const relatedPoints = computed(() => {
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number; type: 'hub' | 'supplier' | 'offer' }> = []
if (hubPoint.value) {
points.push({
uuid: hubPoint.value.uuid,
name: hubPoint.value.name,
latitude: hubPoint.value.latitude,
longitude: hubPoint.value.longitude,
type: 'hub',
})
}
offers.value
.filter(o => o.latitude != null && o.longitude != null)
.forEach(o => {
points.push({
uuid: o.uuid,
name: o.productName || '',
latitude: Number(o.latitude),
longitude: Number(o.longitude),
type: 'offer',
})
})
return points
})
const onMapSelect = (uuid: string) => {
const offer = offers.value.find(o => o.uuid === uuid)
if (offer) onSelectOffer(offer)
}
const onSelectOffer = (offer: NearestOffer) => {
const productUuid = offer.productUuid
if (offer.uuid && productUuid) {
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
}
}
const getOfferStages = (offer: NearestOffer) => {
const r = offer.routes?.[0]
if (!r?.stages) return []
return r.stages
.filter((s): s is NonNullable<typeof s> => s !== null)
.map(s => ({
transportType: s.transportType,
distanceKm: s.distanceKm,
travelTimeSeconds: s.travelTimeSeconds,
fromName: s.fromName,
}))
}
const goBack = () => {
router.push({
path: localePath('/catalog/product'),
})
}
// Search for offers
const searchOffers = async () => {
if (!productUuid.value || !hubUuid.value) return
offersLoading.value = true
try {
// Load hub coordinates
const hubData = await execute(GetNodeDocument, { uuid: hubUuid.value }, 'public', 'geo')
const hub = hubData?.node
if (!hub?.latitude || !hub?.longitude) {
offers.value = []
return
}
hubPoint.value = {
uuid: hub.uuid,
name: hub.name || hubName.value || '',
latitude: Number(hub.latitude),
longitude: Number(hub.longitude),
}
// Search nearest offers
const data = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productUuid.value,
hubUuid: hubUuid.value,
limit: 20,
},
'public',
'geo'
)
offers.value = (data?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
} finally {
offersLoading.value = false
}
}
onMounted(() => {
searchOffers()
})
useHead(() => ({
title: t('catalog.steps.results')
}))
</script>

View File

@@ -1,66 +1,99 @@
<template>
<CatalogPage
:items="displayItems"
:map-items="itemsWithCoords"
:loading="isLoading"
with-map
map-id="addresses-map"
point-color="#10b981"
:selected-id="selectedAddressId"
:hovered-id="hoveredAddressId"
:total-count="items.length"
@select="onSelectAddress"
@update:hovered-id="hoveredAddressId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="[]"
:displayed-count="displayedCount"
:total-count="totalCount"
@search="onSearch"
/>
</template>
<template #header>
<NuxtLink :to="localePath('/clientarea/addresses/new')">
<Button variant="outline" class="w-full">
<Icon name="lucide:plus" size="16" class="mr-2" />
{{ t('profileAddresses.actions.add') }}
</Button>
</NuxtLink>
</template>
<template #card="{ item }">
<NuxtLink :to="localePath(`/clientarea/addresses/${item.uuid}`)" class="block">
<Card padding="sm" interactive>
<div class="flex flex-col gap-1">
<Text size="base" weight="semibold" class="truncate">{{ item.name }}</Text>
<Text tone="muted" size="sm" class="line-clamp-2">{{ item.address }}</Text>
<div class="flex items-center mt-1">
<span class="text-lg">{{ isoToEmoji(item.countryCode) }}</span>
</div>
<div>
<CatalogPage
:items="mapPoints"
:loading="isLoading"
:use-server-clustering="false"
map-id="addresses-map"
point-color="#10b981"
:hovered-id="hoveredAddressId"
:show-panel="!selectedAddressId"
panel-width="w-96"
:hide-view-toggle="true"
@select="onMapSelect"
@update:hovered-id="hoveredAddressId = $event"
>
<template #panel>
<!-- Panel header -->
<div class="p-4 border-b border-white/10 flex-shrink-0">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
</div>
</Card>
</NuxtLink>
</template>
<template #empty>
<EmptyState
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"
/>
</template>
</CatalogPage>
<!-- Search -->
<div class="relative mb-3">
<input
v-model="searchQuery"
type="text"
:placeholder="t('common.search')"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
/>
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
</div>
<!-- Add button -->
<NuxtLink :to="localePath('/clientarea/addresses/new')">
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
<Icon name="lucide:plus" size="14" class="mr-1" />
{{ t('profileAddresses.actions.add') }}
</button>
</NuxtLink>
</div>
<!-- Addresses list -->
<div class="flex-1 overflow-y-auto p-3 space-y-2">
<template v-if="displayItems.length > 0">
<div
v-for="item in displayItems"
:key="item.uuid"
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
:class="{ 'ring-2 ring-emerald-500': selectedAddressId === item.uuid }"
@click="selectedAddressId = item.uuid"
@mouseenter="hoveredAddressId = item.uuid"
@mouseleave="hoveredAddressId = undefined"
>
<div class="flex items-start gap-2">
<span class="text-xl">{{ isoToEmoji(item.countryCode) }}</span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm truncate">{{ item.name }}</div>
<div class="text-xs text-white/60 line-clamp-2">{{ item.address }}</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="text-center py-8">
<div class="text-3xl mb-2">📍</div>
<div class="font-semibold text-sm mb-1">{{ t('profileAddresses.empty.title') }}</div>
<div class="text-xs text-white/60 mb-3">{{ t('profileAddresses.empty.description') }}</div>
<NuxtLink :to="localePath('/clientarea/addresses/new')">
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
<Icon name="lucide:plus" size="14" class="mr-1" />
{{ t('profileAddresses.empty.cta') }}
</button>
</NuxtLink>
</div>
</template>
</div>
<!-- Footer -->
<div class="p-3 border-t border-white/10 flex-shrink-0">
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
</div>
</template>
</CatalogPage>
<!-- Address Detail Bottom Sheet -->
<AddressDetailBottomSheet
:is-open="!!selectedAddressId"
:address-uuid="selectedAddressId"
@close="selectedAddressId = null"
@deleted="selectedAddressId = null"
/>
</div>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -76,49 +109,34 @@ const {
init
} = useTeamAddresses()
const selectedAddressId = ref<string>()
const hoveredAddressId = ref<string>()
// Search bar
const searchQuery = ref('')
const selectedAddressId = ref<string | null>(null)
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
// Map items
const itemsWithCoords = computed(() => {
return items.value.filter(addr =>
addr.latitude != null &&
addr.longitude != null &&
!isNaN(Number(addr.latitude)) &&
!isNaN(Number(addr.longitude))
).map(addr => ({
uuid: addr.uuid,
name: addr.name,
latitude: Number(addr.latitude),
longitude: Number(addr.longitude)
}))
// Map points
const mapPoints = computed(() => {
return items.value
.filter(addr => addr.uuid && addr.latitude && addr.longitude)
.map(addr => ({
uuid: addr.uuid!,
name: addr.name || '',
latitude: Number(addr.latitude),
longitude: Number(addr.longitude)
}))
})
// Filtered items when searchWithMap is enabled
// Display items with search filter
const displayItems = computed(() => {
if (!searchWithMap.value || !currentBounds.value) return items.value
return items.value.filter(item => {
if (item.latitude == null || item.longitude == null) return false
const { west, east, north, south } = currentBounds.value!
const lng = Number(item.longitude)
const lat = Number(item.latitude)
return lng >= west && lng <= east && lat >= south && lat <= north
})
if (!searchQuery.value) return items.value
const query = searchQuery.value.toLowerCase()
return items.value.filter(item =>
item.name?.toLowerCase().includes(query) ||
item.address?.toLowerCase().includes(query)
)
})
// Search handler
const onSearch = () => {
// TODO: Implement search
}
const onSelectAddress = (item: { uuid?: string | null }) => {
const onMapSelect = (item: { uuid?: string | null }) => {
if (item.uuid) {
selectedAddressId.value = item.uuid
}

View File

@@ -1,50 +1,138 @@
<template>
<Section variant="plain">
<Stack gap="8">
<template v-if="hasOrderError">
<div class="text-sm text-error">
{{ orderError }}
<div>
<CatalogPage
:items="mapPoints"
:loading="isLoadingOrder"
:use-server-clustering="false"
map-id="order-detail-map"
point-color="#6366f1"
:show-panel="false"
:hide-view-toggle="true"
/>
<!-- Bottom Sheet with slide-up animation -->
<Transition name="slide-up" appear>
<div class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4" style="height: 72vh">
<div class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent" />
<!-- Sheet -->
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
<!-- Drag handle -->
<div class="flex justify-center py-2">
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
</div>
<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>
<!-- Header -->
<div class="border-b border-base-300 bg-base-100/90 px-6 pb-4">
<!-- Back button -->
<NuxtLink :to="localePath('/clientarea/orders')" class="mb-3 inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content">
<Icon name="lucide:arrow-left" size="16" />
{{ t('common.back') }}
</NuxtLink>
<template v-else>
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
</Card>
<template v-if="hasOrderError">
<div class="rounded-lg border border-error/30 bg-error/10 p-4">
<div class="mb-2 font-black text-base-content">{{ t('common.error') }}</div>
<div class="mb-3 text-sm text-base-content/70">{{ orderError }}</div>
<button class="btn btn-sm btn-outline" @click="loadOrder">
{{ t('ordersDetail.errors.retry') }}
</button>
</div>
</template>
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
<Stack gap="4">
<RouteStagesList
:stages="orderStageItems"
:empty-text="t('ordersDetail.sections.stages.empty')"
<template v-else-if="!isLoadingOrder && order">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15">
<Icon name="lucide:package" size="24" class="text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
<div class="flex items-center gap-2 flex-wrap">
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-base-content/55">
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
</span>
</div>
</div>
</div>
</template>
<template v-else>
<div class="animate-pulse">
<div class="h-12 w-48 rounded-xl bg-base-300/70" />
</div>
</template>
</div>
<!-- Scrollable content -->
<div v-if="!hasOrderError && order" class="h-[calc(72vh-150px)] overflow-y-auto px-6 py-4 space-y-4">
<!-- Route stages -->
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:route" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
</div>
<div class="space-y-3">
<div
v-for="(stage, idx) in orderStageItems"
:key="stage.key || idx"
class="flex gap-3"
>
<div class="flex flex-col items-center">
<div class="h-3 w-3 rounded-full bg-primary" />
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
</div>
<div class="flex-1 pb-3">
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
{{ stage.to }}
</div>
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
{{ stage.meta.join(' · ') }}
</div>
</div>
</div>
</div>
</div>
<!-- Timeline -->
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:calendar" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
</div>
<GanttTimeline
:stages="order.stages"
:showLoading="showLoading"
:showUnloading="showUnloading"
/>
</div>
<div class="divider my-0"></div>
<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>
<!-- Map preview (small) -->
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
</div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
</div>
</div>
</template>
</Stack>
</Section>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>
<script setup lang="ts">
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
@@ -52,7 +140,6 @@ import type { RouteStageItem } from '~/components/RouteStagesList.vue'
// Types from GraphQL
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
type TripType = NonNullable<NonNullable<StageType['trips']>[number]>
type CompanyType = NonNullable<StageType['selectedCompany']>
definePageMeta({
@@ -62,6 +149,7 @@ definePageMeta({
const route = useRoute()
const { t } = useI18n()
const localePath = useLocalePath()
const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(true)
@@ -70,6 +158,35 @@ const orderError = ref('')
const showLoading = ref(true)
const showUnloading = ref(true)
// Map points for route visualization
const mapPoints = computed(() => {
if (!order.value) return []
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number }> = []
// Add source
if (order.value.sourceLatitude && order.value.sourceLongitude) {
points.push({
uuid: 'source',
name: order.value.sourceLocationName || t('ordersDetail.labels.source'),
latitude: order.value.sourceLatitude,
longitude: order.value.sourceLongitude
})
}
// Add destination
if (order.value.destinationLatitude && order.value.destinationLongitude) {
points.push({
uuid: 'destination',
name: order.value.destinationLocationName || t('ordersDetail.labels.destination'),
latitude: order.value.destinationLatitude,
longitude: order.value.destinationLongitude
})
}
return points
})
const orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')

View File

@@ -1,116 +1,129 @@
<template>
<CatalogPage
:items="displayItems"
:map-items="mapPoints"
:loading="isLoading"
with-map
map-id="orders-map"
point-color="#6366f1"
:selected-id="selectedOrderId"
:hovered-id="hoveredOrderId"
:total-count="filteredItems.length"
@select="onSelectOrder"
@update:hovered-id="hoveredOrderId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="activeFilterBadges"
:displayed-count="displayedCount"
:total-count="totalCount"
@remove-filter="onRemoveFilter"
@search="onSearch"
>
<template #filters>
<div class="p-2 space-y-3">
<div>
<CatalogPage
:items="mapPoints"
:loading="isLoading"
:use-server-clustering="false"
map-id="orders-map"
point-color="#6366f1"
:hovered-id="hoveredOrderId"
:show-panel="!selectedOrderId"
panel-width="w-96"
:hide-view-toggle="true"
@select="onMapSelect"
@update:hovered-id="hoveredOrderId = $event"
>
<template #panel>
<!-- Panel header -->
<div class="p-4 border-b border-white/10 flex-shrink-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
<Icon name="lucide:package" size="16" class="text-indigo-400" />
</div>
<div>
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('ordersList.filters.status') }}</div>
<ul class="menu menu-compact">
<li v-for="filter in filters" :key="filter.id">
<a
:class="{ 'active': selectedFilter === filter.id }"
@click="selectedFilter = filter.id"
>{{ filter.label }}</a>
</li>
</ul>
<span class="font-semibold text-sm">{{ t('cabinetNav.orders') }}</span>
<div class="text-xs text-white/50">{{ filteredItems.length }} {{ t('orders.total', 'total') }}</div>
</div>
</div>
</div>
<!-- Search -->
<div class="relative mb-3">
<input
v-model="searchQuery"
type="text"
:placeholder="t('common.search')"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
/>
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
</div>
<!-- Filter dropdown -->
<div class="dropdown dropdown-end w-full">
<label tabindex="0" class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20 justify-between">
<span>{{ selectedFilterLabel }}</span>
<Icon name="lucide:chevron-down" size="14" />
</label>
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-base-200 rounded-box w-full mt-2">
<li v-for="filter in filters" :key="filter.id">
<a
:class="{ 'active': selectedFilter === filter.id }"
@click="selectedFilter = filter.id"
>{{ filter.label }}</a>
</li>
</ul>
</div>
</div>
<!-- Orders list -->
<div class="flex-1 overflow-y-auto p-3 space-y-2">
<template v-if="displayItems.length > 0">
<div
v-for="item in displayItems"
:key="item.uuid"
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
:class="{ 'ring-2 ring-indigo-500': selectedOrderId === item.uuid }"
@click="selectedOrderId = item.uuid"
@mouseenter="hoveredOrderId = item.uuid"
@mouseleave="hoveredOrderId = undefined"
>
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-sm">#{{ item.name }}</span>
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
{{ getStatusText(item.status) }}
</span>
</div>
<div class="text-xs text-white/70 space-y-1">
<div class="flex items-center gap-2">
<Icon name="lucide:map-pin" size="12" class="text-white/40" />
<span class="truncate">{{ item.sourceLocationName }}</span>
</div>
<div class="flex items-center gap-2">
<Icon name="lucide:navigation" size="12" class="text-white/40" />
<span class="truncate">{{ item.destinationLocationName }}</span>
</div>
</div>
<div class="text-xs text-white/50 mt-2">
{{ getOrderDate(item) }}
</div>
</div>
</template>
</CatalogSearchBar>
<template v-else>
<div class="text-center py-8">
<div class="text-3xl mb-2">📦</div>
<div class="font-semibold text-sm mb-1">{{ t('orders.no_orders') }}</div>
<div class="text-xs text-white/60">{{ t('orders.no_orders_desc') }}</div>
</div>
</template>
</div>
<!-- Footer -->
<div class="p-3 border-t border-white/10 flex-shrink-0">
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
</div>
</template>
</CatalogPage>
<template #card="{ item }">
<Card padding="lg" class="cursor-pointer">
<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">#{{ item.name }}</Heading>
</Stack>
<div class="badge badge-outline">
{{ getOrderStartDate(item) }} {{ getOrderEndDate(item) }}
</div>
</Stack>
<div class="divider my-0"></div>
<Grid :cols="1" :md="3" :gap="3">
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.route') }}</Text>
<Text weight="semibold">{{ item.sourceLocationName }} {{ item.destinationLocationName }}</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.product') }}</Text>
<Text>
{{ item.orderLines?.[0]?.productName || t('ordersList.card.product_loading') }}
<template v-if="item.orderLines?.length > 1">
<span class="badge badge-ghost ml-2">+{{ item.orderLines.length - 1 }}</span>
</template>
</Text>
<Text tone="muted" size="sm">
{{ item.orderLines?.[0]?.quantity || 0 }} {{ item.orderLines?.[0]?.unit || t('ordersList.card.unit_tons') }}
</Text>
</Stack>
<Stack gap="1">
<Text size="sm" tone="muted">{{ t('ordersList.card.status') }}</Text>
<Badge :variant="getStatusVariant(item.status)">
{{ getStatusText(item.status) }}
</Badge>
<Text tone="muted" size="sm">{{ t('ordersList.card.stages_completed', { done: getCompletedStages(item), total: item.stages?.length || 0 }) }}</Text>
</Stack>
</Grid>
</Stack>
</Card>
</template>
<template #empty>
<EmptyState
icon="📦"
:title="t('orders.no_orders')"
:description="t('orders.no_orders_desc')"
:action-label="t('orders.create_new')"
:action-to="localePath('/clientarea')"
action-icon="lucide:plus"
/>
</template>
</CatalogPage>
<!-- Order Detail Bottom Sheet -->
<OrderDetailBottomSheet
:is-open="!!selectedOrderId"
:order-uuid="selectedOrderId"
@close="selectedOrderId = null"
/>
</div>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import type { GetTeamOrdersQueryResult } from '~/composables/graphql/team/orders-generated'
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
type TeamOrderStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
})
const localePath = useLocalePath()
const { t } = useI18n()
const {
@@ -123,154 +136,71 @@ const {
getStatusText
} = useTeamOrders()
const selectedOrderId = ref<string>()
const hoveredOrderId = ref<string>()
// Search bar
const searchQuery = ref('')
const selectedOrderId = ref<string | null>(null)
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
// Selected filter label
const selectedFilterLabel = computed(() => {
const filter = filters.value.find(f => f.id === selectedFilter.value)
return filter?.label || t('ordersList.filters.status')
})
// List items - one per order
const listItems = computed(() => {
// Map points - source locations
const mapPoints = computed(() => {
return filteredItems.value
.filter(order => order.uuid)
.filter(order => order.uuid && order.sourceLatitude && order.sourceLongitude)
.map(order => ({
...order,
uuid: order.uuid,
uuid: order.uuid!,
name: order.name || `#${order.uuid!.slice(0, 8)}`,
latitude: order.sourceLatitude,
longitude: order.sourceLongitude,
country: order.sourceLocationName
latitude: order.sourceLatitude!,
longitude: order.sourceLongitude!
}))
})
// Map points - two per order (source + destination)
interface MapPoint {
uuid: string
name: string
latitude: number
longitude: number
}
const mapPoints = computed(() => {
const result: MapPoint[] = []
filteredItems.value.forEach(order => {
// Source point
if (order.sourceLatitude && order.sourceLongitude) {
result.push({
uuid: `${order.uuid}-source`,
name: `📦 ${order.sourceLocationName}`,
latitude: order.sourceLatitude,
longitude: order.sourceLongitude
})
}
// Destination point - get from last stage
const lastStage = order.stages?.[order.stages.length - 1]
if (lastStage?.destinationLatitude && lastStage?.destinationLongitude) {
result.push({
uuid: `${order.uuid}-dest`,
name: `🏁 ${order.destinationLocationName}`,
latitude: lastStage.destinationLatitude,
longitude: lastStage.destinationLongitude
})
}
})
return result
})
// Filtered items when searchWithMap is enabled
// Display items with search filter
const displayItems = computed(() => {
if (!searchWithMap.value || !currentBounds.value) return listItems.value
return listItems.value.filter(item => {
if (item.latitude == null || item.longitude == null) return false
const { west, east, north, south } = currentBounds.value!
const lng = Number(item.longitude)
const lat = Number(item.latitude)
return lng >= west && lng <= east && lat >= south && lat <= north
})
let items = filteredItems.value.filter(order => order.uuid)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
items = items.filter(item =>
item.name?.toLowerCase().includes(query) ||
item.sourceLocationName?.toLowerCase().includes(query) ||
item.destinationLocationName?.toLowerCase().includes(query)
)
}
return items
})
// Active filter badges
const activeFilterBadges = computed(() => {
const badges: { id: string; label: string }[] = []
if (selectedFilter.value && selectedFilter.value !== 'all') {
const filter = filters.value.find(f => f.id === selectedFilter.value)
if (filter) badges.push({ id: `status:${filter.id}`, label: filter.label })
}
return badges
})
// Remove filter badge
const onRemoveFilter = (id: string) => {
if (id.startsWith('status:')) {
selectedFilter.value = 'all'
}
}
// Search handler
const onSearch = () => {
// TODO: Implement search
}
const onSelectOrder = (item: { uuid?: string | null }) => {
const onMapSelect = (item: { uuid?: string | null }) => {
if (item.uuid) {
selectedOrderId.value = item.uuid
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
}
}
await init()
const getOrderStartDate = (order: TeamOrder) => {
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined')
return formatDate(order.createdAt)
}
const getOrderEndDate = (order: TeamOrder) => {
let latestDate: Date | null = null
order.stages?.forEach((stage) => {
if (!stage) return
stage.trips?.forEach((trip) => {
if (!trip) return
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: TeamOrder) => {
if (!order.stages?.length) return 0
// Note: StageType doesn't have a status field, count all stages for now
return order.stages.filter((stage): stage is TeamOrderStage => stage !== null).length
}
const formatDate = (date: string) => {
if (!date) return t('ordersDetail.labels.dates_undefined')
const getOrderDate = (order: TeamOrder) => {
if (!order.createdAt) return ''
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)
month: 'short'
}).format(new Date(order.createdAt))
} catch {
return t('ordersDetail.labels.dates_undefined')
return ''
}
}
const getStatusBadgeClass = (status?: string) => {
const variant = getStatusVariant(status)
switch (variant) {
case 'success': return 'badge-success'
case 'warning': return 'badge-warning'
case 'error': return 'badge-error'
default: return 'badge-ghost'
}
}
</script>

View File

@@ -1,76 +1,135 @@
<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') }]"
/>
<div>
<CatalogPage
:items="[]"
:loading="false"
:use-server-clustering="false"
map-id="profile-map"
point-color="#10b981"
:show-panel="false"
:hide-view-toggle="true"
/>
<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>
<!-- Bottom Sheet -->
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
<!-- Glass sheet -->
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
<!-- Drag handle -->
<div class="flex justify-center py-2">
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
</div>
<Stack v-if="isLoading" align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text>
</Stack>
<!-- Header -->
<div class="px-6 pb-4 border-b border-white/10">
<!-- Error state -->
<div v-if="hasError" class="bg-error/20 border border-error/30 rounded-lg p-4">
<div class="font-semibold text-white mb-2">{{ $t('common.error') }}</div>
<div class="text-sm text-white/70">{{ error }}</div>
</div>
<!-- Profile header -->
<template v-else>
<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 ?? undefined"
:firstName="userData?.firstName ?? undefined"
:lastName="userData?.lastName ?? undefined"
:avatarId="userData?.avatarId ?? undefined"
@avatar-changed="handleAvatarChange"
/>
</Stack>
</Stack>
</GridItem>
</Grid>
</Card>
<div class="flex items-center gap-3">
<UserAvatar
:userId="userData?.id ?? undefined"
:firstName="userData?.firstName ?? undefined"
:lastName="userData?.lastName ?? undefined"
:avatarId="userData?.avatarId ?? undefined"
size="lg"
@avatar-changed="handleAvatarChange"
/>
<div>
<div class="font-bold text-lg text-white">
{{ userData?.firstName || '' }} {{ userData?.lastName || '' }}
</div>
<div class="text-xs text-white/50">{{ $t('dashboard.profile') }}</div>
</div>
</div>
</template>
</Stack>
</Section>
</div>
<!-- Scrollable content -->
<div v-if="!hasError" class="overflow-y-auto h-[calc(70vh-120px)] px-6 py-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Profile form -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<div class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:user" size="18" />
{{ $t('profile.personal_info', 'Personal Information') }}
</div>
<form class="space-y-4" @submit.prevent="updateProfile">
<!-- First name -->
<div>
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.first_name') }}</label>
<input
v-model="profileForm.firstName"
type="text"
:placeholder="$t('profile.first_name_placeholder')"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
/>
</div>
<!-- Last name -->
<div>
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.last_name') }}</label>
<input
v-model="profileForm.lastName"
type="text"
:placeholder="$t('profile.last_name_placeholder')"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
/>
</div>
<!-- Phone -->
<div>
<label class="block text-xs text-white/50 mb-1">{{ $t('profile.phone') }}</label>
<input
v-model="profileForm.phone"
type="tel"
:placeholder="$t('profile.phone_placeholder')"
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/30"
/>
</div>
<!-- Save button -->
<button
type="submit"
class="btn btn-sm w-full bg-primary border-primary text-primary-content hover:bg-primary/80"
:disabled="isUpdating"
>
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
<template v-else>{{ $t('profile.save') }}</template>
</button>
</form>
</div>
<!-- Settings -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<div class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:settings" size="18" />
{{ $t('profile.settings', 'Settings') }}
</div>
<div class="space-y-3">
<!-- Debug tokens link -->
<NuxtLink
:to="localePath('/clientarea/profile/debug-tokens')"
class="flex items-center gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
>
<Icon name="lucide:bug" size="18" class="text-white/50" />
<div>
<div class="text-sm text-white">{{ t('clientProfile.actions.debugTokens') }}</div>
<div class="text-xs text-white/50">{{ t('clientProfile.actions.debugTokensDesc', 'View authentication tokens') }}</div>
</div>
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -159,7 +218,6 @@ const updateProfile = async () => {
const handleAvatarChange = async (newAvatarId?: string) => {
if (!newAvatarId) return
// Only stage avatar change; will be saved on form submit
avatarDraftId.value = newAvatarId
}

View File

@@ -1,113 +1,163 @@
<template>
<Section variant="plain">
<Stack gap="6">
<PageHeader :title="t('clientTeam.header.title')" :actions="teamHeaderActions" />
<div>
<CatalogPage
:items="[]"
:loading="false"
:use-server-clustering="false"
map-id="team-map"
point-color="#8b5cf6"
:show-panel="false"
:hide-view-toggle="true"
/>
<Alert v-if="hasError" variant="error">
<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>
<!-- Bottom Sheet -->
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
<!-- Glass sheet -->
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
<!-- Drag handle -->
<div class="flex justify-center py-2">
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
</div>
<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>
<!-- Header -->
<div class="px-6 pb-4 border-b border-white/10">
<!-- Error state -->
<div v-if="hasError" class="bg-error/20 border border-error/30 rounded-lg p-4">
<div class="font-semibold text-white mb-2">{{ t('clientTeam.error.title') }}</div>
<div class="text-sm text-white/70 mb-3">{{ error }}</div>
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadUserTeams">
{{ t('clientTeam.error.retry') }}
</button>
</div>
<!-- No team - prompt to create KYC application -->
<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"
/>
<!-- No team -->
<div v-else-if="!currentTeam && !isLoading" class="text-center py-4">
<div class="text-4xl mb-3">👥</div>
<div class="font-semibold text-white mb-2">{{ t('clientTeam.empty.title') }}</div>
<div class="text-sm text-white/60 mb-4">{{ t('clientTeam.empty.description') }}</div>
<NuxtLink :to="localePath('/clientarea/kyc')">
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
<Icon name="lucide:plus" size="14" class="mr-1" />
{{ t('clientTeam.empty.cta') }}
</button>
</NuxtLink>
</div>
<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, index) in currentTeamMembers"
:key="member.user?.id ?? `member-${index}`"
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 currentTeamInvitations"
: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>
<!-- Team header -->
<template v-else>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<Icon name="lucide:building-2" size="24" class="text-primary" />
</div>
<Text weight="semibold" tone="muted">{{ t('clientTeam.inviteCard.title') }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
</template>
</Stack>
</Section>
<div>
<div class="font-bold text-lg text-white">{{ currentTeam?.name }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="badge badge-success badge-sm">{{ t('catalog.info.active') }}</span>
<span class="text-xs text-white/50">{{ t('clientTeam.members.title') }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
<NuxtLink :to="localePath('/clientarea/kyc')">
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
<Icon name="lucide:plus" size="14" />
</button>
</NuxtLink>
<NuxtLink v-if="userTeams.length > 1" :to="localePath('/clientarea/company-switch')">
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
<Icon name="lucide:arrow-left-right" size="14" />
</button>
</NuxtLink>
</div>
</div>
</template>
</div>
<!-- Scrollable content -->
<div v-if="currentTeam" class="overflow-y-auto h-[calc(70vh-120px)] px-6 py-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Team members -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<div class="font-semibold text-white mb-3 flex items-center gap-2">
<Icon name="lucide:users" size="18" />
{{ t('clientTeam.members.title') }}
</div>
<div class="space-y-2">
<div
v-for="(member, index) in currentTeamMembers"
:key="member.user?.id ?? `member-${index}`"
class="flex items-center justify-between p-2 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-2">
<div class="avatar placeholder">
<div class="w-8 h-8 rounded-full bg-primary text-primary-content text-xs">
<span>{{ getMemberInitials(member.user) }}</span>
</div>
</div>
<div>
<div class="text-sm text-white">{{ member.user?.firstName }} {{ member.user?.lastName || '' }}</div>
<div class="text-xs text-white/50">{{ roleText(member.role) }}</div>
</div>
</div>
<span class="badge badge-primary badge-sm">{{ roleText(member.role) }}</span>
</div>
<!-- Empty state -->
<div v-if="currentTeamMembers.length === 0" class="text-center py-4 text-white/50 text-sm">
{{ t('clientTeam.members.empty', 'No members yet') }}
</div>
</div>
</div>
<!-- Invitations -->
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
<div class="font-semibold text-white mb-3 flex items-center gap-2">
<Icon name="lucide:mail" size="18" />
{{ t('clientTeam.invitations.title', 'Pending Invitations') }}
</div>
<div class="space-y-2">
<div
v-for="invitation in currentTeamInvitations"
:key="invitation.uuid"
class="flex items-center justify-between p-2 bg-warning/10 rounded-lg border border-warning/20"
>
<div class="flex items-center gap-2">
<div class="avatar placeholder">
<div class="w-8 h-8 rounded-full bg-warning/20 text-warning flex items-center justify-center">
<Icon name="lucide:mail" size="14" />
</div>
</div>
<div>
<div class="text-sm text-white">{{ invitation.email }}</div>
<div class="text-xs text-warning">{{ t('clientTeam.invitations.pending') }}</div>
</div>
</div>
<span class="badge badge-warning badge-outline badge-sm">{{ roleText(invitation.role) }}</span>
</div>
<!-- Empty state -->
<div v-if="currentTeamInvitations.length === 0" class="text-center py-4 text-white/50 text-sm">
{{ t('clientTeam.invitations.empty', 'No pending invitations') }}
</div>
</div>
<!-- Invite button -->
<button
class="mt-4 w-full bg-white/5 border border-dashed border-white/20 rounded-lg p-3 hover:bg-white/10 transition-colors"
@click="inviteMember"
>
<div class="flex items-center justify-center gap-2 text-white/50">
<Icon name="lucide:user-plus" size="16" />
<span class="text-sm">{{ t('clientTeam.inviteCard.title') }}</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -143,14 +193,6 @@ 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 | null) => {
const map: Record<string, string> = {
OWNER: t('clientTeam.roles.owner'),
@@ -174,7 +216,6 @@ const getMemberInitials = (user?: TeamMember | null) => {
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
}
// Type-safe accessors for TeamWithMembers properties
const currentTeamMembers = computed(() => {
const team = currentTeam.value
return team && 'members' in team ? (team.members || []).filter((m): m is NonNullable<typeof m> => m !== null) : []
@@ -207,7 +248,6 @@ const loadUserTeams = async () => {
currentTeam.value = firstTeam
}
}
// Если нет команды - currentTeam остаётся null, показываем EmptyState
} catch (err: unknown) {
hasError.value = true
error.value = err instanceof Error ? err.message : t('clientTeam.error.load')

View File

@@ -1,89 +1,428 @@
<template>
<Stack gap="12">
<!-- How it works -->
<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>
<div class="pb-0 -mx-3 lg:-mx-6">
<!-- Section: How it works -->
<section class="container mx-auto px-4 mb-20 mt-12">
<!-- Section header with line -->
<div class="flex items-center gap-6 mb-10">
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
<h2 class="text-sm font-medium uppercase tracking-[0.2em] text-base-content/60">
{{ $t('howto.title') }}
</h2>
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
</div>
<!-- Who it's for -->
<Section variant="plain">
<Stack gap="8" align="center">
<Heading :level="2">{{ $t('roles.title') }}</Heading>
<!-- Magazine layout -->
<div class="grid grid-cols-12 gap-6 md:gap-8">
<!-- Large hero image (8 cols) -->
<div class="col-span-12 md:col-span-7 relative overflow-hidden rounded-3xl h-[400px] md:h-[500px] group cursor-pointer">
<img
src="/images/promo/search.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-8">
<span class="inline-block px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-medium mb-4">
{{ $t('howto.step', 'Шаг') }} 01
</span>
<h3 class="text-3xl md:text-4xl font-bold text-white mb-3">{{ $t('howto.step1.title') }}</h3>
<p class="text-white/70 text-lg max-w-md">{{ $t('howto.step1.description') }}</p>
</div>
</div>
<Grid :cols="1" :md="3" :gap="6">
<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>
<!-- Right column: text + small cards -->
<div class="col-span-12 md:col-span-5 flex flex-col gap-6">
<!-- Text block -->
<div class="p-8 rounded-3xl bg-base-200/50 border border-base-300/50">
<div class="text-6xl font-black text-primary/20 mb-2">500+</div>
<div class="text-xl font-semibold text-base-content mb-2">{{ $t('stats.suppliers', 'Поставщиков') }}</div>
<p class="text-base-content/60 text-sm">
{{ $t('stats.suppliersDesc', 'Проверенные производители из России, Казахстана и других стран СНГ') }}
</p>
</div>
<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>
<!-- Compare card -->
<div class="flex-1 relative overflow-hidden rounded-3xl group cursor-pointer min-h-[200px]">
<img
src="/images/promo/compare.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
<div class="absolute inset-0 bg-gradient-to-t from-cyan-900/90 via-cyan-900/40 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-6">
<span class="inline-block px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-300 text-xs font-medium mb-3">
{{ $t('howto.step', 'Шаг') }} 02
</span>
<h3 class="text-xl font-bold text-white">{{ $t('howto.step2.title') }}</h3>
</div>
</div>
</div>
</div>
</section>
<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>
</Stack>
<!-- Full-width quote/testimonial -->
<section class="bg-slate-900 py-16 mb-20">
<div class="container mx-auto px-4">
<div class="max-w-3xl mx-auto text-center">
<Icon name="lucide:quote" size="48" class="text-white/20 mb-6 mx-auto" />
<blockquote class="text-2xl md:text-3xl font-light text-white mb-6 leading-relaxed">
{{ $t('testimonial.quote', 'Optovia помогла нам найти надёжных поставщиков за считанные дни. Раньше на это уходили месяцы.') }}
</blockquote>
<div class="flex items-center justify-center gap-3">
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500" />
<div class="text-left">
<div class="text-white font-medium">{{ $t('testimonial.author', 'Алексей Петров') }}</div>
<div class="text-white/50 text-sm">{{ $t('testimonial.role', 'Директор по закупкам, АгроХолдинг') }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- Section: Who it's for -->
<section class="container mx-auto px-4 mb-20">
<!-- Section header -->
<div class="mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-base-content mb-4">{{ $t('roles.title') }}</h2>
<p class="text-base-content/60 text-lg max-w-2xl">
{{ $t('roles.subtitle', 'Платформа для всех участников рынка сельхозпродукции') }}
</p>
</div>
<!-- Asymmetric grid -->
<div class="grid grid-cols-12 gap-6">
<!-- Producers: large card -->
<div class="col-span-12 md:col-span-8 relative overflow-hidden rounded-3xl h-[450px] group">
<img
src="/images/promo/producer.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div class="absolute inset-0 bg-gradient-to-r from-violet-900/95 via-violet-900/70 to-transparent" />
<div class="absolute inset-0 p-8 flex flex-col justify-center max-w-md">
<div class="w-14 h-14 rounded-2xl bg-violet-500/30 backdrop-blur-sm flex items-center justify-center mb-4">
<Icon name="lucide:factory" size="28" class="text-violet-200" />
</div>
<h3 class="text-3xl font-bold text-white mb-3">{{ $t('roles.producers.title') }}</h3>
<p class="text-white/70 mb-4">{{ $t('roles.producers.description') }}</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2 text-white/80">
<Icon name="lucide:check-circle" size="18" class="text-violet-400" />
{{ $t('roles.producers.benefit1') }}
</li>
<li class="flex items-center gap-2 text-white/80">
<Icon name="lucide:check-circle" size="18" class="text-violet-400" />
{{ $t('roles.producers.benefit2') }}
</li>
</ul>
<button class="btn bg-violet-500 hover:bg-violet-600 border-0 text-white w-fit">
{{ $t('roles.producers.cta') }}
</button>
</div>
</div>
<!-- Right column: stats + services -->
<div class="col-span-12 md:col-span-4 flex flex-col gap-6">
<!-- Stats -->
<div class="p-6 rounded-3xl bg-gradient-to-br from-rose-500 to-pink-600 text-white">
<div class="text-5xl font-black mb-2">24/7</div>
<div class="text-white/80">{{ $t('stats.support', 'Поддержка') }}</div>
</div>
<!-- Services mini -->
<div class="flex-1 relative overflow-hidden rounded-3xl group cursor-pointer min-h-[200px]">
<img
src="/images/promo/services.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
<div class="absolute inset-0 bg-gradient-to-t from-amber-900/90 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-6">
<div class="w-10 h-10 rounded-xl bg-amber-500/30 backdrop-blur-sm flex items-center justify-center mb-2">
<Icon name="lucide:sparkles" size="20" class="text-amber-200" />
</div>
<h3 class="text-lg font-bold text-white">{{ $t('howto.step3.title') }}</h3>
</div>
</div>
</div>
<!-- Bottom row: 3 equal cards -->
<div class="col-span-12 md:col-span-4 relative overflow-hidden rounded-3xl h-[300px] group">
<img
src="/images/promo/buyer.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div class="absolute inset-0 bg-gradient-to-t from-cyan-900/95 via-cyan-900/50 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-6">
<div class="w-12 h-12 rounded-xl bg-cyan-500/30 backdrop-blur-sm flex items-center justify-center mb-3">
<Icon name="lucide:building-2" size="24" class="text-cyan-200" />
</div>
<h3 class="text-xl font-bold text-white mb-2">{{ $t('roles.buyers.title') }}</h3>
<p class="text-white/60 text-sm mb-3">{{ $t('roles.buyers.description') }}</p>
<button class="btn btn-sm bg-cyan-500 hover:bg-cyan-600 border-0 text-white">
{{ $t('roles.buyers.cta') }}
</button>
</div>
</div>
<!-- Text only card -->
<div class="col-span-12 md:col-span-4 p-8 rounded-3xl border-2 border-dashed border-base-300 flex flex-col justify-center items-center text-center h-[300px]">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Icon name="lucide:globe" size="32" class="text-base-content/40" />
</div>
<div class="text-4xl font-bold text-base-content mb-2">15+</div>
<div class="text-base-content/60">{{ $t('stats.countries', 'Стран присутствия') }}</div>
</div>
<!-- Partners -->
<div class="col-span-12 md:col-span-4 relative overflow-hidden rounded-3xl h-[300px] group cursor-pointer">
<img
src="/images/promo/partner.jpg"
alt=""
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div class="absolute inset-0 bg-gradient-to-t from-rose-900/95 via-rose-900/50 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-6">
<div class="w-12 h-12 rounded-xl bg-rose-500/30 backdrop-blur-sm flex items-center justify-center mb-3">
<Icon name="lucide:handshake" size="24" class="text-rose-200" />
</div>
<h3 class="text-xl font-bold text-white mb-2">{{ $t('roles.services.title') }}</h3>
<p class="text-white/60 text-sm">{{ $t('roles.services.description') }}</p>
</div>
</div>
</div>
</section>
<!-- CTA section -->
<section class="container mx-auto px-4 mb-20">
<div class="rounded-3xl bg-gradient-to-r from-emerald-600 via-cyan-600 to-blue-600 p-12 md:p-16 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
{{ $t('cta.title', 'Готовы начать?') }}
</h2>
<p class="text-white/80 text-lg mb-8 max-w-xl mx-auto">
{{ $t('cta.description', 'Присоединяйтесь к сотням компаний, которые уже торгуют на Optovia') }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<button class="btn btn-lg bg-white text-emerald-700 hover:bg-white/90 border-0">
{{ $t('cta.register', 'Зарегистрироваться') }}
</button>
<button class="btn btn-lg btn-outline border-white/30 text-white hover:bg-white/10 hover:border-white/50">
{{ $t('cta.demo', 'Запросить демо') }}
</button>
</div>
</div>
</section>
<!-- Dark Footer -->
<footer class="bg-slate-900 text-white pt-16 pb-8">
<div class="container mx-auto px-4">
<!-- Main footer grid -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 mb-12">
<!-- Logo & Description -->
<div class="col-span-2">
<div class="flex items-center gap-2 mb-4">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
<Icon name="lucide:leaf" size="24" class="text-white" />
</div>
<span class="text-2xl font-bold">Optovia</span>
</div>
<p class="text-slate-400 text-sm mb-4">
{{ $t('footer.description', 'Глобальная B2B платформа для торговли сырьём и сельхозпродукцией. Соединяем производителей и покупателей по всему миру.') }}
</p>
<div class="flex gap-3">
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
<Icon name="lucide:linkedin" size="18" class="text-slate-400" />
</a>
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
<Icon name="lucide:twitter" size="18" class="text-slate-400" />
</a>
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
<Icon name="lucide:instagram" size="18" class="text-slate-400" />
</a>
<a href="#" class="w-9 h-9 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center transition-colors">
<Icon name="lucide:youtube" size="18" class="text-slate-400" />
</a>
</div>
</div>
<!-- Europe -->
<div>
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:globe" size="16" class="text-blue-400" />
{{ $t('footer.europe', 'Европа') }}
</h4>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">🇩🇪 {{ $t('footer.germany', 'Германия') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇫🇷 {{ $t('footer.france', 'Франция') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇳🇱 {{ $t('footer.netherlands', 'Нидерланды') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇵🇱 {{ $t('footer.poland', 'Польша') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇪🇸 {{ $t('footer.spain', 'Испания') }}</li>
</ul>
</div>
<!-- CIS -->
<div>
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:map" size="16" class="text-emerald-400" />
{{ $t('footer.cis', 'СНГ') }}
</h4>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">🇷🇺 {{ $t('footer.russia', 'Россия') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇰🇿 {{ $t('footer.kazakhstan', 'Казахстан') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇺🇿 {{ $t('footer.uzbekistan', 'Узбекистан') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇧🇾 {{ $t('footer.belarus', 'Беларусь') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇦🇿 {{ $t('footer.azerbaijan', 'Азербайджан') }}</li>
</ul>
</div>
<!-- Asia & Middle East -->
<div>
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:sunrise" size="16" class="text-amber-400" />
{{ $t('footer.asia', 'Азия') }}
</h4>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">🇨🇳 {{ $t('footer.china', 'Китай') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇮🇳 {{ $t('footer.india', 'Индия') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇹🇷 {{ $t('footer.turkey', 'Турция') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇦🇪 {{ $t('footer.uae', 'ОАЭ') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇸🇦 {{ $t('footer.saudi', 'Саудовская Аравия') }}</li>
</ul>
</div>
<!-- Americas & Africa -->
<div>
<h4 class="font-semibold text-white mb-4 flex items-center gap-2">
<Icon name="lucide:globe-2" size="16" class="text-rose-400" />
{{ $t('footer.americas', 'Америка и Африка') }}
</h4>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">🇺🇸 {{ $t('footer.usa', 'США') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇧🇷 {{ $t('footer.brazil', 'Бразилия') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇦🇷 {{ $t('footer.argentina', 'Аргентина') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇿🇦 {{ $t('footer.southafrica', 'ЮАР') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">🇪🇬 {{ $t('footer.egypt', 'Египет') }}</li>
</ul>
</div>
</div>
<!-- Offices section -->
<div class="border-t border-slate-800 pt-8 mb-8">
<h4 class="font-semibold text-white mb-6 text-center">{{ $t('footer.offices', 'Наши офисы') }}</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- HQ -->
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<Icon name="lucide:building" size="16" class="text-emerald-400" />
</div>
<div>
<div class="text-sm font-semibold">{{ $t('footer.hq', 'Штаб-квартира') }}</div>
<div class="text-xs text-slate-500">Dubai, UAE</div>
</div>
</div>
<p class="text-xs text-slate-400">Business Bay, Churchill Towers</p>
<p class="text-xs text-emerald-400 mt-2">+971 4 XXX XXXX</p>
</div>
<!-- Europe Office -->
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Icon name="lucide:building-2" size="16" class="text-blue-400" />
</div>
<div>
<div class="text-sm font-semibold">{{ $t('footer.europeOffice', 'Европа') }}</div>
<div class="text-xs text-slate-500">Amsterdam, NL</div>
</div>
</div>
<p class="text-xs text-slate-400">Zuidas Business District</p>
<p class="text-xs text-blue-400 mt-2">+31 20 XXX XXXX</p>
</div>
<!-- CIS Office -->
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700/50">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
<Icon name="lucide:landmark" size="16" class="text-violet-400" />
</div>
<div>
<div class="text-sm font-semibold">{{ $t('footer.cisOffice', 'СНГ') }}</div>
<div class="text-xs text-slate-500">Moscow, RU</div>
</div>
</div>
<p class="text-xs text-slate-400">Moscow City, Federation Tower</p>
<p class="text-xs text-violet-400 mt-2">+7 495 XXX XXXX</p>
</div>
</div>
</div>
<!-- Quick links -->
<div class="border-t border-slate-800 pt-8 mb-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.products', 'Продукты') }}</h5>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.grains', 'Зерновые') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.oilseeds', 'Масличные') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.sugar', 'Сахар') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.fertilizers', 'Удобрения') }}</li>
</ul>
</div>
<div>
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.services', 'Сервисы') }}</h5>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.logistics', 'Логистика') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.insurance', 'Страхование') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.financing', 'Финансирование') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.inspection', 'Инспекция') }}</li>
</ul>
</div>
<div>
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.company', 'Компания') }}</h5>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.about', 'О нас') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.careers', 'Карьера') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.press', 'Пресса') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.contact', 'Контакты') }}</li>
</ul>
</div>
<div>
<h5 class="text-sm font-semibold text-white mb-3">{{ $t('footer.legal', 'Юридическое') }}</h5>
<ul class="space-y-2 text-sm text-slate-400">
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.terms', 'Условия') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.privacy', 'Конфиденциальность') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.cookies', 'Cookies') }}</li>
<li class="hover:text-white cursor-pointer transition-colors">{{ $t('footer.compliance', 'Комплаенс') }}</li>
</ul>
</div>
</div>
</div>
<!-- Bottom bar -->
<div class="border-t border-slate-800 pt-6 flex flex-col md:flex-row items-center justify-between gap-4">
<div class="text-sm text-slate-500">
© 2024 Optovia. {{ $t('footer.rights', 'Все права защищены.') }}
</div>
<div class="flex items-center gap-6 text-sm text-slate-400">
<div class="flex items-center gap-2">
<Icon name="lucide:shield-check" size="16" class="text-emerald-500" />
<span>{{ $t('footer.secure', 'Безопасные сделки') }}</span>
</div>
<div class="flex items-center gap-2">
<Icon name="lucide:lock" size="16" class="text-blue-500" />
<span>SSL</span>
</div>
<div class="flex items-center gap-2">
<Icon name="lucide:badge-check" size="16" class="text-violet-500" />
<span>{{ $t('footer.verified', 'Верификация') }}</span>
</div>
</div>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">

349
app/pages/kyc/[uuid].vue Normal file
View File

@@ -0,0 +1,349 @@
<template>
<div class="min-h-screen bg-base-200">
<div class="container mx-auto px-4 py-8 max-w-5xl">
<!-- Back button -->
<NuxtLink :to="backUrl" class="btn btn-ghost btn-sm mb-4">
<Icon name="lucide:arrow-left" size="16" />
{{ $t('common.back') }}
</NuxtLink>
<!-- Mock KYC Profile (demo mode) -->
<div v-if="isDemo" class="flex flex-col gap-6">
<!-- Header Card -->
<Card padding="lg">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<div class="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center">
<Icon name="lucide:building-2" size="32" class="text-primary" />
</div>
<div>
<Text weight="bold" size="xl">ООО "АГРОТОРГ ПЛЮС"</Text>
<div class="flex items-center gap-2 mt-1">
<span class="badge badge-success">Действующая</span>
<span class="badge badge-outline badge-sm">ООО</span>
<span class="text-sm text-base-content/60">с 2015 года</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<Icon name="lucide:shield-check" size="20" class="text-success" />
<span class="text-sm font-medium text-success">Верифицирован</span>
</div>
<span class="text-xs text-base-content/50">Обновлено: {{ new Date().toLocaleDateString('ru-RU') }}</span>
</div>
</div>
</Card>
<!-- Main Info -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="flex flex-col gap-6">
<!-- Реквизиты -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:file-text" size="20" />
Реквизиты
</Text>
<div class="grid grid-cols-2 gap-4">
<div>
<Text tone="muted" size="sm">ИНН</Text>
<Text weight="medium" class="font-mono">7707456789</Text>
</div>
<div>
<Text tone="muted" size="sm">КПП</Text>
<Text weight="medium" class="font-mono">770701001</Text>
</div>
<div>
<Text tone="muted" size="sm">ОГРН</Text>
<Text weight="medium" class="font-mono">1157746123456</Text>
</div>
<div>
<Text tone="muted" size="sm">ОКПО</Text>
<Text weight="medium" class="font-mono">12345678</Text>
</div>
<div class="col-span-2">
<Text tone="muted" size="sm">Дата регистрации</Text>
<Text weight="medium">15 марта 2015 г.</Text>
</div>
</div>
</Card>
<!-- Руководство -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:user-cog" size="20" />
Руководство
</Text>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg">
<div class="avatar placeholder">
<div class="w-10 h-10 rounded-full bg-primary text-primary-content">
<span>ПС</span>
</div>
</div>
<div>
<Text weight="medium">Петров Сергей Александрович</Text>
<Text tone="muted" size="sm">Генеральный директор</Text>
</div>
</div>
</div>
</Card>
<!-- Учредители -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:users" size="20" />
Учредители
</Text>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="w-10 h-10 rounded-full bg-secondary text-secondary-content">
<span>ПС</span>
</div>
</div>
<div>
<Text weight="medium">Петров Сергей Александрович</Text>
<Text tone="muted" size="sm">Физическое лицо</Text>
</div>
</div>
<span class="badge badge-primary">60%</span>
</div>
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="w-10 h-10 rounded-full bg-secondary text-secondary-content">
<span>ИА</span>
</div>
</div>
<div>
<Text weight="medium">Иванова Анна Петровна</Text>
<Text tone="muted" size="sm">Физическое лицо</Text>
</div>
</div>
<span class="badge badge-primary">40%</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-base-200">
<div class="flex justify-between">
<Text tone="muted" size="sm">Уставный капитал</Text>
<Text weight="semibold">500 000 </Text>
</div>
</div>
</Card>
</div>
<!-- Right Column -->
<div class="flex flex-col gap-6">
<!-- Контакты -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:contact" size="20" />
Контакты
</Text>
<div class="space-y-3">
<div class="flex items-center gap-3">
<Icon name="lucide:map-pin" size="18" class="text-base-content/50" />
<Text size="sm">123456, г. Москва, ул. Складская, д. 15, оф. 301</Text>
</div>
<div class="flex items-center gap-3">
<Icon name="lucide:phone" size="18" class="text-base-content/50" />
<Text size="sm">+7 (495) 123-45-67</Text>
</div>
<div class="flex items-center gap-3">
<Icon name="lucide:mail" size="18" class="text-base-content/50" />
<Text size="sm">info@agrotorg-plus.ru</Text>
</div>
<div class="flex items-center gap-3">
<Icon name="lucide:globe" size="18" class="text-base-content/50" />
<Text size="sm">www.agrotorg-plus.ru</Text>
</div>
</div>
</Card>
<!-- Финансы -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:bar-chart-3" size="20" />
Финансовые показатели (2024)
</Text>
<div class="space-y-4">
<div>
<div class="flex justify-between mb-1">
<Text tone="muted" size="sm">Выручка</Text>
<Text weight="semibold" class="text-success"> 15%</Text>
</div>
<Text weight="bold" size="lg">245 800 000 </Text>
</div>
<div>
<div class="flex justify-between mb-1">
<Text tone="muted" size="sm">Чистая прибыль</Text>
<Text weight="semibold" class="text-success"> 23%</Text>
</div>
<Text weight="bold" size="lg">18 450 000 </Text>
</div>
<div>
<div class="flex justify-between mb-1">
<Text tone="muted" size="sm">Активы</Text>
</div>
<Text weight="bold" size="lg">89 200 000 </Text>
</div>
<div class="pt-3 border-t border-base-200">
<div class="flex justify-between">
<Text tone="muted" size="sm">Сотрудников</Text>
<Text weight="medium">47 человек</Text>
</div>
</div>
</div>
</Card>
<!-- Арбитраж -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:scale" size="20" />
Арбитражные дела
</Text>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="badge badge-warning badge-sm">Истец</span>
<Text size="sm">3 дела</Text>
</div>
<Text weight="medium" size="sm">1 250 000 </Text>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="badge badge-error badge-sm">Ответчик</span>
<Text size="sm">1 дело</Text>
</div>
<Text weight="medium" size="sm">320 000 </Text>
</div>
<div class="pt-3 border-t border-base-200">
<Text tone="muted" size="xs">Завершенных: 2 выигранных, 1 урегулировано</Text>
</div>
</div>
</Card>
</div>
</div>
<!-- Виды деятельности -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:briefcase" size="20" />
Виды деятельности (ОКВЭД)
</Text>
<div class="space-y-2">
<div class="flex items-start gap-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
<span class="badge badge-primary badge-sm mt-0.5">Основной</span>
<div>
<Text weight="medium" size="sm">46.21 - Торговля оптовая зерном, необработанным табаком, семенами и кормами для сельскохозяйственных животных</Text>
</div>
</div>
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
<Text size="sm">46.11 - Деятельность агентов по оптовой торговле сельскохозяйственным сырьем</Text>
</div>
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
<Text size="sm">52.10 - Деятельность по складированию и хранению</Text>
</div>
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
<span class="badge badge-ghost badge-sm mt-0.5">Доп.</span>
<Text size="sm">49.41 - Деятельность автомобильного грузового транспорта</Text>
</div>
</div>
</Card>
<!-- История изменений -->
<Card padding="md">
<Text weight="semibold" size="lg" class="mb-4 flex items-center gap-2">
<Icon name="lucide:history" size="20" />
История изменений
</Text>
<div class="relative">
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-base-300" />
<div class="space-y-4">
<div class="flex gap-4 relative">
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center z-10">
<Icon name="lucide:check" size="16" class="text-primary-content" />
</div>
<div class="flex-1 pb-4">
<Text weight="medium" size="sm">Смена юридического адреса</Text>
<Text tone="muted" size="xs">12 января 2024</Text>
</div>
</div>
<div class="flex gap-4 relative">
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
<Icon name="lucide:user-plus" size="16" />
</div>
<div class="flex-1 pb-4">
<Text weight="medium" size="sm">Изменение состава учредителей</Text>
<Text tone="muted" size="xs">5 августа 2023</Text>
</div>
</div>
<div class="flex gap-4 relative">
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
<Icon name="lucide:banknote" size="16" />
</div>
<div class="flex-1 pb-4">
<Text weight="medium" size="sm">Увеличение уставного капитала</Text>
<Text tone="muted" size="xs">20 марта 2022</Text>
</div>
</div>
<div class="flex gap-4 relative">
<div class="w-8 h-8 rounded-full bg-base-300 flex items-center justify-center z-10">
<Icon name="lucide:building" size="16" />
</div>
<div class="flex-1">
<Text weight="medium" size="sm">Регистрация компании</Text>
<Text tone="muted" size="xs">15 марта 2015</Text>
</div>
</div>
</div>
</div>
</Card>
<!-- Sources Footer -->
<div class="flex flex-wrap items-center justify-between gap-4 text-xs text-base-content/50 px-2">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<Icon name="lucide:database" size="14" />
Источники: ЕГРЮЛ, ФНС, Росстат, Арбитр
</span>
</div>
<span>Данные актуальны на {{ new Date().toLocaleDateString('ru-RU') }}</span>
</div>
<!-- Demo notice -->
<div class="alert alert-info">
<Icon name="lucide:info" size="16" />
<span>{{ $t('kyc.demo.notice') }}</span>
</div>
</div>
<!-- Real KYC Profile Card -->
<KycProfileCard v-else :kyc-profile-uuid="uuid" />
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'topnav'
})
const route = useRoute()
const localePath = useLocalePath()
const uuid = computed(() => route.params.uuid as string)
const isDemo = computed(() => uuid.value === 'demo-kyc-profile')
// Back URL - try to go back to previous page or catalog
const backUrl = computed(() => {
return localePath('/catalog')
})
</script>

View File

@@ -11,7 +11,7 @@
</Stack>
<Stack direction="row" gap="3">
<Button :as="'NuxtLink'" :to="localePath('/catalog')" variant="ghost">
<Button :as="'NuxtLink'" :to="localePath('/catalog?select=product')" variant="ghost">
{{ t('searchPage.cta.catalog') }}
</Button>
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">

358
app/pages/whitepaper.vue Normal file
View File

@@ -0,0 +1,358 @@
<template>
<div class="pb-16">
<section class="container mx-auto px-4 mt-10">
<div class="rounded-3xl bg-base-200/70 border border-base-300/70 p-8 md:p-12 relative overflow-hidden">
<div class="absolute -top-24 -right-24 h-64 w-64 rounded-full bg-primary/20 blur-3xl" />
<div class="absolute -bottom-28 -left-20 h-72 w-72 rounded-full bg-secondary/20 blur-3xl" />
<div class="relative">
<div class="flex items-start justify-between gap-4">
<div class="text-xs uppercase tracking-[0.3em] text-base-content/50">White paper</div>
<div class="flex items-center gap-2">
<button
class="btn btn-xs"
:class="lang === 'ru' ? 'btn-primary' : 'btn-ghost'"
@click="lang = 'ru'"
>
RU
</button>
<button
class="btn btn-xs"
:class="lang === 'en' ? 'btn-primary' : 'btn-ghost'"
@click="lang = 'en'"
>
EN
</button>
</div>
</div>
<div class="mt-4">
<h1 class="text-3xl md:text-5xl font-bold text-base-content max-w-3xl">
{{ content.hero.title }}
</h1>
<p class="mt-5 text-lg text-base-content/70 max-w-2xl">
{{ content.hero.subtitle }}
</p>
<div class="mt-8 flex flex-wrap gap-3">
<button class="btn btn-primary">{{ content.hero.ctaPrimary }}</button>
<button class="btn btn-ghost border border-base-300">{{ content.hero.ctaSecondary }}</button>
</div>
</div>
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body p-5">
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[0].label }}</p>
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[0].value }}</p>
</div>
</div>
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body p-5">
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[1].label }}</p>
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[1].value }}</p>
</div>
</div>
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body p-5">
<p class="text-xs uppercase tracking-[0.2em] text-base-content/40">{{ content.hero.cards[2].label }}</p>
<p class="text-lg font-semibold text-base-content">{{ content.hero.cards[2].value }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-4 mt-16">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body">
<h2 class="card-title text-2xl">{{ content.vision.title }}</h2>
<p class="text-base-content/70">{{ content.vision.body }}</p>
<div class="mt-4 space-y-3">
<div class="flex items-start gap-3">
<Icon name="lucide:sparkles" class="text-primary" />
<div>
<p class="font-semibold">{{ content.vision.points[0].title }}</p>
<p class="text-sm text-base-content/60">{{ content.vision.points[0].text }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<Icon name="lucide:globe" class="text-primary" />
<div>
<p class="font-semibold">{{ content.vision.points[1].title }}</p>
<p class="text-sm text-base-content/60">{{ content.vision.points[1].text }}</p>
</div>
</div>
<div class="flex items-start gap-3">
<Icon name="lucide:layers" class="text-primary" />
<div>
<p class="font-semibold">{{ content.vision.points[2].title }}</p>
<p class="text-sm text-base-content/60">{{ content.vision.points[2].text }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body">
<h2 class="card-title text-2xl">{{ content.mission.title }}</h2>
<p class="text-base-content/70">{{ content.mission.body }}</p>
<div class="mt-6 grid grid-cols-1 gap-3">
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[0].label }}</p>
<p class="text-lg font-semibold">{{ content.mission.cards[0].value }}</p>
</div>
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[1].label }}</p>
<p class="text-lg font-semibold">{{ content.mission.cards[1].value }}</p>
</div>
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm uppercase tracking-[0.2em] text-base-content/40">{{ content.mission.cards[2].label }}</p>
<p class="text-lg font-semibold">{{ content.mission.cards[2].value }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-4 mt-16">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
<div class="card-body">
<h3 class="text-xl font-semibold">{{ content.principles[0].title }}</h3>
<p class="text-base-content/70">{{ content.principles[0].text }}</p>
</div>
</div>
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
<div class="card-body">
<h3 class="text-xl font-semibold">{{ content.principles[1].title }}</h3>
<p class="text-base-content/70">{{ content.principles[1].text }}</p>
</div>
</div>
<div class="card bg-gradient-to-br from-base-200 to-base-100 border border-base-300/60">
<div class="card-body">
<h3 class="text-xl font-semibold">{{ content.principles[2].title }}</h3>
<p class="text-base-content/70">{{ content.principles[2].text }}</p>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-4 mt-16">
<div class="rounded-3xl bg-base-100 border border-base-300/60 p-8 md:p-12">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
<div>
<h2 class="text-2xl md:text-3xl font-bold">{{ content.trust.title }}</h2>
<p class="mt-4 text-base-content/70 max-w-2xl">{{ content.trust.body }}</p>
</div>
<div class="grid grid-cols-1 gap-3 w-full max-w-sm">
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm text-base-content/60">{{ content.trust.items[0] }}</p>
</div>
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm text-base-content/60">{{ content.trust.items[1] }}</p>
</div>
<div class="rounded-2xl bg-base-200/70 p-4">
<p class="text-sm text-base-content/60">{{ content.trust.items[2] }}</p>
</div>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-4 mt-16">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body">
<h3 class="text-xl font-semibold">{{ content.team.title }}</h3>
<p class="text-base-content/60">{{ content.team.body }}</p>
<div class="mt-6 flex flex-wrap gap-4">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary/20 text-primary w-12 rounded-full">
<span>Р</span>
</div>
</div>
<div>
<p class="font-semibold">{{ content.team.members[0].name }}</p>
<p class="text-xs text-base-content/50">{{ content.team.members[0].role }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-secondary/20 text-secondary w-12 rounded-full">
<span>Д</span>
</div>
</div>
<div>
<p class="font-semibold">{{ content.team.members[1].name }}</p>
<p class="text-xs text-base-content/50">{{ content.team.members[1].role }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 border border-base-300/60">
<div class="card-body">
<h3 class="text-xl font-semibold">{{ content.roadmap.title }}</h3>
<ul class="mt-3 space-y-2 text-base-content/70">
<li v-for="item in content.roadmap.items" :key="item"> {{ item }}</li>
</ul>
</div>
</div>
</div>
</section>
<section class="container mx-auto px-4 mt-16">
<div class="rounded-3xl bg-gradient-to-r from-primary/80 via-secondary/70 to-accent/80 text-primary-content p-10 md:p-14">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<h2 class="text-2xl md:text-3xl font-bold">{{ content.cta.title }}</h2>
<p class="mt-2 text-primary-content/80">{{ content.cta.body }}</p>
</div>
<button class="btn bg-base-100 text-base-content border-0">{{ content.cta.button }}</button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
const lang = ref<'ru' | 'en'>('ru')
const copy = {
ru: {
hero: {
title: 'Инфраструктура цифровых инструментов, которая стирает границы',
subtitle:
'Optovia строит единое пространство для участников рынка: чтобы процессы были прозрачными, доверенными и простыми, а команды могли работать быстрее и безопаснее.',
ctaPrimary: 'Запросить демо',
ctaSecondary: 'Открыть кабинет',
cards: [
{ label: 'Идея', value: 'Стираем границы и упрощаем рынок' },
{ label: 'Подход', value: 'Инфраструктура вместо точечных продуктов' },
{ label: 'Форма', value: 'B2B / API-first / экосистема' },
],
},
vision: {
title: 'Видение',
body:
'Мы делаем инфраструктуру, которая позволяет компаниям быстрее доверять друг другу и прозрачнее работать с данными. Наша цель — убрать шум, убрать барьеры и дать рынку работающий цифровой фундамент.',
points: [
{ title: 'Стираем границы', text: 'Объединяем процессы и участников рынка в единый контур.' },
{ title: 'Делаем проще', text: 'Снижаем сложность и убираем ручные операции.' },
{ title: 'Строим фундамент', text: 'Даем основу, на которой бизнес может расти.' },
],
},
mission: {
title: 'Что мы строим',
body:
'Optovia — это инфраструктура для цифровых операций: от доверия и безопасности до мониторинга и прозрачности.',
cards: [
{ label: 'Единая экосистема', value: 'Команды, данные, процессы — в одной системе.' },
{ label: 'Прозрачность', value: 'Статусы и история действий в реальном времени.' },
{ label: 'Масштабируемость', value: 'Подходит для компаний любого размера.' },
],
},
principles: [
{ title: 'Прозрачность', text: 'Каждый процесс наблюдаем и измерим.' },
{ title: 'Надежность', text: 'Стабильная инфраструктура и контроль рисков.' },
{ title: 'Скорость', text: 'Ускоряем рутину и даем бизнесу скорость решений.' },
],
trust: {
title: 'Доверие и безопасность',
body:
'Мы строим технологию, которая поддерживает надежность на всех уровнях — от доступа до мониторинга данных. Безопасность не отдельный слой, а часть ДНК платформы.',
items: [
'Токены и изоляция сервисов',
'Аудит действий и история событий',
'Контроль доступа и права команд',
],
},
team: {
title: 'Команда',
body: 'Партнеры проекта, которые выстраивают стратегию и развитие продукта.',
members: [
{ name: 'Руслан', role: 'Партнер' },
{ name: 'Денис', role: 'Партнер' },
],
},
roadmap: {
title: 'Roadmap',
items: ['Private beta и пилоты', 'Усиление мониторинга и алертов', 'Масштабирование и новые вертикали'],
},
cta: {
title: 'Хотите посмотреть систему в действии?',
body: 'Зайдите в демо-кабинет клиента и оцените платформу вживую.',
button: 'Перейти в демо',
},
},
en: {
hero: {
title: 'Infrastructure for digital tools that removes borders',
subtitle:
'Optovia builds a shared space for market participants: transparent, trusted, and simple processes that let teams move faster and safer.',
ctaPrimary: 'Request demo',
ctaSecondary: 'Open demo',
cards: [
{ label: 'Idea', value: 'Remove borders and simplify the market' },
{ label: 'Approach', value: 'Infrastructure, not isolated products' },
{ label: 'Form', value: 'B2B / API-first / ecosystem' },
],
},
vision: {
title: 'Vision',
body:
'We build infrastructure that helps companies trust each other faster and work with data more transparently. Our goal is to remove noise, remove barriers, and deliver a working digital foundation.',
points: [
{ title: 'Remove borders', text: 'Unite processes and participants into one flow.' },
{ title: 'Make it simple', text: 'Reduce complexity and manual actions.' },
{ title: 'Build the foundation', text: 'Give the market a reliable base to grow.' },
],
},
mission: {
title: 'What we build',
body:
'Optovia is infrastructure for digital operations: from trust and safety to monitoring and transparency.',
cards: [
{ label: 'Unified ecosystem', value: 'Teams, data, and processes in one system.' },
{ label: 'Transparency', value: 'Statuses and history in real time.' },
{ label: 'Scalability', value: 'Fits businesses of any size.' },
],
},
principles: [
{ title: 'Transparency', text: 'Every process is observable and measurable.' },
{ title: 'Reliability', text: 'Stable infrastructure and risk control.' },
{ title: 'Speed', text: 'Faster operations and decision making.' },
],
trust: {
title: 'Trust & security',
body:
'Security is embedded in the platform DNA: access, monitoring, and auditability built-in from day one.',
items: ['Tokens and service isolation', 'Audit trails and event history', 'Access control and team permissions'],
},
team: {
title: 'Team',
body: 'Partners shaping strategy and product development.',
members: [
{ name: 'Ruslan', role: 'Partner' },
{ name: 'Denis', role: 'Partner' },
],
},
roadmap: {
title: 'Roadmap',
items: ['Private beta and pilots', 'Monitoring and alerts expansion', 'Scaling and new verticals'],
},
cta: {
title: 'Want to see the system in action?',
body: 'Enter the demo client cabinet and explore the platform live.',
button: 'Go to demo',
},
},
} as const
const content = computed(() => copy[lang.value])
</script>

View File

@@ -1,22 +0,0 @@
import * as Sentry from '@sentry/vue'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const dsn = config.public.sentryDsn
if (!dsn) {
return
}
Sentry.init({
app: nuxtApp.vueApp,
dsn,
integrations: [
Sentry.browserTracingIntegration({ router: nuxtApp.$router as any }),
Sentry.replayIntegration()
],
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
})
})

View File

@@ -24,6 +24,7 @@ const pluginConfig = {
const config: CodegenConfig = {
overwrite: true,
allowPartialOutputs: true,
generates: {
// Public operations (no token)
'./app/composables/graphql/public/exchange-generated.ts': {

View File

@@ -1,5 +1,11 @@
query NearestHubs($lat: Float!, $lon: Float!, $radius: Float, $productUuid: String, $limit: Int) {
nearestHubs(lat: $lat, lon: $lon, radius: $radius, productUuid: $productUuid, limit: $limit) {
nearestHubs(
lat: $lat
lon: $lon
radius: $radius
productUuid: $productUuid
limit: $limit
) {
uuid
name
latitude

View File

@@ -1,5 +1,6 @@
{
"cabinetNav": {
"cabinet": "My Cabinet",
"search": "Search",
"catalog": "Catalog",
"orders": "My orders",
@@ -13,6 +14,10 @@
"seller": "Seller",
"suppliers": "Suppliers",
"hubs": "Hubs",
"ai": "AI assistant"
"ai": "AI assistant",
"roles": {
"client": "I'm a client",
"seller": "I'm a seller"
}
}
}

View File

@@ -63,7 +63,18 @@
"suppliersNearby": "Suppliers nearby",
"noHubs": "No hubs found",
"noSuppliers": "No suppliers found",
"viewSupplier": "View supplier"
"viewSupplier": "View supplier",
"supplier": "Supplier",
"kycTeaser": "Company Information",
"companyType": "Company Type",
"registrationYear": "Registration Year",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"sourcesCount": "Data Sources",
"viewFullKyc": "View full company profile",
"railHubs": "Rail hubs",
"seaHubs": "Sea hubs"
},
"modes": {
"explore": "Explore",
@@ -76,12 +87,23 @@
"selectSupplier": "Select supplier",
"enterQty": "Quantity (t)",
"search": "Search",
"clear": "Clear"
"clear": "Clear",
"findOffers": "Find offers"
},
"explore": {
"title": "Explore the market",
"subtitle": "Switch between offers, hubs, and suppliers"
},
"offers": "offer | offers"
"offers": "offer | offers",
"list": "List",
"applyFilter": "Apply filter",
"step": "Step {n}",
"steps": {
"selectProduct": "What are you looking for?",
"selectDestination": "Where to deliver?",
"setQuantity": "How much do you need?",
"results": "Results",
"newSearch": "New search"
}
}
}
}

View File

@@ -3,7 +3,14 @@
"labels": {
"quantity_with_unit": "{quantity} {unit}",
"default_unit": "t",
"country_unknown": "Not specified"
"unit_kg": "kg",
"distance_km": "{km} km",
"duration_label": "ETA",
"duration_days": "{days} d",
"country_unknown": "Not specified",
"supplier_unknown": "Supplier",
"origin_label": "From",
"origin_unknown": "Origin not specified"
}
}
}

View File

@@ -1,8 +1,10 @@
{
"cta": {
"title": "Ready to Start Trading?",
"description": "Join thousands of companies already using Optovia for their deals",
"title": "Ready to Get Started?",
"description": "Join hundreds of companies already trading on Optovia",
"start_selling": "Start Selling",
"start_buying": "Start Buying"
"start_buying": "Start Buying",
"register": "Register",
"demo": "Request Demo"
}
}

View File

@@ -1,11 +1,68 @@
{
"footer": {
"description": "Global B2B platform for raw materials and agricultural commodities trading. Connecting producers and buyers worldwide.",
"buyers": "For Buyers",
"suppliers": "For Suppliers",
"services": "For Service Companies",
"rights": "All rights reserved",
"rights": "All rights reserved.",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"support": "Support"
"terms": "Terms",
"support": "Support",
"secure": "Secure Transactions",
"verified": "Verification",
"europe": "Europe",
"germany": "Germany",
"france": "France",
"netherlands": "Netherlands",
"poland": "Poland",
"spain": "Spain",
"cis": "CIS",
"russia": "Russia",
"kazakhstan": "Kazakhstan",
"uzbekistan": "Uzbekistan",
"belarus": "Belarus",
"azerbaijan": "Azerbaijan",
"asia": "Asia",
"china": "China",
"india": "India",
"turkey": "Turkey",
"uae": "UAE",
"saudi": "Saudi Arabia",
"americas": "Americas & Africa",
"usa": "USA",
"brazil": "Brazil",
"argentina": "Argentina",
"southafrica": "South Africa",
"egypt": "Egypt",
"offices": "Our Offices",
"hq": "Headquarters",
"europeOffice": "Europe",
"cisOffice": "CIS",
"products": "Products",
"grains": "Grains",
"oilseeds": "Oilseeds",
"sugar": "Sugar",
"fertilizers": "Fertilizers",
"logistics": "Logistics",
"insurance": "Insurance",
"financing": "Financing",
"inspection": "Inspection",
"company": "Company",
"about": "About Us",
"careers": "Careers",
"press": "Press",
"contact": "Contact",
"legal": "Legal",
"cookies": "Cookies",
"compliance": "Compliance"
}
}

View File

@@ -1,6 +1,7 @@
{
"howto": {
"title": "How It Works",
"step": "Step",
"step1": {
"title": "Find Materials",
"description": "Select the raw materials you need, specify quantity and delivery location"

View File

@@ -3,6 +3,16 @@
"verification_status": "Verification Status",
"team_verification_description": "Complete team verification to create orders",
"start_verification": "Start Verification",
"check_status_in_odoo": "Status is being reviewed by administrator"
"check_status_in_odoo": "Status is being reviewed by administrator",
"demo": {
"companyName": "Demo Company LLC",
"director": "Director",
"capital": "Authorized Capital",
"address": "Legal Address",
"activities": "Business Activities",
"sources": "Sources",
"updated": "Updated",
"notice": "This is demo data. Real company information will be available after connecting to the database."
}
}
}

View File

@@ -5,10 +5,15 @@
},
"actions": {
"add": "Add address",
"edit": "Edit",
"confirm_delete": "Delete this address?",
"delete": "Delete",
"deleting": "Deleting..."
},
"detail": {
"location": "Location",
"map": "Map"
},
"form": {
"title": "New address",
"title_edit": "Edit address",

View File

@@ -1,11 +1,12 @@
{
"roles": {
"title": "Who Our Platform Is For",
"subtitle": "Platform for all agricultural commodity market participants",
"producers": {
"title": "Producers",
"description": "Sell raw materials directly to buyers through our platform",
"benefit1": "Sales bulletin board",
"benefit2": "Auction tender participation",
"benefit1": "Direct access to buyers",
"benefit2": "Transparent pricing",
"benefit3": "Access to financing",
"benefit4": "Logistics solutions",
"cta": "Start Selling"

View File

@@ -2,8 +2,10 @@
"stats": {
"title": "Optovia in Numbers",
"suppliers": "Suppliers",
"suppliersDesc": "Verified producers from Russia, Kazakhstan and other CIS countries",
"transactions": "Transaction Volume",
"service_companies": "Service Companies",
"support": "Support"
"support": "Support",
"countries": "Countries of Presence"
}
}

View File

@@ -1,5 +1,10 @@
{
"testimonials": {
"title": "Client Testimonials"
},
"testimonial": {
"quote": "Optovia helped us find reliable suppliers in just days. It used to take months.",
"author": "Alexey Petrov",
"role": "Procurement Director, AgroHolding"
}
}

View File

@@ -1,5 +1,6 @@
{
"cabinetNav": {
"cabinet": "Мой кабинет",
"search": "Поиск",
"catalog": "Каталог",
"orders": "Мои заказы",
@@ -13,6 +14,10 @@
"seller": "Продавец",
"suppliers": "Поставщики",
"hubs": "Хабы",
"ai": "AI ассистент"
"ai": "AI ассистент",
"roles": {
"client": "Я клиент",
"seller": "Я продавец"
}
}
}

View File

@@ -63,7 +63,18 @@
"suppliersNearby": "Поставщики рядом",
"noHubs": "Хабы не найдены",
"noSuppliers": "Поставщики не найдены",
"viewSupplier": "Посмотреть поставщика"
"viewSupplier": "Посмотреть поставщика",
"supplier": "Поставщик",
"kycTeaser": "Информация о компании",
"companyType": "Тип организации",
"registrationYear": "Год регистрации",
"status": "Статус",
"active": "Действующая",
"inactive": "Недействующая",
"sourcesCount": "Источников данных",
"viewFullKyc": "Подробнее о компании",
"railHubs": "ЖД хабы",
"seaHubs": "Морские хабы"
},
"modes": {
"explore": "Исследовать",
@@ -76,7 +87,8 @@
"selectSupplier": "Выберите поставщика",
"enterQty": "Количество (т)",
"search": "Найти",
"clear": "Очистить"
"clear": "Очистить",
"findOffers": "Найти предложения"
},
"explore": {
"title": "Исследуйте рынок",
@@ -84,6 +96,14 @@
},
"offers": "предложение | предложения | предложений",
"list": "Список",
"applyFilter": "Применить фильтр"
"applyFilter": "Применить фильтр",
"step": "Шаг {n}",
"steps": {
"selectProduct": "Что ищете?",
"selectDestination": "Куда доставить?",
"setQuantity": "Сколько нужно?",
"results": "Результаты",
"newSearch": "Новый поиск"
}
}
}
}

View File

@@ -3,7 +3,14 @@
"labels": {
"quantity_with_unit": "{quantity} {unit}",
"default_unit": "т",
"country_unknown": "Не указана"
"unit_kg": "кг",
"distance_km": "{km} км",
"duration_label": "Срок",
"duration_days": "{days} дн",
"country_unknown": "Не указана",
"supplier_unknown": "Поставщик",
"origin_label": "Откуда",
"origin_unknown": "Откуда не указано"
}
}
}

View File

@@ -1,8 +1,10 @@
{
"cta": {
"title": "Готовы начать торговать?",
"description": "Присоединяйтесь к тысячам компаний, которые уже используют Optovia для своих сделок",
"title": "Готовы начать?",
"description": "Присоединяйтесь к сотням компаний, которые уже торгуют на Optovia",
"start_selling": "Начать продавать",
"start_buying": "Начать покупать"
"start_buying": "Начать покупать",
"register": "Зарегистрироваться",
"demo": "Запросить демо"
}
}

View File

@@ -1,11 +1,68 @@
{
"footer": {
"description": "Глобальная B2B платформа для торговли сырьём и сельхозпродукцией. Соединяем производителей и покупателей по всему миру.",
"buyers": "Покупателям",
"suppliers": "Поставщикам",
"services": "Сервисным компаниям",
"rights": "Все права защищены",
"privacy": "Политика конфиденциальности",
"terms": "Пользовательское соглашение",
"support": "Поддержка"
"rights": "Все права защищены.",
"privacy": "Конфиденциальность",
"terms": "Условия",
"support": "Поддержка",
"secure": "Безопасные сделки",
"verified": "Верификация",
"europe": "Европа",
"germany": "Германия",
"france": "Франция",
"netherlands": "Нидерланды",
"poland": "Польша",
"spain": "Испания",
"cis": "СНГ",
"russia": "Россия",
"kazakhstan": "Казахстан",
"uzbekistan": "Узбекистан",
"belarus": "Беларусь",
"azerbaijan": "Азербайджан",
"asia": "Азия",
"china": "Китай",
"india": "Индия",
"turkey": "Турция",
"uae": "ОАЭ",
"saudi": "Саудовская Аравия",
"americas": "Америка и Африка",
"usa": "США",
"brazil": "Бразилия",
"argentina": "Аргентина",
"southafrica": "ЮАР",
"egypt": "Египет",
"offices": "Наши офисы",
"hq": "Штаб-квартира",
"europeOffice": "Европа",
"cisOffice": "СНГ",
"products": "Продукты",
"grains": "Зерновые",
"oilseeds": "Масличные",
"sugar": "Сахар",
"fertilizers": "Удобрения",
"logistics": "Логистика",
"insurance": "Страхование",
"financing": "Финансирование",
"inspection": "Инспекция",
"company": "Компания",
"about": "О нас",
"careers": "Карьера",
"press": "Пресса",
"contact": "Контакты",
"legal": "Юридическое",
"cookies": "Cookies",
"compliance": "Комплаенс"
}
}

View File

@@ -1,8 +1,9 @@
{
"howto": {
"title": "Как это работает",
"step": "Шаг",
"step1": {
"title": "Найдите материалы",
"title": "Найдите сырьё",
"description": "Выберите нужное сырье, укажите количество и место доставки"
},
"step2": {

View File

@@ -3,6 +3,16 @@
"verification_status": "Статус верификации",
"team_verification_description": "Пройдите верификацию команды для создания заказов",
"start_verification": "Начать верификацию",
"check_status_in_odoo": "Статус проверяется администратором"
"check_status_in_odoo": "Статус проверяется администратором",
"demo": {
"companyName": "ООО \"Демо Компания\"",
"director": "Руководитель",
"capital": "Уставный капитал",
"address": "Юридический адрес",
"activities": "Виды деятельности",
"sources": "Источники",
"updated": "Обновлено",
"notice": "Это демонстрационные данные. Реальная информация о компании будет доступна после подключения к базе данных."
}
}
}

View File

@@ -5,10 +5,15 @@
},
"actions": {
"add": "Добавить адрес",
"edit": "Редактировать",
"delete": "Удалить",
"deleting": "Удаление...",
"confirm_delete": "Удалить этот адрес?"
},
"detail": {
"location": "Местоположение",
"map": "Карта"
},
"form": {
"title": "Новый адрес",
"title_edit": "Редактирование адреса",

View File

@@ -1,11 +1,12 @@
{
"roles": {
"title": "Для кого наша платформа",
"subtitle": "Платформа для всех участников рынка сельхозпродукции",
"producers": {
"title": "Производители",
"description": "Продавайте сырье напрямую покупателям через нашу платформу",
"benefit1": "Доска объявлений для продажи",
"benefit2": "Участие в аукционных тендерах",
"benefit1": "Прямой доступ к покупателям",
"benefit2": "Прозрачное ценообразование",
"benefit3": "Доступ к финансированию",
"benefit4": "Логистические решения",
"cta": "Начать продавать"
@@ -26,7 +27,7 @@
"benefit2": "Финансовые организации",
"benefit3": "Лаборатории контроля качества",
"benefit4": "Страховые компании",
"cta": "Стать партнером"
"cta": "Стать партнёром"
},
"buyer": "Покупатель",
"seller": "Продавец"

View File

@@ -1,9 +1,11 @@
{
"stats": {
"title": "Optovia в цифрах",
"suppliers": "Поставщики",
"suppliers": "Поставщиков",
"suppliersDesc": "Проверенные производители из России, Казахстана и других стран СНГ",
"transactions": "Оборот сделок",
"service_companies": "Сервисные компании",
"support": "Поддержка"
"support": "Поддержка",
"countries": "Стран присутствия"
}
}

View File

@@ -1,5 +1,10 @@
{
"testimonials": {
"title": "Отзывы наших клиентов"
},
"testimonial": {
"quote": "Optovia помогла нам найти надёжных поставщиков за считанные дни. Раньше на это уходили месяцы.",
"author": "Алексей Петров",
"role": "Директор по закупкам, АгроХолдинг"
}
}

View File

@@ -1,5 +1,7 @@
import tailwindcss from '@tailwindcss/vite'
const enableSourceMaps = process.env.NUXT_SOURCEMAP === 'true'
const enableMinify = process.env.NUXT_MINIFY !== 'false'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
@@ -7,7 +9,6 @@ export default defineNuxtConfig({
modules: [
'@nuxtjs/i18n',
'@pinia/nuxt',
'@sentry/nuxt/module',
'@nuxt/eslint',
'nuxt-mapbox',
'@nuxt/icon',
@@ -162,8 +163,10 @@ export default defineNuxtConfig({
baseURL: '/',
layoutTransition: { name: 'layout', mode: 'out-in' }
},
sourcemap: enableSourceMaps ? { server: true, client: true } : false,
nitro: {
preset: 'node-server',
minify: enableMinify,
storage: {
logto: {
driver: 'fs',
@@ -177,6 +180,11 @@ export default defineNuxtConfig({
},
vite: {
plugins: [tailwindcss()],
build: {
sourcemap: enableSourceMaps,
minify: enableMinify,
cssMinify: enableMinify
},
server: {
fs: {
strict: false
@@ -192,13 +200,9 @@ export default defineNuxtConfig({
novuAppId: process.env.NUXT_PUBLIC_NOVU_APP_ID,
novuBackendUrl: process.env.NUXT_PUBLIC_NOVU_BACKEND_URL,
novuSocketUrl: process.env.NUXT_PUBLIC_NOVU_SOCKET_URL,
sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
mapboxAccessToken: process.env.NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN || ''
}
},
sentry: {
// DSN, environment, and tracesSampleRate are configured in sentry.client.config.ts
},
mapbox: {
accessToken: process.env.NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN || ''
},
@@ -206,39 +210,39 @@ export default defineNuxtConfig({
clients: {
default: {
httpEndpoint: process.env.NUXT_PUBLIC_EXCHANGE_GRAPHQL_PUBLIC || 'https://exchange.optovia.ru/graphql/public/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
publicGeo: {
httpEndpoint: process.env.NUXT_PUBLIC_GEO_GRAPHQL_PUBLIC || 'https://geo.optovia.ru/graphql/public/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
publicKyc: {
httpEndpoint: process.env.NUXT_PUBLIC_KYC_GRAPHQL_PUBLIC || 'https://kyc.optovia.ru/graphql/public/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
teamsUser: {
httpEndpoint: process.env.NUXT_PUBLIC_TEAMS_GRAPHQL_USER || 'https://teams.optovia.ru/graphql/user/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
teamsTeam: {
httpEndpoint: process.env.NUXT_PUBLIC_TEAMS_GRAPHQL_TEAM || 'https://teams.optovia.ru/graphql/team/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
exchangeTeam: {
httpEndpoint: process.env.NUXT_PUBLIC_EXCHANGE_GRAPHQL_TEAM || 'https://exchange.optovia.ru/graphql/team/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
kycUser: {
httpEndpoint: process.env.NUXT_PUBLIC_KYC_GRAPHQL_USER || 'https://kyc.optovia.ru/graphql/user/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
ordersTeam: {
httpEndpoint: process.env.NUXT_PUBLIC_ORDERS_GRAPHQL_TEAM || 'https://orders.optovia.ru/graphql/team/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
},
billingTeam: {
httpEndpoint: process.env.NUXT_PUBLIC_BILLING_GRAPHQL_TEAM || 'https://billing.optovia.ru/graphql/team/',
connectToDevTools: process.dev
devtools: { enabled: process.dev }
}
}
},

View File

@@ -3,7 +3,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "NODE_OPTIONS='--max-old-space-size=4096' nuxt build",
"build": "NODE_OPTIONS=${NODE_OPTIONS:---max-old-space-size=4096} nuxt build",
"dev": "nuxt dev",
"start": "nuxt preview",
"generate": "nuxt generate",

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

View File

@@ -1,48 +1,50 @@
/**
* Load secrets from Infisical using Machine Identity (Universal Auth)
* Load secrets from Vault HTTP API
* Writes secrets to .env.infisical file for sourcing
*/
import { InfisicalSDK } from "@infisical/sdk";
import { writeFileSync } from "fs";
const INFISICAL_API_URL = process.env.INFISICAL_API_URL;
const INFISICAL_CLIENT_ID = process.env.INFISICAL_CLIENT_ID;
const INFISICAL_CLIENT_SECRET = process.env.INFISICAL_CLIENT_SECRET;
const INFISICAL_PROJECT_ID = process.env.INFISICAL_PROJECT_ID;
const INFISICAL_ENV = process.env.INFISICAL_ENV || "prod";
const VAULT_ADDR = process.env.VAULT_ADDR;
const VAULT_TOKEN = process.env.VAULT_TOKEN;
const VAULT_KV_MOUNT = process.env.VAULT_KV_MOUNT || "secret";
const VAULT_SHARED_PATH = process.env.VAULT_SHARED_PATH;
const VAULT_PROJECT_PATH = process.env.VAULT_PROJECT_PATH;
if (!INFISICAL_API_URL || !INFISICAL_CLIENT_ID || !INFISICAL_CLIENT_SECRET || !INFISICAL_PROJECT_ID) {
process.stderr.write("Missing required Infisical environment variables\n");
if (!VAULT_ADDR || !VAULT_TOKEN) {
process.stderr.write("Missing required Vault environment variables (VAULT_ADDR, VAULT_TOKEN)\n");
process.exit(1);
}
const client = new InfisicalSDK({ siteUrl: INFISICAL_API_URL });
await client.auth().universalAuth.login({
clientId: INFISICAL_CLIENT_ID,
clientSecret: INFISICAL_CLIENT_SECRET,
});
process.stderr.write(`Loading secrets from Infisical (env: ${INFISICAL_ENV})...\n`);
process.stderr.write(`Loading secrets from Vault...\n`);
const envLines = [];
for (const secretPath of ["/webapp", "/shared"]) {
const response = await client.secrets().listSecrets({
projectId: INFISICAL_PROJECT_ID,
environment: INFISICAL_ENV,
secretPath: secretPath,
expandSecretReferences: true,
async function loadPath(path, sourceName) {
if (!path) return;
const url = `${VAULT_ADDR.replace(/\/$/, "")}/v1/${VAULT_KV_MOUNT}/data/${path}`;
const response = await fetch(url, {
headers: { "X-Vault-Token": VAULT_TOKEN },
});
for (const secret of response.secrets) {
// Escape special characters for shell
const escapedValue = secret.secretValue.replace(/'/g, "'\\''");
envLines.push(`export ${secret.secretKey}='${escapedValue}'`);
if (!response.ok) {
throw new Error(`Failed to load Vault path ${VAULT_KV_MOUNT}/${path}: ${response.status}`);
}
process.stderr.write(` ${secretPath}: ${response.secrets.length} secrets loaded\n`);
const json = await response.json();
const secrets = json?.data?.data || {};
const keys = Object.keys(secrets);
for (const [key, value] of Object.entries(secrets)) {
const escapedValue = String(value).replace(/'/g, "'\\''");
envLines.push(`export ${key}='${escapedValue}'`);
}
process.stderr.write(` ${sourceName}: ${keys.length} secrets loaded from ${VAULT_KV_MOUNT}/${path}\n`);
}
await loadPath(VAULT_SHARED_PATH, "shared");
await loadPath(VAULT_PROJECT_PATH, "project");
writeFileSync(".env.infisical", envLines.join("\n"));
process.stderr.write("Secrets written to .env.infisical\n");

View File

@@ -1,12 +0,0 @@
import * as Sentry from '@sentry/nuxt'
const config = useRuntimeConfig()
Sentry.init({
dsn: config.public.sentryDsn,
environment: process.env.NODE_ENV || 'production',
integrations: [
Sentry.browserTracingIntegration()
],
tracesSampleRate: 0.1
})

View File

@@ -1,7 +0,0 @@
import * as Sentry from '@sentry/nuxt'
Sentry.init({
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV || 'production',
tracesSampleRate: 0.1
})