Compare commits

..

136 Commits

Author SHA1 Message Date
Ruslan Bakiev
2761e61f01 chore: trigger dokploy push deploy 2026-03-11 19:20:43 +07:00
Ruslan Bakiev
2188676d25 chore: remove gitea image build workflow 2026-03-11 19:16:09 +07:00
Ruslan Bakiev
4be7cade98 webapp: harden mapbox/chatwoot runtime config
Some checks failed
Build Docker Image / build (push) Failing after 13m29s
2026-03-11 19:01:52 +07:00
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
Ruslan Bakiev
c39bc55ebc Fix InfoPanel for offers - supplier name and map point
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Show supplier name with loading state instead of "View Supplier" link
- Fix offer coordinates on map (use locationLatitude/locationLongitude)
2026-01-27 12:12:05 +07:00
Ruslan Bakiev
c152a5b14c Update catalog cards - logo right in supplier, sparkline in product
All checks were successful
Build Docker Image / build (push) Successful in 3m53s
- SupplierCard: Move logo to right side, text on left
- ProductCard: Generate mock priceHistory from uuid, add product icon
2026-01-27 11:48:46 +07:00
Ruslan Bakiev
2dbe600d8a refactor: remove all any types, add strict GraphQL scalar typing
All checks were successful
Build Docker Image / build (push) Successful in 4m3s
- Add strictScalars: true to codegen.ts with proper scalar mappings
  (Date, Decimal, JSONString, JSON, UUID, BigInt → string/Record)
- Replace all ref<any[]> with proper GraphQL-derived types
- Add type guards for null filtering in arrays
- Fix bugs exposed by typing (locationLatitude vs latitude, etc.)
- Add interfaces for external components (MapboxSearchBox)

This enables end-to-end type safety from GraphQL schema to frontend.
2026-01-27 11:34:12 +07:00
Ruslan Bakiev
ff34c564e1 Fix InfoPanel map: hide toggle, show current entity, auto-center
All checks were successful
Build Docker Image / build (push) Successful in 4m18s
- Hide view mode toggle (offers/hubs/suppliers) when in InfoPanel mode
- Add current entity to relatedPoints so it's visible on the map
- Auto-fit map bounds to show all points (current + related) in InfoPanel mode
2026-01-27 11:25:57 +07:00
Ruslan Bakiev
80474acc0f Update webapp - fix hero animation scroll + dark background
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Fixed animation height to 100vh to prevent squeeze on scroll
- Added dark slate-900 background for transparent animation
2026-01-27 11:09:14 +07:00
Ruslan Bakiev
859eef3761 Update webapp - fix hero animation to use cover layout
All checks were successful
Build Docker Image / build (push) Successful in 3m58s
Use DotLottie's native layout prop with fit: 'cover' instead of
CSS object-cover which doesn't work on canvas elements.
2026-01-27 10:54:41 +07:00
Ruslan Bakiev
7bd4aa37bd Redesign SupplierCard and ProductCard, unify components
All checks were successful
Build Docker Image / build (push) Successful in 4m0s
- SupplierCard: horizontal layout with logo left, verified badge before name, chips at bottom
- ProductCard: add optional sparkline background, trend indicator, and price display
- Replace HubProductCard usage with ProductCard in hub detail page
- Remove HubProductCard.vue and PriceSparkline.vue (unified into ProductCard)
2026-01-27 10:49:58 +07:00
Ruslan Bakiev
20e0e73c58 refactor: remove any types and fix TypeScript errors
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
- Export InfoProductItem, InfoHubItem, InfoSupplierItem, InfoOfferItem types
- Update InfoEntity interface to have explicit fields (no index signature)
- Export CatalogHubItem, CatalogNearestHubItem from useCatalogHubs
- Fix MapItem interfaces to accept nullable GraphQL types
- Fix v-for :key bindings to handle null uuid
- Add null guards in select-location pages
- Update HubCard to accept nullable transportTypes
- Add shims.d.ts for missing module declarations
2026-01-27 10:35:14 +07:00
Ruslan Bakiev
9210f79a3d Always include mode query param (explore/quote)
All checks were successful
Build Docker Image / build (push) Successful in 4m29s
2026-01-27 10:21:09 +07:00
Ruslan Bakiev
65250f1342 Fix hero animation: transparent navbar on home page, glass on collapse
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-27 10:19:00 +07:00
Ruslan Bakiev
3f823b2abc Fix hero animation: always use glass style on home page
All checks were successful
Build Docker Image / build (push) Successful in 4m8s
2026-01-27 10:09:33 +07:00
Ruslan Bakiev
75ce64b46e Fix hero animation: object-fit cover + conditional blur + glass on collapse
All checks were successful
Build Docker Image / build (push) Successful in 5m14s
- HeroBackground.vue:
  - Add object-cover class to Lottie animation (fills container)
  - Make dark overlay conditional (v-if collapseProgress > 0.5)
  - Overlay fades in during collapse (opacity 0→1)

- MainNavigation.vue:
  - Replace glassStyle prop with isCollapsed
  - Glass effect (backdrop-blur-md) applies only when collapsed

- topnav.vue:
  - Pass isCollapsed instead of glass-style
  - Home page: isCollapsed from useHeroScroll
  - Other pages: always true (always collapsed)

Result:
- Animation covers full viewport without crop
- No blur overlay when hero is expanded
- Glass effect appears only when header collapses to 100px
2026-01-27 09:14:20 +07:00
Ruslan Bakiev
70c53da8eb Fix type safety in catalog composables + 3 InfoPanel bugs
All checks were successful
Build Docker Image / build (push) Successful in 3m42s
- Add proper codegen types to all catalog composables:
  - useCatalogHubs: HubItem, NearestHubItem
  - useCatalogSuppliers: SupplierItem, NearestSupplierItem
  - useCatalogProducts: ProductItem
  - useCatalogOffers: OfferItem
  - useCatalogInfo: InfoEntity, ProductItem, HubItem, OfferItem

- Fix InfoPanel bugs for offers:
  - Use locationLatitude/locationLongitude for offer coordinates
  - Enrich entity with supplierName after loading profile
  - Apply-to-filter now adds both product AND hub for offers

- Filter null values from GraphQL array responses
- Add type-safe coordinate helper (getEntityCoords)
- Fix urlBounds type inference in useCatalogSearch
2026-01-26 23:30:16 +07:00
Ruslan Bakiev
839ab4e830 feat(hero): add animated Supply Chain background on home page
All checks were successful
Build Docker Image / build (push) Successful in 3m54s
2026-01-26 22:31:06 +07:00
Ruslan Bakiev
19aca61845 fix(catalog): prevent unnecessary list reloads on map movement
All checks were successful
Build Docker Image / build (push) Successful in 4m14s
- Remove currentMapBounds from watch - it changes on every map move
- Watch only filterByBounds and urlBounds (URL-based state)
- Add early return in setBoundsFilter if bounds haven't changed

This fixes the issue where the list was reloading on every map movement
even when the 'filter by bounds' checkbox was OFF.
2026-01-26 22:24:47 +07:00
Ruslan Bakiev
6545eeabea feat(catalog): persist bounds filter state in URL
All checks were successful
Build Docker Image / build (push) Successful in 4m14s
- Add urlBounds and filterByBounds computed from URL query
- Add setBoundsInUrl and clearBoundsFromUrl actions
- Update index.vue to use URL-based bounds state
- Bounds written to URL as comma-separated values (west,south,east,north)

This enables sharing links with map viewport bounds filter.
2026-01-26 21:40:44 +07:00
Ruslan Bakiev
f9eb027ebd chore: regenerate geo GraphQL types with bounds params
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-26 21:38:44 +07:00
122 changed files with 7559 additions and 2382 deletions

View File

