Compare commits

..

133 Commits

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,15 @@
<OfferResultCard
v-for="(option, index) in productRouteOptions"
:key="option.sourceUuid ?? index"
:supplier-name="getSupplierName(option.sourceUuid)"
:location-name="getOfferData(option.sourceUuid)?.locationName"
:product-name="productName"
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
:quantity="getOfferData(option.sourceUuid)?.quantity"
:currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit"
:stages="getRouteStages(option)"
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
@select="navigateToOffer(option.sourceUuid)"
/>
@@ -81,7 +84,8 @@ interface RoutePathType {
totalTimeSeconds?: number | 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 localePath = useLocalePath()
@@ -90,12 +94,14 @@ const { execute } = useGraphQL()
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
const quantity = computed(() => (route.query.quantity as string) || searchStore.searchForm.quantity)
// 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)
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 summaryMeta = computed(() => {
@@ -149,14 +155,16 @@ const fetchOffersByHub = async () => {
const offers = offersResponse?.nearestOffers || []
// Offers already include routes from backend
const offersWithRoutes = offers.map((offer: any) => ({
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
distanceKm: offer.distanceKm,
routes: offer.routes || []
}))
const offersWithRoutes = offers
.filter((offer): offer is NonNullable<OfferWithRoute> => offer !== null)
.map((offer) => ({
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
distanceKm: offer.distanceKm,
routes: offer.routes || []
}))
return { offersByHub: offersWithRoutes }
}
@@ -198,10 +206,14 @@ const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
const getRouteStages = (option: ProductRouteOption) => {
const route = option.routes?.[0]
if (!route?.stages) return []
return route.stages.filter(Boolean).map((stage: any) => ({
transportType: stage?.transportType,
distanceKm: stage?.distanceKm
}))
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
}))
}
// Get offer data for card
@@ -219,6 +231,14 @@ const getKycProfileUuid = (offerUuid?: string | null) => {
return supplier?.kycProfileUuid || null
}
const getSupplierName = (offerUuid?: string | null) => {
if (!offerUuid) return null
const offer = offersData.value.get(offerUuid)
if (!offer?.teamUuid) return null
const supplier = suppliersData.value.get(offer.teamUuid)
return supplier?.name || null
}
// Navigate to offer detail page
const navigateToOffer = (offerUuid?: string | null) => {
if (!offerUuid) return
@@ -233,8 +253,8 @@ const loadOfferDetails = async (options: ProductRouteOption[]) => {
return
}
const newOffersData = new Map<string, any>()
const newSuppliersData = new Map<string, any>()
const newOffersData = new Map<string, OfferData>()
const newSuppliersData = new Map<string, SupplierData>()
const teamUuidsToLoad = new Set<string>()
// First, load all offers

View File

@@ -35,14 +35,16 @@
</template>
<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 { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
const productsData = computed(() => data.value?.getProducts || [])
const selectProduct = (product: any) => {
const selectProduct = (product: Product) => {
searchStore.setProduct(product.name)
searchStore.setProductUuid(product.uuid)
const locationUuid = searchStore.searchForm.locationUuid

View File

@@ -154,10 +154,44 @@
</template>
<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 emit = defineEmits<{
submit: [data: any]
submit: [data: KycSubmitData]
}>()
const loading = ref(false)
@@ -195,7 +229,7 @@ const isFormValid = computed(() => {
})
// Handlers
const onCompanySelect = (company: any) => {
const onCompanySelect = (company: CompanySuggestion) => {
formData.value.company = {
companyName: company.value,
companyFullName: company.unrestricted_value,
@@ -206,7 +240,7 @@ const onCompanySelect = (company: any) => {
}
}
const onBankSelect = (bank: any) => {
const onBankSelect = (bank: BankSuggestion) => {
formData.value.bank = {
bankName: bank.value,
bik: bank.data.bic,

View File

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

View File

@@ -3,7 +3,7 @@
<!-- Header with back button -->
<div class="p-4 border-b border-base-300">
<NuxtLink
:to="localePath('/catalog')"
:to="localePath('/catalog?select=product')"
class="btn btn-sm btn-ghost gap-2"
>
<Icon name="lucide:arrow-left" size="18" />
@@ -52,8 +52,8 @@
<!-- Hubs Tab -->
<div v-else-if="activeTab === 'hubs'" class="space-y-2">
<HubCard
v-for="hub in hubs"
:key="hub.uuid"
v-for="(hub, index) in hubs"
:key="hub.uuid ?? index"
:hub="hub"
selectable
:is-selected="selectedItemId === hub.uuid"
@@ -67,8 +67,8 @@
<!-- Suppliers Tab -->
<div v-else-if="activeTab === 'suppliers'" class="space-y-2">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
v-for="(supplier, index) in suppliers"
:key="supplier.uuid ?? index"
:supplier="supplier"
selectable
:is-selected="selectedItemId === supplier.uuid"
@@ -81,15 +81,20 @@
<!-- Offers Tab -->
<div v-else-if="activeTab === 'offers'" class="space-y-2">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
selectable
:is-selected="selectedItemId === offer.uuid"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
:supplier-name="offer.supplierName"
:location-name="offer.locationName || offer.locationCountry"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="[]"
@select="$emit('select', offer, 'offer')"
/>
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
<div v-if="offersWithPrice.length === 0" class="text-center text-base-content/50 py-8">
{{ t('catalogMap.empty.offers') }}
</div>
</div>
@@ -98,20 +103,63 @@
</template>
<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'
hubs: any[]
suppliers: any[]
offers: any[]
hubs: Hub[]
suppliers: Supplier[]
offers: Offer[]
selectedItemId: string | null
isLoading: boolean
}>()
defineEmits<{
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
'select': [item: any, type: string]
'select': [item: Hub | Supplier | Offer, type: 'hub' | 'supplier' | 'offer']
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const offersWithPrice = computed(() =>
(props.offers || []).filter(o => o?.pricePerUnit != null)
)
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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
padding="sm"
:interactive="linkable || selectable"
class="relative overflow-hidden"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<Stack gap="2">
<Text size="base" weight="semibold">{{ product.name }}</Text>
<Text v-if="product.offersCount" tone="muted" size="sm">
{{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }}
</Text>
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
</Stack>
<!-- Sparkline background -->
<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">
{{ product.offersCount }} {{ t('catalog.offers', product.offersCount) }}
</Text>
<Text v-if="effectivePrice" size="sm" class="text-primary font-bold shrink-0">
{{ formattedPrice }}
</Text>
</div>
<Text v-if="product.description && !compact" tone="muted" size="sm" class="mt-1">
{{ product.description }}
</Text>
</div>
</div>
</div>
</Card>
</component>
</template>
@@ -34,12 +76,17 @@ interface Product {
offersCount?: number | null
}
const props = defineProps<{
const props = withDefaults(defineProps<{
product: Product
selectable?: boolean
isSelected?: boolean
compact?: boolean
}>()
priceHistory?: number[]
currentPrice?: number | null
currency?: string | null
}>(), {
priceHistory: () => []
})
defineEmits<{
(e: 'select'): void
@@ -49,4 +96,83 @@ const localePath = useLocalePath()
const { t } = useI18n()
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>

View File

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

View File

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

View File

@@ -9,35 +9,45 @@
<Card
padding="small"
:interactive="linkable || selectable"
class="relative"
:class="[
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<!-- Verified badge top-right -->
<span v-if="supplier.isVerified" class="absolute -top-2 -right-2 badge badge-neutral badge-sm">
{{ t('catalogSupplier.badges.verified') }}
</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 class="flex flex-col gap-3">
<!-- Top row: Info + Logo (logo on right) -->
<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>
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
</div>
<!-- Country -->
<Text tone="muted" size="sm">
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
</Text>
</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>
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
{{ supplier.name?.charAt(0) }}
</div>
<!-- Title -->
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
<!-- Badges -->
<!-- Bottom row: Badges/Chips -->
<div class="flex flex-wrap gap-1">
<span class="badge badge-neutral badge-dash text-xs">
<span v-if="reliabilityLabel" class="badge badge-neutral badge-sm">
{{ reliabilityLabel }}
</span>
</div>
<!-- Country below -->
<Text tone="muted" size="sm">
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
</Text>
</div>
</Card>
</component>

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

View File

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

View File

@@ -13,27 +13,21 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
JSONString: { input: any; output: any; }
JSON: { input: Record<string, unknown>; output: Record<string, unknown>; }
};
/** Cluster or individual point for map display. */
export type ClusterPointType = {
__typename?: 'ClusterPointType';
/** 1 for single point, >1 for cluster */
export type ClusterPoint = {
__typename?: 'ClusterPoint';
count?: Maybe<Scalars['Int']['output']>;
/** Zoom level to expand cluster */
expansionZoom?: Maybe<Scalars['Int']['output']>;
/** UUID for points, 'cluster-N' for clusters */
id?: Maybe<Scalars['String']['output']>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
/** Node name (only for single points) */
name?: Maybe<Scalars['String']['output']>;
};
/** Edge between two nodes (route). */
export type EdgeType = {
__typename?: 'EdgeType';
export type Edge = {
__typename?: 'Edge';
distanceKm?: Maybe<Scalars['Float']['output']>;
toLatitude?: Maybe<Scalars['Float']['output']>;
toLongitude?: Maybe<Scalars['Float']['output']>;
@@ -43,22 +37,12 @@ export type EdgeType = {
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Auto + rail edges for a node, rail uses nearest rail node. */
export type NodeConnectionsType = {
__typename?: 'NodeConnectionsType';
autoEdges?: Maybe<Array<Maybe<EdgeType>>>;
hub?: Maybe<NodeType>;
railEdges?: Maybe<Array<Maybe<EdgeType>>>;
railNode?: Maybe<NodeType>;
};
/** Logistics node with edges to neighbors. */
export type NodeType = {
__typename?: 'NodeType';
export type Node = {
__typename?: 'Node';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
distanceKm?: Maybe<Scalars['Float']['output']>;
edges?: Maybe<Array<Maybe<EdgeType>>>;
edges?: Maybe<Array<Maybe<Edge>>>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
name?: Maybe<Scalars['String']['output']>;
@@ -67,9 +51,16 @@ export type NodeType = {
uuid?: Maybe<Scalars['String']['output']>;
};
/** Offer node with location and product info. */
export type OfferNodeType = {
__typename?: 'OfferNodeType';
export type NodeConnections = {
__typename?: 'NodeConnections';
autoEdges?: Maybe<Array<Maybe<Edge>>>;
hub?: Maybe<Node>;
railEdges?: Maybe<Array<Maybe<Edge>>>;
railNode?: Maybe<Node>;
};
export type OfferNode = {
__typename?: 'OfferNode';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
currency?: Maybe<Scalars['String']['output']>;
@@ -86,9 +77,8 @@ export type OfferNodeType = {
uuid?: Maybe<Scalars['String']['output']>;
};
/** Offer with route information to destination. */
export type OfferWithRouteType = {
__typename?: 'OfferWithRouteType';
export type OfferWithRoute = {
__typename?: 'OfferWithRoute';
country?: Maybe<Scalars['String']['output']>;
countryCode?: Maybe<Scalars['String']['output']>;
currency?: Maybe<Scalars['String']['output']>;
@@ -99,94 +89,62 @@ export type OfferWithRouteType = {
productName?: Maybe<Scalars['String']['output']>;
productUuid?: Maybe<Scalars['String']['output']>;
quantity?: Maybe<Scalars['String']['output']>;
routes?: Maybe<Array<Maybe<RoutePathType>>>;
routes?: Maybe<Array<Maybe<RoutePath>>>;
supplierName?: Maybe<Scalars['String']['output']>;
supplierUuid?: Maybe<Scalars['String']['output']>;
unit?: Maybe<Scalars['String']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
/** Route options for a product source to the destination. */
export type ProductRouteOptionType = {
__typename?: 'ProductRouteOptionType';
export type Product = {
__typename?: 'Product';
name?: Maybe<Scalars['String']['output']>;
offersCount?: Maybe<Scalars['Int']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
export type ProductRouteOption = {
__typename?: 'ProductRouteOption';
distanceKm?: Maybe<Scalars['Float']['output']>;
routes?: Maybe<Array<Maybe<RoutePathType>>>;
routes?: Maybe<Array<Maybe<RoutePath>>>;
sourceLat?: Maybe<Scalars['Float']['output']>;
sourceLon?: Maybe<Scalars['Float']['output']>;
sourceName?: Maybe<Scalars['String']['output']>;
sourceUuid?: Maybe<Scalars['String']['output']>;
};
/** Unique product from offers. */
export type ProductType = {
__typename?: 'ProductType';
name?: Maybe<Scalars['String']['output']>;
/** Number of offers for this product */
offersCount?: Maybe<Scalars['Int']['output']>;
uuid?: Maybe<Scalars['String']['output']>;
};
/** Root query. */
export type Query = {
__typename?: 'Query';
/** Get auto route between two points via GraphHopper */
autoRoute?: Maybe<RouteType>;
/** Get clustered nodes for map display (server-side clustering) */
clusteredNodes?: Maybe<Array<Maybe<ClusterPointType>>>;
/** List of countries that have logistics hubs */
hubCountries?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Get hubs where a product is available nearby */
hubsForProduct?: Maybe<Array<Maybe<NodeType>>>;
/** Get paginated list of logistics hubs */
hubsList?: Maybe<Array<Maybe<NodeType>>>;
/** Get nearest hubs to an offer location */
hubsNearOffer?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest hubs to coordinates (optionally filtered by product) */
nearestHubs?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest logistics nodes to given coordinates */
nearestNodes?: Maybe<Array<Maybe<NodeType>>>;
/** Find nearest offers to coordinates with optional routes to hub */
nearestOffers?: Maybe<Array<Maybe<OfferWithRouteType>>>;
/** Find nearest suppliers to coordinates (optionally filtered by product) */
nearestSuppliers?: Maybe<Array<Maybe<SupplierType>>>;
/** Get node by UUID with all edges to neighbors */
node?: Maybe<NodeType>;
/** Get auto + rail edges for a node (rail uses nearest rail node) */
nodeConnections?: Maybe<NodeConnectionsType>;
/** Get all nodes (without edges for performance) */
nodes?: Maybe<Array<Maybe<NodeType>>>;
/** Get total count of nodes (with optional transport/country/bounds filter) */
nodesCount?: Maybe<Scalars['Int']['output']>;
/** Get route from a specific offer to hub */
offerToHub?: Maybe<ProductRouteOptionType>;
/** Get offers for a product with routes to hub (auto → rail* → auto) */
offersByHub?: Maybe<Array<Maybe<ProductRouteOptionType>>>;
/** Get all offers for a product */
offersByProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
/** Get offers from a supplier for a specific product */
offersBySupplierProduct?: Maybe<Array<Maybe<OfferNodeType>>>;
/** Get unique products from all offers */
products?: Maybe<Array<Maybe<ProductType>>>;
/** Get products offered by a supplier */
productsBySupplier?: Maybe<Array<Maybe<ProductType>>>;
/** Get paginated list of products from graph */
productsList?: Maybe<Array<Maybe<ProductType>>>;
/** Get products available near a hub */
productsNearHub?: Maybe<Array<Maybe<ProductType>>>;
/** Get rail route between two points via OpenRailRouting */
railRoute?: Maybe<RouteType>;
/** Get route from offer to target coordinates (finds nearest hub to coordinate) */
routeToCoordinate?: Maybe<ProductRouteOptionType>;
/** Get unique suppliers from all offers */
suppliers?: Maybe<Array<Maybe<SupplierType>>>;
/** Get suppliers that offer a specific product */
suppliersForProduct?: Maybe<Array<Maybe<SupplierType>>>;
/** Get paginated list of suppliers from graph */
suppliersList?: Maybe<Array<Maybe<SupplierType>>>;
autoRoute?: Maybe<Route>;
clusteredNodes: Array<ClusterPoint>;
hubCountries: Array<Scalars['String']['output']>;
hubsForProduct: Array<Node>;
hubsList: Array<Node>;
hubsNearOffer: Array<Node>;
nearestHubs: Array<Node>;
nearestNodes: Array<Node>;
nearestOffers: Array<OfferWithRoute>;
nearestSuppliers: Array<Supplier>;
node?: Maybe<Node>;
nodeConnections?: Maybe<NodeConnections>;
nodes: Array<Node>;
nodesCount: Scalars['Int']['output'];
offerToHub?: Maybe<ProductRouteOption>;
offersByHub: Array<ProductRouteOption>;
offersByProduct: Array<OfferNode>;
offersBySupplierProduct: Array<OfferNode>;
products: Array<Product>;
productsBySupplier: Array<Product>;
productsList: Array<Product>;
productsNearHub: Array<Product>;
railRoute?: Maybe<Route>;
routeToCoordinate?: Maybe<ProductRouteOption>;
suppliers: Array<Supplier>;
suppliersForProduct: Array<Supplier>;
suppliersList: Array<Supplier>;
};
/** Root query. */
export type QueryAutoRouteArgs = {
fromLat: Scalars['Float']['input'];
fromLon: Scalars['Float']['input'];
@@ -195,7 +153,6 @@ export type QueryAutoRouteArgs = {
};
/** Root query. */
export type QueryClusteredNodesArgs = {
east: Scalars['Float']['input'];
nodeType?: InputMaybe<Scalars['String']['input']>;
@@ -207,30 +164,30 @@ export type QueryClusteredNodesArgs = {
};
/** Root query. */
export type QueryHubsForProductArgs = {
productUuid: Scalars['String']['input'];
radiusKm?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryHubsListArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
north?: InputMaybe<Scalars['Float']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
south?: InputMaybe<Scalars['Float']['input']>;
transportType?: InputMaybe<Scalars['String']['input']>;
west?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryHubsNearOfferArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
offerUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryNearestHubsArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -240,7 +197,6 @@ export type QueryNearestHubsArgs = {
};
/** Root query. */
export type QueryNearestNodesArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -248,7 +204,6 @@ export type QueryNearestNodesArgs = {
};
/** Root query. */
export type QueryNearestOffersArgs = {
hubUuid?: InputMaybe<Scalars['String']['input']>;
lat: Scalars['Float']['input'];
@@ -259,7 +214,6 @@ export type QueryNearestOffersArgs = {
};
/** Root query. */
export type QueryNearestSuppliersArgs = {
lat: Scalars['Float']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -269,13 +223,11 @@ export type QueryNearestSuppliersArgs = {
};
/** Root query. */
export type QueryNodeArgs = {
uuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryNodeConnectionsArgs = {
limitAuto?: InputMaybe<Scalars['Int']['input']>;
limitRail?: InputMaybe<Scalars['Int']['input']>;
@@ -283,7 +235,6 @@ export type QueryNodeConnectionsArgs = {
};
/** Root query. */
export type QueryNodesArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -297,7 +248,6 @@ export type QueryNodesArgs = {
};
/** Root query. */
export type QueryNodesCountArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
@@ -308,14 +258,12 @@ export type QueryNodesCountArgs = {
};
/** Root query. */
export type QueryOfferToHubArgs = {
hubUuid: Scalars['String']['input'];
offerUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryOffersByHubArgs = {
hubUuid: Scalars['String']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -323,40 +271,38 @@ export type QueryOffersByHubArgs = {
};
/** Root query. */
export type QueryOffersByProductArgs = {
productUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryOffersBySupplierProductArgs = {
productUuid: Scalars['String']['input'];
supplierUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryProductsBySupplierArgs = {
supplierUuid: Scalars['String']['input'];
};
/** Root query. */
export type QueryProductsListArgs = {
east?: InputMaybe<Scalars['Float']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
north?: InputMaybe<Scalars['Float']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
south?: InputMaybe<Scalars['Float']['input']>;
west?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryProductsNearHubArgs = {
hubUuid: Scalars['String']['input'];
radiusKm?: InputMaybe<Scalars['Float']['input']>;
};
/** Root query. */
export type QueryRailRouteArgs = {
fromLat: Scalars['Float']['input'];
fromLon: Scalars['Float']['input'];
@@ -365,7 +311,6 @@ export type QueryRailRouteArgs = {
};
/** Root query. */
export type QueryRouteToCoordinateArgs = {
lat: Scalars['Float']['input'];
lon: Scalars['Float']['input'];
@@ -373,30 +318,36 @@ export type QueryRouteToCoordinateArgs = {
};
/** Root query. */
export type QuerySuppliersForProductArgs = {
productUuid: Scalars['String']['input'];
};
/** Root query. */
export type QuerySuppliersListArgs = {
country?: InputMaybe<Scalars['String']['input']>;
east?: InputMaybe<Scalars['Float']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
north?: InputMaybe<Scalars['Float']['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 RoutePathType = {
__typename?: 'RoutePathType';
stages?: Maybe<Array<Maybe<RouteStageType>>>;
export type Route = {
__typename?: 'Route';
distanceKm?: Maybe<Scalars['Float']['output']>;
geometry?: Maybe<Scalars['JSON']['output']>;
};
export type RoutePath = {
__typename?: 'RoutePath';
stages?: Maybe<Array<Maybe<RouteStage>>>;
totalDistanceKm?: Maybe<Scalars['Float']['output']>;
totalTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Single stage in a multi-hop route. */
export type RouteStageType = {
__typename?: 'RouteStageType';
export type RouteStage = {
__typename?: 'RouteStage';
distanceKm?: Maybe<Scalars['Float']['output']>;
fromLat?: Maybe<Scalars['Float']['output']>;
fromLon?: Maybe<Scalars['Float']['output']>;
@@ -410,17 +361,8 @@ export type RouteStageType = {
travelTimeSeconds?: Maybe<Scalars['Int']['output']>;
};
/** Route between two points with geometry. */
export type RouteType = {
__typename?: 'RouteType';
distanceKm?: Maybe<Scalars['Float']['output']>;
/** GeoJSON LineString coordinates */
geometry?: Maybe<Scalars['JSONString']['output']>;
};
/** Unique supplier from offers. */
export type SupplierType = {
__typename?: 'SupplierType';
export type Supplier = {
__typename?: 'Supplier';
distanceKm?: Maybe<Scalars['Float']['output']>;
latitude?: Maybe<Scalars['Float']['output']>;
longitude?: Maybe<Scalars['Float']['output']>;
@@ -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<{
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 GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries?: Array<string | null> | null };
export type GetHubCountriesQueryResult = { __typename?: 'Query', hubCountries: Array<string> };
export type GetNodeQueryVariables = Exact<{
uuid: Scalars['String']['input'];
}>;
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'NodeType', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
export type GetNodeQueryResult = { __typename?: 'Query', node?: { __typename?: 'Node', uuid?: string | null, name?: string | null, latitude?: number | null, longitude?: number | null, country?: string | null, countryCode?: string | null, transportTypes?: Array<string | null> | null } | null };
export type GetRailRouteQueryVariables = Exact<{
fromLat: Scalars['Float']['input'];
@@ -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<{
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
country?: 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<{
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<{
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<{
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<{
limit?: 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<{
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['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>;
@@ -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 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 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 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 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 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 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"}}},{"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; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
DateTime: { input: string; output: string; }
};
/** Full company data (requires auth). */
export type CompanyFullType = {
__typename?: 'CompanyFullType';
export type CompanyFull = {
__typename?: 'CompanyFull';
activities?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
address?: Maybe<Scalars['String']['output']>;
capital?: Maybe<Scalars['String']['output']>;
@@ -26,45 +24,35 @@ export type CompanyFullType = {
director?: Maybe<Scalars['String']['output']>;
inn?: Maybe<Scalars['String']['output']>;
isActive?: Maybe<Scalars['Boolean']['output']>;
lastUpdated?: Maybe<Scalars['DateTime']['output']>;
lastUpdated?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
ogrn?: Maybe<Scalars['String']['output']>;
registrationYear?: Maybe<Scalars['Int']['output']>;
sources?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
};
/** Public company data (teaser). */
export type CompanyTeaserType = {
__typename?: 'CompanyTeaserType';
/** Company type: ООО, АО, ИП, etc. */
export type CompanyTeaser = {
__typename?: 'CompanyTeaser';
companyType?: Maybe<Scalars['String']['output']>;
/** Is company active */
isActive?: Maybe<Scalars['Boolean']['output']>;
/** Year of registration */
registrationYear?: Maybe<Scalars['Int']['output']>;
/** Number of data sources */
sourcesCount?: Maybe<Scalars['Int']['output']>;
};
/** Public queries - no authentication required. */
export type PublicQuery = {
__typename?: 'PublicQuery';
health?: Maybe<Scalars['String']['output']>;
/** Get full KYC profile data by UUID (requires auth) */
kycProfileFull?: Maybe<CompanyFullType>;
/** Get public KYC profile teaser data by UUID */
kycProfileTeaser?: Maybe<CompanyTeaserType>;
export type Query = {
__typename?: 'Query';
health: Scalars['String']['output'];
kycProfileFull?: Maybe<CompanyFull>;
kycProfileTeaser?: Maybe<CompanyTeaser>;
};
/** Public queries - no authentication required. */
export type PublicQueryKycProfileFullArgs = {
export type QueryKycProfileFullArgs = {
profileUuid: Scalars['String']['input'];
};
/** Public queries - no authentication required. */
export type PublicQueryKycProfileTeaserArgs = {
export type QueryKycProfileTeaserArgs = {
profileUuid: Scalars['String']['input'];
};
@@ -73,14 +61,14 @@ export type GetKycProfileFullQueryVariables = Exact<{
}>;
export type GetKycProfileFullQueryResult = { __typename?: 'PublicQuery', kycProfileFull?: { __typename?: 'CompanyFullType', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
export type GetKycProfileFullQueryResult = { __typename?: 'Query', kycProfileFull?: { __typename?: 'CompanyFull', inn?: string | null, ogrn?: string | null, name?: string | null, companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, address?: string | null, director?: string | null, capital?: string | null, activities?: Array<string | null> | null, sources?: Array<string | null> | null, lastUpdated?: string | null } | null };
export type GetKycProfileTeaserQueryVariables = Exact<{
profileUuid: Scalars['String']['input'];
}>;
export type GetKycProfileTeaserQueryResult = { __typename?: 'PublicQuery', kycProfileTeaser?: { __typename?: 'CompanyTeaserType', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
export type GetKycProfileTeaserQueryResult = { __typename?: 'Query', kycProfileTeaser?: { __typename?: 'CompanyTeaser', companyType?: string | null, registrationYear?: number | null, isActive?: boolean | null, sourcesCount?: number | null } | null };
export const GetKycProfileFullDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetKycProfileFull"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kycProfileFull"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"profileUuid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"profileUuid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inn"}},{"kind":"Field","name":{"kind":"Name","value":"ogrn"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"companyType"}},{"kind":"Field","name":{"kind":"Name","value":"registrationYear"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"address"}},{"kind":"Field","name":{"kind":"Name","value":"director"}},{"kind":"Field","name":{"kind":"Name","value":"capital"}},{"kind":"Field","name":{"kind":"Name","value":"activities"}},{"kind":"Field","name":{"kind":"Name","value":"sources"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}}]}}]}}]} as unknown as DocumentNode<GetKycProfileFullQueryResult, GetKycProfileFullQueryVariables>;

View File

@@ -13,10 +13,10 @@ export type Scalars = {
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
Date: { input: any; output: any; }
Date: { input: string; output: string; }
DateTime: { input: string; output: string; }
Decimal: { input: any; output: any; }
JSONString: { input: any; output: any; }
Decimal: { input: string; output: string; }
JSONString: { input: Record<string, unknown>; output: Record<string, unknown>; }
};
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<{
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>;

View File

@@ -14,7 +14,7 @@ export type Scalars = {
Int: { input: number; output: number; }
Float: { input: number; output: number; }
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. */
@@ -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<{
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 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>;

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'
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
const items = ref<any[]>([])
const items = ref<Array<HubItem | NearestHubItem>>([])
const total = ref(0)
const selectedFilter = ref('all')
const selectedCountry = ref('all')
@@ -36,7 +45,7 @@ export function useCatalogHubs() {
)
const itemsByCountry = computed(() => {
const grouped = new Map<string, any[]>()
const grouped = new Map<string, Array<HubItem | NearestHubItem>>()
items.value.forEach(hub => {
const country = hub.country || t('catalogMap.labels.country_unknown')
if (!grouped.has(country)) grouped.set(country, [])
@@ -52,22 +61,21 @@ export function useCatalogHubs() {
const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true
try {
// If filtering by product, use nearestHubs with global search
// (center point 0,0 with very large radius to cover entire globe)
// If filtering by product, use nearestHubs (graph-based)
if (filterProductUuid.value) {
const data = await execute(
NearestHubsDocument,
{
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
useGraph: true,
limit: 500 // Increased limit for global search
},
'public',
'geo'
)
const next = data?.nearestHubs || []
const next = (data?.nearestHubs || []).filter((h): h is NearestHubItem => h !== null)
items.value = next
total.value = next.length
isInitialized.value = true
@@ -95,7 +103,7 @@ export function useCatalogHubs() {
'public',
'geo'
)
const next = data?.hubsList || []
const next = (data?.hubsList || []).filter((h): h is HubItem => h !== null)
items.value = replace ? next : items.value.concat(next)
// hubsList doesn't return total count, estimate from fetched items
if (replace) {
@@ -138,12 +146,21 @@ export function useCatalogHubs() {
const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true)
}
fetchPage(0, true)
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// 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
if (isInitialized.value) {
fetchPage(0, true)

View File

@@ -1,24 +1,95 @@
import type { InfoEntityType } from './useCatalogSearch'
import type {
GetNodeQueryResult,
NearestHubsQueryResult,
NearestOffersQueryResult
} from '~/composables/graphql/public/geo-generated'
import {
GetNodeDocument,
NearestOffersDocument,
NearestHubsDocument
} from '~/composables/graphql/public/geo-generated'
import type {
GetOfferQueryResult,
GetSupplierProfileQueryResult
} from '~/composables/graphql/public/exchange-generated'
import {
GetOfferDocument,
GetSupplierProfileDocument
GetSupplierProfileDocument,
GetSupplierOffersDocument
} 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() {
const { execute } = useGraphQL()
// State
const entity = ref<any>(null)
const entityType = ref<InfoEntityType | null>(null) // Track entity type explicitly
const relatedProducts = ref<any[]>([])
const relatedHubs = ref<any[]>([])
const relatedSuppliers = ref<any[]>([])
const relatedOffers = ref<any[]>([])
// State with proper types
const entity = ref<InfoEntity | null>(null)
const entityType = ref<InfoEntityType | null>(null)
const relatedProducts = ref<InfoProductItem[]>([])
const relatedHubs = ref<HubItem[]>([])
const relatedSuppliers = ref<SupplierProfile[]>([])
const relatedOffers = ref<OfferItem[]>([])
const selectedProduct = ref<string | null>(null)
const activeTab = ref<string>('products')
const isLoading = ref(false)
@@ -34,9 +105,10 @@ export function useCatalogInfo() {
try {
// Load hub node details
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')
return
}
@@ -52,40 +124,42 @@ export function useCatalogInfo() {
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
lat: coords.lat,
lon: coords.lon,
hubUuid: uuid,
limit: 500
},
'public',
'geo'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, any>()
const suppliersMap = new Map<string, any>()
const productsMap = new Map<string, InfoProductItem>()
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
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
if (offer.productUuid && offer.productName) {
const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
offersCount: 1
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
// Suppliers (extract from offers)
if (offer?.supplierUuid) {
if (!suppliersMap.has(offer.supplierUuid)) {
suppliersMap.set(offer.supplierUuid, {
uuid: offer.supplierUuid,
name: offer.supplierName || 'Supplier',
latitude: offer.latitude,
longitude: offer.longitude
})
}
if (offer.supplierUuid && !suppliersMap.has(offer.supplierUuid)) {
suppliersMap.set(offer.supplierUuid, {
uuid: offer.supplierUuid,
name: offer.supplierName || 'Supplier',
latitude: offer.latitude,
longitude: offer.longitude
})
}
})
@@ -101,7 +175,7 @@ export function useCatalogInfo() {
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
)
).then(profiles => {
relatedSuppliers.value = profiles.filter(Boolean)
relatedSuppliers.value = profiles.filter((p): p is SupplierProfile => p != null)
isLoadingSuppliers.value = false
})
} else {
@@ -123,7 +197,7 @@ export function useCatalogInfo() {
try {
// Load supplier node details (might be geo node)
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
try {
@@ -152,29 +226,25 @@ export function useCatalogInfo() {
isLoadingProducts.value = true
isLoadingHubs.value = true
// Load products (offers grouped by product)
// Load products from supplier offers (no geo radius)
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
GetSupplierOffersDocument,
{ teamUuid: uuid },
'public',
'geo'
'exchange'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
const productsMap = new Map<string, InfoProductItem>()
offersData?.getOffers?.forEach(offer => {
if (!offer?.productUuid || !offer.productName) return
const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 1
})
}
})
relatedProducts.value = Array.from(productsMap.values())
@@ -188,13 +258,13 @@ export function useCatalogInfo() {
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
sourceUuid: entity.value.uuid,
limit: 12
},
'public',
'geo'
).then(hubsData => {
relatedHubs.value = hubsData?.nearestHubs || []
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => {
isLoadingHubs.value = false
})
@@ -210,9 +280,10 @@ export function useCatalogInfo() {
try {
// Load offer details from exchange API
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')
return
}
@@ -235,15 +306,15 @@ export function useCatalogInfo() {
execute(
NearestHubsDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
lat: coords.lat,
lon: coords.lon,
sourceUuid: entity.value?.uuid ?? null,
limit: 12
},
'public',
'geo'
).then(hubsData => {
relatedHubs.value = hubsData?.nearestHubs || []
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => {
isLoadingHubs.value = false
})
@@ -257,9 +328,12 @@ export function useCatalogInfo() {
'public',
'exchange'
).then(supplierData => {
relatedSuppliers.value = supplierData?.getSupplierProfile
? [supplierData.getSupplierProfile]
: []
const supplier = 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(() => {
// Supplier might not exist
}).finally(() => {
@@ -293,7 +367,6 @@ export function useCatalogInfo() {
lon: hub.longitude,
productUuid,
hubUuid, // Pass hubUuid to get routes calculated on backend
radius: 500,
limit: 12
},
'public',
@@ -301,19 +374,19 @@ export function useCatalogInfo() {
)
// Offers already include routes from backend
relatedOffers.value = offersData?.nearestOffers || []
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
isLoadingOffers.value = false
// Extract unique suppliers from offers (use supplierUuid from offers)
const supplierUuids = new Set<string>()
relatedOffers.value.forEach((offer: any) => {
relatedOffers.value.forEach(offer => {
if (offer.supplierUuid) {
supplierUuids.add(offer.supplierUuid)
}
})
// Load supplier profiles (limit to 12)
const suppliers: any[] = []
const suppliers: SupplierProfile[] = []
for (const uuid of Array.from(supplierUuids).slice(0, 12)) {
try {
const supplierData = await execute(
@@ -352,6 +425,28 @@ export function useCatalogInfo() {
isLoadingHubs.value = true
try {
let hubUuid: string | null = relatedHubs.value?.[0]?.uuid ?? null
if (!hubUuid && supplier.uuid) {
const hubsData = await execute(
NearestHubsDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
sourceUuid: supplier.uuid,
limit: 1
},
'public',
'geo'
)
const hub = (hubsData?.nearestHubs || []).find((h): h is HubItem => h !== null)
if (hub?.uuid) {
hubUuid = hub.uuid
if (!relatedHubs.value.length) {
relatedHubs.value = [hub]
}
}
}
// Find offers near supplier for this product
const offersData = await execute(
NearestOffersDocument,
@@ -359,46 +454,19 @@ export function useCatalogInfo() {
lat: supplier.latitude,
lon: supplier.longitude,
productUuid,
radius: 500,
...(hubUuid ? { hubUuid } : {}),
limit: 12
},
'public',
'geo'
)
relatedOffers.value = offersData?.nearestOffers || []
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => {
if (!o) return false
if (!supplier.uuid) return true
return o.supplierUuid === supplier.uuid
})
isLoadingOffers.value = false
// Load hubs near each offer and aggregate (limit to 12)
const allHubs = new Map<string, 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) {
console.warn('Error loading hubs for offer:', offer.uuid, e)
}
if (allHubs.size >= 12) break
}
relatedHubs.value = Array.from(allHubs.values()).slice(0, 12)
} finally {
isLoadingOffers.value = false
isLoadingHubs.value = false
@@ -415,10 +483,10 @@ export function useCatalogInfo() {
if (!entity.value) return
// 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)
activeTab.value = 'offers'
} else if (entityType.value === 'supplier') {
} else if (entityType.value === 'supplier' && entity.value.uuid) {
await loadOffersForSupplier(entity.value.uuid, productUuid)
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'
const PAGE_SIZE = 24
// Type from codegen
type OfferItem = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
// Shared state across list and map views
const items = ref<any[]>([])
const items = ref<OfferItem[]>([])
const total = ref(0)
const selectedProductUuid = ref<string | null>(null)
const isLoading = ref(false)
@@ -18,7 +22,7 @@ export function useCatalogOffers() {
.filter(offer => offer.locationLatitude && offer.locationLongitude)
.map(offer => ({
uuid: offer.uuid,
name: offer.productName || offer.title,
name: offer.productName || offer.locationName,
latitude: offer.locationLatitude,
longitude: offer.locationLongitude,
country: offer.locationCountry
@@ -40,7 +44,7 @@ export function useCatalogOffers() {
'public',
'exchange'
)
const next = data?.getOffers || []
const next = (data?.getOffers || []).filter((o): o is OfferItem => o !== null)
items.value = replace ? next : items.value.concat(next)
total.value = data?.getOffersCount ?? total.value
isInitialized.value = true

View File

@@ -1,14 +1,26 @@
import type { ProductsListQueryResult, NearestOffersQueryResult } from '~/composables/graphql/public/geo-generated'
import {
ProductsListDocument,
GetNodeDocument,
NearestOffersDocument
} from '~/composables/graphql/public/geo-generated'
import {
GetSupplierProfileDocument
GetSupplierOffersDocument
} 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
const items = ref<any[]>([])
const items = ref<ProductItem[]>([])
const isLoading = ref(false)
const isLoadingMore = ref(false)
const isInitialized = ref(false)
@@ -31,47 +43,26 @@ export function useCatalogProducts() {
let data
if (filterSupplierUuid.value) {
// Products from specific supplier - get supplier coordinates first
const supplierData = await execute(
GetSupplierProfileDocument,
{ uuid: filterSupplierUuid.value },
// Products from specific supplier - get offers directly (no geo radius)
const offersData = await execute(
GetSupplierOffersDocument,
{ teamUuid: filterSupplierUuid.value },
'public',
'exchange'
)
const supplier = supplierData?.getSupplierProfile
if (!supplier?.latitude || !supplier?.longitude) {
console.warn('Supplier has no coordinates')
items.value = []
} else {
// Get offers near supplier and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
radius: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
items.value = Array.from(productsMap.values())
}
const productsMap = new Map<string, AggregatedProduct>()
offersData?.getOffers?.forEach((offer) => {
if (!offer?.productUuid) return
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
})
items.value = Array.from(productsMap.values()) as ProductItem[]
} else if (filterHubUuid.value) {
// Products near hub - get hub coordinates first
const hubData = await execute(
@@ -86,33 +77,33 @@ export function useCatalogProducts() {
console.warn('Hub has no coordinates')
items.value = []
} else {
// Get offers near hub and group by product
// Get offers by graph from hub and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
radius: 500
hubUuid: filterHubUuid.value,
limit: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
const productsMap = new Map<string, AggregatedProduct>()
offersData?.nearestOffers?.forEach((offer) => {
if (!offer?.productUuid) return
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
})
items.value = Array.from(productsMap.values())
items.value = Array.from(productsMap.values()) as ProductItem[]
}
} else {
// All products from graph
@@ -130,7 +121,7 @@ export function useCatalogProducts() {
'public',
'geo'
)
items.value = data?.productsList || []
items.value = (data?.productsList || []).filter((p): p is ProductItem => p !== null)
}
isInitialized.value = true
@@ -179,6 +170,17 @@ export function useCatalogProducts() {
// Products are filtered by offer locations within bounds
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
if (isInitialized.value) {
fetchProducts()

View File

@@ -84,6 +84,19 @@ export function useCatalogSearch() {
const hubId = computed(() => route.query.hub 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)
const getLabel = (type: string, id: string | undefined): string | null => {
if (!id) return null
@@ -208,19 +221,26 @@ export function useCatalogSearch() {
}
const startSelect = (type: SelectMode) => {
if (!selectMode.value) {
lastViewMode.value = mapViewMode.value
}
updateQuery({ select: type })
}
const cancelSelect = () => {
updateQuery({ select: null })
updateQuery({
select: null
})
}
const selectItem = (type: string, id: string, label: string) => {
setLabel(type, id, label)
const forcedView = (type === 'hub' || type === 'supplier') ? null : (lastViewMode.value === 'offers' ? null : lastViewMode.value)
updateQuery({
[type]: id,
select: null, // Exit selection mode
info: null // Exit info mode
info: null, // Exit info mode
view: forcedView
})
}
@@ -237,6 +257,26 @@ export function useCatalogSearch() {
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) => {
updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null })
}
@@ -272,8 +312,15 @@ export function useCatalogSearch() {
}
return 'offers' // default
})
const lastViewMode = useState<MapViewMode>('catalog-last-view-mode', () => 'offers')
const setMapViewMode = (mode: MapViewMode) => {
updateQuery({ view: mode === 'offers' ? null : mode })
const newSelectMode: SelectMode = mode === 'hubs' ? 'hub'
: mode === 'suppliers' ? 'supplier'
: 'product'
updateQuery({
view: mode === 'offers' ? null : mode,
select: newSelectMode
})
}
// Drawer state for list view
@@ -323,7 +370,15 @@ export function useCatalogSearch() {
})
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)
@@ -350,6 +405,8 @@ export function useCatalogSearch() {
quantity,
searchQuery,
mapViewMode,
urlBounds,
filterByBounds,
// Drawer state
isDrawerOpen,
@@ -373,6 +430,9 @@ export function useCatalogSearch() {
removeFilter,
editFilter,
setQuantity,
setBoundsInUrl,
clearBoundsFromUrl,
setBoundsFilterEnabled,
openInfo,
closeInfo,
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'
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
const items = ref<any[]>([])
const items = ref<Array<SupplierItem | NearestSupplierItem>>([])
const total = ref(0)
const isLoading = ref(false)
const isLoadingMore = ref(false)
@@ -15,7 +20,7 @@ export function useCatalogSuppliers() {
const { execute } = useGraphQL()
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)
@@ -23,22 +28,20 @@ export function useCatalogSuppliers() {
const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true
try {
// If filtering by product, use nearestSuppliers with global search
// (center point 0,0 with very large radius to cover entire globe)
// If filtering by product, use nearestSuppliers (product-only list)
if (filterProductUuid.value) {
const data = await execute(
NearestSuppliersDocument,
{
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
limit: 500 // Increased limit for global search
},
'public',
'geo'
)
items.value = data?.nearestSuppliers || []
items.value = (data?.nearestSuppliers || []).filter((s): s is NearestSupplierItem => s !== null)
total.value = items.value.length
isInitialized.value = true
return
@@ -60,7 +63,7 @@ export function useCatalogSuppliers() {
'public',
'geo'
)
const next = data?.suppliersList || []
const next = (data?.suppliersList || []).filter((s): s is SupplierItem => s !== null)
items.value = replace ? next : items.value.concat(next)
// suppliersList doesn't return total count, estimate from fetched items
@@ -95,12 +98,21 @@ export function useCatalogSuppliers() {
const setProductFilter = (uuid: string | null) => {
if (filterProductUuid.value === uuid) return // Early return if unchanged
filterProductUuid.value = uuid
if (isInitialized.value) {
fetchPage(0, true)
}
fetchPage(0, true)
}
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// 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
if (isInitialized.value) {
fetchPage(0, true)

View File

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

View File

@@ -1,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 isInitialized = ref(false)
@@ -23,7 +27,7 @@ export function useTeamAddresses() {
try {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
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
} catch (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 isInitialized = ref(false)
@@ -23,13 +28,14 @@ export function useTeamOrders() {
const routesForMap = computed(() =>
filteredItems.value
.filter(order => order.uuid && order.name)
.map(order => ({
uuid: order.uuid,
name: order.name,
status: order.status,
uuid: order.uuid!,
name: order.name!,
status: order.status ?? undefined,
stages: (order.stages || [])
.filter((s: any) => s.stageType === 'transport' && s.sourceLatitude && s.sourceLongitude && s.destinationLatitude && s.destinationLongitude)
.map((s: any) => ({
.filter((s): s is TeamOrderStage => s !== null && s.stageType === 'transport' && !!s.sourceLatitude && !!s.sourceLongitude && !!s.destinationLatitude && !!s.destinationLongitude)
.map((s) => ({
fromLat: s.sourceLatitude,
fromLon: s.sourceLongitude,
toLat: s.destinationLatitude,
@@ -47,7 +53,7 @@ export function useTeamOrders() {
try {
const { GetTeamOrdersDocument } = await import('~/composables/graphql/team/orders-generated')
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
} catch (e) {
console.error('Failed to load orders', e)

View File

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

View File

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

View File

@@ -26,9 +26,10 @@
</template>
<template #card="{ item }">
<HubProductCard
:name="item.name"
<ProductCard
:product="item"
:price-history="getMockPriceHistory(item.uuid)"
selectable
@select="goToProduct(item.uuid)"
/>
</template>
@@ -43,7 +44,9 @@
</template>
<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({
layout: 'topnav'
@@ -55,7 +58,7 @@ const { t } = useI18n()
const isLoading = ref(true)
const hoveredId = ref<string>()
const hub = ref<any>(null)
const hub = ref<Hub | null>(null)
const products = ref<Array<{ uuid: string; name: string }>>([])
const hubId = computed(() => route.params.id as string)
@@ -149,7 +152,7 @@ try {
// Group offers by product
const productsMap = new Map<string, { uuid: string; name: string }>()
offersData.value?.nearestOffers?.forEach((offer: any) => {
offersData.value?.nearestOffers?.forEach((offer) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {

View File

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

View File

@@ -37,19 +37,19 @@
</div>
<!-- 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>
<MapboxMap
map-id="offer-location-map"
class="w-full h-full"
:options="{
style: 'mapbox://styles/mapbox/streets-v12',
center: [offer.longitude, offer.latitude],
center: [offer.locationLongitude, offer.locationLatitude],
zoom: 8
}"
>
<MapboxDefaultMarker
:lnglat="[offer.longitude, offer.latitude]"
:lnglat="[offer.locationLongitude, offer.locationLatitude]"
color="#10b981"
/>
</MapboxMap>
@@ -101,7 +101,10 @@
</template>
<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({
layout: 'topnav'
@@ -114,8 +117,8 @@ const { execute } = useGraphQL()
const offerId = computed(() => route.params.offerId as string)
const isLoading = ref(true)
const offer = ref<any>(null)
const supplier = ref<any>(null)
const offer = ref<Offer | null>(null)
const supplier = ref<SupplierProfile | null>(null)
// Load offer data
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>
<Heading :level="2">{{ t('catalogProduct.not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogProduct.not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))">
<Button @click="navigateTo(localePath('/catalog?select=product'))">
{{ t('catalogProduct.actions.back_to_catalog') }}
</Button>
</Stack>
@@ -106,7 +106,7 @@
</Stack>
<!-- 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">
<Stack direction="row" align="center" justify="between">
<Stack gap="0">
@@ -204,8 +204,14 @@ import {
GetProductsDocument,
GetProductOffersDocument,
GetSupplierProfilesDocument,
type GetProductsQueryResult,
type GetProductOffersQueryResult
} from '~/composables/graphql/public/exchange-generated'
// Types from GraphQL
type Product = NonNullable<NonNullable<GetProductsQueryResult['getProducts']>[number]>
type ProductOffer = NonNullable<NonNullable<GetProductOffersQueryResult['getOffers']>[number]>
definePageMeta({
layout: 'topnav'
})
@@ -237,7 +243,7 @@ const allSuppliers = computed(() => suppliersData.value?.getSupplierProfiles ||
const productId = computed(() => route.params.id as string)
// Find product by uuid from list
const findProduct = (products: any[]) => {
const findProduct = (products: (Product | null)[]) => {
return products.find(p => p?.uuid === productId.value)
}
@@ -295,11 +301,13 @@ const mapLocations = computed(() => {
const priceRange = computed(() => {
const prices: number[] = []
offers.value.forEach(offer => {
(offer as any).lines?.forEach((line: any) => {
if (line?.productUuid === productId.value && line?.pricePerUnit) {
prices.push(Number(line.pricePerUnit))
// Offers for this product already filtered by productUuid
if (offer.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')
const min = Math.min(...prices)
@@ -308,9 +316,24 @@ const priceRange = computed(() => {
return t('catalogProduct.labels.price_range', { min: min.toLocaleString(), max: max.toLocaleString() })
})
// Get lines with this product
const getProductLines = (offer: any) => {
return (offer.lines || []).filter((line: any) => line?.productUuid === productId.value)
// Get offer as "line" - offers already have quantity/unit/price directly
interface OfferLine {
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) => {
@@ -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 '—'
const num = Number(price)
const num = typeof price === 'string' ? parseFloat(price) : Number(price)
if (isNaN(num)) return '—'
const curr = currency || 'USD'
try {
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

@@ -94,6 +94,10 @@
import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -112,7 +116,7 @@ const isSaving = ref(false)
const isDeleting = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null)
const searchBoxRef = ref<MapboxSearchBox | null>(null)
const addressData = ref<{
uuid: string
@@ -130,7 +134,7 @@ const loadAddress = async () => {
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
const data = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
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) {
addressData.value = {
@@ -167,7 +171,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
if (!feature) return { address: null, countryCode: null }
// 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
return { address: feature.place_name, countryCode }
@@ -215,7 +219,7 @@ onMounted(async () => {
searchBox.value = addressData.value.address
}
searchBox.addEventListener('retrieve', (event: any) => {
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
if (!addressData.value) return
const feature = event.detail.features?.[0]

View File

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

View File

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

View File

@@ -71,6 +71,10 @@
import { NuxtLink } from '#components'
import type { MapMouseEvent, Map as MapboxMapType } from 'mapbox-gl'
interface MapboxSearchBox {
value: string
}
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -84,7 +88,7 @@ const config = useRuntimeConfig()
const isCreating = ref(false)
const searchBoxContainer = ref<HTMLElement | null>(null)
const mapInstance = ref<MapboxMapType | null>(null)
const searchBoxRef = ref<any>(null)
const searchBoxRef = ref<MapboxSearchBox | null>(null)
const newAddress = reactive({
name: '',
@@ -110,7 +114,7 @@ const reverseGeocode = async (lat: number, lng: number): Promise<{ address: stri
if (!feature) return { address: null, countryCode: null }
// 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
return { address: feature.place_name, countryCode }
@@ -151,7 +155,7 @@ onMounted(async () => {
}
searchBox.placeholder = t('profileAddresses.form.address.placeholder')
searchBox.addEventListener('retrieve', (event: any) => {
searchBox.addEventListener('retrieve', (event: CustomEvent) => {
const feature = event.detail.features?.[0]
if (feature) {
const [lng, lat] = feature.geometry.coordinates

View File

@@ -109,9 +109,9 @@ const handleSend = async () => {
const content = last?.content?.[0]?.text || last?.content || t('aiAssistants.view.emptyResponse')
chat.value.push({ role: 'assistant', content })
scrollToBottom()
} catch (e: any) {
} catch (e: unknown) {
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') })
scrollToBottom()
} finally {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,7 +122,9 @@
<script setup lang="ts">
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({
layout: 'topnav',
@@ -135,7 +137,7 @@ const { activeTeamId } = useActiveTeam()
const { execute } = useGraphQL()
const PAGE_SIZE = 24
const offers = ref<any[]>([])
const offers = ref<Offer[]>([])
const totalOffers = ref(0)
const isLoadingMore = ref(false)
@@ -164,7 +166,7 @@ const {
watchEffect(() => {
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
}
})
@@ -231,9 +233,11 @@ const onSearch = () => {
// TODO: Implement search
}
const onSelectOffer = (offer: any) => {
selectedOfferId.value = offer.uuid
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
const onSelectOffer = (offer: { uuid?: string | null }) => {
if (offer.uuid) {
selectedOfferId.value = offer.uuid
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
}
}
const getStatusVariant = (status: string) => {
@@ -293,7 +297,7 @@ const fetchOffers = async (offset = 0, replace = false) => {
'public',
'exchange'
)
const next = data?.getOffers || []
const next = (data?.getOffers || []).filter((o): o is Offer => o !== null)
offers.value = replace ? next : offers.value.concat(next)
totalOffers.value = data?.getOffersCount ?? totalOffers.value
}

View File

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

View File

@@ -1,54 +1,147 @@
<template>
<Section variant="plain">
<Stack gap="8">
<template v-if="hasOrderError">
<div class="text-sm text-error">
{{ orderError }}
<div>
<CatalogPage
:items="mapPoints"
:loading="isLoadingOrder"
:use-server-clustering="false"
map-id="order-detail-map"
point-color="#6366f1"
:show-panel="false"
:hide-view-toggle="true"
/>
<!-- Bottom Sheet with slide-up animation -->
<Transition name="slide-up" appear>
<div class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4" style="height: 72vh">
<div class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent" />
<!-- Sheet -->
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
<!-- Drag handle -->
<div class="flex justify-center py-2">
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
</div>
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button>
</template>
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
{{ t('ordersDetail.states.loading') }}
</div>
<!-- Header -->
<div class="border-b border-base-300 bg-base-100/90 px-6 pb-4">
<!-- Back button -->
<NuxtLink :to="localePath('/clientarea/orders')" class="mb-3 inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-base-content">
<Icon name="lucide:arrow-left" size="16" />
{{ t('common.back') }}
</NuxtLink>
<template v-else>
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
</Card>
<template v-if="hasOrderError">
<div class="rounded-lg border border-error/30 bg-error/10 p-4">
<div class="mb-2 font-black text-base-content">{{ t('common.error') }}</div>
<div class="mb-3 text-sm text-base-content/70">{{ orderError }}</div>
<button class="btn btn-sm btn-outline" @click="loadOrder">
{{ t('ordersDetail.errors.retry') }}
</button>
</div>
</template>
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
<Stack gap="4">
<RouteStagesList
:stages="orderStageItems"
:empty-text="t('ordersDetail.sections.stages.empty')"
<template v-else-if="!isLoadingOrder && order">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15">
<Icon name="lucide:package" size="24" class="text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
<div class="flex items-center gap-2 flex-wrap">
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-base-content/55">
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
</span>
</div>
</div>
</div>
</template>
<template v-else>
<div class="animate-pulse">
<div class="h-12 w-48 rounded-xl bg-base-300/70" />
</div>
</template>
</div>
<!-- Scrollable content -->
<div v-if="!hasOrderError && order" class="h-[calc(72vh-150px)] overflow-y-auto px-6 py-4 space-y-4">
<!-- Route stages -->
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:route" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
</div>
<div class="space-y-3">
<div
v-for="(stage, idx) in orderStageItems"
:key="stage.key || idx"
class="flex gap-3"
>
<div class="flex flex-col items-center">
<div class="h-3 w-3 rounded-full bg-primary" />
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
</div>
<div class="flex-1 pb-3">
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
{{ stage.to }}
</div>
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
{{ stage.meta.join(' · ') }}
</div>
</div>
</div>
</div>
</div>
<!-- Timeline -->
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:calendar" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
</div>
<GanttTimeline
:stages="order.stages"
:showLoading="showLoading"
:showUnloading="showUnloading"
/>
</div>
<div class="divider my-0"></div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" />
</Stack>
</Card>
<div class="space-y-3">
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading>
<GanttTimeline
v-if="order?.stages"
:stages="order.stages"
:showLoading="showLoading"
:showUnloading="showUnloading"
/>
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text>
<!-- Map preview (small) -->
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
</div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
</div>
</div>
</template>
</Stack>
</Section>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>
<script setup lang="ts">
import { GetOrderDocument } from '~/composables/graphql/team/orders-generated'
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']>
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
@@ -56,14 +149,44 @@ definePageMeta({
const route = useRoute()
const { t } = useI18n()
const localePath = useLocalePath()
const order = ref<any>(null)
const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(true)
const hasOrderError = ref(false)
const orderError = ref('')
const showLoading = ref(true)
const showUnloading = ref(true)
// Map points for route visualization
const mapPoints = computed(() => {
if (!order.value) return []
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number }> = []
// Add source
if (order.value.sourceLatitude && order.value.sourceLongitude) {
points.push({
uuid: 'source',
name: order.value.sourceLocationName || t('ordersDetail.labels.source'),
latitude: order.value.sourceLatitude,
longitude: order.value.sourceLongitude
})
}
// Add destination
if (order.value.destinationLatitude && order.value.destinationLongitude) {
points.push({
uuid: 'destination',
name: order.value.destinationLocationName || t('ordersDetail.labels.destination'),
latitude: order.value.destinationLatitude,
longitude: order.value.destinationLongitude
})
}
return points
})
const orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
@@ -96,8 +219,8 @@ const orderMeta = computed(() => {
const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || [])
.filter(Boolean)
.map((stage: any) => {
.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 {
@@ -118,33 +241,43 @@ const orderRoutesForMap = computed(() => {
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 || []).map((stage: any) => {
const isTransport = stage.stageType === 'transport'
const from = isTransport ? stage.sourceLocationName : stage.locationName
const to = isTransport ? stage.destinationLocationName : stage.locationName
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 meta: string[] = []
const dateRange = getStageDateRange(stage)
if (dateRange) {
meta.push(dateRange)
}
const companies = getCompaniesSummary(stage)
companies.forEach((company: any) => {
meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
)
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
}
})
return {
key: stage.uuid,
from,
to,
label: stage.name,
meta
}
})
})
const loadOrder = async () => {
@@ -154,10 +287,10 @@ const loadOrder = async () => {
const orderUuid = route.params.id as string
const { data, error: orderErrorResp } = await useServerQuery('order-detail', GetOrderDocument, { orderUuid }, 'team', 'orders')
if (orderErrorResp.value) throw orderErrorResp.value
order.value = data.value?.getOrder
} catch (err: any) {
order.value = data.value?.getOrder ?? null
} catch (err: unknown) {
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 {
isLoadingOrder.value = false
}
@@ -172,8 +305,8 @@ const formatPrice = (price: number, currency?: string | null) => {
}).format(price)
}
const getCompaniesSummary = (stage: any) => {
const companies = []
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
const companies: CompanySummary[] = []
if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({
name: stage.selectedCompany.name,
@@ -185,12 +318,13 @@ const getCompaniesSummary = (stage: any) => {
}
if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map()
stage.trips.forEach((trip: any) => {
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)
const existing = companiesMap.get(companyName)!
existing.totalWeight += weight
existing.tripsCount += 1
} else {
@@ -211,10 +345,12 @@ const getOrderDuration = () => {
if (!order.value?.stages?.length) return 0
let minDate: Date | null = null
let maxDate: Date | null = null
order.value.stages.forEach((stage: any) => {
stage.trips?.forEach((trip: any) => {
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
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
})
@@ -224,13 +360,14 @@ const getOrderDuration = () => {
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')
let minDate: Date | null = null
let maxDate: Date | null = null
stage.trips.forEach((trip: any) => {
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate)
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate)
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
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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