@@ -1,37 +0,0 @@
name: Build Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.dsrptlab.com
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: gitea.dsrptlab.com/optovia/webapp/webapp:latest
build-args: |
INFISICAL_API_URL=${{ secrets.INFISICAL_API_URL }}
INFISICAL_CLIENT_ID=${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET=${{ secrets.INFISICAL_CLIENT_SECRET }}
INFISICAL_PROJECT_ID=${{ secrets.INFISICAL_PROJECT_ID }}
INFISICAL_ENV=prod
- name: Deploy to Dokploy
run: curl -X POST "https://dokploy.optovia.ru/api/deploy/0_iNAXPDx28BLZIddGTzB"

View File

@@ -2,28 +2,21 @@ FROM node:22-slim AS build
ENV PNPM_HOME=/pnpm ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH 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 WORKDIR /app
RUN corepack enable 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 ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build RUN pnpm run build
FROM node:22-slim FROM node:22-slim
@@ -41,4 +34,4 @@ COPY --from=build /app/package.json ./package.json
EXPOSE 3000 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; --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" { @plugin "daisyui/theme" {
name: "silk"; name: "silk";
default: false; default: false;

View File

@@ -47,13 +47,22 @@ interface BankData {
correspondentAccount: string correspondentAccount: string
} }
interface BankSuggestion {
value: string
data: {
bic: string
correspondent_account?: string
address?: { value: string }
}
}
interface Props { interface Props {
modelValue?: BankData modelValue?: BankData
} }
interface Emits { interface Emits {
(e: 'update:modelValue', value: BankData): void (e: 'update:modelValue', value: BankData): void
(e: 'select', bank: any): void (e: 'select', bank: BankSuggestion): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -66,15 +75,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
interface BankSuggestion {
value: string
data: {
bic: string
correspondent_account?: string
address?: { value: string }
}
}
const query = ref('') const query = ref('')
const suggestions = ref<BankSuggestion[]>([]) const suggestions = ref<BankSuggestion[]>([])
const loading = ref(false) const loading = ref(false)
@@ -123,7 +123,7 @@ const onInput = async () => {
} }
} }
const selectBank = (bank: any) => { const selectBank = (bank: BankSuggestion) => {
query.value = bank.value query.value = bank.value
showDropdown.value = false showDropdown.value = false

View File

@@ -20,12 +20,15 @@
<OfferResultCard <OfferResultCard
v-for="(option, index) in productRouteOptions" v-for="(option, index) in productRouteOptions"
:key="option.sourceUuid ?? index" :key="option.sourceUuid ?? index"
:supplier-name="getSupplierName(option.sourceUuid)"
:location-name="getOfferData(option.sourceUuid)?.locationName" :location-name="getOfferData(option.sourceUuid)?.locationName"
:product-name="productName" :product-name="productName"
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit" :price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
:quantity="getOfferData(option.sourceUuid)?.quantity"
:currency="getOfferData(option.sourceUuid)?.currency" :currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit" :unit="getOfferData(option.sourceUuid)?.unit"
:stages="getRouteStages(option)" :stages="getRouteStages(option)"
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)" :kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
@select="navigateToOffer(option.sourceUuid)" @select="navigateToOffer(option.sourceUuid)"
/> />
@@ -81,7 +84,8 @@ interface RoutePathType {
totalTimeSeconds?: number | null totalTimeSeconds?: number | null
stages?: (RouteStage | null)[] stages?: (RouteStage | null)[]
} }
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated' import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
import type { OfferWithRoute, RouteStage } from '~/composables/graphql/public/geo-generated'
const route = useRoute() const route = useRoute()
const localePath = useLocalePath() const localePath = useLocalePath()
@@ -90,12 +94,14 @@ const { execute } = useGraphQL()
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар') const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение') const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity) const quantity = computed(() => (route.query.quantity as string) || searchStore.searchForm.quantity)
// Offer data for prices // Offer data for prices
const offersData = ref<Map<string, any>>(new Map()) type OfferData = NonNullable<GetOfferQueryResult['getOffer']>
const offersData = ref<Map<string, OfferData>>(new Map())
// Supplier data for KYC profile UUID (by team_uuid) // Supplier data for KYC profile UUID (by team_uuid)
const suppliersData = ref<Map<string, any>>(new Map()) type SupplierData = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
const suppliersData = ref<Map<string, SupplierData>>(new Map())
const summaryTitle = computed(() => `${productName.value}${locationName.value}`) const summaryTitle = computed(() => `${productName.value}${locationName.value}`)
const summaryMeta = computed(() => { const summaryMeta = computed(() => {
@@ -149,7 +155,9 @@ const fetchOffersByHub = async () => {
const offers = offersResponse?.nearestOffers || [] const offers = offersResponse?.nearestOffers || []
// Offers already include routes from backend // Offers already include routes from backend
const offersWithRoutes = offers.map((offer: any) => ({ const offersWithRoutes = offers
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
.map((offer) => ({
sourceUuid: offer.uuid, sourceUuid: offer.uuid,
sourceName: offer.productName, sourceName: offer.productName,
sourceLat: offer.latitude, sourceLat: offer.latitude,
@@ -198,9 +206,13 @@ const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
const getRouteStages = (option: ProductRouteOption) => { const getRouteStages = (option: ProductRouteOption) => {
const route = option.routes?.[0] const route = option.routes?.[0]
if (!route?.stages) return [] if (!route?.stages) return []
return route.stages.filter(Boolean).map((stage: any) => ({ return route.stages
transportType: stage?.transportType, .filter((stage): stage is NonNullable<RouteStage> => stage !== null)
distanceKm: stage?.distanceKm .map((stage) => ({
transportType: stage.transportType,
distanceKm: stage.distanceKm,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
})) }))
} }
@@ -219,6 +231,14 @@ const getKycProfileUuid = (offerUuid?: string | null) => {
return supplier?.kycProfileUuid || 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 // Navigate to offer detail page
const navigateToOffer = (offerUuid?: string | null) => { const navigateToOffer = (offerUuid?: string | null) => {
if (!offerUuid) return if (!offerUuid) return
@@ -233,8 +253,8 @@ const loadOfferDetails = async (options: ProductRouteOption[]) => {
return return
} }
const newOffersData = new Map<string, any>() const newOffersData = new Map<string, OfferData>()
const newSuppliersData = new Map<string, any>() const newSuppliersData = new Map<string, SupplierData>()
const teamUuidsToLoad = new Set<string>() const teamUuidsToLoad = new Set<string>()
// First, load all offers // First, load all offers

View File

@@ -35,14 +35,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetProductsDocument, type GetProductsQueryResult } from '~/composables/graphql/public/exchange-generated'
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
const searchStore = useSearchStore() const searchStore = useSearchStore()
const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange') const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
const productsData = computed(() => data.value?.getProducts || []) const productsData = computed(() => data.value?.getProducts || [])
const selectProduct = (product: any) => { const selectProduct = (product: Product) => {
searchStore.setProduct(product.name) searchStore.setProduct(product.name)
searchStore.setProductUuid(product.uuid) searchStore.setProductUuid(product.uuid)
const locationUuid = searchStore.searchForm.locationUuid const locationUuid = searchStore.searchForm.locationUuid

View File

@@ -154,10 +154,44 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface KycSubmitData {
company_name: string
company_full_name: string
inn: string
kpp: string
ogrn: string
address: string
bank_name: string
bik: string
correspondent_account: string
contact_person: string
contact_email: string
contact_phone: string
}
interface CompanySuggestion {
value: string
unrestricted_value: string
data: {
inn: string
kpp?: string
ogrn?: string
address?: { value: string }
}
}
interface BankSuggestion {
value: string
data: {
bic: string
correspondent_account?: string
}
}
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [data: any] submit: [data: KycSubmitData]
}>() }>()
const loading = ref(false) const loading = ref(false)
@@ -195,7 +229,7 @@ const isFormValid = computed(() => {
}) })
// Handlers // Handlers
const onCompanySelect = (company: any) => { const onCompanySelect = (company: CompanySuggestion) => {
formData.value.company = { formData.value.company = {
companyName: company.value, companyName: company.value,
companyFullName: company.unrestricted_value, companyFullName: company.unrestricted_value,
@@ -206,7 +240,7 @@ const onCompanySelect = (company: any) => {
} }
} }
const onBankSelect = (bank: any) => { const onBankSelect = (bank: BankSuggestion) => {
formData.value.bank = { formData.value.bank = {
bankName: bank.value, bankName: bank.value,
bik: bank.data.bic, bik: bank.data.bic,

View File

@@ -16,8 +16,8 @@
<Grid :cols="1" :md="2" :lg="3" :gap="4"> <Grid :cols="1" :md="2" :lg="3" :gap="4">
<Card <Card
v-for="addr in teamAddresses" v-for="(addr, index) in teamAddresses"
:key="addr.uuid" :key="addr.uuid ?? index"
padding="small" padding="small"
interactive interactive
@click="selectTeamAddress(addr)" @click="selectTeamAddress(addr)"
@@ -57,8 +57,8 @@
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4"> <Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
<HubCard <HubCard
v-for="location in locationsData" v-for="(location, index) in locationsData"
:key="location.uuid" :key="location.uuid ?? index"
:hub="location" :hub="location"
selectable selectable
@select="selectLocation(location)" @select="selectLocation(location)"
@@ -69,7 +69,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { HubsListDocument } from '~/composables/graphql/public/geo-generated' import { HubsListDocument, type HubsListQueryResult } from '~/composables/graphql/public/geo-generated'
type HubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
type HubWithDistance = HubItem & { distance?: string }
interface TeamAddress {
uuid?: string | null
name?: string | null
address?: string | null
isDefault?: boolean | null
}
const { t } = useI18n() const { t } = useI18n()
const searchStore = useSearchStore() const searchStore = useSearchStore()
@@ -85,35 +95,37 @@ const calculateDistance = (lat: number, lng: number) => {
// Load logistics hubs // Load logistics hubs
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', HubsListDocument, { limit: 100 }, 'public', 'geo') const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', HubsListDocument, { limit: 100 }, 'public', 'geo')
const locationsData = computed(() => { const locationsData = computed<HubWithDistance[]>(() => {
return (locationsDataRaw.value?.hubsList || []).map((location: any) => ({ return (locationsDataRaw.value?.hubsList || [])
.filter((location): location is HubItem => location !== null)
.map((location) => ({
...location, ...location,
distance: location?.latitude && location?.longitude distance: location.latitude && location.longitude
? calculateDistance(location.latitude, location.longitude) ? calculateDistance(location.latitude, location.longitude)
: undefined, : undefined,
})) }))
}) })
// Load team addresses (if authenticated) // Load team addresses (if authenticated)
const teamAddresses = ref<any[]>([]) const teamAddresses = ref<TeamAddress[]>([])
if (isAuthenticated.value) { if (isAuthenticated.value) {
try { try {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated') const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams') const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
teamAddresses.value = addressData.value?.teamAddresses || [] teamAddresses.value = (addressData.value?.teamAddresses || []).filter((a): a is NonNullable<typeof a> => a !== null)
} catch (e) { } catch (e) {
console.log('Team addresses not available') console.log('Team addresses not available')
} }
} }
const selectLocation = (location: any) => { const selectLocation = (location: HubWithDistance) => {
searchStore.setLocation(location.name) searchStore.setLocation(location.name)
searchStore.setLocationUuid(location.uuid) searchStore.setLocationUuid(location.uuid)
history.back() history.back()
} }
const selectTeamAddress = (addr: any) => { const selectTeamAddress = (addr: TeamAddress) => {
searchStore.setLocation(addr.address) searchStore.setLocation(addr.address)
searchStore.setLocationUuid(addr.uuid) searchStore.setLocationUuid(addr.uuid)
history.back() history.back()

View File

@@ -3,7 +3,7 @@
<!-- Header with back button --> <!-- Header with back button -->
<div class="p-4 border-b border-base-300"> <div class="p-4 border-b border-base-300">
<NuxtLink <NuxtLink
:to="localePath('/catalog')" :to="localePath('/catalog?select=product')"
class="btn btn-sm btn-ghost gap-2" class="btn btn-sm btn-ghost gap-2"
> >
<Icon name="lucide:arrow-left" size="18" /> <Icon name="lucide:arrow-left" size="18" />
@@ -52,8 +52,8 @@
<!-- Hubs Tab --> <!-- Hubs Tab -->
<div v-else-if="activeTab === 'hubs'" class="space-y-2"> <div v-else-if="activeTab === 'hubs'" class="space-y-2">
<HubCard <HubCard
v-for="hub in hubs" v-for="(hub, index) in hubs"
:key="hub.uuid" :key="hub.uuid ?? index"
:hub="hub" :hub="hub"
selectable selectable
:is-selected="selectedItemId === hub.uuid" :is-selected="selectedItemId === hub.uuid"
@@ -67,8 +67,8 @@
<!-- Suppliers Tab --> <!-- Suppliers Tab -->
<div v-else-if="activeTab === 'suppliers'" class="space-y-2"> <div v-else-if="activeTab === 'suppliers'" class="space-y-2">
<SupplierCard <SupplierCard
v-for="supplier in suppliers" v-for="(supplier, index) in suppliers"
:key="supplier.uuid" :key="supplier.uuid ?? index"
:supplier="supplier" :supplier="supplier"
selectable selectable
:is-selected="selectedItemId === supplier.uuid" :is-selected="selectedItemId === supplier.uuid"
@@ -81,15 +81,20 @@
<!-- Offers Tab --> <!-- Offers Tab -->
<div v-else-if="activeTab === 'offers'" class="space-y-2"> <div v-else-if="activeTab === 'offers'" class="space-y-2">
<OfferCard <OfferResultCard
v-for="offer in offers" v-for="(offer, index) in offersWithPrice"
:key="offer.uuid" :key="offer.uuid ?? index"
:offer="offer" :supplier-name="offer.supplierName"
selectable :location-name="offer.locationName || offer.locationCountry"
:is-selected="selectedItemId === offer.uuid" :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')" @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') }} {{ t('catalogMap.empty.offers') }}
</div> </div>
</div> </div>
@@ -98,20 +103,63 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ interface Hub {
uuid?: string | null
name?: string | null
country?: string | null
countryCode?: string | null
distance?: string
transportTypes?: (string | null)[] | null
}
interface Supplier {
uuid?: string | null
teamUuid?: string | null
name?: string | null
country?: string | null
countryCode?: string | null
logo?: string | null
onTimeRate?: number | null
offersCount?: number | null
isVerified?: boolean | null
}
interface Offer {
uuid?: string | null
productUuid?: string | null
productName?: string | null
categoryName?: string | null
supplierName?: string | null
locationUuid?: string | null
locationName?: string | null
locationCountry?: string | null
locationCountryCode?: string | null
quantity?: number | string | null
unit?: string | null
pricePerUnit?: number | string | null
currency?: string | null
status?: string | null
validUntil?: string | null
}
const props = defineProps<{
activeTab: 'hubs' | 'suppliers' | 'offers' activeTab: 'hubs' | 'suppliers' | 'offers'
hubs: any[] hubs: Hub[]
suppliers: any[] suppliers: Supplier[]
offers: any[] offers: Offer[]
selectedItemId: string | null selectedItemId: string | null
isLoading: boolean isLoading: boolean
}>() }>()
defineEmits<{ defineEmits<{
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers'] 'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
'select': [item: any, type: string] 'select': [item: Hub | Supplier | Offer, type: 'hub' | 'supplier' | 'offer']
}>() }>()
const localePath = useLocalePath() const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
</script> </script>

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<MapboxMap <MapboxMap
:key="mapId" :key="mapId"
:map-id="mapId" :map-id="mapId"
:style="`height: ${height}px; width: 100%;`" :style="`height: ${heightValue}px; width: 100%;`"
class="rounded-lg border border-base-300" class="rounded-lg border border-base-300"
:options="mapOptions" :options="mapOptions"
@load="onMapCreated" @load="onMapCreated"
@@ -26,16 +26,46 @@ import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds, Popup } from 'mapbox-gl' import { LngLatBounds, Popup } from 'mapbox-gl'
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
const props = defineProps({ interface StageCompany {
stages: { uuid?: string | null
type: Array, name?: string | null
default: () => []
},
height: {
type: Number,
default: 400
} }
})
interface StageTrip {
uuid?: string | null
company?: StageCompany | null
}
interface RouteStage {
uuid?: string | null
stageType?: string | null
sourceLatitude?: number | null
sourceLongitude?: number | null
sourceLocationName?: string | null
destinationLatitude?: number | null
destinationLongitude?: number | null
destinationLocationName?: string | null
locationLatitude?: number | null
locationLongitude?: number | null
locationName?: string | null
selectedCompany?: StageCompany | null
trips?: StageTrip[] | null
}
interface RoutePoint {
id: string
name: string
lat: number
lng: number
companies: StageCompany[]
}
const props = defineProps<{
stages?: RouteStage[]
height?: number
}>()
const defaultHeight = 400
const { t } = useI18n() const { t } = useI18n()
const mapRef = ref<MapboxMapType | null>(null) const mapRef = ref<MapboxMapType | null>(null)
@@ -44,10 +74,12 @@ const didFitBounds = ref(false)
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000) const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
const mapId = computed(() => `route-map-${instanceId}`) const mapId = computed(() => `route-map-${instanceId}`)
const routePoints = computed(() => { const heightValue = computed(() => props.height ?? defaultHeight)
const points: Array<{ id: string; name: string; lat: number; lng: number; companies: any[] }> = []
props.stages.forEach((stage: any) => { const routePoints = computed(() => {
const points: RoutePoint[] = []
props.stages?.forEach((stage: RouteStage) => {
if (stage.stageType === 'transport') { if (stage.stageType === 'transport') {
if (stage.sourceLatitude && stage.sourceLongitude) { if (stage.sourceLatitude && stage.sourceLongitude) {
const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude) const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude)
@@ -263,16 +295,16 @@ watch(
{ deep: true } { deep: true }
) )
const getStageCompanies = (stage: any) => { const getStageCompanies = (stage: RouteStage): StageCompany[] => {
const companies: any[] = [] const companies: StageCompany[] = []
if (stage.selectedCompany) { if (stage.selectedCompany) {
companies.push(stage.selectedCompany) companies.push(stage.selectedCompany)
} }
const uniqueCompanies = new Set() const uniqueCompanies = new Set<string>()
stage.trips?.forEach((trip: any) => { stage.trips?.forEach((trip: StageTrip) => {
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) { if (trip.company && trip.company.uuid && !uniqueCompanies.has(trip.company.uuid)) {
uniqueCompanies.add(trip.company.uuid) uniqueCompanies.add(trip.company.uuid)
companies.push(trip.company) companies.push(trip.company)
} }

View File

@@ -70,6 +70,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated' import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated'
const { t } = useI18n()
const emit = defineEmits(['teamCreated', 'cancel']) const emit = defineEmits(['teamCreated', 'cancel'])
const teamName = ref('') const teamName = ref('')
@@ -93,9 +94,9 @@ const handleSubmit = async () => {
emit('teamCreated', result.createTeam?.team) emit('teamCreated', result.createTeam?.team)
teamName.value = '' teamName.value = ''
teamType.value = 'BUYER' teamType.value = 'BUYER'
} catch (err: any) { } catch (err: unknown) {
hasError.value = true hasError.value = true
error.value = err?.message || $t('teams.errors.create_failed') error.value = err instanceof Error ? err.message : t('teams.errors.create_failed')
console.error('Error creating team:', err) console.error('Error creating team:', err)
} finally { } finally {
isLoading.value = false isLoading.value = false

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,14 +17,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Map as MapboxMapType } from 'mapbox-gl' import type { Map as MapboxMapType } from 'mapbox-gl'
import { LngLatBounds } from 'mapbox-gl' import { LngLatBounds } from 'mapbox-gl'
import type { ClusterPointType } from '~/composables/graphql/public/geo-generated' import type { ClusterPoint } from '~/composables/graphql/public/geo-generated'
interface MapItem { interface MapItem {
uuid: string uuid?: string | null
name: string name?: string | null
latitude: number latitude?: number | null
longitude: number longitude?: number | null
country?: string country?: string | null
} }
export interface MapBounds { export interface MapBounds {
@@ -43,7 +43,8 @@ interface HoveredItem {
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
mapId: string mapId: string
items?: MapItem[] items?: MapItem[]
clusteredPoints?: ClusterPointType[] clusteredPoints?: ClusterPoint[]
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPoint[]>>
useServerClustering?: boolean useServerClustering?: boolean
hoveredItemId?: string | null hoveredItemId?: string | null
hoveredItem?: HoveredItem | null hoveredItem?: HoveredItem | null
@@ -51,6 +52,8 @@ const props = withDefaults(defineProps<{
entityType?: 'offer' | 'hub' | 'supplier' entityType?: 'offer' | 'hub' | 'supplier'
initialCenter?: [number, number] initialCenter?: [number, number]
initialZoom?: number initialZoom?: number
infoLoading?: boolean
fitPaddingLeft?: number
relatedPoints?: Array<{ relatedPoints?: Array<{
uuid: string uuid: string
name: string name: string
@@ -64,8 +67,11 @@ const props = withDefaults(defineProps<{
initialCenter: () => [37.64, 55.76], initialCenter: () => [37.64, 55.76],
initialZoom: 2, initialZoom: 2,
useServerClustering: false, useServerClustering: false,
infoLoading: false,
fitPaddingLeft: 0,
items: () => [], items: () => [],
clusteredPoints: () => [], clusteredPoints: () => [],
clusteredPointsByType: undefined,
relatedPoints: () => [] relatedPoints: () => []
}) })
@@ -79,6 +85,21 @@ const { flyThroughSpace } = useMapboxFlyAnimation()
const didFitBounds = ref(false) const didFitBounds = ref(false)
const mapInitialized = 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 // Entity type icons - SVG data URLs with specific colors
const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => { const createEntityIcon = (type: 'offer' | 'hub' | 'supplier', color: string) => {
const icons = { const icons = {
@@ -135,6 +156,8 @@ const ENTITY_COLORS = {
offer: '#f97316' // orange offer: '#f97316' // orange
} as const } as const
const CLUSTER_TYPES: Array<'offer' | 'hub' | 'supplier'> = ['offer', 'hub', 'supplier']
// Load all icons for related points (each type with its standard color) // Load all icons for related points (each type with its standard color)
const loadRelatedPointIcons = async (map: MapboxMapType) => { const loadRelatedPointIcons = async (map: MapboxMapType) => {
const types: Array<'hub' | 'supplier' | 'offer'> = ['hub', 'supplier', 'offer'] const types: Array<'hub' | 'supplier' | 'offer'> = ['hub', 'supplier', 'offer']
@@ -186,10 +209,12 @@ const mapOptions = computed(() => ({
// Client-side clustering GeoJSON (when not using server clustering) // Client-side clustering GeoJSON (when not using server clustering)
const geoJsonData = computed(() => ({ const geoJsonData = computed(() => ({
type: 'FeatureCollection' as const, type: 'FeatureCollection' as const,
features: props.items.map(item => ({ features: props.items
.filter(item => item.latitude != null && item.longitude != null)
.map(item => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { uuid: item.uuid, name: item.name, country: item.country }, properties: { uuid: item.uuid, name: item.name, country: item.country },
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] } geometry: { type: 'Point' as const, coordinates: [item.longitude!, item.latitude!] }
})) }))
})) }))
@@ -212,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) // Hovered point GeoJSON (separate layer on top)
const hoveredPointGeoJson = computed(() => ({ const hoveredPointGeoJson = computed(() => ({
type: 'FeatureCollection' as const, type: 'FeatureCollection' as const,
@@ -252,6 +304,13 @@ const sourceId = computed(() => `${props.mapId}-points`)
const hoveredSourceId = computed(() => `${props.mapId}-hovered`) const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
const relatedSourceId = computed(() => `${props.mapId}-related`) 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 emitBoundsChange = (map: MapboxMapType) => {
const bounds = map.getBounds() const bounds = map.getBounds()
if (!bounds) return if (!bounds) return
@@ -275,7 +334,11 @@ const onMapCreated = (map: MapboxMapType) => {
}) })
if (props.useServerClustering) { if (props.useServerClustering) {
if (usesTypedClusters.value) {
await initServerClusteringLayersByType(map)
} else {
await initServerClusteringLayers(map) await initServerClusteringLayers(map)
}
} else { } else {
await initClientClusteringLayers(map) await initClientClusteringLayers(map)
} }
@@ -481,9 +544,11 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
if (!didFitBounds.value && props.items.length > 0) { if (!didFitBounds.value && props.items.length > 0) {
const bounds = new LngLatBounds() const bounds = new LngLatBounds()
props.items.forEach(item => { props.items.forEach(item => {
if (item.longitude != null && item.latitude != null) {
bounds.extend([item.longitude, item.latitude]) bounds.extend([item.longitude, item.latitude])
}
}) })
map.fitBounds(bounds, { padding: 50, maxZoom: 10 }) map.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 10 })
didFitBounds.value = true didFitBounds.value = true
} }
} }
@@ -671,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 // Update map data when items or clusteredPoints change
watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => { watch(() => props.useServerClustering ? serverClusteredGeoJson.value : geoJsonData.value, (newData) => {
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
if (usesTypedClusters.value) return
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) { if (source) {
source.setData(newData) source.setData(newData)
} }
}, { deep: true }) }, { 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 // Update hovered point layer when hoveredItem changes
watch(() => props.hoveredItem, () => { watch(() => props.hoveredItem, () => {
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
@@ -692,12 +954,31 @@ watch(() => props.hoveredItem, () => {
// Update related points layer when relatedPoints changes // Update related points layer when relatedPoints changes
watch(() => props.relatedPoints, () => { watch(() => props.relatedPoints, () => {
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
// Update the source data immediately
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
if (source) { if (source) {
source.setData(relatedPointsGeoJson.value) source.setData(relatedPointsGeoJson.value)
} }
}, { deep: true }) }, { deep: true })
// 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()
props.relatedPoints.forEach(p => {
bounds.extend([p.longitude, p.latitude])
})
if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: buildFitPadding(80), maxZoom: 12 })
}
}
})
// Watch for pointColor or entityType changes - update colors and icons // Watch for pointColor or entityType changes - update colors and icons
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => { watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
@@ -708,10 +989,12 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
// Update cluster circle colors // Update cluster circle colors
if (props.useServerClustering) { if (props.useServerClustering) {
if (usesTypedClusters.value) {
return
}
if (map.getLayer('server-clusters')) { if (map.getLayer('server-clusters')) {
map.setPaintProperty('server-clusters', 'circle-color', newColor) map.setPaintProperty('server-clusters', 'circle-color', newColor)
} }
// Update icon reference for points
if (map.getLayer('server-points')) { if (map.getLayer('server-points')) {
map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`) map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`)
} }
@@ -732,6 +1015,7 @@ watch([() => props.pointColor, () => props.entityType], async ([newColor, newTyp
// fitBounds for server clustering when first data arrives // fitBounds for server clustering when first data arrives
watch(() => props.clusteredPoints, (points) => { watch(() => props.clusteredPoints, (points) => {
if (usesTypedClusters.value) return
if (!mapRef.value || !mapInitialized.value) return if (!mapRef.value || !mapInitialized.value) return
if (!didFitBounds.value && points && points.length > 0) { if (!didFitBounds.value && points && points.length > 0) {
const bounds = new LngLatBounds() const bounds = new LngLatBounds()
@@ -741,12 +1025,31 @@ watch(() => props.clusteredPoints, (points) => {
} }
}) })
if (!bounds.isEmpty()) { if (!bounds.isEmpty()) {
mapRef.value.fitBounds(bounds, { padding: 50, maxZoom: 6 }) mapRef.value.fitBounds(bounds, { padding: buildFitPadding(50), maxZoom: 6 })
didFitBounds.value = true didFitBounds.value = true
} }
} }
}, { immediate: 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) // Expose flyTo method for external use (with space fly animation)
const flyTo = async (lat: number, lng: number, zoom = 8) => { const flyTo = async (lat: number, lng: number, zoom = 8) => {
if (!mapRef.value) return if (!mapRef.value) return

View File

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

View File

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

View File

@@ -61,11 +61,10 @@
<script setup lang="ts"> <script setup lang="ts">
interface SelectedItem { interface SelectedItem {
uuid: string uuid: string
name?: string name?: string | null
country?: string country?: string | null
latitude?: number | null latitude?: number | null
longitude?: number | null longitude?: number | null
[key: string]: any
} }
defineProps<{ defineProps<{

View File

@@ -16,16 +16,28 @@
]" ]"
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<!-- Title --> <!-- Title + distance/compass -->
<div class="flex items-start justify-between gap-2">
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text> <Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
<!-- Country left, distance right --> <div class="flex items-center gap-2 text-xs text-base-content/60 whitespace-nowrap">
<Text v-if="distanceLabel" size="xs" class="text-base-content/60">{{ distanceLabel }}</Text>
<div v-if="bearing !== null" class="flex items-center gap-1">
<div class="w-6 h-6 rounded-full border border-base-content/10 bg-base-200/40 flex items-center justify-center">
<Icon
name="lucide:arrow-up"
size="12"
class="text-base-content/60"
:style="{ transform: `rotate(${bearing}deg)` }"
/>
</div>
</div>
</div>
</div>
<!-- Country -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Text tone="muted" size="sm"> <Text tone="muted" size="sm">
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }} {{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
</Text> </Text>
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
{{ hub.distance }}
</span>
</div> </div>
<!-- Transport icons bottom --> <!-- Transport icons bottom -->
<div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1"> <div v-if="hub.transportTypes?.length" class="flex items-center gap-1 pt-1">
@@ -47,12 +59,16 @@ interface Hub {
name?: string | null name?: string | null
country?: string | null country?: string | null
countryCode?: string | null countryCode?: string | null
latitude?: number | null
longitude?: number | null
distance?: string distance?: string
transportTypes?: string[] | null distanceKm?: number | null
transportTypes?: (string | null)[] | null
} }
const props = defineProps<{ const props = defineProps<{
hub: Hub hub: Hub
origin?: { latitude: number; longitude: number } | null
selectable?: boolean selectable?: boolean
isSelected?: boolean isSelected?: boolean
linkTo?: string linkTo?: string
@@ -81,5 +97,33 @@ const countryFlag = computed(() => {
return '🌍' return '🌍'
}) })
const hasTransport = (type: string) => props.hub.transportTypes?.includes(type) 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> </script>

View File

@@ -1,112 +0,0 @@
<template>
<Card
padding="md"
interactive
class="cursor-pointer overflow-hidden"
:class="{ 'bg-base-200': selected }"
@click="$emit('select')"
>
<div class="relative min-h-14">
<!-- Sparkline chart background -->
<div v-if="priceHistory.length > 1" class="absolute inset-0 opacity-15">
<ClientOnly>
<apexchart
type="area"
height="56"
:options="chartOptions"
:series="chartSeries"
/>
</ClientOnly>
</div>
<!-- Content -->
<div class="relative z-10">
<Text weight="semibold" size="sm" class="mb-1">{{ name }}</Text>
<div class="flex items-center gap-2">
<Text v-if="currentPrice" size="sm" class="text-primary font-bold">
{{ formattedPrice }}
</Text>
<span
v-if="trend !== 0"
class="text-xs font-medium"
:class="trend > 0 ? 'text-success' : 'text-error'"
>
{{ trend > 0 ? '↑' : '↓' }} {{ Math.abs(trend) }}%
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
name: string
currentPrice?: number | null
currency?: string | null
priceHistory?: number[]
selected?: boolean
}>(), {
priceHistory: () => [],
selected: false
})
defineEmits<{
select: []
}>()
const formattedPrice = computed(() => {
if (!props.currentPrice) return ''
const symbol = getCurrencySymbol(props.currency)
return `${symbol}${props.currentPrice.toLocaleString()}`
})
const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) {
case 'USD': return '$'
case 'EUR': return '€'
case 'RUB': return '₽'
case 'CNY': return '¥'
default: return '$'
}
}
// Calculate trend from price history
const trend = computed(() => {
if (props.priceHistory.length < 2) return 0
const first = props.priceHistory[0]
const last = props.priceHistory[props.priceHistory.length - 1]
if (!first || first === 0 || !last) return 0
return Math.round(((last - first) / first) * 100)
})
// Chart configuration
const chartOptions = computed(() => ({
chart: {
type: 'area',
sparkline: { enabled: true },
animations: { enabled: false }
},
stroke: {
curve: 'smooth',
width: 2
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.4,
opacityTo: 0.1
}
},
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
tooltip: { enabled: false },
xaxis: { labels: { show: false } },
yaxis: { labels: { show: false } }
}))
const chartSeries = computed(() => [{
name: 'Price',
data: props.priceHistory.length > 0 ? props.priceHistory : [0]
}])
</script>

View File

@@ -12,11 +12,22 @@
</div> </div>
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3> <h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
</div> </div>
<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')"> <button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
<Icon name="lucide:x" size="16" /> <Icon name="lucide:x" size="16" />
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- Content (scrollable) --> <!-- Content (scrollable) -->
<div class="flex-1 overflow-y-auto p-4"> <div class="flex-1 overflow-y-auto p-4">
@@ -41,19 +52,65 @@
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }} {{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
</p> </p>
<!-- Supplier link for offer --> <!-- Supplier for offer (clickable name) -->
<button <button
v-if="entityType === 'offer' && entity?.teamUuid" v-if="entityType === 'offer' && entity?.teamUuid"
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1" class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
@click="emit('open-info', 'supplier', entity.teamUuid)" @click="emit('open-info', 'supplier', entity.teamUuid)"
> >
<Icon name="lucide:factory" size="14" /> <Icon name="lucide:factory" size="14" />
{{ entity.teamName || $t('catalog.info.viewSupplier') }} <span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
<span v-else>{{ supplierDisplayName || $t('catalog.info.supplier') }}</span>
</button> </button>
</div> </div>
<!-- Products Section (for hub/supplier) --> <!-- KYC Teaser Section (for supplier) -->
<section v-if="entityType === 'hub' || entityType === '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"> <h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:package" size="16" /> <Icon name="lucide:package" size="16" />
{{ productsSectionTitle }} {{ productsSectionTitle }}
@@ -65,19 +122,71 @@
{{ $t('catalog.empty.noProducts') }} {{ $t('catalog.empty.noProducts') }}
</div> </div>
<div v-else-if="!loadingProducts" class="flex flex-col gap-2"> <div v-else-if="!loadingProducts" class="flex flex-col gap-2">
<div
v-for="(product, index) in relatedProducts"
:key="product.uuid ?? index"
class="relative group"
>
<ProductCard <ProductCard
v-for="product in relatedProducts"
:key="product.uuid"
:product="product" :product="product"
compact compact
selectable selectable
@select="onProductSelect(product)" @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> </div>
</section> </section>
<!-- Suppliers Section (for hub only) --> <!-- Suppliers Section (for hub only) -->
<section v-if="entityType === 'hub'"> <section v-if="entityType === 'hub' && !selectedProduct">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2"> <h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:factory" size="16" /> <Icon name="lucide:factory" size="16" />
{{ $t('catalog.info.suppliersNearby') }} {{ $t('catalog.info.suppliersNearby') }}
@@ -89,13 +198,25 @@
{{ $t('catalog.info.noSuppliers') }} {{ $t('catalog.info.noSuppliers') }}
</div> </div>
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2"> <div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
<div
v-for="(supplier, index) in relatedSuppliers"
:key="supplier.uuid ?? index"
class="relative group"
>
<SupplierCard <SupplierCard
v-for="supplier in relatedSuppliers"
:key="supplier.uuid"
:supplier="supplier" :supplier="supplier"
selectable selectable
@select="onSupplierSelect(supplier)" @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> </div>
</section> </section>
@@ -111,22 +232,75 @@
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2"> <div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.info.noHubs') }} {{ $t('catalog.info.noHubs') }}
</div> </div>
<div v-else-if="!loadingHubs" class="flex flex-col gap-2"> <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 <HubCard
v-for="hub in relatedHubs"
:key="hub.uuid"
:hub="hub" :hub="hub"
:origin="originCoords"
selectable selectable
@select="onHubSelect(hub)" @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> </div>
</section> </section>
<!-- Add to filter button -->
<button class="btn btn-primary btn-sm mt-2" @click="emit('add-to-filter')">
<Icon name="lucide:filter-plus" size="16" />
{{ $t('catalog.info.addToFilter') }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -134,15 +308,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InfoEntityType } from '~/composables/useCatalogSearch' import type { InfoEntityType } from '~/composables/useCatalogSearch'
import type {
InfoEntity,
InfoProductItem,
InfoHubItem,
InfoSupplierItem,
InfoOfferItem
} from '~/composables/useCatalogInfo'
import type { RouteStage } from '~/composables/graphql/public/geo-generated'
const props = defineProps<{ const props = defineProps<{
entityType: InfoEntityType entityType: InfoEntityType
entityId: string entityId: string
entity: any entity: InfoEntity | null
relatedProducts?: any[] relatedProducts?: InfoProductItem[]
relatedHubs?: any[] relatedHubs?: InfoHubItem[]
relatedSuppliers?: any[] relatedSuppliers?: InfoSupplierItem[]
relatedOffers?: any[] relatedOffers?: InfoOfferItem[]
selectedProduct?: string | null selectedProduct?: string | null
currentTab?: string currentTab?: string
loading?: boolean loading?: boolean
@@ -154,10 +336,12 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'close': [] 'close': []
'add-to-filter': []
'open-info': [type: InfoEntityType, uuid: string] 'open-info': [type: InfoEntityType, uuid: string]
'select-product': [uuid: string | null] 'select-product': [uuid: string | null]
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
'update:current-tab': [tab: string] '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() const { t } = useI18n()
@@ -167,6 +351,37 @@ const { entityColors } = useCatalogSearch()
const relatedProducts = computed(() => props.relatedProducts ?? []) const relatedProducts = computed(() => props.relatedProducts ?? [])
const relatedHubs = computed(() => props.relatedHubs ?? []) const relatedHubs = computed(() => props.relatedHubs ?? [])
const relatedSuppliers = computed(() => props.relatedSuppliers ?? []) 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 // Entity name
const entityName = computed(() => { const entityName = computed(() => {
@@ -180,6 +395,13 @@ const entityLocation = computed(() => {
return parts.length > 0 ? parts.join(', ') : null return parts.length > 0 ? parts.join(', ') : null
}) })
const originCoords = computed(() => {
const lat = props.entity?.locationLatitude ?? props.entity?.latitude
const lon = props.entity?.locationLongitude ?? props.entity?.longitude
if (lat == null || lon == null) return null
return { latitude: Number(lat), longitude: Number(lon) }
})
// Products section title based on entity type // Products section title based on entity type
const productsSectionTitle = computed(() => { const productsSectionTitle = computed(() => {
return props.entityType === 'hub' return props.entityType === 'hub'
@@ -202,29 +424,81 @@ const entityIcon = computed(() => {
return 'lucide:info' return 'lucide:info'
}) })
// Supplier name for offer (from entity or relatedSuppliers)
const supplierDisplayName = computed(() => {
if (props.entity?.supplierName) return props.entity.supplierName
if (props.entity?.teamName) return props.entity.teamName
if (relatedSuppliers.value.length > 0 && relatedSuppliers.value[0]?.name) {
return relatedSuppliers.value[0].name
}
return null
})
// Format price // Format price
const formatPrice = (price: number | string) => { const formatPrice = (price: number | string) => {
const num = typeof price === 'string' ? parseFloat(price) : price const num = typeof price === 'string' ? parseFloat(price) : price
return new Intl.NumberFormat('ru-RU').format(num) 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 // Handlers for selecting related items
const onProductSelect = (product: any) => { const onProductSelect = (product: InfoProductItem) => {
if (product.uuid) {
// Navigate to offer info for this product
emit('select-product', product.uuid) emit('select-product', product.uuid)
} }
const onOfferSelect = (offer: InfoOfferItem) => {
if (offer.uuid) {
emit('select-offer', { uuid: offer.uuid, productUuid: offer.productUuid })
}
} }
const onHubSelect = (hub: any) => { const onHubSelect = (hub: InfoHubItem) => {
if (hub.uuid) { if (hub.uuid) {
emit('open-info', 'hub', hub.uuid) emit('open-info', 'hub', hub.uuid)
} }
} }
const onSupplierSelect = (supplier: any) => { const onSupplierSelect = (supplier: InfoSupplierItem) => {
if (supplier.uuid) { if (supplier.uuid) {
emit('open-info', 'supplier', supplier.uuid) 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> </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> <template>
<Card padding="md" interactive @click="$emit('select')"> <Card padding="md" interactive :class="groupClass" @click="$emit('select')">
<!-- Header: Location + Price --> <!-- Header: Supplier + Price -->
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between gap-4">
<div> <div class="flex flex-col gap-1">
<Text weight="semibold">{{ locationName || 'Локация' }}</Text> <div class="flex items-center gap-2">
<Text v-if="productName" tone="muted" size="sm">{{ productName }}</Text> <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> </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"> <Text v-if="priceDisplay" weight="semibold" class="text-primary text-lg">
{{ priceDisplay }} {{ priceDisplay }}
</Text> </Text>
<Text v-if="durationDisplay" size="xs" class="text-base-content/60">
{{ t('catalogOfferCard.labels.duration_label') }} {{ durationDisplay }}
</Text>
</div>
</div> </div>
<!-- Supplier info --> <!-- Supplier info -->
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" /> <SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
<!-- Route stepper --> <!-- Route lines -->
<RouteStepper <div v-if="routeRows.length" class="mt-3 pt-2 border-t border-base-200/60">
v-if="stages.length > 0" <div v-for="(row, index) in routeRows" :key="index" class="flex items-center gap-2 text-sm text-base-content/70">
:stages="stages" <Icon :name="row.icon" size="14" class="text-base-content/60" />
:start-name="startName" <span>{{ row.distanceLabel }}</span>
:end-name="endName" </div>
/> </div>
</Card> </Card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteStage } from './RouteStepper.vue' interface RouteStage {
transportType?: string | null
distanceKm?: number | null
travelTimeSeconds?: number | null
fromName?: string | null
}
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
locationName?: string locationName?: string
supplierName?: string
productName?: string productName?: string
pricePerUnit?: number | null pricePerUnit?: number | null
quantity?: number | string | null
currency?: string | null currency?: string | null
unit?: string | null unit?: string | null
stages?: RouteStage[] stages?: RouteStage[]
startName?: string totalTimeSeconds?: number | null
endName?: string
kycProfileUuid?: string | null kycProfileUuid?: string | null
grouped?: boolean
}>(), { }>(), {
stages: () => [] stages: () => [],
grouped: false
}) })
defineEmits<{ defineEmits<{
select: [] 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(() => { const priceDisplay = computed(() => {
if (!props.pricePerUnit) return null if (props.pricePerUnit == null) return null
const currSymbol = getCurrencySymbol(props.currency) const currSymbol = getCurrencySymbol(props.currency)
const unitName = getUnitName(props.unit) const unitName = getUnitName(props.unit)
const formattedPrice = props.pricePerUnit.toLocaleString() const basePrice = Number(props.pricePerUnit)
const totalPrice = basePrice + (logisticsCost.value ?? 0)
const formattedPrice = totalPrice.toLocaleString()
return `${currSymbol}${formattedPrice}/${unitName}` return `${currSymbol}${formattedPrice}/${unitName}`
}) })
const quantityDisplay = computed(() => {
if (props.quantity == null || props.quantity === '') return null
const quantityValue = Number(props.quantity)
if (Number.isNaN(quantityValue)) return null
const formattedQuantity = quantityValue.toLocaleString()
const unitName = getUnitName(props.unit)
return t('catalogOfferCard.labels.quantity_with_unit', {
quantity: formattedQuantity,
unit: unitName
})
})
const getCurrencySymbol = (currency?: string | null) => { const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) { switch (currency?.toUpperCase()) {
case 'USD': return '$' case 'USD': return '$'
@@ -66,14 +124,104 @@ const getCurrencySymbol = (currency?: string | null) => {
const getUnitName = (unit?: string | null) => { const getUnitName = (unit?: string | null) => {
switch (unit?.toLowerCase()) { switch (unit?.toLowerCase()) {
case 'т': case 'т':
case 't':
case 'ton': case 'ton':
case 'tonne': case 'tonne':
return 'тонна' return t('catalogOfferCard.labels.default_unit')
case 'кг': case 'кг':
case 'kg': case 'kg':
return 'кг' return t('catalogOfferCard.labels.unit_kg')
default: default:
return 'тонна' return t('catalogOfferCard.labels.default_unit')
} }
} }
const formatDistance = (km?: number | null) => {
if (km == null) return null
const formatted = Math.round(km).toLocaleString()
return t('catalogOfferCard.labels.distance_km', { km: formatted })
}
const formatDurationDays = (days?: number | null) => {
if (!days) return null
const rounded = Math.max(1, Math.ceil(days))
return t('catalogOfferCard.labels.duration_days', { days: rounded })
}
const getTransportIcon = (type?: string | null) => {
switch (type) {
case 'rail':
return 'lucide:train-front'
case 'sea':
return 'lucide:ship'
case 'road':
case 'auto':
default:
return 'lucide:truck'
}
}
const getTransportRate = (type?: string | null) => {
switch (type) {
case 'rail':
return 0.12
case 'sea':
return 0.06
case 'road':
case 'auto':
default:
return 0.22
}
}
const getTransportSpeedPerDay = (type?: string | null) => {
switch (type) {
case 'rail':
return 900
case 'sea':
return 800
case 'road':
case 'auto':
default:
return 600
}
}
const logisticsCost = computed(() => {
if (!props.stages?.length) return null
return props.stages.reduce((sum, stage) => {
const km = stage?.distanceKm
if (km == null) return sum
return sum + km * getTransportRate(stage?.transportType) + 40
}, 0)
})
const totalDurationDays = computed(() => {
if (!props.stages?.length) return null
const stageDays = props.stages.reduce((sum, stage) => {
const km = stage?.distanceKm
if (km == null) return sum
return sum + km / getTransportSpeedPerDay(stage?.transportType)
}, 0)
const transfers = Math.max(0, props.stages.length - 1) * 0.5
const buffer = 1
return stageDays + transfers + buffer
})
const durationDisplay = computed(() => formatDurationDays(totalDurationDays.value))
const groupClass = computed(() => {
if (!props.grouped) return ''
return 'rounded-none shadow-none hover:shadow-none'
})
const routeRows = computed(() =>
(props.stages || [])
.filter(stage => stage?.distanceKm != null)
.map(stage => ({
icon: getTransportIcon(stage?.transportType),
distanceLabel: formatDistance(stage?.distanceKm)
}))
.filter(row => !!row.distanceLabel)
)
</script> </script>

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,61 +0,0 @@
<template>
<div class="w-20 h-10">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler
} from 'chart.js'
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler)
const props = defineProps<{
data: number[]
}>()
const isUptrend = computed(() => {
if (props.data.length < 2) return true
const last = props.data[props.data.length - 1]
const first = props.data[0]
return (last ?? 0) >= (first ?? 0)
})
const lineColor = computed(() => isUptrend.value ? '#22c55e' : '#ef4444')
const chartData = computed(() => ({
labels: props.data.map((_, i) => i.toString()),
datasets: [{
data: props.data,
borderColor: lineColor.value,
backgroundColor: `${lineColor.value}20`,
borderWidth: 1.5,
fill: true,
tension: 0.3,
pointRadius: 0
}]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
x: { display: false },
y: { display: false }
},
elements: {
line: { borderCapStyle: 'round' as const }
}
}
</script>

View File

@@ -9,17 +9,59 @@
<Card <Card
padding="sm" padding="sm"
:interactive="linkable || selectable" :interactive="linkable || selectable"
class="relative overflow-hidden"
:class="[ :class="[
isSelected && 'ring-2 ring-primary ring-offset-2' isSelected && 'ring-2 ring-primary ring-offset-2'
]" ]"
> >
<Stack gap="2"> <!-- Sparkline background -->
<Text size="base" weight="semibold">{{ product.name }}</Text> <div v-if="effectivePriceHistory.length > 1" class="absolute inset-0 opacity-15">
<ClientOnly>
<apexchart
type="area"
height="100%"
:options="chartOptions"
:series="chartSeries"
/>
</ClientOnly>
</div>
<!-- Content -->
<div class="relative z-10">
<div class="flex items-start gap-3">
<!-- Product icon -->
<div class="w-10 h-10 shrink-0 bg-primary/10 text-primary rounded-lg flex items-center justify-center">
<Icon name="lucide:package" size="20" />
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2 mb-1">
<Text size="base" weight="semibold" class="truncate">{{ product.name }}</Text>
<span
v-if="trend !== 0"
class="text-xs font-medium shrink-0"
:class="trend > 0 ? 'text-success' : 'text-error'"
>
{{ trend > 0 ? '↑' : '↓' }} {{ Math.abs(trend) }}%
</span>
</div>
<div class="flex items-center justify-between gap-2">
<Text v-if="product.offersCount" tone="muted" size="sm"> <Text v-if="product.offersCount" tone="muted" size="sm">
{{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }} {{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }}
</Text> </Text>
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text> <Text v-if="effectivePrice" size="sm" class="text-primary font-bold shrink-0">
</Stack> {{ formattedPrice }}
</Text>
</div>
<Text v-if="product.description && !compact" tone="muted" size="sm" class="mt-1">
{{ product.description }}
</Text>
</div>
</div>
</div>
</Card> </Card>
</component> </component>
</template> </template>
@@ -34,12 +76,17 @@ interface Product {
offersCount?: number | null offersCount?: number | null
} }
const props = defineProps<{ const props = withDefaults(defineProps<{
product: Product product: Product
selectable?: boolean selectable?: boolean
isSelected?: boolean isSelected?: boolean
compact?: boolean compact?: boolean
}>() priceHistory?: number[]
currentPrice?: number | null
currency?: string | null
}>(), {
priceHistory: () => []
})
defineEmits<{ defineEmits<{
(e: 'select'): void (e: 'select'): void
@@ -49,4 +96,83 @@ const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
const linkable = computed(() => !props.selectable && !!props.product.uuid) const linkable = computed(() => !props.selectable && !!props.product.uuid)
// Generate mock price history based on uuid for consistency
const effectivePriceHistory = computed(() => {
if (props.priceHistory && props.priceHistory.length > 0) {
return props.priceHistory
}
if (!props.product.uuid) return []
const seed = props.product.uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const basePrice = 100 + (seed % 200)
return Array.from({ length: 7 }, (_, i) => {
const variation = Math.sin(seed + i * 0.5) * 20 + Math.cos(seed * 0.3 + i) * 10
return Math.round(basePrice + variation)
})
})
// Effective price - use provided or last from history
const effectivePrice = computed(() => {
if (props.currentPrice) return props.currentPrice
if (effectivePriceHistory.value.length > 0) {
return effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
}
return null
})
// Price formatting
const formattedPrice = computed(() => {
if (!effectivePrice.value) return ''
const symbol = getCurrencySymbol(props.currency)
return `${symbol}${effectivePrice.value.toLocaleString()}`
})
const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) {
case 'USD': return '$'
case 'EUR': return '€'
case 'RUB': return '₽'
case 'CNY': return '¥'
default: return '$'
}
}
// Calculate trend from price history
const trend = computed(() => {
if (effectivePriceHistory.value.length < 2) return 0
const first = effectivePriceHistory.value[0]
const last = effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
if (!first || first === 0 || !last) return 0
return Math.round(((last - first) / first) * 100)
})
// Chart configuration
const chartOptions = computed(() => ({
chart: {
type: 'area',
sparkline: { enabled: true },
animations: { enabled: false }
},
stroke: {
curve: 'smooth',
width: 2
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.4,
opacityTo: 0.1
}
},
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
tooltip: { enabled: false },
xaxis: { labels: { show: false } },
yaxis: { labels: { show: false } }
}))
const chartSeries = computed(() => [{
name: 'Price',
data: effectivePriceHistory.value.length > 0 ? effectivePriceHistory.value : [0]
}])
</script> </script>

View File

@@ -1,49 +1,148 @@
<template> <template>
<MapPanel> <div class="flex flex-col h-full">
<template #header> <!-- Header -->
<div class="flex-shrink-0 p-4 border-b border-white/10">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="font-semibold text-base text-base-content">{{ $t('catalog.headers.offers') }}</h3> <h3 class="font-semibold text-base text-white">{{ $t('catalog.headers.offers') }}</h3>
<span class="badge badge-neutral">{{ offers.length }}</span> <span class="badge badge-neutral">{{ totalOffers }}</span>
</div>
</div> </div>
</template>
<!-- Content --> <!-- Content (scrollable) -->
<div class="flex-1 overflow-y-auto p-4">
<div v-if="loading" class="flex items-center justify-center py-8"> <div v-if="loading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" /> <span class="loading loading-spinner loading-md text-white" />
</div> </div>
<div v-else-if="offers.length === 0" class="text-center py-8 text-white/60"> <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" /> <Icon name="lucide:search-x" size="32" class="mb-2" />
<p>{{ $t('catalog.empty.noOffers') }}</p> <p>{{ $t('catalog.empty.noOffers') }}</p>
</div> </div>
<div v-else class="flex flex-col gap-3"> <div v-else class="flex flex-col gap-3">
<div <div
v-for="offer in offers" 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 group.offers"
:key="offer.uuid" :key="offer.uuid"
class="cursor-pointer" class="cursor-pointer"
@click="emit('select-offer', offer)" @click="emit('select-offer', offer)"
> >
<slot name="offer-card" :offer="offer"> <OfferResultCard
<OfferCard :offer="offer" linkable /> grouped
</slot> :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
v-else
v-for="offer in group.offers"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
>
<OfferResultCard
:supplier-name="offer.supplierName"
:location-name="offer.country || ''"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
</div>
</div> </div>
</div> </div>
</MapPanel>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface Offer { interface Offer {
uuid: string uuid: string
[key: string]: any 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<{ interface OfferGroup {
loading: boolean id: string
offers: Offer[] offers: Offer[]
}>() }
const emit = defineEmits<{ const emit = defineEmits<{
'select-offer': [offer: Offer] '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> </script>

View File

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

View File

@@ -9,36 +9,46 @@
<Card <Card
padding="small" padding="small"
:interactive="linkable || selectable" :interactive="linkable || selectable"
class="relative"
:class="[ :class="[
isSelected && 'ring-2 ring-primary ring-offset-2' isSelected && 'ring-2 ring-primary ring-offset-2'
]" ]"
> >
<!-- Verified badge top-right --> <div class="flex flex-col gap-3">
<span v-if="supplier.isVerified" class="absolute -top-2 -right-2 badge badge-neutral badge-sm"> <!-- Top row: Info + Logo (logo on right) -->
{{ t('catalogSupplier.badges.verified') }} <div class="flex gap-3 items-start">
<!-- Info (left) -->
<div class="min-w-0 flex-1">
<!-- Name with verified badge -->
<div class="flex items-center gap-1.5">
<span v-if="supplier.isVerified" class="text-primary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</span> </span>
<div class="flex flex-col gap-1">
<!-- Logo -->
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
</div>
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
{{ supplier.name?.charAt(0) }}
</div>
<!-- Title -->
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text> <Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
<!-- Badges -->
<div class="flex flex-wrap gap-1">
<span class="badge badge-neutral badge-dash text-xs">
{{ reliabilityLabel }}
</span>
</div> </div>
<!-- Country below --> <!-- Country -->
<Text tone="muted" size="sm"> <Text tone="muted" size="sm">
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }} {{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
</Text> </Text>
</div> </div>
<!-- Logo (right) -->
<div v-if="supplier.logo" class="w-12 h-12 shrink-0">
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
</div>
<div v-else class="w-12 h-12 shrink-0 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg">
{{ supplier.name?.charAt(0) }}
</div>
</div>
<!-- Bottom row: Badges/Chips -->
<div class="flex flex-wrap gap-1">
<span v-if="reliabilityLabel" class="badge badge-neutral badge-sm">
{{ reliabilityLabel }}
</span>
</div>
</div>
</Card> </Card>
</component> </component>
</template> </template>

View File

@@ -0,0 +1,34 @@
<template>
<div class="absolute inset-0 overflow-hidden bg-slate-900">
<!-- Lottie animation -->
<ClientOnly>
<DotLottieVue
src="/animations/supply-chain.lottie"
autoplay
loop
:layout="{ fit: 'cover', align: [0.5, 0.5] }"
class="absolute top-0 left-0 w-full"
:style="{
height: '100vh',
opacity: 1 - collapseProgress * 0.7,
transform: `scale(${1 + collapseProgress * 0.1})`
}"
/>
</ClientOnly>
<!-- Overlay for text readability - only when hero starts collapsing -->
<div
v-if="collapseProgress > 0.5"
class="absolute inset-0 bg-gradient-to-b from-slate-900/60 via-slate-900/40 to-slate-900/70"
:style="{ opacity: (collapseProgress - 0.5) * 2 }"
/>
</div>
</template>
<script setup lang="ts">
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
defineProps<{
collapseProgress: number
}>()
</script>

View File

@@ -1,86 +1,177 @@
<template> <template>
<header <header
class="shadow-lg" class="relative"
:class="glassStyle ? 'bg-black/30 backdrop-blur-md border-b border-white/10' : 'bg-base-100 border-b border-base-300'"
:style="{ height: `${height}px` }" :style="{ height: `${height}px` }"
> >
<!-- Single row: Logo + Search + Icons --> <div class="relative mx-auto max-w-[2200px] px-3 py-2 md:px-4">
<div class="flex items-stretch h-full px-4 lg:px-6 gap-4"> <div
<!-- Left: Logo + Nav links (top aligned) --> class="flex items-center gap-2"
<div class="flex items-start gap-6 flex-shrink-0 pt-4"> :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"> <NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl" :class="glassStyle ? 'text-white' : 'text-base-content'">Optovia</span> <span class="font-black text-xl tracking-tight" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink> </NuxtLink>
<button
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="[
useWhiteText
? (chatOpen ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10')
: (chatOpen ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')
]"
aria-label="Toggle AI assistant"
@click="$emit('toggle-chat')"
>
<Icon name="lucide:bot" size="18" />
</button>
</div>
<!-- Service nav links --> <!-- Service nav links -->
<nav v-if="showModeToggle" class="flex items-center gap-1"> <div v-if="showModeToggle" class="w-px h-6 bg-white/20 self-center" />
<div v-if="showModeToggle" class="flex items-center px-3 py-2">
<nav class="flex items-center gap-1">
<button <button
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors" class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="showActiveMode && catalogMode === 'explore' :class="showActiveMode && catalogMode === 'explore' && !isClientArea
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content') ? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')" : (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'explore')" @click="$emit('set-catalog-mode', 'explore')"
> >
{{ $t('catalog.modes.explore') }} {{ $t('catalog.modes.explore') }}
</button> </button>
<button <NuxtLink
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors" :to="localePath('/catalog/product')"
:class="showActiveMode && catalogMode === 'quote' class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content') :class="isQuoteStepPage
: (glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')" ? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
@click="$emit('set-catalog-mode', 'quote')" : (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') }} {{ $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> </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> </nav>
</div> </div>
</div>
<!-- Center: Search input (vertically centered) --> <!-- Center: Search input OR Client Area tabs (vertically centered) -->
<div class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 justify-center"> <div
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 --> <!-- Hero slot for home page title -->
<slot name="hero" /> <slot name="hero" />
<!-- Quote mode: Simple segmented input with search inside (white glass) --> <!-- Client Area tabs -->
<template v-if="catalogMode === 'quote'"> <template v-if="isClientArea">
<div class="flex items-center w-full rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg divide-x divide-base-300/30"> <div class="flex 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 --> <!-- Product segment -->
<button <NuxtLink
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 rounded-l-full transition-colors min-w-0" :to="localePath('/catalog/product')"
@click="$emit('edit-token', 'product')" class="flex-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> <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 --> <!-- Hub segment -->
<button <NuxtLink
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 transition-colors min-w-0" :to="localePath('/catalog/destination')"
@click="$emit('edit-token', 'hub')" class="flex-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> <div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
</button> </NuxtLink>
<!-- Quantity segment (inline input) --> <div class="w-px h-8 bg-base-300/40 self-center" />
<div class="flex-1 px-4 py-2 min-w-0"> <!-- Quantity segment -->
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div> <NuxtLink
<div class="flex items-center gap-1"> :to="localePath('/catalog/quantity')"
<input class="flex-1 px-4 py-2 text-left hover:bg-white/10 transition-colors min-w-0"
v-model="localQuantity" >
type="number" <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">{{ $t('catalog.filters.quantity') }}</span>
min="0" <div class="font-medium truncate text-base-content">{{ quantity || '—' }} {{ quantity ? $t('units.t') : '' }}</div>
step="0.1" </NuxtLink>
placeholder="—" <!-- Search button -->
class="w-16 font-medium bg-transparent outline-none text-base-content [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@blur="$emit('update-quantity', localQuantity)"
@keyup.enter="$emit('update-quantity', localQuantity)"
/>
<span v-if="localQuantity" class="text-base-content/60 text-sm">{{ $t('units.t') }}</span>
</div>
</div>
<!-- Search button inside -->
<button <button
class="btn btn-primary btn-circle m-1" class="btn btn-primary btn-circle m-1"
:disabled="!canSearch" @click="navigateToSearch"
@click="$emit('search')"
> >
<Icon name="lucide:search" size="18" /> <Icon name="lucide:search" size="18" />
</button> </button>
@@ -91,7 +182,7 @@
<template v-else> <template v-else>
<!-- Big pill input --> <!-- Big pill input -->
<div <div
class="flex items-center gap-3 w-full px-5 py-3 rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text" class="flex items-center gap-3 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" @click="focusInput"
> >
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" /> <Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
@@ -135,23 +226,16 @@
</template> </template>
</div> </div>
<!-- Right: AI + Globe + Team + User (top aligned like logo) --> <!-- Right: Globe + Team + User (top aligned like logo) -->
<div class="flex items-start gap-1 flex-shrink-0 pt-4"> <div class="flex items-center flex-shrink-0 rounded-full pill-glass">
<!-- AI Assistant button --> <div class="w-px h-6 bg-white/20 self-center" />
<NuxtLink <div class="flex items-center px-2 py-2">
:to="localePath('/clientarea/ai')"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="glassStyle ? '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 --> <!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button <button
tabindex="0" tabindex="0"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors" class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'" :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" /> <Icon name="lucide:globe" size="18" />
</button> </button>
@@ -178,14 +262,16 @@
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- Team dropdown --> <!-- Team dropdown -->
<template v-if="loggedIn && userData?.teams?.length"> <div v-if="loggedIn && userData?.teams?.length" class="w-px h-6 bg-white/20 self-center" />
<div v-if="loggedIn && userData?.teams?.length" class="flex items-center px-2 py-2">
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button <button
tabindex="0" tabindex="0"
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors" class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
:class="glassStyle ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'" :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:building-2" size="16" /> <Icon name="lucide:building-2" size="16" />
<span class="hidden lg:inline max-w-24 truncate text-xs"> <span class="hidden lg:inline max-w-24 truncate text-xs">
@@ -212,23 +298,24 @@
</li> </li>
</ul> </ul>
</div> </div>
</template> </div>
<!-- User menu --> <!-- User menu -->
<template v-if="sessionChecked"> <div v-if="sessionChecked" class="w-px h-6 bg-white/20 self-center" />
<div v-if="sessionChecked" class="flex items-center px-2 py-2">
<template v-if="loggedIn"> <template v-if="loggedIn">
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div <div
tabindex="0" tabindex="0"
role="button" role="button"
class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer" class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer"
:class="glassStyle ? 'ring-white/20 hover:ring-white/40' : 'ring-base-300 hover:ring-primary'" :class="useWhiteText ? 'ring-white/20 hover:ring-white/40' : 'ring-base-300 hover:ring-primary'"
> >
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" /> <div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
<div <div
v-else v-else
class="w-full h-full flex items-center justify-center font-bold text-xs" class="w-full h-full flex items-center justify-center font-bold text-xs"
:class="glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'" :class="useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'"
> >
{{ userInitials }} {{ userInitials }}
</div> </div>
@@ -260,12 +347,13 @@
<button <button
@click="$emit('sign-in')" @click="$emit('sign-in')"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors" class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="glassStyle ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'" :class="useWhiteText ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'"
> >
{{ $t('auth.login') }} {{ $t('auth.login') }}
</button> </button>
</template> </template>
</template> </div>
</div>
</div> </div>
</div> </div>
@@ -292,6 +380,9 @@ const props = withDefaults(defineProps<{
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }> teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null } | null
isSeller?: boolean isSeller?: boolean
// Role switching props
hasMultipleRoles?: boolean
currentRole?: string
// Search props // Search props
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }> activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
availableChips?: Array<{ type: string; label: string }> availableChips?: Array<{ type: string; label: string }>
@@ -306,19 +397,30 @@ const props = withDefaults(defineProps<{
canSearch?: boolean canSearch?: boolean
showModeToggle?: boolean showModeToggle?: boolean
showActiveMode?: boolean // Whether to show active state on mode toggle showActiveMode?: boolean // Whether to show active state on mode toggle
// Glass style (transparent) for map pages // Glass style applied when header is collapsed
glassStyle?: boolean 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 // Dynamic height for hero effect
height?: number height?: number
// Collapse progress for hero layout
collapseProgress?: number
}>(), { }>(), {
height: 100 height: 100,
collapseProgress: 1
}) })
defineEmits([ defineEmits([
'toggle-chat',
'toggle-theme', 'toggle-theme',
'sign-out', 'sign-out',
'sign-in', 'sign-in',
'switch-team', 'switch-team',
'switch-role',
// Search events // Search events
'start-select', 'start-select',
'cancel-select', 'cancel-select',
@@ -332,9 +434,34 @@ defineEmits([
]) ])
const localePath = useLocalePath() const localePath = useLocalePath()
const route = useRoute()
const { locale, locales } = useI18n() const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath() const switchLocalePath = useSwitchLocalePath()
const { t } = useI18n() const { t } = useI18n()
const { chatOpen } = toRefs(props)
const router = useRouter()
// Check if we're on a quote step page
const isQuoteStepPage = computed(() => {
const path = route.path
return path.includes('/catalog/product') ||
path.includes('/catalog/destination') ||
path.includes('/catalog/quantity') ||
path.includes('/catalog/results')
})
// 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 inputRef = ref<HTMLInputElement>()
const localSearchQuery = ref(props.searchQuery || '') const localSearchQuery = ref(props.searchQuery || '')
@@ -386,5 +513,27 @@ const getTokenIcon = (type: string) => {
} }
return icons[type] || 'lucide:tag' return icons[type] || 'lucide:tag'
} }
</script>
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
const topRowHeight = 100
const rowStyle = computed(() => {
if (isHeroLayout.value) {
return { height: `${topRowHeight}px` }
}
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> <template>
<div class="fixed inset-0 flex flex-col"> <div class="fixed inset-0 flex flex-col">
<!-- Loading state -->
<div v-if="loading" class="absolute inset-0 z-50 flex items-center justify-center bg-base-100/80">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ $t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</div>
<!-- Fullscreen Map --> <!-- Fullscreen Map -->
<div class="absolute inset-0"> <div class="absolute inset-0">
<ClientOnly> <ClientOnly>
@@ -17,13 +7,16 @@
ref="mapRef" ref="mapRef"
:map-id="mapId" :map-id="mapId"
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)" :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" :use-server-clustering="useServerClustering && !isInfoMode"
:point-color="activePointColor" :point-color="activePointColor"
:entity-type="activeEntityType" :entity-type="activeEntityType"
:hovered-item-id="hoveredId" :hovered-item-id="hoveredId"
:hovered-item="hoveredItem" :hovered-item="hoveredItem"
:related-points="relatedPoints" :related-points="relatedPoints"
:info-loading="infoLoading"
:fit-padding-left="fitPaddingLeft"
@select-item="onMapSelect" @select-item="onMapSelect"
@bounds-change="onBoundsChange" @bounds-change="onBoundsChange"
/> />
@@ -32,17 +25,17 @@
<!-- View mode loading indicator --> <!-- View mode loading indicator -->
<div <div
v-if="clusterLoading" v-if="clusterLoading || loading"
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-black/50 backdrop-blur-md rounded-full px-4 py-2 border border-white/20" class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 pill-glass rounded-full px-4 py-2"
> >
<span class="loading loading-spinner loading-sm text-white" /> <span class="loading loading-spinner loading-sm text-base-content" />
<span class="text-white text-sm">{{ $t('common.loading') }}</span> <span class="text-base-content text-sm font-medium">{{ $t('common.loading') }}</span>
</div> </div>
<!-- List button (LEFT, opens panel) - hide when panel is open --> <!-- List button (LEFT, opens panel) - hide when panel is open -->
<button <button
v-if="!isPanelOpen" v-if="!isPanelOpen"
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 text-white text-sm hover:bg-black/40 transition-colors" class="absolute 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" @click="openPanel"
> >
<Icon name="lucide:menu" size="16" /> <Icon name="lucide:menu" size="16" />
@@ -52,7 +45,7 @@
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode --> <!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
<label <label
v-if="selectMode !== null" v-if="selectMode !== null"
class="absolute top-[116px] left-[420px] z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 cursor-pointer text-white text-sm hover:bg-black/40 transition-colors" class="absolute top-[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 <input
type="checkbox" type="checkbox"
@@ -64,13 +57,14 @@
</label> </label>
<!-- View toggle (top RIGHT overlay, below header) --> <!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
<div class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2"> <div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
<!-- View mode toggle --> <!-- View mode toggle -->
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10"> <div class="flex gap-1 pill-glass rounded-full p-1">
<button <button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors" v-if="showOffersToggle"
:class="mapViewMode === 'offers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'" class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'offers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('offers')" @click="setMapViewMode('offers')"
> >
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316"> <span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
@@ -79,8 +73,9 @@
{{ $t('catalog.views.offers') }} {{ $t('catalog.views.offers') }}
</button> </button>
<button <button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors" v-if="showHubsToggle"
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'" class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('hubs')" @click="setMapViewMode('hubs')"
> >
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e"> <span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
@@ -89,8 +84,9 @@
{{ $t('catalog.views.hubs') }} {{ $t('catalog.views.hubs') }}
</button> </button>
<button <button
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors" v-if="showSuppliersToggle"
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'" class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-white/10'"
@click="setMapViewMode('suppliers')" @click="setMapViewMode('suppliers')"
> >
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6"> <span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
@@ -105,9 +101,10 @@
<Transition name="slide-left"> <Transition name="slide-left">
<div <div
v-if="isPanelOpen" v-if="isPanelOpen"
class="absolute top-[116px] left-4 bottom-4 z-30 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" /> <slot name="panel" />
</div> </div>
</div> </div>
@@ -119,17 +116,18 @@
<div class="flex justify-between px-4 mb-2"> <div class="flex justify-between px-4 mb-2">
<!-- List button (mobile) --> <!-- List button (mobile) -->
<button <button
class="flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-2 border border-white/10 text-white text-sm" class="flex items-center gap-2 pill-glass rounded-full px-3 py-2 text-base-content text-sm font-medium"
@click="openPanel" @click="openPanel"
> >
<Icon name="lucide:menu" size="16" /> <Icon name="lucide:menu" size="16" />
<span>{{ $t('catalog.list') }}</span> <span>{{ $t('catalog.list') }}</span>
</button> </button>
<!-- Mobile view toggle --> <!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10"> <div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 pill-glass rounded-full p-1">
<button <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'" :class="mapViewMode === 'offers' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('offers')" @click="setMapViewMode('offers')"
> >
@@ -138,7 +136,8 @@
</span> </span>
</button> </button>
<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'" :class="mapViewMode === 'hubs' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('hubs')" @click="setMapViewMode('hubs')"
> >
@@ -147,7 +146,8 @@
</span> </span>
</button> </button>
<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'" :class="mapViewMode === 'suppliers' ? 'bg-white/20' : 'hover:bg-white/10'"
@click="setMapViewMode('suppliers')" @click="setMapViewMode('suppliers')"
> >
@@ -162,14 +162,14 @@
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-if="isPanelOpen" v-if="isPanelOpen"
class="bg-black/50 backdrop-blur-md rounded-t-xl shadow-lg border border-white/10 transition-all duration-300 text-white h-[60vh]" class="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 --> <!-- Drag handle / close -->
<div <div
class="flex justify-center py-2 cursor-pointer" class="flex justify-center py-2 cursor-pointer"
@click="closePanel" @click="closePanel"
> >
<div class="w-10 h-1 bg-white/30 rounded-full" /> <div class="w-10 h-1 bg-base-300 rounded-full" />
</div> </div>
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]"> <div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
@@ -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) // Panel is open when selectMode is set OR when showPanel prop is true (info/quote)
const isPanelOpen = computed(() => props.showPanel || selectMode.value !== null) const isPanelOpen = computed(() => props.showPanel || selectMode.value !== null)
const isDesktop = ref(false)
onMounted(() => {
const media = window.matchMedia('(min-width: 1024px)')
const update = () => {
isDesktop.value = media.matches
}
update()
media.addEventListener('change', update)
onUnmounted(() => {
media.removeEventListener('change', update)
})
})
const panelWidthPx = computed(() => {
const match = props.panelWidth.match(/w-\[(\d+(?:\.\d+)?)rem\]/)
if (match) return Number(match[1]) * 16
if (props.panelWidth === 'w-96') return 24 * 16
if (props.panelWidth === 'w-80') return 20 * 16
return 0
})
const fitPaddingLeft = computed(() => {
if (!isPanelOpen.value || !isDesktop.value || panelWidthPx.value === 0) return 0
const leftInset = 16
const rightInset = 16
return leftInset + panelWidthPx.value + rightInset
})
// Open panel based on current mapViewMode // Open panel based on current mapViewMode
const openPanel = () => { const openPanel = () => {
const newSelectMode = mapViewMode.value === 'hubs' ? 'hub' const newSelectMode = mapViewMode.value === 'hubs' ? 'hub'
@@ -233,24 +261,34 @@ const activeClusterNodeType = computed(() => VIEW_MODE_NODE_TYPES[mapViewMode.va
const currentBounds = ref<MapBounds | null>(null) const currentBounds = ref<MapBounds | null>(null)
interface MapItem { interface MapItem {
uuid: string uuid?: string | null
latitude?: number | null latitude?: number | null
longitude?: number | null longitude?: number | null
name?: string name?: string | null
country?: string country?: string | null
[key: string]: any
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
loading?: boolean loading?: boolean
useServerClustering?: boolean useServerClustering?: boolean
clusterNodeType?: string clusterNodeType?: string
useTypedClusters?: boolean
mapId?: string mapId?: string
pointColor?: string pointColor?: string
hoveredId?: string hoveredId?: string
items?: MapItem[] items?: MapItem[]
showPanel?: boolean showPanel?: boolean
filterByBounds?: 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<{ relatedPoints?: Array<{
uuid: string uuid: string
name: string name: string
@@ -262,11 +300,22 @@ const props = withDefaults(defineProps<{
loading: false, loading: false,
useServerClustering: true, useServerClustering: true,
clusterNodeType: 'offer', clusterNodeType: 'offer',
useTypedClusters: false,
mapId: 'catalog-map', mapId: 'catalog-map',
pointColor: '#f97316', pointColor: '#f97316',
items: () => [], items: () => [],
showPanel: false, showPanel: false,
filterByBounds: 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: () => [] relatedPoints: () => []
}) })
@@ -277,11 +326,73 @@ const emit = defineEmits<{
'update:filter-by-bounds': [value: boolean] 'update:filter-by-bounds': [value: boolean]
}>() }>()
// Server-side clustering - use computed node type based on view mode const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
const { clusteredNodes, fetchClusters, loading: clusterLoading, clearNodes } = useClusteredNodes(undefined, activeClusterNodeType)
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 // Refetch clusters when view mode changes
watch(mapViewMode, async () => { 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 // Clear old data first
clearNodes() clearNodes()
// Refetch with current bounds if available // Refetch with current bounds if available
@@ -290,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 // Map refs
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null) const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
@@ -300,7 +422,7 @@ const selectedMapItem = ref<MapItem | null>(null)
const mobilePanelExpanded = ref(false) const mobilePanelExpanded = ref(false)
// Info mode - when relatedPoints are present, hide clusters and show only related points // Info mode - when relatedPoints are present, hide clusters and show only related points
const isInfoMode = computed(() => props.relatedPoints && props.relatedPoints.length > 0) const isInfoMode = computed(() => props.forceInfoMode || (props.relatedPoints && props.relatedPoints.length > 0))
// Hovered item with coordinates for map highlight // Hovered item with coordinates for map highlight
const hoveredItem = computed(() => { const hoveredItem = computed(() => {
@@ -331,9 +453,13 @@ const onBoundsChange = (bounds: MapBounds) => {
emit('bounds-change', bounds) emit('bounds-change', bounds)
// Don't fetch clusters when in info mode // Don't fetch clusters when in info mode
if (props.useServerClustering && !isInfoMode.value) { if (props.useServerClustering && !isInfoMode.value) {
if (useTypedClusters.value) {
fetchActiveClusters()
} else {
fetchClusters(bounds) fetchClusters(bounds)
} }
} }
}
// Handle selection from map // Handle selection from map
const onMapSelect = (uuid: string, properties?: Record<string, any>) => { const onMapSelect = (uuid: string, properties?: Record<string, any>) => {

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,10 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; } Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; } Int: { input: number; output: number; }
Float: { input: number; output: number; } Float: { input: number; output: number; }
Date: { input: any; output: any; } Date: { input: string; output: string; }
DateTime: { input: string; output: string; } DateTime: { input: string; output: string; }
Decimal: { input: any; output: any; } Decimal: { input: string; output: string; }
JSONString: { input: any; output: any; } JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
}; };
export type CreateOffer = { export type CreateOffer = {
@@ -205,14 +205,14 @@ export type CreateRequestMutationVariables = Exact<{
}>; }>;
export type CreateRequestMutationResult = { __typename?: 'TeamMutation', createRequest?: { __typename?: 'CreateRequest', request?: { __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null } | null }; export type CreateRequestMutationResult = { __typename?: 'TeamMutation', createRequest?: { __typename?: 'CreateRequest', request?: { __typename?: 'RequestType', uuid: string, productUuid: string, quantity: string, sourceLocationUuid: string, userId: string } | null } | null };
export type GetRequestsQueryVariables = Exact<{ export type GetRequestsQueryVariables = Exact<{
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
}>; }>;
export type GetRequestsQueryResult = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null> | null }; export type GetRequestsQueryResult = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: string, sourceLocationUuid: string, userId: string } | null> | null };
export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}},{"kind":"Field","name":{"kind":"Name","value":"offerUuid"}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutationResult, CreateOfferMutationVariables>; export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}},{"kind":"Field","name":{"kind":"Name","value":"offerUuid"}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutationResult, CreateOfferMutationVariables>;

View File

@@ -14,7 +14,7 @@ export type Scalars = {
Int: { input: number; output: number; } Int: { input: number; output: number; }
Float: { input: number; output: number; } Float: { input: number; output: number; }
DateTime: { input: string; output: string; } DateTime: { input: string; output: string; }
JSONString: { input: any; output: any; } JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
}; };
/** Create KYC Application for Russian company. */ /** Create KYC Application for Russian company. */
@@ -110,19 +110,19 @@ export type CreateKycApplicationRussiaMutationVariables = Exact<{
}>; }>;
export type CreateKycApplicationRussiaMutationResult = { __typename?: 'UserMutation', createKycApplicationRussia?: { __typename?: 'CreateKYCApplicationRussia', success?: boolean | null, kycApplication?: { __typename?: 'KYCApplicationType', uuid: string, contactEmail: string, createdAt: string, countryData?: any | null } | null } | null }; export type CreateKycApplicationRussiaMutationResult = { __typename?: 'UserMutation', createKycApplicationRussia?: { __typename?: 'CreateKYCApplicationRussia', success?: boolean | null, kycApplication?: { __typename?: 'KYCApplicationType', uuid: string, contactEmail: string, createdAt: string, countryData?: Record<string, unknown> | null } | null } | null };
export type GetKycRequestRussiaQueryVariables = Exact<{ export type GetKycRequestRussiaQueryVariables = Exact<{
uuid: Scalars['String']['input']; uuid: Scalars['String']['input'];
}>; }>;
export type GetKycRequestRussiaQueryResult = { __typename?: 'UserQuery', kycRequest?: { __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: any | null } | null }; export type GetKycRequestRussiaQueryResult = { __typename?: 'UserQuery', kycRequest?: { __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: Record<string, unknown> | null } | null };
export type GetKycRequestsRussiaQueryVariables = Exact<{ [key: string]: never; }>; export type GetKycRequestsRussiaQueryVariables = Exact<{ [key: string]: never; }>;
export type GetKycRequestsRussiaQueryResult = { __typename?: 'UserQuery', kycRequests?: Array<{ __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: any | null } | null> | null }; export type GetKycRequestsRussiaQueryResult = { __typename?: 'UserQuery', kycRequests?: Array<{ __typename?: 'KYCApplicationType', uuid: string, userId: string, teamName: string, countryCode: string, contactPerson: string, contactEmail: string, contactPhone: string, approvedBy?: string | null, approvedAt?: string | null, createdAt: string, updatedAt: string, countryData?: Record<string, unknown> | null } | null> | null };
export const CreateKycApplicationRussiaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateKYCApplicationRussia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"KYCApplicationRussiaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createKycApplicationRussia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"kycApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"contactEmail"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"countryData"}}]}}]}}]}}]} as unknown as DocumentNode<CreateKycApplicationRussiaMutationResult, CreateKycApplicationRussiaMutationVariables>; export const CreateKycApplicationRussiaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateKYCApplicationRussia"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"KYCApplicationRussiaInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createKycApplicationRussia"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"kycApplication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"contactEmail"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"countryData"}}]}}]}}]}}]} as unknown as DocumentNode<CreateKycApplicationRussiaMutationResult, CreateKycApplicationRussiaMutationVariables>;

View File

@@ -1,9 +1,18 @@
import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated'
import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated' import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 500
// Type from codegen - exported for use in pages
export type CatalogHubItem = NonNullable<NonNullable<HubsListQueryResult['hubsList']>[number]>
export type CatalogNearestHubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
// Internal aliases
type HubItem = CatalogHubItem
type NearestHubItem = CatalogNearestHubItem
// Shared state across list and map views // Shared state across list and map views
const items = ref<any[]>([]) const items = ref<Array<HubItem | NearestHubItem>>([])
const total = ref(0) const total = ref(0)
const selectedFilter = ref('all') const selectedFilter = ref('all')
const selectedCountry = ref('all') const selectedCountry = ref('all')
@@ -36,7 +45,7 @@ export function useCatalogHubs() {
) )
const itemsByCountry = computed(() => { const itemsByCountry = computed(() => {
const grouped = new Map<string, any[]>() const grouped = new Map<string, Array<HubItem | NearestHubItem>>()
items.value.forEach(hub => { items.value.forEach(hub => {
const country = hub.country || t('catalogMap.labels.country_unknown') const country = hub.country || t('catalogMap.labels.country_unknown')
if (!grouped.has(country)) grouped.set(country, []) if (!grouped.has(country)) grouped.set(country, [])
@@ -52,22 +61,21 @@ export function useCatalogHubs() {
const fetchPage = async (offset: number, replace = false) => { const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
// If filtering by product, use nearestHubs with global search // If filtering by product, use nearestHubs (graph-based)
// (center point 0,0 with very large radius to cover entire globe)
if (filterProductUuid.value) { if (filterProductUuid.value) {
const data = await execute( const data = await execute(
NearestHubsDocument, NearestHubsDocument,
{ {
lat: 0, lat: 0,
lon: 0, lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value, productUuid: filterProductUuid.value,
useGraph: true,
limit: 500 // Increased limit for global search limit: 500 // Increased limit for global search
}, },
'public', 'public',
'geo' 'geo'
) )
const next = data?.nearestHubs || [] const next = (data?.nearestHubs || []).filter((h): h is NearestHubItem => h !== null)
items.value = next items.value = next
total.value = next.length total.value = next.length
isInitialized.value = true isInitialized.value = true
@@ -95,7 +103,7 @@ export function useCatalogHubs() {
'public', 'public',
'geo' 'geo'
) )
const next = data?.hubsList || [] const next = (data?.hubsList || []).filter((h): h is HubItem => h !== null)
items.value = replace ? next : items.value.concat(next) items.value = replace ? next : items.value.concat(next)
// hubsList doesn't return total count, estimate from fetched items // hubsList doesn't return total count, estimate from fetched items
if (replace) { if (replace) {
@@ -138,12 +146,21 @@ export function useCatalogHubs() {
const setProductFilter = (uuid: string | null) => { const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true) fetchPage(0, true)
} }
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => { const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// Early return if bounds haven't changed
const prev = filterBounds.value
const same = prev === bounds || (
prev && bounds &&
prev.west === bounds.west &&
prev.south === bounds.south &&
prev.east === bounds.east &&
prev.north === bounds.north
)
if (same) return
filterBounds.value = bounds filterBounds.value = bounds
if (isInitialized.value) { if (isInitialized.value) {
fetchPage(0, true) fetchPage(0, true)

View File

@@ -1,24 +1,95 @@
import type { InfoEntityType } from './useCatalogSearch' import type { InfoEntityType } from './useCatalogSearch'
import type {
GetNodeQueryResult,
NearestHubsQueryResult,
NearestOffersQueryResult
} from '~/composables/graphql/public/geo-generated'
import { import {
GetNodeDocument, GetNodeDocument,
NearestOffersDocument, NearestOffersDocument,
NearestHubsDocument NearestHubsDocument
} from '~/composables/graphql/public/geo-generated' } from '~/composables/graphql/public/geo-generated'
import type {
GetOfferQueryResult,
GetSupplierProfileQueryResult
} from '~/composables/graphql/public/exchange-generated'
import { import {
GetOfferDocument, GetOfferDocument,
GetSupplierProfileDocument GetSupplierProfileDocument,
GetSupplierOffersDocument
} from '~/composables/graphql/public/exchange-generated' } from '~/composables/graphql/public/exchange-generated'
// Types from codegen
type NodeEntity = NonNullable<GetNodeQueryResult['node']>
type OfferEntity = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileQueryResult['getSupplierProfile']>
type HubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
// Product type (aggregated from offers)
export interface InfoProductItem {
uuid: string
name: string
offersCount?: number
}
// Re-export types for InfoPanel
export type InfoHubItem = HubItem
export type InfoSupplierItem = SupplierProfile
export type InfoOfferItem = OfferItem
// Extended entity type with all known fields (NO index signature!)
export interface InfoEntity {
uuid?: string | null
name?: string | null
// Node coordinates
latitude?: number | null
longitude?: number | null
// Location fields
address?: string | null
city?: string | null
country?: string | null
// Offer coordinates (different field names)
locationLatitude?: number | null
locationLongitude?: number | null
locationUuid?: string | null
locationName?: string | null
// Offer fields
productUuid?: string | null
productName?: string | null
teamUuid?: string | null
teamName?: string | null
pricePerUnit?: number | string | null
currency?: string | null
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)
function getEntityCoords(e: InfoEntity | null): { lat: number; lon: number } | null {
if (!e) return null
// Try offer coords first (locationLatitude/locationLongitude)
const lat = e.locationLatitude ?? e.latitude
const lon = e.locationLongitude ?? e.longitude
if (lat != null && lon != null) {
return { lat, lon }
}
return null
}
export function useCatalogInfo() { export function useCatalogInfo() {
const { execute } = useGraphQL() const { execute } = useGraphQL()
// State // State with proper types
const entity = ref<any>(null) const entity = ref<InfoEntity | null>(null)
const entityType = ref<InfoEntityType | null>(null) // Track entity type explicitly const entityType = ref<InfoEntityType | null>(null)
const relatedProducts = ref<any[]>([]) const relatedProducts = ref<InfoProductItem[]>([])
const relatedHubs = ref<any[]>([]) const relatedHubs = ref<HubItem[]>([])
const relatedSuppliers = ref<any[]>([]) const relatedSuppliers = ref<SupplierProfile[]>([])
const relatedOffers = ref<any[]>([]) const relatedOffers = ref<OfferItem[]>([])
const selectedProduct = ref<string | null>(null) const selectedProduct = ref<string | null>(null)
const activeTab = ref<string>('products') const activeTab = ref<string>('products')
const isLoading = ref(false) const isLoading = ref(false)
@@ -34,9 +105,10 @@ export function useCatalogInfo() {
try { try {
// Load hub node details // Load hub node details
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node entity.value = nodeData?.node ?? null
if (!entity.value?.latitude || !entity.value?.longitude) { const coords = getEntityCoords(entity.value)
if (!coords) {
console.warn('Hub has no coordinates') console.warn('Hub has no coordinates')
return return
} }
@@ -52,33 +124,36 @@ export function useCatalogInfo() {
execute( execute(
NearestOffersDocument, NearestOffersDocument,
{ {
lat: entity.value.latitude, lat: coords.lat,
lon: entity.value.longitude, lon: coords.lon,
radius: 500 hubUuid: uuid,
limit: 500
}, },
'public', 'public',
'geo' 'geo'
).then(offersData => { ).then(offersData => {
// Group offers by product // Group offers by product
const productsMap = new Map<string, any>() const productsMap = new Map<string, InfoProductItem>()
const suppliersMap = new Map<string, any>() const suppliersMap = new Map<string, { uuid: string; name: string; latitude?: number | null; longitude?: number | null }>()
offersData?.nearestOffers?.forEach((offer: any) => { offersData?.nearestOffers?.forEach(offer => {
if (!offer) return
// Products // Products
if (offer?.productUuid) { if (offer.productUuid && offer.productName) {
if (!productsMap.has(offer.productUuid)) { const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, { productsMap.set(offer.productUuid, {
uuid: offer.productUuid, uuid: offer.productUuid,
name: offer.productName, name: offer.productName,
offersCount: 0 offersCount: 1
}) })
} }
productsMap.get(offer.productUuid)!.offersCount++
} }
// Suppliers (extract from offers) // Suppliers (extract from offers)
if (offer?.supplierUuid) { if (offer.supplierUuid && !suppliersMap.has(offer.supplierUuid)) {
if (!suppliersMap.has(offer.supplierUuid)) {
suppliersMap.set(offer.supplierUuid, { suppliersMap.set(offer.supplierUuid, {
uuid: offer.supplierUuid, uuid: offer.supplierUuid,
name: offer.supplierName || 'Supplier', name: offer.supplierName || 'Supplier',
@@ -86,7 +161,6 @@ export function useCatalogInfo() {
longitude: offer.longitude longitude: offer.longitude
}) })
} }
}
}) })
relatedProducts.value = Array.from(productsMap.values()) relatedProducts.value = Array.from(productsMap.values())
@@ -101,7 +175,7 @@ export function useCatalogInfo() {
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info .catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
) )
).then(profiles => { ).then(profiles => {
relatedSuppliers.value = profiles.filter(Boolean) relatedSuppliers.value = profiles.filter((p): p is SupplierProfile => p != null)
isLoadingSuppliers.value = false isLoadingSuppliers.value = false
}) })
} else { } else {
@@ -123,7 +197,7 @@ export function useCatalogInfo() {
try { try {
// Load supplier node details (might be geo node) // Load supplier node details (might be geo node)
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node entity.value = nodeData?.node ?? null
// Also try to get supplier profile from exchange API for additional details // Also try to get supplier profile from exchange API for additional details
try { try {
@@ -152,30 +226,26 @@ export function useCatalogInfo() {
isLoadingProducts.value = true isLoadingProducts.value = true
isLoadingHubs.value = true isLoadingHubs.value = true
// Load products (offers grouped by product) // Load products from supplier offers (no geo radius)
execute( execute(
NearestOffersDocument, GetSupplierOffersDocument,
{ { teamUuid: uuid },
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public', 'public',
'geo' 'exchange'
).then(offersData => { ).then(offersData => {
// Group offers by product const productsMap = new Map<string, InfoProductItem>()
const productsMap = new Map<string, any>() offersData?.getOffers?.forEach(offer => {
offersData?.nearestOffers?.forEach((offer: any) => { if (!offer?.productUuid || !offer.productName) return
if (offer?.productUuid) { const existing = productsMap.get(offer.productUuid)
if (!productsMap.has(offer.productUuid)) { if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, { productsMap.set(offer.productUuid, {
uuid: offer.productUuid, uuid: offer.productUuid,
name: offer.productName, name: offer.productName,
offersCount: 0 offersCount: 1
}) })
} }
productsMap.get(offer.productUuid)!.offersCount++
}
}) })
relatedProducts.value = Array.from(productsMap.values()) relatedProducts.value = Array.from(productsMap.values())
}).finally(() => { }).finally(() => {
@@ -188,13 +258,13 @@ export function useCatalogInfo() {
{ {
lat: entity.value.latitude, lat: entity.value.latitude,
lon: entity.value.longitude, lon: entity.value.longitude,
radius: 1000, sourceUuid: entity.value.uuid,
limit: 12 limit: 12
}, },
'public', 'public',
'geo' 'geo'
).then(hubsData => { ).then(hubsData => {
relatedHubs.value = hubsData?.nearestHubs || [] relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => { }).finally(() => {
isLoadingHubs.value = false isLoadingHubs.value = false
}) })
@@ -210,9 +280,10 @@ export function useCatalogInfo() {
try { try {
// Load offer details from exchange API // Load offer details from exchange API
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange') const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
entity.value = offerData?.getOffer entity.value = offerData?.getOffer ?? null
if (!entity.value?.latitude || !entity.value?.longitude) { const coords = getEntityCoords(entity.value)
if (!coords) {
console.warn('Offer has no coordinates') console.warn('Offer has no coordinates')
return return
} }
@@ -235,15 +306,15 @@ export function useCatalogInfo() {
execute( execute(
NearestHubsDocument, NearestHubsDocument,
{ {
lat: entity.value.latitude, lat: coords.lat,
lon: entity.value.longitude, lon: coords.lon,
radius: 1000, sourceUuid: entity.value?.uuid ?? null,
limit: 12 limit: 12
}, },
'public', 'public',
'geo' 'geo'
).then(hubsData => { ).then(hubsData => {
relatedHubs.value = hubsData?.nearestHubs || [] relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => { }).finally(() => {
isLoadingHubs.value = false isLoadingHubs.value = false
}) })
@@ -257,9 +328,12 @@ export function useCatalogInfo() {
'public', 'public',
'exchange' 'exchange'
).then(supplierData => { ).then(supplierData => {
relatedSuppliers.value = supplierData?.getSupplierProfile const supplier = supplierData?.getSupplierProfile
? [supplierData.getSupplierProfile] relatedSuppliers.value = supplier ? [supplier] : []
: [] // Enrich entity with supplier name for display
if (supplier?.name && entity.value) {
entity.value = { ...entity.value, supplierName: supplier.name }
}
}).catch(() => { }).catch(() => {
// Supplier might not exist // Supplier might not exist
}).finally(() => { }).finally(() => {
@@ -293,7 +367,6 @@ export function useCatalogInfo() {
lon: hub.longitude, lon: hub.longitude,
productUuid, productUuid,
hubUuid, // Pass hubUuid to get routes calculated on backend hubUuid, // Pass hubUuid to get routes calculated on backend
radius: 500,
limit: 12 limit: 12
}, },
'public', 'public',
@@ -301,19 +374,19 @@ export function useCatalogInfo() {
) )
// Offers already include routes from backend // Offers already include routes from backend
relatedOffers.value = offersData?.nearestOffers || [] relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
isLoadingOffers.value = false isLoadingOffers.value = false
// Extract unique suppliers from offers (use supplierUuid from offers) // Extract unique suppliers from offers (use supplierUuid from offers)
const supplierUuids = new Set<string>() const supplierUuids = new Set<string>()
relatedOffers.value.forEach((offer: any) => { relatedOffers.value.forEach(offer => {
if (offer.supplierUuid) { if (offer.supplierUuid) {
supplierUuids.add(offer.supplierUuid) supplierUuids.add(offer.supplierUuid)
} }
}) })
// Load supplier profiles (limit to 12) // Load supplier profiles (limit to 12)
const suppliers: any[] = [] const suppliers: SupplierProfile[] = []
for (const uuid of Array.from(supplierUuids).slice(0, 12)) { for (const uuid of Array.from(supplierUuids).slice(0, 12)) {
try { try {
const supplierData = await execute( const supplierData = await execute(
@@ -352,6 +425,28 @@ export function useCatalogInfo() {
isLoadingHubs.value = true isLoadingHubs.value = true
try { 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 // Find offers near supplier for this product
const offersData = await execute( const offersData = await execute(
NearestOffersDocument, NearestOffersDocument,
@@ -359,46 +454,19 @@ export function useCatalogInfo() {
lat: supplier.latitude, lat: supplier.latitude,
lon: supplier.longitude, lon: supplier.longitude,
productUuid, productUuid,
radius: 500, ...(hubUuid ? { hubUuid } : {}),
limit: 12 limit: 12
}, },
'public', 'public',
'geo' 'geo'
) )
relatedOffers.value = offersData?.nearestOffers || [] relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => {
isLoadingOffers.value = false if (!o) return false
if (!supplier.uuid) return true
// Load hubs near each offer and aggregate (limit to 12) return o.supplierUuid === supplier.uuid
const allHubs = new Map<string, any>()
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: any) => {
if (!allHubs.has(hub.uuid)) {
allHubs.set(hub.uuid, hub)
}
}) })
} catch (e) { isLoadingOffers.value = false
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 { } finally {
isLoadingOffers.value = false isLoadingOffers.value = false
isLoadingHubs.value = false isLoadingHubs.value = false
@@ -415,10 +483,10 @@ export function useCatalogInfo() {
if (!entity.value) return if (!entity.value) return
// Use stored entity type instead of inferring from properties // Use stored entity type instead of inferring from properties
if (entityType.value === 'hub') { if (entityType.value === 'hub' && entity.value.uuid) {
await loadOffersForHub(entity.value.uuid, productUuid) await loadOffersForHub(entity.value.uuid, productUuid)
activeTab.value = 'offers' activeTab.value = 'offers'
} else if (entityType.value === 'supplier') { } else if (entityType.value === 'supplier' && entity.value.uuid) {
await loadOffersForSupplier(entity.value.uuid, productUuid) await loadOffersForSupplier(entity.value.uuid, productUuid)
activeTab.value = 'offers' activeTab.value = 'offers'
} }

View File

@@ -1,9 +1,13 @@
import type { GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated' import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
// Type from codegen
type OfferItem = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
// Shared state across list and map views // Shared state across list and map views
const items = ref<any[]>([]) const items = ref<OfferItem[]>([])
const total = ref(0) const total = ref(0)
const selectedProductUuid = ref<string | null>(null) const selectedProductUuid = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
@@ -18,7 +22,7 @@ export function useCatalogOffers() {
.filter(offer => offer.locationLatitude && offer.locationLongitude) .filter(offer => offer.locationLatitude && offer.locationLongitude)
.map(offer => ({ .map(offer => ({
uuid: offer.uuid, uuid: offer.uuid,
name: offer.productName || offer.title, name: offer.productName || offer.locationName,
latitude: offer.locationLatitude, latitude: offer.locationLatitude,
longitude: offer.locationLongitude, longitude: offer.locationLongitude,
country: offer.locationCountry country: offer.locationCountry
@@ -40,7 +44,7 @@ export function useCatalogOffers() {
'public', 'public',
'exchange' 'exchange'
) )
const next = data?.getOffers || [] const next = (data?.getOffers || []).filter((o): o is OfferItem => o !== null)
items.value = replace ? next : items.value.concat(next) items.value = replace ? next : items.value.concat(next)
total.value = data?.getOffersCount ?? total.value total.value = data?.getOffersCount ?? total.value
isInitialized.value = true isInitialized.value = true

View File

@@ -1,14 +1,26 @@
import type { ProductsListQueryResult, NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
import { import {
ProductsListDocument, ProductsListDocument,
GetNodeDocument, GetNodeDocument,
NearestOffersDocument NearestOffersDocument
} from '~/composables/graphql/public/geo-generated' } from '~/composables/graphql/public/geo-generated'
import { import {
GetSupplierProfileDocument GetSupplierOffersDocument
} from '~/composables/graphql/public/exchange-generated' } from '~/composables/graphql/public/exchange-generated'
// Type from codegen
type ProductItem = NonNullable<NonNullable<ProductsListQueryResult['productsList']>[number]>
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
// Product aggregated from offers
interface AggregatedProduct {
uuid: string
name: string | null | undefined
offersCount: number
}
// Shared state // Shared state
const items = ref<any[]>([]) const items = ref<ProductItem[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
@@ -31,35 +43,16 @@ export function useCatalogProducts() {
let data let data
if (filterSupplierUuid.value) { if (filterSupplierUuid.value) {
// Products from specific supplier - get supplier coordinates first // Products from specific supplier - get offers directly (no geo radius)
const supplierData = await execute( const offersData = await execute(
GetSupplierProfileDocument, GetSupplierOffersDocument,
{ uuid: filterSupplierUuid.value }, { teamUuid: filterSupplierUuid.value },
'public', 'public',
'exchange' 'exchange'
) )
const supplier = supplierData?.getSupplierProfile const productsMap = new Map<string, AggregatedProduct>()
offersData?.getOffers?.forEach((offer) => {
if (!supplier?.latitude || !supplier?.longitude) { if (!offer?.productUuid) return
console.warn('Supplier has no coordinates')
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, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) { if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, { productsMap.set(offer.productUuid, {
uuid: offer.productUuid, uuid: offer.productUuid,
@@ -68,10 +61,8 @@ export function useCatalogProducts() {
}) })
} }
productsMap.get(offer.productUuid)!.offersCount++ productsMap.get(offer.productUuid)!.offersCount++
}
}) })
items.value = Array.from(productsMap.values()) items.value = Array.from(productsMap.values()) as ProductItem[]
}
} else if (filterHubUuid.value) { } else if (filterHubUuid.value) {
// Products near hub - get hub coordinates first // Products near hub - get hub coordinates first
const hubData = await execute( const hubData = await execute(
@@ -86,22 +77,23 @@ export function useCatalogProducts() {
console.warn('Hub has no coordinates') console.warn('Hub has no coordinates')
items.value = [] items.value = []
} else { } else {
// Get offers near hub and group by product // Get offers by graph from hub and group by product
const offersData = await execute( const offersData = await execute(
NearestOffersDocument, NearestOffersDocument,
{ {
lat: hub.latitude, lat: hub.latitude,
lon: hub.longitude, lon: hub.longitude,
radius: 500 hubUuid: filterHubUuid.value,
limit: 500
}, },
'public', 'public',
'geo' 'geo'
) )
// Group offers by product // Group offers by product
const productsMap = new Map<string, any>() const productsMap = new Map<string, AggregatedProduct>()
offersData?.nearestOffers?.forEach((offer: any) => { offersData?.nearestOffers?.forEach((offer) => {
if (offer?.productUuid) { if (!offer?.productUuid) return
if (!productsMap.has(offer.productUuid)) { if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, { productsMap.set(offer.productUuid, {
uuid: offer.productUuid, uuid: offer.productUuid,
@@ -110,9 +102,8 @@ export function useCatalogProducts() {
}) })
} }
productsMap.get(offer.productUuid)!.offersCount++ productsMap.get(offer.productUuid)!.offersCount++
}
}) })
items.value = Array.from(productsMap.values()) items.value = Array.from(productsMap.values()) as ProductItem[]
} }
} else { } else {
// All products from graph // All products from graph
@@ -130,7 +121,7 @@ export function useCatalogProducts() {
'public', 'public',
'geo' 'geo'
) )
items.value = data?.productsList || [] items.value = (data?.productsList || []).filter((p): p is ProductItem => p !== null)
} }
isInitialized.value = true isInitialized.value = true
@@ -179,6 +170,17 @@ export function useCatalogProducts() {
// Products are filtered by offer locations within bounds // Products are filtered by offer locations within bounds
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => { const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// Early return if bounds haven't changed
const prev = filterBounds.value
const same = prev === bounds || (
prev && bounds &&
prev.west === bounds.west &&
prev.south === bounds.south &&
prev.east === bounds.east &&
prev.north === bounds.north
)
if (same) return
filterBounds.value = bounds filterBounds.value = bounds
if (isInitialized.value) { if (isInitialized.value) {
fetchProducts() fetchProducts()

View File

@@ -84,6 +84,19 @@ export function useCatalogSearch() {
const hubId = computed(() => route.query.hub as string | undefined) const hubId = computed(() => route.query.hub as string | undefined)
const quantity = computed(() => route.query.qty as string | undefined) const quantity = computed(() => route.query.qty as string | undefined)
// Map bounds from URL (format: west,south,east,north)
const urlBounds = computed((): { west: number; south: number; east: number; north: number } | null => {
const b = route.query.bounds as string | undefined
if (!b) return null
const parts = b.split(',').map(Number)
if (parts.length !== 4 || parts.some(isNaN)) return null
return { west: parts[0]!, south: parts[1]!, east: parts[2]!, north: parts[3]! }
})
// Filter by bounds checkbox state from URL
// Use explicit flag so bounds don't auto-enable filtering.
const filterByBounds = computed(() => route.query.boundsFilter === '1')
// Get label for a filter (from cache or fallback to ID) // Get label for a filter (from cache or fallback to ID)
const getLabel = (type: string, id: string | undefined): string | null => { const getLabel = (type: string, id: string | undefined): string | null => {
if (!id) return null if (!id) return null
@@ -208,19 +221,26 @@ export function useCatalogSearch() {
} }
const startSelect = (type: SelectMode) => { const startSelect = (type: SelectMode) => {
if (!selectMode.value) {
lastViewMode.value = mapViewMode.value
}
updateQuery({ select: type }) updateQuery({ select: type })
} }
const cancelSelect = () => { const cancelSelect = () => {
updateQuery({ select: null }) updateQuery({
select: null
})
} }
const selectItem = (type: string, id: string, label: string) => { const selectItem = (type: string, id: string, label: string) => {
setLabel(type, id, label) setLabel(type, id, label)
const forcedView = (type === 'hub' || type === 'supplier') ? null : (lastViewMode.value === 'offers' ? null : lastViewMode.value)
updateQuery({ updateQuery({
[type]: id, [type]: id,
select: null, // Exit selection mode select: null, // Exit selection mode
info: null // Exit info mode info: null, // Exit info mode
view: forcedView
}) })
} }
@@ -237,6 +257,26 @@ export function useCatalogSearch() {
updateQuery({ qty }) updateQuery({ qty })
} }
// Set map bounds in URL (for filter by map feature)
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, boundsFilter: '1' })
} else {
updateQuery({ bounds: null })
}
}
// Clear bounds from URL
const clearBoundsFromUrl = () => {
updateQuery({ bounds: null, boundsFilter: null })
}
// Explicitly enable/disable bounds filter flag in URL
const setBoundsFilterEnabled = (enabled: boolean) => {
updateQuery({ boundsFilter: enabled ? '1' : null })
}
const openInfo = (type: InfoEntityType, uuid: string) => { const openInfo = (type: InfoEntityType, uuid: string) => {
updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null }) updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null })
} }
@@ -272,8 +312,15 @@ export function useCatalogSearch() {
} }
return 'offers' // default return 'offers' // default
}) })
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
const setMapViewMode = (mode: MapViewMode) => { 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 // Drawer state for list view
@@ -323,7 +370,15 @@ export function useCatalogSearch() {
}) })
const setCatalogMode = (newMode: CatalogMode) => { const setCatalogMode = (newMode: CatalogMode) => {
updateQuery({ mode: newMode === 'explore' ? null : 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) // Can search for offers (product + hub or product + supplier required)
@@ -350,6 +405,8 @@ export function useCatalogSearch() {
quantity, quantity,
searchQuery, searchQuery,
mapViewMode, mapViewMode,
urlBounds,
filterByBounds,
// Drawer state // Drawer state
isDrawerOpen, isDrawerOpen,
@@ -373,6 +430,9 @@ export function useCatalogSearch() {
removeFilter, removeFilter,
editFilter, editFilter,
setQuantity, setQuantity,
setBoundsInUrl,
clearBoundsFromUrl,
setBoundsFilterEnabled,
openInfo, openInfo,
closeInfo, closeInfo,
setInfoTab, setInfoTab,

View File

@@ -1,9 +1,14 @@
import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated'
import { SuppliersListDocument, NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated' import { SuppliersListDocument, NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 500
// Types from codegen
type SupplierItem = NonNullable<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
type NearestSupplierItem = NonNullable<NonNullable<NearestSuppliersQueryResult['nearestSuppliers']>[number]>
// Shared state across list and map views // Shared state across list and map views
const items = ref<any[]>([]) const items = ref<Array<SupplierItem | NearestSupplierItem>>([])
const total = ref(0) const total = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
@@ -15,7 +20,7 @@ export function useCatalogSuppliers() {
const { execute } = useGraphQL() const { execute } = useGraphQL()
const itemsWithCoords = computed(() => const itemsWithCoords = computed(() =>
items.value.filter(s => s.latitude && s.longitude) items.value.filter((s): s is NearestSupplierItem => 'latitude' in s && 'longitude' in s && s.latitude != null && s.longitude != null)
) )
const canLoadMore = computed(() => items.value.length < total.value) const canLoadMore = computed(() => items.value.length < total.value)
@@ -23,22 +28,20 @@ export function useCatalogSuppliers() {
const fetchPage = async (offset: number, replace = false) => { const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
// If filtering by product, use nearestSuppliers with global search // If filtering by product, use nearestSuppliers (product-only list)
// (center point 0,0 with very large radius to cover entire globe)
if (filterProductUuid.value) { if (filterProductUuid.value) {
const data = await execute( const data = await execute(
NearestSuppliersDocument, NearestSuppliersDocument,
{ {
lat: 0, lat: 0,
lon: 0, lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value, productUuid: filterProductUuid.value,
limit: 500 // Increased limit for global search limit: 500 // Increased limit for global search
}, },
'public', 'public',
'geo' 'geo'
) )
items.value = data?.nearestSuppliers || [] items.value = (data?.nearestSuppliers || []).filter((s): s is NearestSupplierItem => s !== null)
total.value = items.value.length total.value = items.value.length
isInitialized.value = true isInitialized.value = true
return return
@@ -60,7 +63,7 @@ export function useCatalogSuppliers() {
'public', 'public',
'geo' 'geo'
) )
const next = data?.suppliersList || [] const next = (data?.suppliersList || []).filter((s): s is SupplierItem => s !== null)
items.value = replace ? next : items.value.concat(next) items.value = replace ? next : items.value.concat(next)
// suppliersList doesn't return total count, estimate from fetched items // suppliersList doesn't return total count, estimate from fetched items
@@ -95,12 +98,21 @@ export function useCatalogSuppliers() {
const setProductFilter = (uuid: string | null) => { const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true) fetchPage(0, true)
} }
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => { const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// Early return if bounds haven't changed
const prev = filterBounds.value
const same = prev === bounds || (
prev && bounds &&
prev.west === bounds.west &&
prev.south === bounds.south &&
prev.east === bounds.east &&
prev.north === bounds.north
)
if (same) return
filterBounds.value = bounds filterBounds.value = bounds
if (isInitialized.value) { if (isInitialized.value) {
fetchPage(0, true) fetchPage(0, true)

View File

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

View File

@@ -1,4 +1,8 @@
const items = ref<any[]>([]) import type { GetTeamAddressesQueryResult } from '~/composables/graphql/team/teams-generated'
type TeamAddress = NonNullable<NonNullable<GetTeamAddressesQueryResult['teamAddresses']>[number]>
const items = ref<TeamAddress[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
@@ -23,7 +27,7 @@ export function useTeamAddresses() {
try { try {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated') const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams') const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
items.value = data?.teamAddresses || [] items.value = (data?.teamAddresses || []).filter((a): a is TeamAddress => a !== null)
isInitialized.value = true isInitialized.value = true
} catch (e) { } catch (e) {
console.error('Failed to load addresses', e) console.error('Failed to load addresses', e)

View File

@@ -1,4 +1,9 @@
const items = ref<any[]>([]) import type { GetTeamOrdersQueryResult } from '~/composables/graphql/team/orders-generated'
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
type TeamOrderStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
const items = ref<TeamOrder[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
@@ -23,13 +28,14 @@ export function useTeamOrders() {
const routesForMap = computed(() => const routesForMap = computed(() =>
filteredItems.value filteredItems.value
.filter(order => order.uuid && order.name)
.map(order => ({ .map(order => ({
uuid: order.uuid, uuid: order.uuid!,
name: order.name, name: order.name!,
status: order.status, status: order.status ?? undefined,
stages: (order.stages || []) stages: (order.stages || [])
.filter((s: any) => s.stageType === 'transport' && s.sourceLatitude && s.sourceLongitude && s.destinationLatitude && s.destinationLongitude) .filter((s): s is TeamOrderStage => s !== null && s.stageType === 'transport' && !!s.sourceLatitude && !!s.sourceLongitude && !!s.destinationLatitude && !!s.destinationLongitude)
.map((s: any) => ({ .map((s) => ({
fromLat: s.sourceLatitude, fromLat: s.sourceLatitude,
fromLon: s.sourceLongitude, fromLon: s.sourceLongitude,
toLat: s.destinationLatitude, toLat: s.destinationLatitude,
@@ -47,7 +53,7 @@ export function useTeamOrders() {
try { try {
const { GetTeamOrdersDocument } = await import('~/composables/graphql/team/orders-generated') const { GetTeamOrdersDocument } = await import('~/composables/graphql/team/orders-generated')
const data = await execute(GetTeamOrdersDocument, {}, 'team', 'orders') const data = await execute(GetTeamOrdersDocument, {}, 'team', 'orders')
items.value = data?.getTeamOrders || [] items.value = (data?.getTeamOrders || []).filter((o): o is TeamOrder => o !== null)
isInitialized.value = true isInitialized.value = true
} catch (e) { } catch (e) {
console.error('Failed to load orders', e) console.error('Failed to load orders', e)

View File

@@ -1,17 +1,23 @@
<template> <template>
<div class="min-h-screen flex flex-col bg-base-300"> <div class="min-h-screen flex flex-col bg-base-300">
<AiChatSidebar
:open="isChatOpen"
:width="chatWidth"
@close="isChatOpen = false"
/>
<div class="flex-1 flex flex-col" :style="contentStyle">
<!-- Fixed Header Container --> <!-- Fixed Header Container -->
<div class="fixed top-0 left-0 right-0 z-40" :style="headerContainerStyle"> <div class="header-glass fixed inset-x-0 top-0 z-50 border-0" :style="headerContainerStyle">
<!-- Dark gradient background for home page --> <div class="header-glass-backdrop" aria-hidden="true" />
<template v-if="isHomePage"> <!-- Animated background for home page -->
<div class="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900" /> <HeroBackground v-if="isHomePage" :collapse-progress="collapseProgress" />
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-primary/20 via-transparent to-transparent" />
</template>
<!-- MainNavigation - dynamic height on home page --> <!-- MainNavigation - dynamic height on home page -->
<MainNavigation <MainNavigation
class="relative z-10" class="relative z-10"
:height="isHomePage ? heroHeight : 100" :height="isHomePage ? heroHeight : 100"
:collapse-progress="isHomePage ? collapseProgress : 1"
:session-checked="sessionChecked" :session-checked="sessionChecked"
:logged-in="isLoggedIn" :logged-in="isLoggedIn"
:user-avatar-svg="userAvatarSvg" :user-avatar-svg="userAvatarSvg"
@@ -20,6 +26,8 @@
:theme="theme" :theme="theme"
:user-data="userData" :user-data="userData"
:is-seller="isSeller" :is-seller="isSeller"
:has-multiple-roles="hasMultipleRoles"
:current-role="currentRole"
:active-tokens="activeTokens" :active-tokens="activeTokens"
:available-chips="availableChips" :available-chips="availableChips"
:select-mode="selectMode" :select-mode="selectMode"
@@ -31,12 +39,17 @@
:can-search="canSearch" :can-search="canSearch"
:show-mode-toggle="true" :show-mode-toggle="true"
:show-active-mode="isCatalogSection" :show-active-mode="isCatalogSection"
:glass-style="isHomePage || isCatalogSection" :is-collapsed="isHomePage ? heroIsCollapsed : (isCatalogSection || isClientArea)"
:is-home-page="isHomePage"
:is-client-area="isClientArea"
:chat-open="isChatOpen"
@toggle-theme="toggleTheme" @toggle-theme="toggleTheme"
@toggle-chat="isChatOpen = !isChatOpen"
@set-catalog-mode="setCatalogMode" @set-catalog-mode="setCatalogMode"
@sign-out="onClickSignOut" @sign-out="onClickSignOut"
@sign-in="signIn()" @sign-in="signIn()"
@switch-team="switchToTeam" @switch-team="switchToTeam"
@switch-role="switchToRole"
@start-select="startSelect" @start-select="startSelect"
@cancel-select="cancelSelect" @cancel-select="cancelSelect"
@edit-token="editFilter" @edit-token="editFilter"
@@ -48,7 +61,7 @@
<!-- Hero content for home page --> <!-- Hero content for home page -->
<template v-if="isHomePage && collapseProgress < 1" #hero> <template v-if="isHomePage && collapseProgress < 1" #hero>
<h1 <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 }" :style="{ opacity: 1 - collapseProgress }"
> >
{{ $t('hero.tagline', 'Make trade easy') }} {{ $t('hero.tagline', 'Make trade easy') }}
@@ -56,9 +69,9 @@
</template> </template>
</MainNavigation> </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 <SubNavigation
v-if="!isHomePage && !isCatalogSection" v-if="!isHomePage && !isCatalogSection && !isClientArea"
:section="currentSection" :section="currentSection"
/> />
</div> </div>
@@ -68,6 +81,7 @@
<slot /> <slot />
</main> </main>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -80,6 +94,14 @@ const localePath = useLocalePath()
const { locale, locales } = useI18n() const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath() 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 // Catalog search state
const { const {
selectMode, selectMode,
@@ -115,14 +137,22 @@ const {
const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake') const theme = useState<'cupcake' | 'night'>('theme', () => 'cupcake')
// User data state (shared across layouts) // User data state (shared across layouts)
interface SelectedLocation {
type: string
uuid: string
name: string
latitude: number
longitude: number
}
const userData = useState<{ const userData = useState<{
id?: string id?: string
firstName?: string firstName?: string
lastName?: string lastName?: string
avatarId?: string avatarId?: string
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: any } activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: SelectedLocation | null }
activeTeamId?: string activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }> teams?: Array<{ id?: string; name?: string; logtoOrgId?: string; teamType?: string }>
} | null>('me', () => null) } | null>('me', () => null)
const sessionChecked = ref(false) const sessionChecked = ref(false)
@@ -133,6 +163,20 @@ const isSeller = computed(() => {
return userData.value?.activeTeam?.teamType === 'SELLER' 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 isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
const userName = computed(() => { const userName = computed(() => {
@@ -168,8 +212,13 @@ const isCatalogSection = computed(() => {
route.path.startsWith('/ru/catalog') route.path.startsWith('/ru/catalog')
}) })
// Client area detection (cabinet tabs in MainNavigation, no SubNav needed)
const isClientArea = computed(() => {
return route.path.includes('/clientarea')
})
// Collapsible header logic - only for pages with SubNav // 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 canCollapse = computed(() => hasSubNav.value)
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value) const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
@@ -187,6 +236,7 @@ const headerContainerStyle = computed(() => {
const mainStyle = computed(() => { const mainStyle = computed(() => {
if (isCatalogSection.value) return { paddingTop: '0' } if (isCatalogSection.value) return { paddingTop: '0' }
if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` } if (isHomePage.value) return { paddingTop: `${heroBaseHeight.value}px` }
if (isClientArea.value) return { paddingTop: '116px' } // Header only, no SubNav
return { paddingTop: '154px' } return { paddingTop: '154px' }
}) })
@@ -239,7 +289,7 @@ watch(userData, () => {
await fetchSession().catch(() => {}) await fetchSession().catch(() => {})
sessionChecked.value = true 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 if (!team?.id) return
try { try {
@@ -258,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 = () => { const onClickSignOut = () => {
signOut(siteUrl) signOut(siteUrl)
} }
@@ -288,9 +352,10 @@ const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
const onSearch = () => { const onSearch = () => {
// Navigate to catalog page if not there // Navigate to catalog page if not there
if (!route.path.includes('/catalog')) { 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) // Trigger search by incrementing the counter (page watches this)
searchTrigger.value++ searchTrigger.value++
} }
</script> </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

@@ -26,9 +26,10 @@
</template> </template>
<template #card="{ item }"> <template #card="{ item }">
<HubProductCard <ProductCard
:name="item.name" :product="item"
:price-history="getMockPriceHistory(item.uuid)" :price-history="getMockPriceHistory(item.uuid)"
selectable
@select="goToProduct(item.uuid)" @select="goToProduct(item.uuid)"
/> />
</template> </template>
@@ -43,7 +44,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetNodeDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated' import { GetNodeDocument, NearestOffersDocument, type OfferWithRoute, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
type Hub = NonNullable<GetNodeQueryResult['node']>
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
@@ -55,7 +58,7 @@ const { t } = useI18n()
const isLoading = ref(true) const isLoading = ref(true)
const hoveredId = ref<string>() const hoveredId = ref<string>()
const hub = ref<any>(null) const hub = ref<Hub | null>(null)
const products = ref<Array<{ uuid: string; name: string }>>([]) const products = ref<Array<{ uuid: string; name: string }>>([])
const hubId = computed(() => route.params.id as string) const hubId = computed(() => route.params.id as string)
@@ -149,7 +152,7 @@ try {
// Group offers by product // Group offers by product
const productsMap = new Map<string, { uuid: string; name: string }>() const productsMap = new Map<string, { uuid: string; name: string }>()
offersData.value?.nearestOffers?.forEach((offer: any) => { offersData.value?.nearestOffers?.forEach((offer) => {
if (offer?.productUuid) { if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) { if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, { productsMap.set(offer.productUuid, {

View File

@@ -1,19 +1,31 @@
<template> <template>
<div>
<CatalogPage <CatalogPage
ref="catalogPageRef" ref="catalogPageRef"
:loading="isLoading" :loading="isLoading"
:use-server-clustering="true" :use-server-clustering="useServerClustering"
:use-typed-clusters="useServerClustering"
:cluster-node-type="clusterNodeType" :cluster-node-type="clusterNodeType"
panel-width="w-[32rem]"
map-id="unified-catalog-map" map-id="unified-catalog-map"
:point-color="mapPointColor" :point-color="mapPointColor"
:items="currentSelectionItems" :items="mapItems"
:hovered-id="hoveredItemId ?? undefined" :hovered-id="hoveredItemId ?? undefined"
:show-panel="showPanel" :show-panel="showPanel && !kycSheetUuid"
:filter-by-bounds="filterByBounds" :filter-by-bounds="filterByBounds"
:related-points="relatedPoints" :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" @select="onMapSelect"
@bounds-change="onBoundsChange" @bounds-change="onBoundsChange"
@update:filter-by-bounds="filterByBounds = $event" @update:filter-by-bounds="onToggleBoundsFilter"
> >
<!-- Panel slot - shows selection list OR info OR quote results --> <!-- Panel slot - shows selection list OR info OR quote results -->
<template #panel> <template #panel>
@@ -28,6 +40,7 @@
:loading-more="selectionLoadingMore" :loading-more="selectionLoadingMore"
:has-more="selectionHasMore && !filterByBounds" :has-more="selectionHasMore && !filterByBounds"
@select="onSelectItem" @select="onSelectItem"
@pin="onPinItem"
@close="onClosePanel" @close="onClosePanel"
@load-more="onLoadMore" @load-more="onLoadMore"
@hover="onHoverItem" @hover="onHoverItem"
@@ -50,9 +63,11 @@
:loading-suppliers="isLoadingSuppliers" :loading-suppliers="isLoadingSuppliers"
:loading-offers="isLoadingOffers" :loading-offers="isLoadingOffers"
@close="onInfoClose" @close="onInfoClose"
@add-to-filter="onInfoAddToFilter"
@open-info="onInfoOpenRelated" @open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct" @select-product="onInfoSelectProduct"
@select-offer="onSelectOffer"
@open-kyc="onOpenKyc"
@pin="onPinItem"
/> />
<!-- Quote results: show offers after search --> <!-- Quote results: show offers after search -->
@@ -60,16 +75,28 @@
v-else-if="showQuoteResults" v-else-if="showQuoteResults"
:loading="offersLoading" :loading="offersLoading"
:offers="offers" :offers="offers"
:calculations="quoteCalculations"
@select-offer="onSelectOffer" @select-offer="onSelectOffer"
/> />
</template> </template>
</CatalogPage> </CatalogPage>
<!-- KYC Bottom Sheet (overlays everything) -->
<KycBottomSheet
:is-open="!!kycSheetUuid"
:uuid="kycSheetUuid"
@close="onCloseKycSheet"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetOffersDocument, GetOfferDocument } from '~/composables/graphql/public/exchange-generated' import { GetOffersDocument, type GetOffersQueryVariables } from '~/composables/graphql/public/exchange-generated'
import { GetNodeDocument, NearestOffersDocument, type NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue' import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
type NearestOffer = NonNullable<NearestOffersQueryResult['nearestOffers'][number]>
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
}) })
@@ -82,9 +109,9 @@ const localePath = useLocalePath()
// Ref to CatalogPage for accessing bounds // Ref to CatalogPage for accessing bounds
const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null) const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null)
// Filter by map bounds state // Current map bounds (local state, updated when map moves)
const filterByBounds = ref(false)
const currentMapBounds = ref<MapBounds | null>(null) 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 // Hovered item for map highlight
const hoveredItemId = ref<string | null>(null) const hoveredItemId = ref<string | null>(null)
@@ -92,17 +119,38 @@ const onHoverItem = (uuid: string | null) => {
hoveredItemId.value = uuid hoveredItemId.value = uuid
} }
// Type for map items - must have required string uuid and number coordinates
type MapItemWithCoords = { uuid: string; name: string; latitude: number; longitude: number; country?: string }
// Helper to convert items to map-compatible format (filter null values)
const toMapItems = <T extends { uuid?: string | null; name?: string | null; latitude?: number | null; longitude?: number | null }>(
items: T[]
): MapItemWithCoords[] =>
items.filter((item): item is T & { uuid: string; latitude: number; longitude: number } =>
item.uuid != null && item.latitude != null && item.longitude != null
).map(item => ({
uuid: item.uuid,
name: item.name || '',
latitude: item.latitude,
longitude: item.longitude
}))
// Current selection items for hover highlighting on map // Current selection items for hover highlighting on map
const currentSelectionItems = computed(() => { const currentSelectionItems = computed((): MapItemWithCoords[] => {
if (selectMode.value === 'product') return filteredProducts.value if (showQuoteResults.value) return []
if (selectMode.value === 'hub') return filteredHubs.value if (selectMode.value === 'product') return [] // Products don't have coordinates
if (selectMode.value === 'supplier') return filteredSuppliers.value if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
if (selectMode.value === 'supplier') return toMapItems(filteredSuppliers.value)
return [] return []
}) })
// Handle bounds change from map // Handle bounds change from map
const onBoundsChange = (bounds: MapBounds) => { const onBoundsChange = (bounds: MapBounds) => {
currentMapBounds.value = bounds currentMapBounds.value = bounds
// If filter by bounds is enabled, write to URL
if (filterByBounds.value) {
setBoundsInUrl(bounds)
}
} }
const { const {
@@ -122,7 +170,12 @@ const {
openInfo, openInfo,
closeInfo, closeInfo,
setInfoProduct, setInfoProduct,
setLabel setLabel,
urlBounds,
filterByBounds,
setBoundsInUrl,
clearBoundsFromUrl,
setBoundsFilterEnabled
} = useCatalogSearch() } = useCatalogSearch()
// Info panel composable // Info panel composable
@@ -196,8 +249,56 @@ const onLoadMore = () => {
if (selectMode.value === 'supplier') loadMoreSuppliers() 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 // Initialize data and sync map view when selectMode changes
watch(selectMode, async (mode) => { watch(selectMode, async (mode) => {
if (mode) {
applySelectionBounds()
} else {
restoreSelectionBounds()
}
if (mode === 'product') { if (mode === 'product') {
await initProducts() await initProducts()
setMapViewMode('offers') setMapViewMode('offers')
@@ -229,13 +330,25 @@ watch(productId, (newProductId) => {
setSupplierProductFilter(newProductId || null) setSupplierProductFilter(newProductId || null)
}, { immediate: true }) }, { immediate: true })
// If a filter locks a view type, switch away from that view
watch([hubId, supplierId], ([newHubId, newSupplierId]) => {
if (newHubId && mapViewMode.value === 'hubs') {
setMapViewMode('offers')
}
if (newSupplierId && mapViewMode.value === 'suppliers') {
setMapViewMode('offers')
}
}, { immediate: true })
// Apply bounds filter when "filter by map bounds" is enabled // Apply bounds filter when "filter by map bounds" is enabled
watch([filterByBounds, currentMapBounds], ([enabled, bounds]) => { // Only watch URL bounds - currentMapBounds changes too often (every map move)
const boundsToApply = enabled && bounds ? bounds : null watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
// Apply bounds filter only when checkbox is ON and bounds are in URL
const boundsToApply = enabled && urlB ? urlB : null
setHubBoundsFilter(boundsToApply) setHubBoundsFilter(boundsToApply)
setSupplierBoundsFilter(boundsToApply) setSupplierBoundsFilter(boundsToApply)
setProductBoundsFilter(boundsToApply) setProductBoundsFilter(boundsToApply)
}) }, { immediate: true })
// Watch infoId to load info data // Watch infoId to load info data
watch(infoId, async (info) => { watch(infoId, async (info) => {
@@ -257,8 +370,8 @@ watch(infoProduct, async (productUuid) => {
} }
}) })
// Related points for Info mode (shown on map) - show all related entities // Related points for Info mode (shown on map) - show current entity + all related entities
const relatedPoints = computed(() => { const infoRelatedPoints = computed(() => {
if (!infoId.value) return [] if (!infoId.value) return []
const points: Array<{ const points: Array<{
@@ -269,12 +382,26 @@ const relatedPoints = computed(() => {
type: 'hub' | 'supplier' | 'offer' type: 'hub' | 'supplier' | 'offer'
}> = [] }> = []
// Add all hubs // Add current entity first (the one we're viewing in InfoPanel)
// For offers, coordinates are in locationLatitude/locationLongitude
const lat = entity.value?.latitude ?? entity.value?.locationLatitude
const lon = entity.value?.longitude ?? entity.value?.locationLongitude
if (lat && lon) {
points.push({
uuid: infoId.value.uuid,
name: entity.value.name || entity.value.productName || '',
latitude: Number(lat),
longitude: Number(lon),
type: infoId.value.type
})
}
// Add all related hubs
relatedHubs.value.forEach(hub => { relatedHubs.value.forEach(hub => {
if (hub.latitude && hub.longitude) { if (hub.uuid && hub.latitude && hub.longitude) {
points.push({ points.push({
uuid: hub.uuid, uuid: hub.uuid,
name: hub.name, name: hub.name || '',
latitude: hub.latitude, latitude: hub.latitude,
longitude: hub.longitude, longitude: hub.longitude,
type: 'hub' type: 'hub'
@@ -282,12 +409,12 @@ const relatedPoints = computed(() => {
} }
}) })
// Add all suppliers // Add all related suppliers
relatedSuppliers.value.forEach(supplier => { relatedSuppliers.value.forEach(supplier => {
if (supplier.latitude && supplier.longitude) { if (supplier.uuid && supplier.latitude && supplier.longitude) {
points.push({ points.push({
uuid: supplier.uuid, uuid: supplier.uuid,
name: supplier.name, name: supplier.name || '',
latitude: supplier.latitude, latitude: supplier.latitude,
longitude: supplier.longitude, longitude: supplier.longitude,
type: 'supplier' type: 'supplier'
@@ -298,11 +425,60 @@ const relatedPoints = computed(() => {
return points return points
}) })
// Related points for Quote mode (shown on map)
const searchHubPoint = ref<MapItemWithCoords | null>(null)
const searchOfferPoints = computed(() =>
offers.value
.filter((offer) => offer.latitude != null && offer.longitude != null)
.map((offer) => ({
uuid: offer.uuid,
name: offer.productName || '',
latitude: Number(offer.latitude),
longitude: Number(offer.longitude),
type: 'offer' as const
}))
)
const searchRelatedPoints = computed(() => {
const points: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}> = []
if (searchHubPoint.value) {
points.push({
uuid: searchHubPoint.value.uuid,
name: searchHubPoint.value.name,
latitude: searchHubPoint.value.latitude,
longitude: searchHubPoint.value.longitude,
type: 'hub'
})
}
searchOfferPoints.value.forEach((point) => points.push(point))
return points
})
const relatedPoints = computed(() => {
if (infoId.value) return infoRelatedPoints.value
if (showQuoteResults.value) return searchRelatedPoints.value
return []
})
// Offers data for quote results // Offers data for quote results
const offers = ref<any[]>([]) const offers = ref<NearestOffer[]>([])
const quoteCalculations = ref<{ offers: NearestOffer[] }[]>([])
const buildCalculationsFromOffers = (list: NearestOffer[]) =>
list.map((offer) => ({ offers: [offer] }))
const offersLoading = ref(false) const offersLoading = ref(false)
const showQuoteResults = ref(false) const showQuoteResults = ref(false)
// Watch for search trigger from topnav // Watch for search trigger from topnav
const searchTrigger = useState<number>('catalog-search-trigger', () => 0) const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
watch(searchTrigger, () => { watch(searchTrigger, () => {
@@ -312,7 +488,98 @@ watch(searchTrigger, () => {
}) })
// Loading state // Loading state
const isLoading = computed(() => offersLoading.value || selectionLoading.value) const isLoading = computed(() => offersLoading.value || selectionLoading.value || exploreOffersLoading.value)
// Info loading state for map fitBounds (true while any info data is still loading)
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 // Show panel when selecting OR when showing info OR when showing quote results
const showPanel = computed(() => { const showPanel = computed(() => {
@@ -335,61 +602,52 @@ const mapPointColor = computed(() => {
return entityColors.offer return entityColors.offer
}) })
// Map item type from CatalogMap
interface MapSelectItem {
uuid?: string | null
id?: string
name?: string | null
}
// Handle map item selection // Handle map item selection
const onMapSelect = async (item: any) => { const onMapSelect = (item: MapSelectItem) => {
// Get uuid from item - clusters use 'id', regular items use 'uuid' // Get uuid from item - clusters use 'id', regular items use 'uuid'
const itemId = item.uuid || item.id const itemId = item.uuid || item.id
if (!itemId || itemId.startsWith('cluster-')) return if (!itemId || itemId.startsWith('cluster-')) return
const itemName = item.name || itemId.slice(0, 8) + '...' const itemName = item.name || itemId.slice(0, 8) + '...'
// If in selection mode, use map click to fill the selector const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
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
}
// For supplier selection - click on supplier fills supplier selector // Default behavior - open Info directly
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
let infoType: 'hub' | 'supplier' | 'offer' let infoType: 'hub' | 'supplier' | 'offer'
if (mapViewMode.value === 'hubs') infoType = 'hub' if (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
else if (mapViewMode.value === 'suppliers') infoType = 'supplier' else if (itemType === 'supplier' || mapViewMode.value === 'suppliers') infoType = 'supplier'
else infoType = 'offer' else infoType = 'offer'
openInfo(infoType, itemId) openInfo(infoType, itemId)
setLabel(infoType, itemId, itemName) setLabel(infoType, itemId, itemName)
} }
// Handle selection from SelectionPanel - add to filter (show badge in search) // Handle selection from SelectionPanel - open info card (pin only via pin button)
const onSelectItem = (type: string, item: any) => { const onSelectItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
if (item.uuid && item.name) { if (!item.uuid) return
selectItem(type, item.uuid, item.name) if (type === 'hub' || type === 'supplier') {
if (item.name) {
setLabel(type, item.uuid, item.name)
} }
openInfo(type, item.uuid)
return
}
if (type === 'product') {
router.push(localePath(`/catalog/products/${item.uuid}`))
}
}
const onPinItem = (type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }) => {
if (!item.uuid) return
const label = item.name || item.uuid.slice(0, 8) + '...'
selectItem(type, item.uuid, label)
} }
// Close panel (cancel select mode) // Close panel (cancel select mode)
@@ -403,23 +661,6 @@ const onInfoClose = () => {
clearInfo() clearInfo()
} }
const onInfoAddToFilter = () => {
if (!infoId.value || !entity.value) return
const { type, uuid } = infoId.value
// For offers, add the product to filter (not the offer itself)
if (type === 'offer' && entity.value.productUuid) {
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
selectItem('product', entity.value.productUuid, productName)
} else {
// For hubs and suppliers, add directly
const name = entity.value.name || uuid.slice(0, 8) + '...'
selectItem(type, uuid, name)
}
closeInfo()
clearInfo()
}
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => { const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
openInfo(type, uuid) openInfo(type, uuid)
@@ -430,33 +671,101 @@ const onInfoSelectProduct = (uuid: string | null) => {
setInfoProduct(uuid) 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 // Search for offers
const onSearch = async () => { const onSearch = async () => {
if (!canSearch.value) return if (!canSearch.value) return
offersLoading.value = true offersLoading.value = true
showQuoteResults.value = true showQuoteResults.value = true
searchHubPoint.value = null
try { try {
const vars: any = {} // 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'
)
let nearest = (geoData?.nearestOffers || []).filter((o): o is NearestOffer => o !== null)
if (supplierId.value) {
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
}
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 = []
}
} else {
searchHubPoint.value = null
const vars: GetOffersQueryVariables = {}
if (productId.value) vars.productUuid = productId.value if (productId.value) vars.productUuid = productId.value
if (supplierId.value) vars.teamUuid = supplierId.value if (supplierId.value) vars.teamUuid = supplierId.value
if (hubId.value) vars.locationUuid = hubId.value if (hubId.value) vars.locationUuid = hubId.value
const data = await execute(GetOffersDocument, vars, 'public', 'exchange') const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
offers.value = data?.getOffers || [] 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 // Update labels from response
if (offers.value.length > 0) {
const first = offers.value[0] const first = offers.value[0]
if (first) {
if (productId.value && first.productName) { if (productId.value && first.productName) {
setLabel('product', productId.value, first.productName) setLabel('product', productId.value, first.productName)
} }
if (hubId.value && first.locationName) { if (hubId.value && first.locationName) {
setLabel('hub', hubId.value, first.locationName) setLabel('hub', hubId.value, first.locationName)
} }
if (supplierId.value && first.teamName) {
setLabel('supplier', supplierId.value, first.teamName)
} }
} }
} finally { } finally {
@@ -465,9 +774,10 @@ const onSearch = async () => {
} }
// Select offer - navigate to detail page // Select offer - navigate to detail page
const onSelectOffer = (offer: any) => { const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
if (offer.uuid && offer.productUuid) { const productUuid = offer.productUuid
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`)) if (offer.uuid && productUuid) {
router.push(localePath(`/catalog/offers/${productUuid}?offer=${offer.uuid}`))
} }
} }

View File

@@ -37,19 +37,19 @@
</div> </div>
<!-- Location on map --> <!-- Location on map -->
<div v-if="offer.latitude && offer.longitude" class="h-48 rounded-lg overflow-hidden"> <div v-if="offer.locationLatitude && offer.locationLongitude" class="h-48 rounded-lg overflow-hidden">
<ClientOnly> <ClientOnly>
<MapboxMap <MapboxMap
map-id="offer-location-map" map-id="offer-location-map"
class="w-full h-full" class="w-full h-full"
:options="{ :options="{
style: 'mapbox://styles/mapbox/streets-v12', style: 'mapbox://styles/mapbox/streets-v12',
center: [offer.longitude, offer.latitude], center: [offer.locationLongitude, offer.locationLatitude],
zoom: 8 zoom: 8
}" }"
> >
<MapboxDefaultMarker <MapboxDefaultMarker
:lnglat="[offer.longitude, offer.latitude]" :lnglat="[offer.locationLongitude, offer.locationLatitude]"
color="#10b981" color="#10b981"
/> />
</MapboxMap> </MapboxMap>
@@ -101,7 +101,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated' import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
type Offer = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
@@ -114,8 +117,8 @@ const { execute } = useGraphQL()
const offerId = computed(() => route.params.offerId as string) const offerId = computed(() => route.params.offerId as string)
const isLoading = ref(true) const isLoading = ref(true)
const offer = ref<any>(null) const offer = ref<Offer | null>(null)
const supplier = ref<any>(null) const supplier = ref<SupplierProfile | null>(null)
// Load offer data // Load offer data
const loadOffer = async () => { const loadOffer = async () => {

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> </IconCircle>
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading> <Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text> <Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))"> <Button @click="navigateTo(localePath('/catalog?select=product'))">
{{ t('catalogProduct.actions.back_to_catalog') }} {{ t('catalogProduct.actions.back_to_catalog') }}
</Button> </Button>
</Stack> </Stack>
@@ -106,7 +106,7 @@
</Stack> </Stack>
<!-- Line with this product --> <!-- Line with this product -->
<template v-for="line in getProductLines(offer)" :key="line?.uuid"> <template v-for="(line, lineIndex) in getProductLines(offer)" :key="line?.uuid ?? lineIndex">
<Card padding="sm" class="bg-base-200"> <Card padding="sm" class="bg-base-200">
<Stack direction="row" align="center" justify="between"> <Stack direction="row" align="center" justify="between">
<Stack gap="0"> <Stack gap="0">
@@ -204,8 +204,14 @@ import {
GetProductsDocument, GetProductsDocument,
GetProductOffersDocument, GetProductOffersDocument,
GetSupplierProfilesDocument, GetSupplierProfilesDocument,
type GetProductsQueryResult,
type GetProductOffersQueryResult
} from '~/composables/graphql/public/exchange-generated' } from '~/composables/graphql/public/exchange-generated'
// Types from GraphQL
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
type ProductOffer = NonNullable<NonNullable<GetProductOffersQueryResult['getOffers']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
}) })
@@ -237,7 +243,7 @@ const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles ||
const productId = computed(() => route.params.id as string) const productId = computed(() => route.params.id as string)
// Find product by uuid from list // Find product by uuid from list
const findProduct = (products: any[]) => { const findProduct = (products: (Product | null)[]) => {
return products.find(p => p?.uuid === productId.value) return products.find(p => p?.uuid === productId.value)
} }
@@ -295,11 +301,13 @@ const mapLocations = computed(() => {
const priceRange = computed(() => { const priceRange = computed(() => {
const prices: number[] = [] const prices: number[] = []
offers.value.forEach(offer => { offers.value.forEach(offer => {
(offer as any).lines?.forEach((line: any) => { // Offers for this product already filtered by productUuid
if (line?.productUuid === productId.value && line?.pricePerUnit) { if (offer.pricePerUnit) {
prices.push(Number(line.pricePerUnit)) const price = typeof offer.pricePerUnit === 'string' ? parseFloat(offer.pricePerUnit) : Number(offer.pricePerUnit)
if (!isNaN(price)) {
prices.push(price)
}
} }
})
}) })
if (prices.length === 0) return t('common.values.not_available') if (prices.length === 0) return t('common.values.not_available')
const min = Math.min(...prices) const min = Math.min(...prices)
@@ -308,9 +316,24 @@ const priceRange = computed(() => {
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() }) return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
}) })
// Get lines with this product // Get offer as "line" - offers already have quantity/unit/price directly
const getProductLines = (offer: any) => { interface OfferLine {
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value) uuid?: string | null
quantity?: string | number | null
unit?: string | null
pricePerUnit?: string | number | null
currency?: string | null
}
const getProductLines = (offer: ProductOffer): OfferLine[] => {
// Each offer is a single "line" with quantity, unit, and price
return [{
uuid: offer.uuid,
quantity: offer.quantity,
unit: offer.unit,
pricePerUnit: offer.pricePerUnit,
currency: offer.currency
}]
} }
const getCategoryIcon = (categoryName: string | null | undefined) => { const getCategoryIcon = (categoryName: string | null | undefined) => {
@@ -355,9 +378,10 @@ const formatDate = (dateStr: string | null | undefined) => {
} }
} }
const formatPrice = (price: any, currency: string | null | undefined) => { const formatPrice = (price: string | number | null | undefined, currency: string | null | undefined) => {
if (!price) return '—' if (!price) return '—'
const num = Number(price) const num = typeof price === 'string' ? parseFloat(price) : Number(price)
if (isNaN(num)) return '—'
const curr = currency || 'USD' const curr = currency || 'USD'
try { try {
return new Intl.NumberFormat('ru', { return new Intl.NumberFormat('ru', {

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

@@ -21,16 +21,21 @@
<label class="label"> <label class="label">
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span> <span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
</label> </label>
<div ref="searchBoxContainer" class="w-full" /> <div
<input v-if="hasMapboxToken"
v-if="addressData.address" ref="searchBoxContainer"
type="hidden" class="w-full"
:value="addressData.address"
/> />
<Input
v-else
v-model="addressData.address"
:placeholder="t('profileAddresses.form.address.placeholder')"
/>
<input v-if="addressData.address && hasMapboxToken" type="hidden" :value="addressData.address" />
</div> </div>
<!-- Mapbox map for selecting coordinates --> <!-- Mapbox map for selecting coordinates -->
<div class="form-control"> <div v-if="hasMapboxToken" class="form-control">
<label class="label"> <label class="label">
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span> <span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
</label> </label>
@@ -61,9 +66,12 @@
</span> </span>
</label> </label>
</div> </div>
<div v-else class="alert alert-warning text-sm">
Mapbox is not configured. Enter address manually.
</div>
<Stack direction="row" gap="3"> <Stack direction="row" gap="3">
<Button @click="updateAddress" :disabled="isSaving || !addressData.latitude"> <Button @click="updateAddress" :disabled="isSaving || (hasMapboxToken && !addressData.latitude)">
{{ isSaving ? t('profileAddresses.form.updating') : t('profileAddresses.form.update') }} {{ isSaving ? t('profileAddresses.form.updating') : t('profileAddresses.form.update') }}
</Button> </Button>
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')"> <Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
@@ -94,6 +102,10 @@
import { NuxtLink } from '#components' import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl' import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
middleware: ['auth-oidc'] middleware: ['auth-oidc']
@@ -104,6 +116,8 @@ const { execute, mutate } = useGraphQL()
const { t } = useI18n() const { t } = useI18n()
const localePath = useLocalePath() const localePath = useLocalePath()
const config = useRuntimeConfig() const config = useRuntimeConfig()
const mapboxAccessToken = computed(() => String(config.public.mapboxAccessToken || '').trim())
const hasMapboxToken = computed(() => mapboxAccessToken.value.length > 0)
const uuid = computed(() => route.params.uuid as string) const uuid = computed(() => route.params.uuid as string)
@@ -112,7 +126,7 @@ const isSaving = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null) const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null) const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null) const searchBoxRef = ref<MapboxSearchBox | null>(null)
const addressData = ref<{ const addressData = ref<{
uuid: string uuid: string
@@ -130,7 +144,7 @@ const loadAddress = async () => {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated') const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams') const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
const addresses = data?.teamAddresses || [] const addresses = data?.teamAddresses || []
const found = addresses.find((a: any) => a.uuid === uuid.value) const found = addresses.find((a) => a?.uuid === uuid.value)
if (found) { if (found) {
addressData.value = { addressData.value = {
@@ -157,17 +171,20 @@ const onMapCreated = (map: MapboxMapType) => {
// Reverse geocode: get address by coordinates (local language) // Reverse geocode: get address by coordinates (local language)
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => { const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
if (!hasMapboxToken.value) {
return { address: null, countryCode: null }
}
try { try {
const token = config.public.mapboxAccessToken
const response = await fetch( const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}` `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxAccessToken.value}`
) )
const data = await response.json() const data = await response.json()
const feature = data.features?.[0] const feature = data.features?.[0]
if (!feature) return { address: null, countryCode: null } if (!feature) return { address: null, countryCode: null }
// Extract country code from context // Extract country code from context
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.')) const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
const countryCode = countryContext?.short_code?.toUpperCase() || null const countryCode = countryContext?.short_code?.toUpperCase() || null
return { address: feature.place_name, countryCode } return { address: feature.place_name, countryCode }
@@ -199,12 +216,12 @@ const onMapClick = async (event: MapMouseEvent) => {
// Initialize Mapbox SearchBox // Initialize Mapbox SearchBox
onMounted(async () => { onMounted(async () => {
if (!searchBoxContainer.value) return if (!hasMapboxToken.value || !searchBoxContainer.value) return
const { MapboxSearchBox } = await import('@mapbox/search-js-web') const { MapboxSearchBox } = await import('@mapbox/search-js-web')
const searchBox = new MapboxSearchBox() const searchBox = new MapboxSearchBox()
searchBox.accessToken = config.public.mapboxAccessToken as string searchBox.accessToken = mapboxAccessToken.value
searchBox.options = { searchBox.options = {
// Without language: uses local country language // Without language: uses local country language
} }
@@ -215,7 +232,7 @@ onMounted(async () => {
searchBox.value = addressData.value.address searchBox.value = addressData.value.address
} }
searchBox.addEventListener('retrieve', (event: any) => { searchBox.addEventListener('retrieve', (event: CustomEvent) => {
if (!addressData.value) return if (!addressData.value) return
const feature = event.detail.features?.[0] const feature = event.detail.features?.[0]
@@ -244,7 +261,7 @@ onMounted(async () => {
}) })
const updateAddress = async () => { const updateAddress = async () => {
if (!addressData.value || !addressData.value.name || !addressData.value.address || !addressData.value.latitude) return if (!addressData.value || !addressData.value.name || !addressData.value.address || (hasMapboxToken.value && !addressData.value.latitude)) return
isSaving.value = true isSaving.value = true
try { try {

View File

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

View File

@@ -62,12 +62,14 @@ await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null) const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null) const selectedItemId = ref<string | null>(null)
const selectItem = (item: any) => { const selectItem = (item: { uuid?: string | null; latitude?: number | null; longitude?: number | null }) => {
if (item.uuid) {
selectedItemId.value = item.uuid selectedItemId.value = item.uuid
if (item.latitude && item.longitude) { if (item.latitude && item.longitude) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8) mapRef.value?.flyTo(item.latitude, item.longitude, 8)
} }
} }
}
const onMapSelectItem = (uuid: string) => { const onMapSelectItem = (uuid: string) => {
selectedItemId.value = uuid selectedItemId.value = uuid

View File

@@ -14,16 +14,21 @@
<label class="label"> <label class="label">
<span class="label-text">{{ t('profileAddresses.form.address.label') }}</span> <span class="label-text">{{ t('profileAddresses.form.address.label') }}</span>
</label> </label>
<div ref="searchBoxContainer" class="w-full" /> <div
<input v-if="hasMapboxToken"
v-if="newAddress.address" ref="searchBoxContainer"
type="hidden" class="w-full"
:value="newAddress.address"
/> />
<Input
v-else
v-model="newAddress.address"
:placeholder="t('profileAddresses.form.address.placeholder')"
/>
<input v-if="newAddress.address && hasMapboxToken" type="hidden" :value="newAddress.address" />
</div> </div>
<!-- Mapbox map for selecting coordinates --> <!-- Mapbox map for selecting coordinates -->
<div class="form-control"> <div v-if="hasMapboxToken" class="form-control">
<label class="label"> <label class="label">
<span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span> <span class="label-text">{{ t('profileAddresses.form.mapLabel') }}</span>
</label> </label>
@@ -54,9 +59,12 @@
</span> </span>
</label> </label>
</div> </div>
<div v-else class="alert alert-warning text-sm">
Mapbox is not configured. Enter address manually.
</div>
<Stack direction="row" gap="3"> <Stack direction="row" gap="3">
<Button @click="createAddress" :disabled="isCreating || !newAddress.latitude"> <Button @click="createAddress" :disabled="isCreating || (hasMapboxToken && !newAddress.latitude)">
{{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }} {{ isCreating ? t('profileAddresses.form.saving') : t('profileAddresses.form.save') }}
</Button> </Button>
<Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')"> <Button variant="outline" :as="NuxtLink" :to="localePath('/clientarea/addresses')">
@@ -71,6 +79,10 @@
import { NuxtLink } from '#components' import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl' import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
middleware: ['auth-oidc'] middleware: ['auth-oidc']
@@ -80,11 +92,13 @@ const { mutate } = useGraphQL()
const { t } = useI18n() const { t } = useI18n()
const localePath = useLocalePath() const localePath = useLocalePath()
const config = useRuntimeConfig() const config = useRuntimeConfig()
const mapboxAccessToken = computed(() => String(config.public.mapboxAccessToken || '').trim())
const hasMapboxToken = computed(() => mapboxAccessToken.value.length > 0)
const isCreating = ref(false) const isCreating = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null) const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null) const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null) const searchBoxRef = ref<MapboxSearchBox | null>(null)
const newAddress = reactive({ const newAddress = reactive({
name: '', name: '',
@@ -100,17 +114,20 @@ const onMapCreated = (map: MapboxMapType) => {
// Reverse geocode: get address by coordinates (local language) // Reverse geocode: get address by coordinates (local language)
const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => { const reverseGeocode = async (lat: number, lng: number): Promise<{ address: string | null; countryCode: string | null }> => {
if (!hasMapboxToken.value) {
return { address: null, countryCode: null }
}
try { try {
const token = config.public.mapboxAccessToken
const response = await fetch( const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}` `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${mapboxAccessToken.value}`
) )
const data = await response.json() const data = await response.json()
const feature = data.features?.[0] const feature = data.features?.[0]
if (!feature) return { address: null, countryCode: null } if (!feature) return { address: null, countryCode: null }
// Extract country code from context // Extract country code from context
const countryContext = feature.context?.find((c: any) => c.id?.startsWith('country.')) const countryContext = feature.context?.find((c: { id?: string }) => c.id?.startsWith('country.'))
const countryCode = countryContext?.short_code?.toUpperCase() || null const countryCode = countryContext?.short_code?.toUpperCase() || null
return { address: feature.place_name, countryCode } return { address: feature.place_name, countryCode }
@@ -140,18 +157,18 @@ const onMapClick = async (event: MapMouseEvent) => {
// Initialize Mapbox SearchBox // Initialize Mapbox SearchBox
onMounted(async () => { onMounted(async () => {
if (!searchBoxContainer.value) return if (!hasMapboxToken.value || !searchBoxContainer.value) return
const { MapboxSearchBox } = await import('@mapbox/search-js-web') const { MapboxSearchBox } = await import('@mapbox/search-js-web')
const searchBox = new MapboxSearchBox() const searchBox = new MapboxSearchBox()
searchBox.accessToken = config.public.mapboxAccessToken as string searchBox.accessToken = mapboxAccessToken.value
searchBox.options = { searchBox.options = {
// Without language: uses local country language // Without language: uses local country language
} }
searchBox.placeholder = t('profileAddresses.form.address.placeholder') searchBox.placeholder = t('profileAddresses.form.address.placeholder')
searchBox.addEventListener('retrieve', (event: any) => { searchBox.addEventListener('retrieve', (event: CustomEvent) => {
const feature = event.detail.features?.[0] const feature = event.detail.features?.[0]
if (feature) { if (feature) {
const [lng, lat] = feature.geometry.coordinates const [lng, lat] = feature.geometry.coordinates
@@ -178,7 +195,7 @@ onMounted(async () => {
}) })
const createAddress = async () => { const createAddress = async () => {
if (!newAddress.name || !newAddress.address || !newAddress.latitude) return if (!newAddress.name || !newAddress.address || (hasMapboxToken.value && !newAddress.latitude)) return
isCreating.value = true isCreating.value = true
try { try {

View File

@@ -109,9 +109,9 @@ const handleSend = async () => {
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse') const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
chat.value.push({ role: 'assistant', content }) chat.value.push({ role: 'assistant', content })
scrollToBottom() scrollToBottom()
} catch (e: any) { } catch (e: unknown) {
console.error('Agent error', e) console.error('Agent error', e)
error.value = e?.message || t('aiAssistants.view.error') error.value = e instanceof Error ? e.message : t('aiAssistants.view.error')
chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') }) chat.value.push({ role: 'assistant', content: t('aiAssistants.view.error') })
scrollToBottom() scrollToBottom()
} finally { } finally {

View File

@@ -95,6 +95,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GetTeamTransactionsQueryResult } from '~/composables/graphql/team/billing-generated'
type Transaction = NonNullable<NonNullable<GetTeamTransactionsQueryResult['teamTransactions']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
middleware: ['auth-oidc'] middleware: ['auth-oidc']
@@ -112,7 +116,7 @@ const balance = ref({
exists: false exists: false
}) })
const transactions = ref<any[]>([]) const transactions = ref<Transaction[]>([])
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
// Amount is in kopecks, convert to base units // Amount is in kopecks, convert to base units
@@ -130,7 +134,7 @@ const formatAmount = (amount: number) => {
}).format(amount / 100) }).format(amount / 100)
} }
const formatTimestamp = (timestamp: number) => { const formatTimestamp = (timestamp: number | null | undefined) => {
if (!timestamp) return '—' if (!timestamp) return '—'
// TigerBeetle timestamp is in nanoseconds since epoch // TigerBeetle timestamp is in nanoseconds since epoch
const date = new Date(timestamp / 1000000) const date = new Date(timestamp / 1000000)
@@ -157,8 +161,8 @@ const loadBalance = async () => {
if (data.value?.teamBalance) { if (data.value?.teamBalance) {
balance.value = data.value.teamBalance balance.value = data.value.teamBalance
} }
} catch (e: any) { } catch (e: unknown) {
error.value = e.message || t('billing.errors.load_failed') error.value = e instanceof Error ? e.message : t('billing.errors.load_failed')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -171,7 +175,7 @@ const loadTransactions = async () => {
if (txError.value) throw txError.value if (txError.value) throw txError.value
transactions.value = data.value?.teamTransactions || [] transactions.value = (data.value?.teamTransactions || []).filter((tx): tx is Transaction => tx !== null)
} catch (e) { } catch (e) {
console.error('Failed to load transactions', e) console.error('Failed to load transactions', e)
} }

View File

@@ -146,8 +146,8 @@ const switchToTeam = async (teamId: string) => {
markActiveTeam(newActiveId) markActiveTeam(newActiveId)
navigateTo(localePath('/clientarea/team')) navigateTo(localePath('/clientarea/team'))
} }
} catch (err: any) { } catch (err: unknown) {
error.value = err.message || t('clientTeamSwitch.error.switch') error.value = err instanceof Error ? err.message : t('clientTeamSwitch.error.switch')
hasError.value = true hasError.value = true
} }
} }

View File

@@ -24,7 +24,7 @@
<Card v-for="request in kycRequests" :key="request.uuid" padding="lg"> <Card v-for="request in kycRequests" :key="request.uuid" padding="lg">
<Stack gap="3"> <Stack gap="3">
<Stack direction="row" gap="2" align="center" justify="between"> <Stack direction="row" gap="2" align="center" justify="between">
<Heading :level="4" weight="semibold">{{ request.companyName || t('kycOverview.list.unnamed') }}</Heading> <Heading :level="4" weight="semibold">{{ request.teamName || t('kycOverview.list.unnamed') }}</Heading>
<Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)"> <Pill :variant="getStatusVariant(request)" :tone="getStatusTone(request)">
{{ getStatusText(request) }} {{ getStatusText(request) }}
</Pill> </Pill>
@@ -32,8 +32,8 @@
<Text tone="muted" size="base"> <Text tone="muted" size="base">
{{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }} {{ t('kycOverview.list.submitted') }}: {{ formatDate(request.createdAt) }}
</Text> </Text>
<Text v-if="request.inn" tone="muted" size="base"> <Text tone="muted" size="base">
{{ t('kycOverview.list.inn') }}: {{ request.inn }} {{ t('kycOverview.list.country') }}: {{ request.countryCode }}
</Text> </Text>
</Stack> </Stack>
</Card> </Card>
@@ -91,7 +91,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetKycRequestsRussiaDocument } from '~/composables/graphql/user/kyc-generated' import { GetKycRequestsRussiaDocument, type GetKycRequestsRussiaQueryResult } from '~/composables/graphql/user/kyc-generated'
type KycRequest = NonNullable<NonNullable<GetKycRequestsRussiaQueryResult['kycRequests']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
@@ -102,7 +104,7 @@ const { t } = useI18n()
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const kycRequests = ref<any[]>([]) const kycRequests = ref<KycRequest[]>([])
const selectCountry = (country: string) => { const selectCountry = (country: string) => {
if (country === 'russia') { if (country === 'russia') {
@@ -110,21 +112,18 @@ const selectCountry = (country: string) => {
} }
} }
const getStatusVariant = (request: any) => { const getStatusVariant = (request: KycRequest) => {
if (request.approvedAt) return 'primary' if (request.approvedAt) return 'primary'
if (request.rejectedAt) return 'outline'
return 'outline' return 'outline'
} }
const getStatusTone = (request: any) => { const getStatusTone = (request: KycRequest) => {
if (request.approvedAt) return 'success' if (request.approvedAt) return 'success'
if (request.rejectedAt) return 'error'
return 'warning' return 'warning'
} }
const getStatusText = (request: any) => { const getStatusText = (request: KycRequest) => {
if (request.approvedAt) return t('kycOverview.list.status.approved') if (request.approvedAt) return t('kycOverview.list.status.approved')
if (request.rejectedAt) return t('kycOverview.list.status.rejected')
return t('kycOverview.list.status.pending') return t('kycOverview.list.status.pending')
} }
@@ -143,10 +142,10 @@ const loadKYCStatus = async () => {
if (kycError.value) throw kycError.value if (kycError.value) throw kycError.value
const requests = data.value?.kycRequests || [] const requests = data.value?.kycRequests || []
// Сортируем по дате создания (новые первые) // Сортируем по дате создания (новые первые)
kycRequests.value = [...requests].sort((a: any, b: any) => kycRequests.value = [...requests]
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() .filter((r): r is KycRequest => r !== null)
) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
} catch (err: any) { } catch (err: unknown) {
error.value = t('kycOverview.errors.load_failed') error.value = t('kycOverview.errors.load_failed')
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -57,24 +57,39 @@ const submitting = ref(false)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const submitSuccess = ref(false) const submitSuccess = ref(false)
const handleSubmit = async (formData: any) => { interface KycFormData {
company_name?: string
company_full_name?: string
inn?: string
kpp?: string
ogrn?: string
address?: string
bank_name?: string
bik?: string
correspondent_account?: string
contact_person?: string
contact_email?: string
contact_phone?: string
}
const handleSubmit = async (formData: KycFormData) => {
try { try {
submitting.value = true submitting.value = true
submitError.value = null submitError.value = null
const submitData = { const submitData = {
companyName: formData.company_name, companyName: formData.company_name || '',
companyFullName: formData.company_full_name, companyFullName: formData.company_full_name || '',
inn: formData.inn, inn: formData.inn || '',
kpp: formData.kpp || '', kpp: formData.kpp || '',
ogrn: formData.ogrn || '', ogrn: formData.ogrn || '',
address: formData.address, address: formData.address || '',
bankName: formData.bank_name, bankName: formData.bank_name || '',
bik: formData.bik, bik: formData.bik || '',
correspondentAccount: formData.correspondent_account || '', correspondentAccount: formData.correspondent_account || '',
contactPerson: formData.contact_person, contactPerson: formData.contact_person || '',
contactEmail: formData.contact_email, contactEmail: formData.contact_email || '',
contactPhone: formData.contact_phone, contactPhone: formData.contact_phone || '',
} }
const result = await mutate(CreateKycApplicationRussiaDocument, { input: submitData }, 'user', 'kyc') const result = await mutate(CreateKycApplicationRussiaDocument, { input: submitData }, 'user', 'kyc')
@@ -85,8 +100,8 @@ const handleSubmit = async (formData: any) => {
} else { } else {
throw new Error(t('kycRussia.errors.create_failed')) throw new Error(t('kycRussia.errors.create_failed'))
} }
} catch (err: any) { } catch (err: unknown) {
submitError.value = err.message || t('kycRussia.errors.submit_failed') submitError.value = err instanceof Error ? err.message : t('kycRussia.errors.submit_failed')
} finally { } finally {
submitting.value = false submitting.value = false
} }

View File

@@ -118,7 +118,9 @@ import { FormKitSchema } from '@formkit/vue'
import type { FormKitSchemaNode } from '@formkit/core' import type { FormKitSchemaNode } from '@formkit/core'
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated' import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
import { GetTeamAddressesDocument } from '~/composables/graphql/team/teams-generated' import { GetTeamAddressesDocument, type GetTeamAddressesQueryResult } from '~/composables/graphql/team/teams-generated'
type TeamAddress = NonNullable<NonNullable<GetTeamAddressesQueryResult['teamAddresses']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
@@ -147,7 +149,7 @@ const productName = ref<string>('')
const schemaId = ref<string | null>(null) const schemaId = ref<string | null>(null)
const schemaDescription = ref<string | null>(null) const schemaDescription = ref<string | null>(null)
const formkitSchema = ref<FormKitSchemaNode[]>([]) const formkitSchema = ref<FormKitSchemaNode[]>([])
const addresses = ref<any[]>([]) const addresses = ref<TeamAddress[]>([])
const selectedAddressUuid = ref<string | null>(null) const selectedAddressUuid = ref<string | null>(null)
const formKitConfig = { const formKitConfig = {
classes: { classes: {
@@ -169,8 +171,8 @@ const loadAddresses = async () => {
try { try {
const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams') const { data, error: addressesError } = await useServerQuery('offer-form-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
if (addressesError.value) throw addressesError.value if (addressesError.value) throw addressesError.value
addresses.value = data.value?.teamAddresses || [] addresses.value = (data.value?.teamAddresses || []).filter((a): a is TeamAddress => a !== null)
const defaultAddress = addresses.value.find((address: any) => address.isDefault) const defaultAddress = addresses.value.find((address) => address.isDefault)
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
} catch (err) { } catch (err) {
console.error('Failed to load addresses:', err) console.error('Failed to load addresses:', err)
@@ -189,7 +191,7 @@ const loadData = async () => {
const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange') const { data: productsData, error: productsError } = await useServerQuery('offer-form-products', GetProductsDocument, {}, 'public', 'exchange')
if (productsError.value) throw productsError.value if (productsError.value) throw productsError.value
const products = productsData.value?.getProducts || [] const products = productsData.value?.getProducts || []
const product = products.find((p: any) => p.uuid === productUuid.value) const product = products.find((p) => p?.uuid === productUuid.value)
if (!product) { if (!product) {
throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value })) throw new Error(t('clientOfferForm.errors.productNotFound', { uuid: productUuid.value }))
@@ -219,9 +221,9 @@ const loadData = async () => {
formkitSchema.value = schemaToFormKit(terminusClass, enums) formkitSchema.value = schemaToFormKit(terminusClass, enums)
await loadAddresses() await loadAddresses()
} catch (err: any) { } catch (err: unknown) {
hasError.value = true hasError.value = true
error.value = err.message || t('clientOfferForm.error.load') error.value = err instanceof Error ? err.message : t('clientOfferForm.error.load')
console.error('Load error:', err) console.error('Load error:', err)
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -237,7 +239,7 @@ const handleSubmit = async (data: Record<string, unknown>) => {
throw new Error(t('clientOfferForm.error.load')) throw new Error(t('clientOfferForm.error.load'))
} }
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value) const selectedAddress = addresses.value.find((address) => address?.uuid === selectedAddressUuid.value)
if (!selectedAddress) { if (!selectedAddress) {
throw new Error(t('clientOfferForm.error.save')) throw new Error(t('clientOfferForm.error.save'))
} }
@@ -253,14 +255,14 @@ const handleSubmit = async (data: Record<string, unknown>) => {
locationCountryCode: selectedAddress.countryCode || '', locationCountryCode: selectedAddress.countryCode || '',
locationLatitude: selectedAddress.latitude, locationLatitude: selectedAddress.latitude,
locationLongitude: selectedAddress.longitude, locationLongitude: selectedAddress.longitude,
quantity: data.quantity || 0, quantity: String(data.quantity || '0'),
unit: String(data.unit || 'ton'), unit: String(data.unit || 'ton'),
pricePerUnit: data.price_per_unit || data.pricePerUnit || null, pricePerUnit: String(data.price_per_unit || data.pricePerUnit || ''),
currency: String(data.currency || 'USD'), currency: String(data.currency || 'USD'),
description: String(data.description || ''), description: String(data.description || ''),
validUntil: data.valid_until || data.validUntil || null, validUntil: (data.valid_until as string | undefined) ?? (data.validUntil as string | undefined) ?? undefined,
terminusSchemaId: schemaId.value, terminusSchemaId: schemaId.value,
terminusPayload: JSON.stringify(data), terminusPayload: data,
} }
const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange') const result = await mutate(CreateOfferDocument, { input }, 'team', 'exchange')
@@ -270,8 +272,8 @@ const handleSubmit = async (data: Record<string, unknown>) => {
await navigateTo(localePath('/clientarea/offers')) await navigateTo(localePath('/clientarea/offers'))
} catch (err: any) { } catch (err: unknown) {
error.value = err.message || t('clientOfferForm.error.save') error.value = err instanceof Error ? err.message : t('clientOfferForm.error.save')
hasError.value = true hasError.value = true
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false

View File

@@ -122,7 +122,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue' import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated' import { GetOffersDocument, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
type Offer = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
@@ -135,7 +137,7 @@ const { activeTeamId } = useActiveTeam()
const { execute } = useGraphQL() const { execute } = useGraphQL()
const PAGE_SIZE = 24 const PAGE_SIZE = 24
const offers = ref<any[]>([]) const offers = ref<Offer[]>([])
const totalOffers = ref(0) const totalOffers = ref(0)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
@@ -164,7 +166,7 @@ const {
watchEffect(() => { watchEffect(() => {
if (offersData.value?.getOffers) { if (offersData.value?.getOffers) {
offers.value = offersData.value.getOffers offers.value = offersData.value.getOffers.filter((o): o is Offer => o !== null)
totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length totalOffers.value = offersData.value.getOffersCount ?? offersData.value.getOffers.length
} }
}) })
@@ -231,10 +233,12 @@ const onSearch = () => {
// TODO: Implement search // TODO: Implement search
} }
const onSelectOffer = (offer: any) => { const onSelectOffer = (offer: { uuid?: string | null }) => {
if (offer.uuid) {
selectedOfferId.value = offer.uuid selectedOfferId.value = offer.uuid
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`)) navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
} }
}
const getStatusVariant = (status: string) => { const getStatusVariant = (status: string) => {
const variants: Record<string, string> = { const variants: Record<string, string> = {
@@ -293,7 +297,7 @@ const fetchOffers = async (offset = 0, replace = false) => {
'public', 'public',
'exchange' 'exchange'
) )
const next = data?.getOffers || [] const next = (data?.getOffers || []).filter((o): o is Offer => o !== null)
offers.value = replace ? next : offers.value.concat(next) offers.value = replace ? next : offers.value.concat(next)
totalOffers.value = data?.getOffersCount ?? totalOffers.value totalOffers.value = data?.getOffersCount ?? totalOffers.value
} }

View File

@@ -26,8 +26,8 @@
<template v-else> <template v-else>
<Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4"> <Grid v-if="products.length" :cols="1" :md="2" :lg="3" :gap="4">
<Card <Card
v-for="product in products" v-for="(product, index) in products"
:key="product.uuid" :key="product.uuid ?? index"
padding="lg" padding="lg"
class="cursor-pointer hover:shadow-md transition-shadow" class="cursor-pointer hover:shadow-md transition-shadow"
@click="selectProduct(product)" @click="selectProduct(product)"
@@ -51,7 +51,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetProductsDocument, type GetProductsQueryResult } from '~/composables/graphql/public/exchange-generated'
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
@@ -62,7 +64,7 @@ const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
const { execute } = useGraphQL() const { execute } = useGraphQL()
const products = ref<any[]>([]) const products = ref<Product[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const hasError = ref(false) const hasError = ref(false)
const error = ref('') const error = ref('')
@@ -73,19 +75,19 @@ const loadProducts = async () => {
hasError.value = false hasError.value = false
const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange') const { data, error: productsError } = await useServerQuery('offers-new-products', GetProductsDocument, {}, 'public', 'exchange')
if (productsError.value) throw productsError.value if (productsError.value) throw productsError.value
products.value = data.value?.getProducts || [] products.value = (data.value?.getProducts || []).filter((p): p is Product => p !== null)
} catch (err: any) { } catch (err: unknown) {
hasError.value = true hasError.value = true
error.value = err.message || t('offersNew.errors.load_failed') error.value = err instanceof Error ? err.message : t('offersNew.errors.load_failed')
products.value = [] products.value = []
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const selectProduct = (product: any) => { const selectProduct = (product: { uuid?: string | null }) => {
// Navigate to product details page // Navigate to product details page
navigateTo(localePath(`/clientarea/offers/${product.uuid}`)) if (product.uuid) navigateTo(localePath(`/clientarea/offers/${product.uuid}`))
} }
await loadProducts() await loadProducts()

View File

@@ -1,54 +1,147 @@
<template> <template>
<Section variant="plain"> <div>
<Stack gap="8"> <CatalogPage
<template v-if="hasOrderError"> :items="mapPoints"
<div class="text-sm text-error"> :loading="isLoadingOrder"
{{ orderError }} :use-server-clustering="false"
</div> map-id="order-detail-map"
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button> point-color="#6366f1"
</template> :show-panel="false"
:hide-view-toggle="true"
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
{{ t('ordersDetail.states.loading') }}
</div>
<template v-else>
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
</Card>
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
<Stack gap="4">
<RouteStagesList
:stages="orderStageItems"
:empty-text="t('ordersDetail.sections.stages.empty')"
/> />
<div class="divider my-0"></div> <!-- 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>
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" /> <!-- Header -->
</Stack> <div class="border-b border-base-300 bg-base-100/90 px-6 pb-4">
</Card> <!-- 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-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>
<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 class="space-y-3">
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading> <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 <GanttTimeline
v-if="order?.stages"
:stages="order.stages" :stages="order.stages"
:showLoading="showLoading" :showLoading="showLoading"
:showUnloading="showUnloading" :showUnloading="showUnloading"
/> />
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text> </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>
</div>
</div>
</Transition>
</div> </div>
</template> </template>
</Stack>
</Section> <style scoped>
</template> .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"> <script setup lang="ts">
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated' import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue' 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']>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
middleware: ['auth-oidc'] middleware: ['auth-oidc']
@@ -56,14 +149,44 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const localePath = useLocalePath()
const order = ref<any>(null) const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(true) const isLoadingOrder = ref(true)
const hasOrderError = ref(false) const hasOrderError = ref(false)
const orderError = ref('') const orderError = ref('')
const showLoading = ref(true) const showLoading = ref(true)
const showUnloading = 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 orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown') const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown') const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
@@ -96,8 +219,8 @@ const orderMeta = computed(() => {
const orderRoutesForMap = computed(() => { const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || []) const stages = (order.value?.stages || [])
.filter(Boolean) .filter((stage): stage is StageType => stage !== null)
.map((stage: any) => { .map((stage) => {
if (stage.stageType === 'transport') { if (stage.stageType === 'transport') {
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
return { return {
@@ -118,8 +241,18 @@ const orderRoutesForMap = computed(() => {
return [{ stages }] return [{ stages }]
}) })
// Company summary type
interface CompanySummary {
name: string | null | undefined
totalWeight: number
tripsCount: number
company: CompanyType | null | undefined
}
const orderStageItems = computed<RouteStageItem[]>(() => { const orderStageItems = computed<RouteStageItem[]>(() => {
return (order.value?.stages || []).map((stage: any) => { return (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
const isTransport = stage.stageType === 'transport' const isTransport = stage.stageType === 'transport'
const from = isTransport ? stage.sourceLocationName : stage.locationName const from = isTransport ? stage.sourceLocationName : stage.locationName
const to = isTransport ? stage.destinationLocationName : stage.locationName const to = isTransport ? stage.destinationLocationName : stage.locationName
@@ -131,17 +264,17 @@ const orderStageItems = computed<RouteStageItem[]>(() => {
} }
const companies = getCompaniesSummary(stage) const companies = getCompaniesSummary(stage)
companies.forEach((company: any) => { companies.forEach((company: CompanySummary) => {
meta.push( meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}` `${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
) )
}) })
return { return {
key: stage.uuid, key: stage.uuid ?? undefined,
from, from: from ?? undefined,
to, to: to ?? undefined,
label: stage.name, label: stage.name ?? undefined,
meta meta
} }
}) })
@@ -154,10 +287,10 @@ const loadOrder = async () => {
const orderUuid = route.params.id as string const orderUuid = route.params.id as string
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders') const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
if (orderErrorResp.value) throw orderErrorResp.value if (orderErrorResp.value) throw orderErrorResp.value
order.value = data.value?.getOrder order.value = data.value?.getOrder ?? null
} catch (err: any) { } catch (err: unknown) {
hasOrderError.value = true hasOrderError.value = true
orderError.value = err.message || t('ordersDetail.errors.load_failed') orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
} finally { } finally {
isLoadingOrder.value = false isLoadingOrder.value = false
} }
@@ -172,8 +305,8 @@ const formatPrice = (price: number, currency?: string | null) => {
}).format(price) }).format(price)
} }
const getCompaniesSummary = (stage: any) => { const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
const companies = [] const companies: CompanySummary[] = []
if (stage.stageType === 'service' && stage.selectedCompany) { if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({ companies.push({
name: stage.selectedCompany.name, name: stage.selectedCompany.name,
@@ -185,12 +318,13 @@ const getCompaniesSummary = (stage: any) => {
} }
if (stage.stageType === 'transport' && stage.trips?.length) { if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map() const companiesMap = new Map<string, CompanySummary>()
stage.trips.forEach((trip: any) => { stage.trips.forEach((trip) => {
if (!trip) return
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown') const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
const weight = trip.plannedWeight || 0 const weight = trip.plannedWeight || 0
if (companiesMap.has(companyName)) { if (companiesMap.has(companyName)) {
const existing = companiesMap.get(companyName) const existing = companiesMap.get(companyName)!
existing.totalWeight += weight existing.totalWeight += weight
existing.tripsCount += 1 existing.tripsCount += 1
} else { } else {
@@ -211,10 +345,12 @@ const getOrderDuration = () => {
if (!order.value?.stages?.length) return 0 if (!order.value?.stages?.length) return 0
let minDate: Date | null = null let minDate: Date | null = null
let maxDate: Date | null = null let maxDate: Date | null = null
order.value.stages.forEach((stage: any) => { order.value.stages.forEach((stage) => {
stage.trips?.forEach((trip: any) => { if (!stage) return
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate) stage.trips?.forEach((trip) => {
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate) 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 (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate if (!maxDate || endDate > maxDate) maxDate = endDate
}) })
@@ -224,13 +360,14 @@ const getOrderDuration = () => {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
} }
const getStageDateRange = (stage: any) => { const getStageDateRange = (stage: StageType) => {
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined') if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
let minDate: Date | null = null let minDate: Date | null = null
let maxDate: Date | null = null let maxDate: Date | null = null
stage.trips.forEach((trip: any) => { stage.trips.forEach((trip) => {
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate) if (!trip) return
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate) const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
if (!minDate || startDate < minDate) minDate = startDate if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate if (!maxDate || endDate > maxDate) maxDate = endDate
}) })

View File

@@ -1,31 +1,51 @@
<template> <template>
<div>
<CatalogPage <CatalogPage
:items="displayItems" :items="mapPoints"
:map-items="mapPoints"
:loading="isLoading" :loading="isLoading"
with-map :use-server-clustering="false"
map-id="orders-map" map-id="orders-map"
point-color="#6366f1" point-color="#6366f1"
:selected-id="selectedOrderId"
:hovered-id="hoveredOrderId" :hovered-id="hoveredOrderId"
:total-count="filteredItems.length" :show-panel="!selectedOrderId"
@select="onSelectOrder" panel-width="w-96"
:hide-view-toggle="true"
@select="onMapSelect"
@update:hovered-id="hoveredOrderId = $event" @update:hovered-id="hoveredOrderId = $event"
> >
<template #searchBar="{ displayedCount, totalCount }"> <template #panel>
<CatalogSearchBar <!-- Panel header -->
v-model:search-query="searchQuery" <div class="p-4 border-b border-white/10 flex-shrink-0">
:active-filters="activeFilterBadges" <div class="flex items-center justify-between mb-3">
:displayed-count="displayedCount" <div class="flex items-center gap-2">
:total-count="totalCount" <div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
@remove-filter="onRemoveFilter" <Icon name="lucide:package" size="16" class="text-indigo-400" />
@search="onSearch" </div>
>
<template #filters>
<div class="p-2 space-y-3">
<div> <div>
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('ordersList.filters.status') }}</div> <span class="font-semibold text-sm">{{ t('cabinetNav.orders') }}</span>
<ul class="menu menu-compact"> <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"> <li v-for="filter in filters" :key="filter.id">
<a <a
:class="{ 'active': selectedFilter === filter.id }" :class="{ 'active': selectedFilter === filter.id }"
@@ -35,78 +55,75 @@
</ul> </ul>
</div> </div>
</div> </div>
</template>
</CatalogSearchBar>
</template>
<template #card="{ item }"> <!-- Orders list -->
<Card padding="lg" class="cursor-pointer"> <div class="flex-1 overflow-y-auto p-3 space-y-2">
<Stack gap="4"> <template v-if="displayItems.length > 0">
<Stack direction="row" justify="between" align="center"> <div
<Stack gap="1"> v-for="item in displayItems"
<Text size="sm" tone="muted">{{ t('ordersList.card.order_label') }}</Text> :key="item.uuid"
<Heading :level="3">#{{ item.name }}</Heading> class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
</Stack> :class="{ 'ring-2 ring-indigo-500': selectedOrderId === item.uuid }"
<div class="badge badge-outline"> @click="selectedOrderId = item.uuid"
{{ getOrderStartDate(item) }} {{ getOrderEndDate(item) }} @mouseenter="hoveredOrderId = item.uuid"
</div> @mouseleave="hoveredOrderId = undefined"
</Stack> >
<div class="flex items-center justify-between mb-2">
<div class="divider my-0"></div> <span class="font-semibold text-sm">#{{ item.name }}</span>
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
<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) }} {{ getStatusText(item.status) }}
</Badge> </span>
<Text tone="muted" size="sm">{{ t('ordersList.card.stages_completed', { done: getCompletedStages(item), total: item.stages?.length || 0 }) }}</Text> </div>
</Stack> <div class="text-xs text-white/70 space-y-1">
</Grid> <div class="flex items-center gap-2">
</Stack> <Icon name="lucide:map-pin" size="12" class="text-white/40" />
</Card> <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> </template>
<template v-else>
<div class="text-center py-8">
<div class="text-3xl mb-2">📦</div>
<div class="font-semibold text-sm mb-1">{{ t('orders.no_orders') }}</div>
<div class="text-xs text-white/60">{{ t('orders.no_orders_desc') }}</div>
</div>
</template>
</div>
<template #empty> <!-- Footer -->
<EmptyState <div class="p-3 border-t border-white/10 flex-shrink-0">
icon="📦" <span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
:title="t('orders.no_orders')" </div>
:description="t('orders.no_orders_desc')"
:action-label="t('orders.create_new')"
:action-to="localePath('/clientarea')"
action-icon="lucide:plus"
/>
</template> </template>
</CatalogPage> </CatalogPage>
<!-- Order Detail Bottom Sheet -->
<OrderDetailBottomSheet
:is-open="!!selectedOrderId"
:order-uuid="selectedOrderId"
@close="selectedOrderId = null"
/>
</div>
</template> </template>
<script setup lang="ts"> <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]>
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
middleware: ['auth-oidc'] middleware: ['auth-oidc']
}) })
const localePath = useLocalePath()
const { t } = useI18n() const { t } = useI18n()
const { const {
@@ -119,140 +136,71 @@ const {
getStatusText getStatusText
} = useTeamOrders() } = useTeamOrders()
const selectedOrderId = ref<string>()
const hoveredOrderId = ref<string>() const hoveredOrderId = ref<string>()
// Search bar
const searchQuery = ref('') const searchQuery = ref('')
const selectedOrderId = ref<string | null>(null)
// Search with map checkbox // Selected filter label
const searchWithMap = ref(false) const selectedFilterLabel = computed(() => {
const currentBounds = ref<MapBounds | null>(null) const filter = filters.value.find(f => f.id === selectedFilter.value)
return filter?.label || t('ordersList.filters.status')
})
// List items - one per order // Map points - source locations
const listItems = computed(() => { const mapPoints = computed(() => {
return filteredItems.value.map(order => ({ return filteredItems.value
...order, .filter(order => order.uuid && order.sourceLatitude && order.sourceLongitude)
uuid: order.uuid, .map(order => ({
name: order.name || `#${order.uuid.slice(0, 8)}`, uuid: order.uuid!,
latitude: order.sourceLatitude, name: order.name || `#${order.uuid!.slice(0, 8)}`,
longitude: order.sourceLongitude, latitude: order.sourceLatitude!,
country: order.sourceLocationName longitude: order.sourceLongitude!
})) }))
}) })
// Map points - two per order (source + destination) // Display items with search filter
const mapPoints = computed(() => {
const result: any[] = []
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
const displayItems = computed(() => { const displayItems = computed(() => {
if (!searchWithMap.value || !currentBounds.value) return listItems.value let items = filteredItems.value.filter(order => order.uuid)
return listItems.value.filter(item => {
if (item.latitude == null || item.longitude == null) return false if (searchQuery.value) {
const { west, east, north, south } = currentBounds.value! const query = searchQuery.value.toLowerCase()
const lng = Number(item.longitude) items = items.filter(item =>
const lat = Number(item.latitude) item.name?.toLowerCase().includes(query) ||
return lng >= west && lng <= east && lat >= south && lat <= north item.sourceLocationName?.toLowerCase().includes(query) ||
}) item.destinationLocationName?.toLowerCase().includes(query)
)
}
return items
}) })
// Active filter badges const onMapSelect = (item: { uuid?: string | null }) => {
const activeFilterBadges = computed(() => { if (item.uuid) {
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: any) => {
selectedOrderId.value = item.uuid selectedOrderId.value = item.uuid
navigateTo(localePath(`/clientarea/orders/${item.uuid}`)) }
} }
await init() await init()
const getOrderStartDate = (order: any) => { const getOrderDate = (order: TeamOrder) => {
if (!order.createdAt) return t('ordersDetail.labels.dates_undefined') if (!order.createdAt) return ''
return formatDate(order.createdAt)
}
const getOrderEndDate = (order: any) => {
let latestDate: Date | null = null
order.stages?.forEach((stage: any) => {
stage.trips?.forEach((trip: any) => {
const endDate = trip.actualUnloadingDate || trip.plannedUnloadingDate
if (endDate) {
const date = new Date(endDate)
if (!latestDate || date > latestDate) {
latestDate = date
}
}
})
})
if (latestDate) return formatDate((latestDate as Date).toISOString())
if (order.createdAt) {
const fallbackDate = new Date(order.createdAt)
fallbackDate.setMonth(fallbackDate.getMonth() + 1)
return formatDate(fallbackDate.toISOString())
}
return t('ordersDetail.labels.dates_undefined')
}
const getCompletedStages = (order: any) => {
if (!order.stages?.length) return 0
return order.stages.filter((stage: any) => stage.status === 'completed').length
}
const formatDate = (date: string) => {
if (!date) return t('ordersDetail.labels.dates_undefined')
try { 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', { return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'short'
year: 'numeric' }).format(new Date(order.createdAt))
}).format(dateObj)
} catch { } 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> </script>

View File

@@ -14,8 +14,8 @@
> >
<template #cards> <template #cards>
<Card <Card
v-for="order in filteredItems" v-for="(order, index) in filteredItems"
:key="order.uuid" :key="order.uuid ?? index"
padding="small" padding="small"
interactive interactive
:class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }" :class="{ 'ring-2 ring-primary': selectedOrderId === order.uuid }"
@@ -24,8 +24,8 @@
<Stack gap="2"> <Stack gap="2">
<Stack direction="row" justify="between" align="center"> <Stack direction="row" justify="between" align="center">
<Text weight="semibold">#{{ order.name }}</Text> <Text weight="semibold">#{{ order.name }}</Text>
<Badge :variant="getStatusVariant(order.status)" size="sm"> <Badge :variant="getStatusVariant(order.status || '')" size="sm">
{{ getStatusText(order.status) }} {{ getStatusText(order.status || '') }}
</Badge> </Badge>
</Stack> </Stack>
<Text tone="muted" size="sm" class="truncate"> <Text tone="muted" size="sm" class="truncate">
@@ -74,10 +74,12 @@ await init()
const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null) const mapRef = ref<{ flyTo: (orderId: string) => void } | null>(null)
const selectedOrderId = ref<string | null>(null) const selectedOrderId = ref<string | null>(null)
const selectOrder = (order: any) => { const selectOrder = (order: { uuid?: string | null }) => {
if (order.uuid) {
selectedOrderId.value = order.uuid selectedOrderId.value = order.uuid
mapRef.value?.flyTo(order.uuid) mapRef.value?.flyTo(order.uuid)
} }
}
const onMapSelectOrder = (uuid: string) => { const onMapSelectOrder = (uuid: string) => {
selectedOrderId.value = uuid selectedOrderId.value = uuid

View File

@@ -1,76 +1,135 @@
<template> <template>
<Section variant="plain" paddingY="md"> <div>
<Stack gap="6"> <CatalogPage
<PageHeader :items="[]"
:title="$t('dashboard.profile')" :loading="false"
:actions="[{ label: t('clientProfile.actions.debugTokens'), icon: 'lucide:bug', to: localePath('/clientarea/profile/debug-tokens') }]" :use-server-clustering="false"
map-id="profile-map"
point-color="#10b981"
:show-panel="false"
:hide-view-toggle="true"
/> />
<Alert v-if="hasError" variant="error"> <!-- Bottom Sheet -->
<Stack gap="1"> <div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading> <!-- Glass sheet -->
<Text tone="muted">{{ error }}</Text> <div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
</Stack> <!-- Drag handle -->
</Alert> <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"> <!-- Header -->
<Spinner /> <div class="px-6 pb-4 border-b border-white/10">
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text> <!-- Error state -->
</Stack> <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> <template v-else>
<Card padding="lg"> <div class="flex items-center gap-3">
<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 <UserAvatar
:userId="userData?.id ?? undefined" :userId="userData?.id ?? undefined"
:firstName="userData?.firstName ?? undefined" :firstName="userData?.firstName ?? undefined"
:lastName="userData?.lastName ?? undefined" :lastName="userData?.lastName ?? undefined"
:avatarId="userData?.avatarId ?? undefined" :avatarId="userData?.avatarId ?? undefined"
size="lg"
@avatar-changed="handleAvatarChange" @avatar-changed="handleAvatarChange"
/> />
</Stack> <div>
</Stack> <div class="font-bold text-lg text-white">
</GridItem> {{ userData?.firstName || '' }} {{ userData?.lastName || '' }}
</Grid> </div>
</Card> <div class="text-xs text-white/50">{{ $t('dashboard.profile') }}</div>
</div>
</div>
</template> </template>
</Stack> </div>
</Section>
<!-- 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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -159,7 +218,6 @@ const updateProfile = async () => {
const handleAvatarChange = async (newAvatarId?: string) => { const handleAvatarChange = async (newAvatarId?: string) => {
if (!newAvatarId) return if (!newAvatarId) return
// Only stage avatar change; will be saved on form submit
avatarDraftId.value = newAvatarId avatarDraftId.value = newAvatarId
} }

View File

@@ -1,117 +1,175 @@
<template> <template>
<Section variant="plain"> <div>
<Stack gap="6"> <CatalogPage
<PageHeader :title="t('clientTeam.header.title')" :actions="teamHeaderActions" /> :items="[]"
:loading="false"
<Alert v-if="hasError" variant="error"> :use-server-clustering="false"
<Stack gap="2"> map-id="team-map"
<Heading :level="4" weight="semibold">{{ t('clientTeam.error.title') }}</Heading> point-color="#8b5cf6"
<Text tone="muted">{{ error }}</Text> :show-panel="false"
<Button @click="loadUserTeams">{{ t('clientTeam.error.retry') }}</Button> :hide-view-toggle="true"
</Stack>
</Alert>
<Card v-else-if="isLoading" tone="muted" padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('clientTeam.loading.message') }}</Text>
</Stack>
</Card>
<!-- No team - prompt to create KYC application -->
<EmptyState
v-else-if="!currentTeam"
icon="👥"
:title="t('clientTeam.empty.title')"
:description="t('clientTeam.empty.description')"
:action-label="t('clientTeam.empty.cta')"
:action-to="localePath('/clientarea/kyc')"
action-icon="lucide:plus"
/> />
<!-- 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>
<!-- 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 -->
<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>
<!-- Team header -->
<template v-else> <template v-else>
<Card padding="lg"> <div class="flex items-center justify-between">
<Stack gap="4"> <div class="flex items-center gap-3">
<Stack direction="row" gap="4" align="start" justify="between"> <div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<Stack direction="row" gap="3" align="center"> <Icon name="lucide:building-2" size="24" class="text-primary" />
<IconCircle tone="neutral" size="lg"> </div>
{{ currentTeam.name?.charAt(0)?.toUpperCase() || '?' }} <div>
</IconCircle> <div class="font-bold text-lg text-white">{{ currentTeam?.name }}</div>
<Heading :level="2" weight="semibold">{{ currentTeam.name }}</Heading> <div class="flex items-center gap-2 mt-0.5">
</Stack> <span class="badge badge-success badge-sm">{{ t('catalog.info.active') }}</span>
</Stack> <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>
</Stack> <!-- Scrollable content -->
</Card> <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">
<Stack gap="3"> <!-- Team members -->
<Heading :level="2">{{ t('clientTeam.members.title') }}</Heading> <div class="bg-white/5 rounded-xl p-4 border border-white/10">
<Grid :cols="1" :md="2" :lg="3" :gap="4"> <div class="font-semibold text-white mb-3 flex items-center gap-2">
<Card <Icon name="lucide:users" size="18" />
v-for="member in currentTeam?.members || []" {{ t('clientTeam.members.title') }}
:key="member.user?.id" </div>
padding="lg" <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"
> >
<Stack gap="3"> <div class="flex items-center gap-2">
<Stack direction="row" gap="3" align="center"> <div class="avatar placeholder">
<IconCircle tone="neutral">{{ getMemberInitials(member.user) }}</IconCircle> <div class="w-8 h-8 rounded-full bg-primary text-primary-content text-xs">
<Stack gap="1"> <span>{{ getMemberInitials(member.user) }}</span>
<Text weight="semibold">{{ member.user?.firstName }} {{ member.user?.lastName || '—' }}</Text> </div>
</Stack> </div>
</Stack> <div>
<Stack direction="row" gap="2" wrap> <div class="text-sm text-white">{{ member.user?.firstName }} {{ member.user?.lastName || '' }}</div>
<Pill variant="primary">{{ roleText(member.role) }}</Pill> <div class="text-xs text-white/50">{{ roleText(member.role) }}</div>
</Stack> </div>
</Stack> </div>
</Card> <span class="badge badge-primary badge-sm">{{ roleText(member.role) }}</span>
</div>
<!-- Pending invitations --> <!-- Empty state -->
<Card <div v-if="currentTeamMembers.length === 0" class="text-center py-4 text-white/50 text-sm">
v-for="invitation in currentTeam?.invitations || []" {{ 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" :key="invitation.uuid"
padding="lg" class="flex items-center justify-between p-2 bg-warning/10 rounded-lg border border-warning/20"
class="border-dashed border-warning"
> >
<Stack gap="3"> <div class="flex items-center gap-2">
<Stack direction="row" gap="3" align="center"> <div class="avatar placeholder">
<IconCircle tone="warning"> <div class="w-8 h-8 rounded-full bg-warning/20 text-warning flex items-center justify-center">
<Icon name="lucide:mail" size="16" /> <Icon name="lucide:mail" size="14" />
</IconCircle> </div>
<Stack gap="1"> </div>
<Text weight="semibold">{{ invitation.email }}</Text> <div>
<Text tone="muted" size="sm">{{ t('clientTeam.invitations.pending') }}</Text> <div class="text-sm text-white">{{ invitation.email }}</div>
</Stack> <div class="text-xs text-warning">{{ t('clientTeam.invitations.pending') }}</div>
</Stack> </div>
<Stack direction="row" gap="2" wrap> </div>
<Pill variant="outline" tone="warning">{{ roleText(invitation.role) }}</Pill> <span class="badge badge-warning badge-outline badge-sm">{{ roleText(invitation.role) }}</span>
<Pill variant="ghost" tone="muted">{{ t('clientTeam.invitations.sent') }}</Pill> </div>
</Stack>
</Stack>
</Card>
<Card <!-- Empty state -->
padding="lg" <div v-if="currentTeamInvitations.length === 0" class="text-center py-4 text-white/50 text-sm">
class="border-2 border-dashed border-base-300 hover:border-primary cursor-pointer transition-colors" {{ 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" @click="inviteMember"
> >
<Stack gap="3" align="center" justify="center" class="h-full min-h-[100px]"> <div class="flex items-center justify-center gap-2 text-white/50">
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center text-base-content/50"> <Icon name="lucide:user-plus" size="16" />
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="text-sm">{{ t('clientTeam.inviteCard.title') }}</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> </div>
</svg> </button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<Text weight="semibold" tone="muted">{{ t('clientTeam.inviteCard.title') }}</Text>
</Stack>
</Card>
</Grid>
</Stack>
</template>
</Stack>
</Section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetTeamDocument } from '~/composables/graphql/user/teams-generated' import { GetTeamDocument, type GetTeamQueryResult } from '~/composables/graphql/user/teams-generated'
interface UserTeam {
id?: string | null
name: string
logtoOrgId?: string | null
}
type TeamWithMembers = NonNullable<GetTeamQueryResult['getTeam']>
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
@@ -129,21 +187,13 @@ const me = useState<{
} | null>('me', () => null) } | null>('me', () => null)
const { setActiveTeam } = useActiveTeam() const { setActiveTeam } = useActiveTeam()
const userTeams = ref<any[]>([]) const userTeams = ref<UserTeam[]>([])
const currentTeam = ref<any>(null) const currentTeam = ref<TeamWithMembers | UserTeam | null>(null)
const isLoading = ref(true) const isLoading = ref(true)
const hasError = ref(false) const hasError = ref(false)
const error = ref('') const error = ref('')
const teamHeaderActions = computed(() => { const roleText = (role?: string | null) => {
const actions: Array<{ label: string; icon?: string; to?: string }> = []
actions.push({ label: t('clientTeam.actions.addCompany'), icon: 'lucide:plus', to: localePath('/clientarea/kyc') })
if (userTeams.value.length > 1) {
actions.push({ label: t('clientTeam.actions.switch'), icon: 'lucide:arrow-left-right', to: localePath('/clientarea/company-switch') })
}
return actions
})
const roleText = (role?: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
OWNER: t('clientTeam.roles.owner'), OWNER: t('clientTeam.roles.owner'),
ADMIN: t('clientTeam.roles.admin'), ADMIN: t('clientTeam.roles.admin'),
@@ -153,13 +203,29 @@ const roleText = (role?: string) => {
return map[role || ''] || role || t('clientTeam.roles.member') return map[role || ''] || role || t('clientTeam.roles.member')
} }
const getMemberInitials = (user?: any) => { interface TeamMember {
id?: string | null
firstName?: string | null
lastName?: string | null
}
const getMemberInitials = (user?: TeamMember | null) => {
if (!user) return '??' if (!user) return '??'
const first = user.firstName?.charAt(0) || '' const first = user.firstName?.charAt(0) || ''
const last = user.lastName?.charAt(0) || '' const last = user.lastName?.charAt(0) || ''
return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??' return (first + last).toUpperCase() || user.id?.charAt(0).toUpperCase() || '??'
} }
const currentTeamMembers = computed(() => {
const team = currentTeam.value
return team && 'members' in team ? (team.members || []).filter((m): m is NonNullable<typeof m> => m !== null) : []
})
const currentTeamInvitations = computed(() => {
const team = currentTeam.value
return team && 'invitations' in team ? (team.invitations || []).filter((i): i is NonNullable<typeof i> => i !== null) : []
})
const loadUserTeams = async () => { const loadUserTeams = async () => {
try { try {
isLoading.value = true isLoading.value = true
@@ -177,13 +243,14 @@ const loadUserTeams = async () => {
currentTeam.value = teamData.value?.getTeam || null currentTeam.value = teamData.value?.getTeam || null
} else if (userTeams.value.length > 0) { } else if (userTeams.value.length > 0) {
const firstTeam = userTeams.value[0] const firstTeam = userTeams.value[0]
setActiveTeam(firstTeam?.id || null, firstTeam?.logtoOrgId) if (firstTeam) {
setActiveTeam(firstTeam.id || null, firstTeam.logtoOrgId)
currentTeam.value = firstTeam currentTeam.value = firstTeam
} }
// Если нет команды - currentTeam остаётся null, показываем EmptyState }
} catch (err: any) { } catch (err: unknown) {
hasError.value = true hasError.value = true
error.value = err.message || t('clientTeam.error.load') error.value = err instanceof Error ? err.message : t('clientTeam.error.load')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -95,8 +95,8 @@ const submitInvite = async () => {
} else { } else {
inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error') inviteError.value = result?.inviteMember?.message || t('clientTeam.invite.error')
} }
} catch (err: any) { } catch (err: unknown) {
inviteError.value = err.message || t('clientTeam.invite.error') inviteError.value = err instanceof Error ? err.message : t('clientTeam.invite.error')
} finally { } finally {
inviteLoading.value = false inviteLoading.value = false
} }

View File

@@ -1,89 +1,428 @@
<template> <template>
<Stack gap="12"> <div class="pb-0 -mx-3 lg:-mx-6">
<!-- How it works --> <!-- Section: How it works -->
<Section variant="plain"> <section class="container mx-auto px-4 mb-20 mt-12">
<Stack gap="6" align="center"> <!-- Section header with line -->
<Heading :level="2">{{ $t('howto.title') }}</Heading> <div class="flex items-center gap-6 mb-10">
<Grid :cols="1" :md="3" :gap="6"> <div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
<Card padding="lg"> <h2 class="text-sm font-medium uppercase tracking-[0.2em] text-base-content/60">
<Stack gap="3" align="center"> {{ $t('howto.title') }}
<IconCircle tone="primary">🔍</IconCircle> </h2>
<Heading :level="3" weight="semibold">{{ $t('howto.step1.title') }}</Heading> <div class="h-px flex-1 bg-gradient-to-r from-transparent via-base-content/20 to-transparent" />
<Text tone="muted" align="center">{{ $t('howto.step1.description') }}</Text> </div>
</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>
<!-- Who it's for --> <!-- Magazine layout -->
<Section variant="plain"> <div class="grid grid-cols-12 gap-6 md:gap-8">
<Stack gap="8" align="center"> <!-- Large hero image (8 cols) -->
<Heading :level="2">{{ $t('roles.title') }}</Heading> <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"> <!-- Right column: text + small cards -->
<Card padding="lg"> <div class="col-span-12 md:col-span-5 flex flex-col gap-6">
<Stack gap="4" align="center"> <!-- Text block -->
<IconCircle tone="primary">🏭</IconCircle> <div class="p-8 rounded-3xl bg-base-200/50 border border-base-300/50">
<Heading :level="3">{{ $t('roles.producers.title') }}</Heading> <div class="text-6xl font-black text-primary/20 mb-2">500+</div>
<Text tone="muted" align="center">{{ $t('roles.producers.description') }}</Text> <div class="text-xl font-semibold text-base-content mb-2">{{ $t('stats.suppliers', 'Поставщиков') }}</div>
<Stack tag="ul" gap="1"> <p class="text-base-content/60 text-sm">
<li>✓ {{ $t('roles.producers.benefit1') }}</li> {{ $t('stats.suppliersDesc', 'Проверенные производители из России, Казахстана и других стран СНГ') }}
<li>✓ {{ $t('roles.producers.benefit2') }}</li> </p>
<li>✓ {{ $t('roles.producers.benefit3') }}</li> </div>
<li>✓ {{ $t('roles.producers.benefit4') }}</li>
</Stack>
<Button :full-width="true" variant="outline">{{ $t('roles.producers.cta') }}</Button>
</Stack>
</Card>
<Card padding="lg"> <!-- Compare card -->
<Stack gap="4" align="center"> <div class="flex-1 relative overflow-hidden rounded-3xl group cursor-pointer min-h-[200px]">
<IconCircle tone="primary">🏢</IconCircle> <img
<Heading :level="3">{{ $t('roles.buyers.title') }}</Heading> src="/images/promo/compare.jpg"
<Text tone="muted" align="center">{{ $t('roles.buyers.description') }}</Text> alt=""
<Stack tag="ul" gap="1"> class="absolute inset-0 w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
<li>✓ {{ $t('roles.buyers.benefit1') }}</li> />
<li>✓ {{ $t('roles.buyers.benefit2') }}</li> <div class="absolute inset-0 bg-gradient-to-t from-cyan-900/90 via-cyan-900/40 to-transparent" />
<li>✓ {{ $t('roles.buyers.benefit3') }}</li> <div class="absolute bottom-0 left-0 right-0 p-6">
<li>✓ {{ $t('roles.buyers.benefit4') }}</li> <span class="inline-block px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-300 text-xs font-medium mb-3">
</Stack> {{ $t('howto.step', 'Шаг') }} 02
<Button :full-width="true" variant="outline">{{ $t('roles.buyers.cta') }}</Button> </span>
</Stack> <h3 class="text-xl font-bold text-white">{{ $t('howto.step2.title') }}</h3>
</Card> </div>
</div>
</div>
</div>
</section>
<Card padding="lg"> <!-- Full-width quote/testimonial -->
<Stack gap="4" align="center"> <section class="bg-slate-900 py-16 mb-20">
<IconCircle tone="primary">⚙️</IconCircle> <div class="container mx-auto px-4">
<Heading :level="3">{{ $t('roles.services.title') }}</Heading> <div class="max-w-3xl mx-auto text-center">
<Text tone="muted" align="center">{{ $t('roles.services.description') }}</Text> <Icon name="lucide:quote" size="48" class="text-white/20 mb-6 mx-auto" />
<Stack tag="ul" gap="1"> <blockquote class="text-2xl md:text-3xl font-light text-white mb-6 leading-relaxed">
<li>✓ {{ $t('roles.services.benefit1') }}</li> {{ $t('testimonial.quote', 'Optovia помогла нам найти надёжных поставщиков за считанные дни. Раньше на это уходили месяцы.') }}
<li>✓ {{ $t('roles.services.benefit2') }}</li> </blockquote>
<li>✓ {{ $t('roles.services.benefit3') }}</li> <div class="flex items-center justify-center gap-3">
<li>✓ {{ $t('roles.services.benefit4') }}</li> <div class="w-12 h-12 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500" />
</Stack> <div class="text-left">
<Button :full-width="true" variant="outline">{{ $t('roles.services.cta') }}</Button> <div class="text-white font-medium">{{ $t('testimonial.author', 'Алексей Петров') }}</div>
</Stack> <div class="text-white/50 text-sm">{{ $t('testimonial.role', 'Директор по закупкам, АгроХолдинг') }}</div>
</Card> </div>
</Grid> </div>
</Stack> </div>
</Section> </div>
</Stack> </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> </template>
<script setup lang="ts"> <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>
<Stack direction="row" gap="3"> <Stack direction="row" gap="3">
<Button :as="'NuxtLink'" :to="localePath('/catalog')" variant="ghost"> <Button :as="'NuxtLink'" :to="localePath('/catalog?select=product')" variant="ghost">
{{ t('searchPage.cta.catalog') }} {{ t('searchPage.cta.catalog') }}
</Button> </Button>
<Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline"> <Button :as="'NuxtLink'" :to="localePath('/clientarea/orders')" variant="outline">

View File

@@ -78,6 +78,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLocationStore } from '~/stores/location' import { useLocationStore } from '~/stores/location'
import type { CatalogHubItem, CatalogNearestHubItem } from '~/composables/useCatalogHubs'
import type { TeamAddress } from '~/composables/graphql/team/teams-generated'
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
@@ -107,20 +109,20 @@ const {
} = useCatalogHubs() } = useCatalogHubs()
// Selected/hovered hub for map // Selected/hovered hub for map
const selectedHubId = ref<string>() const selectedHubId = ref<string | undefined>()
const hoveredHubId = ref<string>() const hoveredHubId = ref<string | undefined>()
await init() await init()
// Load team addresses // Load team addresses
const teamAddresses = ref<any[]>([]) const teamAddresses = ref<TeamAddress[]>([])
if (isAuthenticated.value) { if (isAuthenticated.value) {
try { try {
const { execute } = useGraphQL() const { execute } = useGraphQL()
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated') const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams') const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
teamAddresses.value = data?.teamAddresses || [] teamAddresses.value = (data?.teamAddresses || []).filter((a): a is TeamAddress => a != null)
} catch { } catch {
// Not critical // Not critical
} }
@@ -147,11 +149,12 @@ const goToRequestIfReady = () => {
return false return false
} }
const selectHub = async (hub: any) => { const selectHub = async (hub: CatalogHubItem | CatalogNearestHubItem) => {
if (!hub.uuid) return
selectedHubId.value = hub.uuid selectedHubId.value = hub.uuid
if (isSearchMode.value) { if (isSearchMode.value) {
searchStore.setLocation(hub.name) searchStore.setLocation(hub.name ?? '')
searchStore.setLocationUuid(hub.uuid) searchStore.setLocationUuid(hub.uuid)
if (goToRequestIfReady()) return if (goToRequestIfReady()) return
router.back() router.back()
@@ -159,7 +162,7 @@ const selectHub = async (hub: any) => {
} }
try { try {
const success = await locationStore.select('hub', hub.uuid, hub.name, hub.latitude, hub.longitude) const success = await locationStore.select('hub', hub.uuid, hub.name ?? '', hub.latitude ?? 0, hub.longitude ?? 0)
if (success) { if (success) {
router.back() router.back()
} }
@@ -168,7 +171,7 @@ const selectHub = async (hub: any) => {
} }
} }
const selectAddress = async (addr: any) => { const selectAddress = async (addr: TeamAddress) => {
if (isSearchMode.value) { if (isSearchMode.value) {
searchStore.setLocation(addr.address || addr.name) searchStore.setLocation(addr.address || addr.name)
searchStore.setLocationUuid(addr.uuid) searchStore.setLocationUuid(addr.uuid)
@@ -178,7 +181,7 @@ const selectAddress = async (addr: any) => {
} }
try { try {
const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude, addr.longitude) const success = await locationStore.select('address', addr.uuid, addr.name, addr.latitude ?? 0, addr.longitude ?? 0)
if (success) { if (success) {
router.back() router.back()
} }

View File

@@ -14,8 +14,8 @@
> >
<template #cards> <template #cards>
<HubCard <HubCard
v-for="hub in items" v-for="(hub, index) in items"
:key="hub.uuid" :key="hub.uuid ?? index"
:hub="hub" :hub="hub"
selectable selectable
:is-selected="selectedItemId === hub.uuid" :is-selected="selectedItemId === hub.uuid"
@@ -37,6 +37,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLocationStore } from '~/stores/location' import { useLocationStore } from '~/stores/location'
import type { CatalogHubItem, CatalogNearestHubItem } from '~/composables/useCatalogHubs'
definePageMeta({ definePageMeta({
layout: false layout: false
@@ -64,7 +65,8 @@ await init()
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null) const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const selectedItemId = ref<string | null>(null) const selectedItemId = ref<string | null>(null)
const selectItem = async (item: any) => { const selectItem = async (item: CatalogHubItem | CatalogNearestHubItem) => {
if (!item.uuid) return
selectedItemId.value = item.uuid selectedItemId.value = item.uuid
if (item.latitude && item.longitude) { if (item.latitude && item.longitude) {
@@ -73,7 +75,7 @@ const selectItem = async (item: any) => {
// Selection logic // Selection logic
if (isSearchMode.value) { if (isSearchMode.value) {
searchStore.setLocation(item.name) searchStore.setLocation(item.name ?? '')
searchStore.setLocationUuid(item.uuid) searchStore.setLocationUuid(item.uuid)
if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) { if (route.query.after === 'request' && searchStore.searchForm.productUuid && searchStore.searchForm.locationUuid) {
const query: Record<string, string> = { const query: Record<string, string> = {
@@ -92,7 +94,7 @@ const selectItem = async (item: any) => {
return return
} }
const success = await locationStore.select('hub', item.uuid, item.name, item.latitude, item.longitude) const success = await locationStore.select('hub', item.uuid, item.name ?? '', item.latitude ?? 0, item.longitude ?? 0)
if (success) router.push(localePath('/select-location')) if (success) router.push(localePath('/select-location'))
} }

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

@@ -0,0 +1,20 @@
export default defineNuxtPlugin(() => {
const originalConsoleError = console.error
console.error = (...args: unknown[]) => {
const hasApolloDevtoolsWarning = args.some((arg) => {
if (typeof arg !== 'string') return false
return (
arg.includes('connectToDevTools') &&
arg.includes('devtools.enabled')
)
})
if (hasApolloDevtoolsWarning) {
return
}
originalConsoleError(...args)
}
})

View File

@@ -1,8 +1,15 @@
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const baseUrl = String(config.public.chatwootBaseUrl || '').trim()
const websiteToken = String(config.public.chatwootWebsiteToken || '').trim()
if (!baseUrl || !websiteToken) {
return
}
const loadChatwoot = () => { const loadChatwoot = () => {
if (document.getElementById('chatwoot-sdk')) return if (document.getElementById('chatwoot-sdk')) return
const baseUrl = 'https://chatwoot.optovia.ru'
const script = document.createElement('script') const script = document.createElement('script')
script.id = 'chatwoot-sdk' script.id = 'chatwoot-sdk'
script.src = `${baseUrl}/packs/js/sdk.js` script.src = `${baseUrl}/packs/js/sdk.js`
@@ -10,7 +17,7 @@ export default defineNuxtPlugin(() => {
script.defer = true script.defer = true
script.onload = () => { script.onload = () => {
window.chatwootSDK?.run({ window.chatwootSDK?.run({
websiteToken: 'bc668ge3hM5ZpPeUgGEV1ZU9', websiteToken,
baseUrl baseUrl
}) })
} }

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
})
})

38
app/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
// Type declarations for modules without TypeScript support
declare module '@lottiefiles/dotlottie-vue' {
import type { DefineComponent } from 'vue'
export const DotLottieVue: DefineComponent<{
src?: string
autoplay?: boolean
loop?: boolean
class?: string
style?: Record<string, string | number>
}>
}
declare module 'vue-chartjs' {
import type { DefineComponent } from 'vue'
export const Line: DefineComponent
export const Bar: DefineComponent
export const Pie: DefineComponent
export const Doughnut: DefineComponent
}
declare module 'chart.js' {
export const CategoryScale: unknown
export const LinearScale: unknown
export const PointElement: unknown
export const LineElement: unknown
export const Title: unknown
export const Tooltip: unknown
export const Legend: unknown
export const Filler: unknown
export const Chart: {
register: (...args: unknown[]) => void
}
export type ChartData<T = unknown> = T
export type ChartOptions<T = unknown> = T
}

View File

@@ -9,14 +9,22 @@ const plugins = [
const pluginConfig = { const pluginConfig = {
scalars: { scalars: {
DateTime: 'string', DateTime: 'string',
Date: 'string',
Decimal: 'string',
JSONString: 'Record<string, unknown>',
JSON: 'Record<string, unknown>',
UUID: 'string',
BigInt: 'string',
}, },
useTypeImports: true, useTypeImports: true,
strictScalars: true,
// Add suffix to operation result types to avoid conflicts with schema types // Add suffix to operation result types to avoid conflicts with schema types
operationResultSuffix: 'Result', operationResultSuffix: 'Result',
} }
const config: CodegenConfig = { const config: CodegenConfig = {
overwrite: true, overwrite: true,
allowPartialOutputs: true,
generates: { generates: {
// Public operations (no token) // Public operations (no token)
'./app/composables/graphql/public/exchange-generated.ts': { './app/composables/graphql/public/exchange-generated.ts': {

View File

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

View File

@@ -1,5 +1,6 @@
{ {
"cabinetNav": { "cabinetNav": {
"cabinet": "My Cabinet",
"search": "Search", "search": "Search",
"catalog": "Catalog", "catalog": "Catalog",
"orders": "My orders", "orders": "My orders",
@@ -13,6 +14,10 @@
"seller": "Seller", "seller": "Seller",
"suppliers": "Suppliers", "suppliers": "Suppliers",
"hubs": "Hubs", "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", "suppliersNearby": "Suppliers nearby",
"noHubs": "No hubs found", "noHubs": "No hubs found",
"noSuppliers": "No suppliers 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": { "modes": {
"explore": "Explore", "explore": "Explore",
@@ -76,12 +87,23 @@
"selectSupplier": "Select supplier", "selectSupplier": "Select supplier",
"enterQty": "Quantity (t)", "enterQty": "Quantity (t)",
"search": "Search", "search": "Search",
"clear": "Clear" "clear": "Clear",
"findOffers": "Find offers"
}, },
"explore": { "explore": {
"title": "Explore the market", "title": "Explore the market",
"subtitle": "Switch between offers, hubs, and suppliers" "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": { "labels": {
"quantity_with_unit": "{quantity} {unit}", "quantity_with_unit": "{quantity} {unit}",
"default_unit": "t", "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": { "cta": {
"title": "Ready to Start Trading?", "title": "Ready to Get Started?",
"description": "Join thousands of companies already using Optovia for their deals", "description": "Join hundreds of companies already trading on Optovia",
"start_selling": "Start Selling", "start_selling": "Start Selling",
"start_buying": "Start Buying" "start_buying": "Start Buying",
"register": "Register",
"demo": "Request Demo"
} }
} }

View File

@@ -1,11 +1,68 @@
{ {
"footer": { "footer": {
"description": "Global B2B platform for raw materials and agricultural commodities trading. Connecting producers and buyers worldwide.",
"buyers": "For Buyers", "buyers": "For Buyers",
"suppliers": "For Suppliers", "suppliers": "For Suppliers",
"services": "For Service Companies", "services": "For Service Companies",
"rights": "All rights reserved", "rights": "All rights reserved.",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"terms": "Terms of Service", "terms": "Terms",
"support": "Support" "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": { "howto": {
"title": "How It Works", "title": "How It Works",
"step": "Step",
"step1": { "step1": {
"title": "Find Materials", "title": "Find Materials",
"description": "Select the raw materials you need, specify quantity and delivery location" "description": "Select the raw materials you need, specify quantity and delivery location"

View File

@@ -3,6 +3,16 @@
"verification_status": "Verification Status", "verification_status": "Verification Status",
"team_verification_description": "Complete team verification to create orders", "team_verification_description": "Complete team verification to create orders",
"start_verification": "Start Verification", "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": { "actions": {
"add": "Add address", "add": "Add address",
"edit": "Edit",
"confirm_delete": "Delete this address?", "confirm_delete": "Delete this address?",
"delete": "Delete", "delete": "Delete",
"deleting": "Deleting..." "deleting": "Deleting..."
}, },
"detail": {
"location": "Location",
"map": "Map"
},
"form": { "form": {
"title": "New address", "title": "New address",
"title_edit": "Edit address", "title_edit": "Edit address",

View File

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

View File

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

View File

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

View File

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

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