Compare commits

...

274 Commits

Author SHA1 Message Date
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
Ruslan Bakiev
b02e3882cc feat(catalog): add bounds filtering to list queries
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Add west/south/east/north params to HubsList, SuppliersList, ProductsList GraphQL
- Update useCatalogHubs to pass bounds to query
- Update useCatalogSuppliers to pass bounds to query
- Update useCatalogProducts to pass bounds to query
2026-01-26 21:37:23 +07:00
Ruslan Bakiev
c56bb57fbf fix(CatalogMap): use proper icons with colors for related points
- Add loadRelatedPointIcons to load icons for all entity types
- Change related points layer from circle to symbol with icons
- Each type (hub, supplier, offer) gets its standard color:
  - hub: green (#22c55e)
  - supplier: blue (#3b82f6)
  - offer: orange (#f97316)
2026-01-26 21:34:07 +07:00
Ruslan Bakiev
c6abf8ad4a fix(catalog): hide filter checkbox in info mode + color related points by type
All checks were successful
Build Docker Image / build (push) Successful in 3m48s
2026-01-26 21:21:19 +07:00
Ruslan Bakiev
33c1559ab7 fix(catalog): hide clusters when InfoPanel is open, show only related points
All checks were successful
Build Docker Image / build (push) Successful in 3m51s
2026-01-26 20:47:05 +07:00
Ruslan Bakiev
e905098cb5 refactor(catalog): replace InfoPanel tabs with vertical sections
All checks were successful
Build Docker Image / build (push) Successful in 3m57s
- Remove all tabs from InfoPanel, use stacked sections instead
- Load suppliers (for hub) and hubs (for supplier) immediately
- Show entity header as text, not card
- Simplify relatedPoints to show all points on map
- Add translations for new section titles
2026-01-26 19:34:04 +07:00
Ruslan Bakiev
69bb978526 fix(catalog): add badge when selecting from list + fix checkbox position
All checks were successful
Build Docker Image / build (push) Successful in 3m51s
- onSelectItem now calls selectItem to add filter badge
- Checkbox repositioned to left-[420px] to not hide behind panel
- Hide List button when panel is open
2026-01-26 18:15:26 +07:00
Ruslan Bakiev
263e60e003 feat: simplify hero to single tagline
All checks were successful
Build Docker Image / build (push) Successful in 3m36s
2026-01-26 18:06:07 +07:00
Ruslan Bakiev
eb2266d66f feat: hero effect with dynamic navbar height and inline title
All checks were successful
Build Docker Image / build (push) Successful in 3m41s
2026-01-26 17:56:24 +07:00
Ruslan Bakiev
3f56a2f117 feat(catalog): add loading states for InfoPanel tabs and filter map by active tab
All checks were successful
Build Docker Image / build (push) Successful in 3m35s
- Add separate loading states for products, hubs, suppliers, offers
- Show spinner on tabs while loading, disable tab during load
- Filter relatedPoints on map by current active tab
2026-01-26 17:49:59 +07:00
Ruslan Bakiev
f680740f52 Center MainNav vertically on home hero, add fading title
All checks were successful
Build Docker Image / build (push) Successful in 3m47s
2026-01-26 17:24:08 +07:00
Ruslan Bakiev
53a51ed80c Simplify: use same MainNavigation everywhere, just taller container on home
All checks were successful
Build Docker Image / build (push) Successful in 3m56s
2026-01-26 17:14:40 +07:00
Ruslan Bakiev
d4b4f7011f Fix hero scroll - use fixed padding so content stays in place
All checks were successful
Build Docker Image / build (push) Successful in 3m58s
2026-01-26 16:59:19 +07:00
Ruslan Bakiev
11a52003e7 Make hero scroll linear - direct 1:1 scroll to height/opacity
All checks were successful
Build Docker Image / build (push) Successful in 3m55s
2026-01-26 16:29:20 +07:00
Ruslan Bakiev
80a587c74f Fix langDir path - remove duplicate i18n prefix
All checks were successful
Build Docker Image / build (push) Successful in 3m54s
2026-01-26 16:15:36 +07:00
Ruslan Bakiev
cecbed99b5 Add hero section to home page with scroll collapse
Some checks failed
Build Docker Image / build (push) Failing after 50s
- Full-screen dark gradient hero on home page
- Search input centered vertically
- Smooth collapse to fixed header on scroll
- Added hero translations (ru/en)
2026-01-26 16:12:00 +07:00
Ruslan Bakiev
f973784257 Add URL params for InfoPanel tab and product (infoTab, infoProduct)
All checks were successful
Build Docker Image / build (push) Successful in 3m39s
2026-01-26 15:55:25 +07:00
Ruslan Bakiev
8354102895 Restyle InfoPanel to match SelectionPanel (dark glass styling)
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-26 15:52:16 +07:00
Ruslan Bakiev
a569942e24 Show mode toggle on home page without active state
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-26 15:48:55 +07:00
Ruslan Bakiev
2275f956ae Fix: hide mode toggle on home page, store mapViewMode in URL
All checks were successful
Build Docker Image / build (push) Successful in 3m56s
2026-01-26 15:44:44 +07:00
Ruslan Bakiev
6b359b177c Fix: trigger reactivity when setting filter labels
All checks were successful
Build Docker Image / build (push) Successful in 3m44s
2026-01-26 15:35:05 +07:00
Ruslan Bakiev
1c298951b1 Fix: entity type detection in selectProduct, handle offer in add-to-filter
All checks were successful
Build Docker Image / build (push) Successful in 3m47s
2026-01-26 15:29:17 +07:00
Ruslan Bakiev
c76750a738 Fix: InfoPanel not showing - use showPanel prop
All checks were successful
Build Docker Image / build (push) Successful in 3m48s
2026-01-26 15:13:06 +07:00
Ruslan Bakiev
2d83110ef1 Move filterByBounds to map, show only when panel is open
All checks were successful
Build Docker Image / build (push) Successful in 3m46s
2026-01-26 15:00:30 +07:00
Ruslan Bakiev
5ca995ebcc Move filterByBounds checkbox into SelectionPanel
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-26 14:56:40 +07:00
Ruslan Bakiev
3211c5a881 Rename drawer to panel, use selectMode for visibility
All checks were successful
Build Docker Image / build (push) Successful in 3m44s
2026-01-26 14:52:19 +07:00
Ruslan Bakiev
911de423f6 Fix SelectionPanel - click applies immediately, opens Info
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-26 14:49:55 +07:00
Ruslan Bakiev
a48dcf24ee Remove explore mode chips from MainNavigation
All checks were successful
Build Docker Image / build (push) Successful in 3m37s
2026-01-26 14:46:05 +07:00
Ruslan Bakiev
0efc4eddfd Simplify catalog UI - remove chips, add drawer for list
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
- Remove product/hub chips from QuoteForm.vue (duplicate of toggle)
- Add drawer state to useCatalogSearch.ts (isDrawerOpen, selectDrawerItem, applyDrawerFilter)
- Convert SelectionPanel to drawer with header, scrollable content, and footer
- Add "Список" button to CatalogPage.vue to open drawer
- Add "Применить фильтр" button in drawer footer
- Add slide animations for drawer (left on desktop, up on mobile)
- Update translations: catalog.list, catalog.applyFilter
2026-01-26 14:36:42 +07:00
Ruslan Bakiev
65b07271d9 Simplify GEO API - use new list endpoints and routes in nearestOffers
All checks were successful
Build Docker Image / build (push) Successful in 4m11s
- Replace GetNodesDocument with HubsListDocument in useCatalogHubs.ts
- Replace GetSupplierProfilesDocument with SuppliersListDocument in useCatalogSuppliers.ts
- Replace manual grouping with ProductsListDocument in useCatalogProducts.ts
- Update nearestOffers to pass hubUuid for server-side route calculation
- Remove RouteToCoordinate calls - routes now included in nearestOffers response
- Delete 15 obsolete GraphQL files
- Add 3 new list endpoints: HubsList, SuppliersList, ProductsList
- Fix TypeScript errors in CalcResultContent, LocationsContent, hubs page, location store
2026-01-26 14:08:21 +07:00
Ruslan Bakiev
6d916d65a0 Show routes in hub info panel offers
All checks were successful
Build Docker Image / build (push) Successful in 3m47s
Replace OfferCard with OfferResultCard when displaying offers
for a hub after product selection. This shows the route stages
and distance to the hub instead of just offer info.
2026-01-26 08:36:14 +07:00
Ruslan Bakiev
2b6cccdead Fix all TypeScript errors and remove Storybook
All checks were successful
Build Docker Image / build (push) Successful in 5m8s
- Remove all Storybook files and configuration
- Add type declarations for @vueuse/core, @formkit/core, vue3-apexcharts
- Fix TypeScript configuration (typeRoots, include paths)
- Fix Sentry config - move settings to plugin
- Fix nullable prop assignments with ?? operator
- Fix type narrowing issues with explicit type assertions
- Fix Card component linkable computed properties
- Update codegen with operationResultSuffix
- Fix GraphQL operation type definitions
2026-01-26 00:32:36 +07:00
Ruslan Bakiev
b326d8cd76 Fix supplierUuid -> uuid parameter in GetSupplierProfile call
All checks were successful
Build Docker Image / build (push) Successful in 3m32s
2026-01-25 22:38:59 +07:00
Ruslan Bakiev
ed7dec304f Update geo GraphQL types after backend fixes
All checks were successful
Build Docker Image / build (push) Successful in 3m25s
2026-01-25 22:20:47 +07:00
Ruslan Bakiev
cc52aa6179 Fix supplier info and catalog filtering bugs
All checks were successful
Build Docker Image / build (push) Successful in 3m22s
1. Add latitude/longitude to GetSupplierProfile query
   - Without coordinates, supplier merge overwrites geo node data
   - Causes "Supplier has no coordinates" warning and no offers loading
   - Affects: useCatalogInfo.ts loadSupplierInfo() and useCatalogProducts.ts fetchProducts()

2. Add bounds validation in catalog composables
   - Validate bounds coordinates before passing to GraphQL or using in filters
   - Prevents 400 errors when bounds contain NaN/undefined/Infinity
   - Fixed in: useCatalogHubs.ts and useCatalogSuppliers.ts

Fixes:
- https://optovia.ru/catalog?info=supplier:c7f2e3f1-b16a-423d-a947-359e30858d94
- https://optovia.ru/catalog?select=hub 400 error

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 21:01:23 +07:00
Ruslan Bakiev
50375f2a74 Refactor catalog to use coordinate-based GraphQL endpoints
All checks were successful
Build Docker Image / build (push) Successful in 3m33s
Replace entity-specific queries (GetProductsNearHub, GetOffersByHub, GetHubsForProduct, GetSuppliersForProduct) with unified coordinate-based endpoints (NearestHubs, NearestOffers, NearestSuppliers, RouteToCoordinate). This simplifies backend architecture from 18 to 8 core endpoints while maintaining identical UI/UX behavior.

All composables and pages now use coordinates + client-side grouping instead of specialized backend queries. For global product filtering, uses center point (0,0) with 20000km radius.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 17:39:33 +07:00
Ruslan Bakiev
7403d4f063 Add coordinate-based GraphQL operations for geo API
All checks were successful
Build Docker Image / build (push) Successful in 3m31s
- Add NearestHubs.graphql - find hubs near coordinates
- Add NearestOffers.graphql - find offers near coordinates
- Add NearestSuppliers.graphql - find suppliers near coordinates
- Add RouteToCoordinate.graphql - route from offer to coordinates
- Regenerate geo-generated.ts with new operations

These operations simplify frontend logic by working with coordinates
instead of requiring entity-specific queries.
2026-01-25 17:28:40 +07:00
Ruslan Bakiev
39c3d24b3a Fix Info panel - translations, two-step offers flow, icon, add to filter
All checks were successful
Build Docker Image / build (push) Successful in 3m36s
- Add i18n translations for entities, tabs, and info sections (EN/RU)
- Refactor offers tab to two-step flow (products → offers) for Hub/Supplier
- Replace entity badge with circular icon in header
- Fix "Add to filter" button with name fallback and proper cleanup
- Update selectItem() to clear info param when adding to filter
2026-01-25 16:44:00 +07:00
Ruslan Bakiev
908d63062c Merge branch 'info-panel'
All checks were successful
Build Docker Image / build (push) Successful in 4m22s
2026-01-25 15:38:31 +07:00
Ruslan Bakiev
2ce3bd0bd2 Add Info panel for catalog with tabbed interface
Implemented Info mode для детального просмотра объектов каталога (hub/supplier/offer) с навигацией между связанными объектами.

Новые компоненты:
- InfoPanel.vue - панель с детальной информацией и табами для связанных объектов
- useCatalogInfo.ts - composable для управления Info state и загрузки данных

Изменения:
- useCatalogSearch.ts - добавлен infoId state и функции openInfo/closeInfo
- catalog/index.vue - интеграция InfoPanel, обработчики событий, relatedPoints для карты
- CatalogPage.vue - проброс relatedPoints в CatalogMap
- CatalogMap.vue - related points layer (cyan circles) для отображения связанных объектов

Флоу:
1. Клик на чип → Selection → Выбор → Info открывается
2. Клик на карту → Info открывается напрямую
3. В Info показываются табы со связанными объектами (top-12)
4. Клик на связанный объект → навигация к его Info
5. Кнопка "Добавить в фильтр" - добавляет объект в chips

URL sharing: ?info=type:uuid для шаринга ссылок

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 14:17:47 +07:00
Ruslan Bakiev
9b99d8981c Optimize catalog loading: backend bounds filtering + early returns
All checks were successful
Build Docker Image / build (push) Successful in 4m12s
- Add bounds (west/south/east/north) parameters to GetNodes query
- Add setBoundsFilter to useCatalogHubs, useCatalogSuppliers, useCatalogProducts
- Replace client-side bounds filtering with backend query
- Add early return to setProductFilter to avoid redundant fetches
- Watch filterByBounds to trigger backend refetch when checkbox changes
2026-01-24 12:19:00 +07:00
Ruslan Bakiev
8c753edb28 Add cascading filters for Explore mode
All checks were successful
Build Docker Image / build (push) Successful in 3m34s
When a product is selected, hubs and suppliers are filtered
to show only those that are relevant to that product.
2026-01-24 11:58:56 +07:00
Ruslan Bakiev
726c63efb7 Add GraphQL documents for cascading filters 2026-01-24 11:54:37 +07:00
Ruslan Bakiev
4d018323e7 Fix catalog issues: quantity input, checkbox position, glass header
All checks were successful
Build Docker Image / build (push) Successful in 3m21s
1. Quantity input in Quote mode: replaced button with inline number input
2. Checkbox position: moved to left side (next to panel) instead of right
3. MapPanel glass header: fixed sticky positioning by moving negative margins to children
2026-01-24 11:40:33 +07:00
Ruslan Bakiev
690c76ac79 Fix product selection from map offer click
All checks were successful
Build Docker Image / build (push) Successful in 3m22s
- Use GetOffer to fetch productUuid (not in cluster data)
- Handle both uuid and id properties from clusters
- Skip cluster items (id starts with 'cluster-')
2026-01-24 11:31:05 +07:00
Ruslan Bakiev
467f099130 Add unified MapPanel component for left map panels
All checks were successful
Build Docker Image / build (push) Successful in 3m25s
- Create MapPanel with white glass header, dark content
- Refactor SelectionPanel to use MapPanel
- Refactor QuotePanel to use MapPanel
- Single source of truth for panel styling
2026-01-24 11:20:32 +07:00
Ruslan Bakiev
7c566aeafc Fix SelectionPanel styling + add product filtering by supplier/hub
All checks were successful
Build Docker Image / build (push) Successful in 4m3s
- SelectionPanel header: dark glass style instead of white
- useCatalogProducts: filter by supplierId or hubId using dedicated queries
- catalog/index: connect filters from query params to composable
2026-01-24 11:13:22 +07:00
Ruslan Bakiev
2fc4dfb834 Add Airbnb-style "search as I move" checkbox + hover highlight
All checks were successful
Build Docker Image / build (push) Successful in 3m33s
- Move filter checkbox to right side, same line as view toggle
- Add hover events on selection cards to highlight map points
- Update translations: "Искать при перемещении" / "Search as I move the map"
2026-01-24 11:07:31 +07:00
Ruslan Bakiev
d03564a2d9 Add filter by map bounds checkbox to SelectionPanel
All checks were successful
Build Docker Image / build (push) Successful in 3m36s
- Remove map search input (was wrong implementation)
- Add checkbox "In map area" to filter list by visible map bounds
- Filter products/hubs/suppliers when checkbox is enabled
- Disable "load more" when filtering by bounds (client-side only)
2026-01-24 10:54:09 +07:00
Ruslan Bakiev
74324ff337 Fix: align right navbar icons to top like logo
All checks were successful
Build Docker Image / build (push) Successful in 3m44s
2026-01-24 10:23:31 +07:00
Ruslan Bakiev
404375248b Fix catalog UI: navbar alignment, selection panel, map search, infinite scroll
All checks were successful
Build Docker Image / build (push) Successful in 3m37s
- Fix team selector alignment in navbar (use items-center, fixed height)
- Fix SelectionPanel header padding to account for parent p-4
- Add map search input (white glass, positioned next to panel)
- Add infinite scroll to SelectionPanel with IntersectionObserver
- Products load all at once (no server-side pagination yet)
- Hubs and Suppliers support pagination with loadMore
2026-01-24 10:09:55 +07:00
Ruslan Bakiev
2a607d0d2d Fix catalog UI issues
All checks were successful
Build Docker Image / build (push) Successful in 3m31s
1. Fix navbar height - prevent tag wrapping with overflow-hidden
2. Fix translation keys for mode labels and search form labels
3. Fix SelectionPanel - white glass header/search, no top gap
4. Map click fills active selector - emit full properties from map
2026-01-24 09:47:41 +07:00
Ruslan Bakiev
3140226bc3 Navbar glass style only on catalog/map pages
All checks were successful
Build Docker Image / build (push) Successful in 3m46s
- Add glassStyle prop to MainNavigation component
- When glassStyle=true: dark transparent bg with white text
- When glassStyle=false: solid bg-base-100 with normal text
- Pass isCatalogSection from layout to toggle glass effect
2026-01-24 09:22:25 +07:00
Ruslan Bakiev
5e55443975 Fix map points: icons, color updates, loading state
All checks were successful
Build Docker Image / build (push) Successful in 3m41s
- Add entityType prop to CatalogMap for icon selection
- Change circle layers to symbol layers with entity-specific icons
- Icons: shopping bag (offers), warehouse (hubs), factory (suppliers)
- Add watcher to update colors when pointColor/entityType changes
- Clear old points and show loading indicator when switching view modes
- Add clearNodes function to useClusteredNodes composable
2026-01-24 09:18:27 +07:00
Ruslan Bakiev
63d81ab42f Search forms: white glass style (bg-white/80) for contrast
All checks were successful
Build Docker Image / build (push) Successful in 4m26s
2026-01-24 09:11:00 +07:00
Ruslan Bakiev
593aa0df12 Make map fullscreen behind transparent navbar
All checks were successful
Build Docker Image / build (push) Successful in 3m14s
2026-01-23 12:48:25 +07:00
Ruslan Bakiev
aa5a0a66fa Apply dark glass style (bg-black/30) to navbar, left panel, mobile panel
All checks were successful
Build Docker Image / build (push) Successful in 3m13s
2026-01-23 12:30:28 +07:00
Ruslan Bakiev
9d46bab93f Fix nav height, view toggle transparency, dynamic map colors by view mode
All checks were successful
Build Docker Image / build (push) Successful in 3m24s
2026-01-23 12:17:40 +07:00
Ruslan Bakiev
655c02d6fc Replace mode toggle with TradeScanner/Search nav links in header
All checks were successful
Build Docker Image / build (push) Successful in 3m14s
2026-01-23 12:11:48 +07:00
Ruslan Bakiev
999658aee1 UI: Glass effect everywhere, fix nav height, simplify quote form
All checks were successful
Build Docker Image / build (push) Successful in 3m9s
- Fixed nav height (h-20), logo and user menu aligned to top
- Quote form: removed colored circles, simple labels, search button inside pill
- Panels closer to nav (top-4 instead of top-20)
- Glass effect on all overlays (bg-base-100/70 backdrop-blur-md)
- Selection panel sticky headers with glass effect
2026-01-23 11:36:20 +07:00
Ruslan Bakiev
f31ceacdee Add catalog.json to i18n config (was missing)
All checks were successful
Build Docker Image / build (push) Successful in 3m14s
2026-01-23 11:02:23 +07:00
Ruslan Bakiev
5258347ccb UI fixes: header height, map color, panel scroll
All checks were successful
Build Docker Image / build (push) Successful in 3m10s
- MainNavigation: fixed min-height to prevent jumping on mode switch
- CatalogMap: default pointColor changed from green to orange (#f97316)
- CatalogPage: panel scroll on entire container, not inner
- SelectionPanel: sticky header and search, removed inner scroll
2026-01-23 10:53:21 +07:00
Ruslan Bakiev
fc6ce31659 Add unified icon system to navigation
All checks were successful
Build Docker Image / build (push) Successful in 3m14s
- Chips: colored circle with entity icon (product/hub/supplier)
- Active tokens: outline style with icon in circle
- Quote segments: labeled with colored circle icons
2026-01-23 10:35:59 +07:00
Ruslan Bakiev
4c6f5abd78 UI fixes: identical headers, panel styling, view toggle icons, sync map view
All checks were successful
Build Docker Image / build (push) Successful in 3m20s
- Show mode toggle on all pages (not just catalog)
- Panel background base-300, top-20 spacing from navbar
- View toggle with colored icons in circles
- Sync mapViewMode when selecting (supplier chip -> suppliers view)
2026-01-23 10:25:33 +07:00
Ruslan Bakiev
c7054579f1 Fix catalog: selection panels instead of modals, remove duplicate QuoteForm
All checks were successful
Build Docker Image / build (push) Successful in 3m55s
- Add SelectionPanel.vue for product/hub/supplier selection lists
- Remove QuoteForm from QuotePanel (header already has controls)
- Show SelectionPanel when selectMode is active
- Connect search button in header to page via shared state
2026-01-23 09:56:17 +07:00
Ruslan Bakiev
ae9985023c Add mode toggle [Explore|Quote] left of search form in header
All checks were successful
Build Docker Image / build (push) Successful in 3m43s
2026-01-22 20:57:37 +07:00
Ruslan Bakiev
c0f38a25cd Transform search bar in Quote mode to Airbnb-style segmented input
All checks were successful
Build Docker Image / build (push) Successful in 3m21s
- Remove mode toggle [Explore/Quote] tabs from header
- In Quote mode: show segmented input (Product | Hub | Quantity) + Search button
- In Explore mode: keep regular pill input with chips
- Add productLabel, hubLabel, supplierLabel computed values to useCatalogSearch
- Pass Quote mode props to MainNavigation
2026-01-22 20:52:06 +07:00
Ruslan Bakiev
7465b1d6a2 Move mode toggle to TopNav, view toggle to map right
All checks were successful
Build Docker Image / build (push) Successful in 3m32s
- Add [Explore/Quote] mode toggle to MainNavigation.vue (TopNav)
- Remove mode toggle from CatalogPage.vue (now in header)
- Move [Offers/Hubs/Suppliers] view toggle from top-left to top-right on map
- View toggle now visible in both modes (Explore and Quote)
- Simplify mobile layout to show only view toggle
2026-01-22 19:32:39 +07:00
Ruslan Bakiev
ddf691c83b Refactor catalog layout: mode toggle to top right, view toggle to top left
All checks were successful
Build Docker Image / build (push) Successful in 3m35s
- Move Explore/Quote mode toggle to top right corner
- Add view toggle (Offers/Hubs/Suppliers) to top left in Explore mode
- Panel shows only in Quote mode when showPanel prop is true
- Simplified panel slot structure
2026-01-22 19:20:11 +07:00
Ruslan Bakiev
850ab3f252 Add Explore/Quote dual mode to catalog page
All checks were successful
Build Docker Image / build (push) Successful in 3m52s
- Add CatalogMode type (explore/quote) to useCatalogSearch
- Create ExplorePanel component with view toggle (offers/hubs/suppliers)
- Create QuoteForm and QuotePanel components for search form
- Refactor CatalogPage to fullscreen map with overlay panel
- Simplify catalog/index.vue to use new components
- Add translations for modes and quote form (ru/en)

The catalog now has two modes:
- Explore: Browse map with offers/hubs/suppliers toggle
- Quote: Search form with product/hub/qty filters to find offers
2026-01-22 19:13:45 +07:00
Ruslan Bakiev
749f15131b Add map view toggle for fullWidthMap mode
All checks were successful
Build Docker Image / build (push) Successful in 3m25s
- Add MapViewMode type (offers/hubs/suppliers) with cookie storage
- Add view toggle button group on full-width map
- Update clusterNodeType and mapPointColor based on selected view
- Add translations for view options
2026-01-22 18:41:38 +07:00
Ruslan Bakiev
2d86c79b06 Fix: escape @ in email placeholder for vue-i18n
All checks were successful
Build Docker Image / build (push) Successful in 3m29s
2026-01-22 18:00:01 +07:00
Ruslan Bakiev
eb664c0387 Add missing translations for LocationsContent, Notifications, KYCFormRussia, TopBar
Some checks failed
Build Docker Image / build (push) Failing after 47s
2026-01-22 17:45:57 +07:00
Ruslan Bakiev
ba49a8d24f Regenerate GraphQL types with offersCount field
All checks were successful
Build Docker Image / build (push) Successful in 3m20s
2026-01-22 17:27:16 +07:00
Ruslan Bakiev
062fcd2a50 Show map by default on /catalog, add offersCount to products
All checks were successful
Build Docker Image / build (push) Successful in 3m27s
- Change displayMode from 'hero' to 'map-default' for /catalog
- Always show map on catalog page (fullWidthMap when no selectMode)
- Add offersCount to GetProducts, GetProductsBySupplier, GetProductsNearHub
- Remove CatalogHero from catalog page (hero content stays on /)
2026-01-22 17:22:22 +07:00
Ruslan Bakiev
39f8364edb Improve catalog UX: remove category, add offers count, dynamic layout
All checks were successful
Build Docker Image / build (push) Successful in 3m37s
- ProductCard: remove category field, add offersCount display
- CatalogPage: add fullWidthMap prop for map-only view
- catalog/index: pass fullWidthMap based on selectMode
- i18n: add offers pluralization
2026-01-22 16:59:33 +07:00
Ruslan Bakiev
6da5bf10c9 Remove shadow from search input
All checks were successful
Build Docker Image / build (push) Successful in 4m2s
2026-01-22 11:55:39 +07:00
Ruslan Bakiev
863425e46e Restore original token/chip styling in header search
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Restore badge-lg for tokens (was badge-md)
- Restore btn btn-xs btn-ghost for chips (was minimal text)
- Restore text-lg for input, icon sizes 14/22
- Keep pill input design without card wrapper
2026-01-22 11:55:08 +07:00
Ruslan Bakiev
8c4613e0d6 Simplify header: clean pill input without card styling
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Remove shadow and extra padding from input
- Smaller, cleaner tokens (badge-md)
- Minimal chips styling (just text links)
- Reduce header height
2026-01-22 11:52:31 +07:00
Ruslan Bakiev
0dc265c6b4 Fix header alignment: logo and icons same level as input
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-22 11:51:10 +07:00
Ruslan Bakiev
a8612c20b5 Fix header: remove selectMode indicator, align logo and icons
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Remove 'Товар:' badge during selection - tags appear only after selection
- Align logo and icons vertically with input (h-12)
- Simplify search input styling
2026-01-22 11:49:21 +07:00
Ruslan Bakiev
3c6ae03c30 Add entity color scheme and improve map hover effect
All checks were successful
Build Docker Image / build (push) Successful in 3m6s
- Add color scheme: product/offer=orange, supplier=blue, hub=green
- Remove 'location' filter (same as hub)
- Quantity filter appears only after product is selected
- Map hover shows 'target' ring effect (outer white ring)
- Tokens in header use entity-specific colors
2026-01-22 11:45:23 +07:00
Ruslan Bakiev
c468bd8679 Redesign header: single row with unified search block
All checks were successful
Build Docker Image / build (push) Successful in 3m6s
- Merge two rows into one: logo + search block + icons
- Search block now contains input and chips together
- Input is bigger with larger tokens
- Remove hero section from landing page
- Update padding to match new header height
2026-01-22 11:38:32 +07:00
Ruslan Bakiev
31f3c622eb Restore landing page + improve header search input
All checks were successful
Build Docker Image / build (push) Successful in 3m11s
- Restore full landing with How it works + Who it's for sections
- Make search input bigger and rounder (rounded-full, shadow)
- Remove border between input and chips
- Bigger badges and icons
2026-01-22 11:27:59 +07:00
Ruslan Bakiev
584a423e86 Redesign header - move search bar into main navigation
All checks were successful
Build Docker Image / build (push) Successful in 3m10s
- Move search input with tokens into center of header
- Remove tabs (Search, Catalog, Orders, Seller)
- Icons (bot, globe, user) remain on right side
- Chips for filter selection below input
- Delete GlobalSearchBar.vue and UnifiedSearchBar.vue
- Share searchQuery via useState across composable calls
- Simplify main page to just show hero
2026-01-22 11:22:44 +07:00
Ruslan Bakiev
13325825d7 Fix map clustering for all grid modes
All checks were successful
Build Docker Image / build (push) Successful in 3m29s
2026-01-22 11:10:28 +07:00
Ruslan Bakiev
166c404ff6 Main page shows hero, redirects to /catalog on first selection
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-22 11:08:57 +07:00
Ruslan Bakiev
d837b9b90b Fix unified catalog: add map, tokens inside input, redirect from main
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Main page (/) now redirects to /catalog
- Catalog page uses CatalogPage component with map on the right
- Search bar tokens are now inside the input field (like Gmail)
- Removed separate grid components, using cards directly
- Added missing translations (refine, noResults)
2026-01-22 11:06:58 +07:00
Ruslan Bakiev
08d7e0ade9 Implement unified catalog search with token-based filtering
All checks were successful
Build Docker Image / build (push) Successful in 3m23s
- Add useCatalogSearch composable for managing unified search state
- Add UnifiedSearchBar component with token chips for filters
- Add CatalogHero component for empty/landing state
- Create grid components for each display mode:
  - CatalogGridProducts, CatalogGridSuppliers, CatalogGridHubs
  - CatalogGridHubsForProduct, CatalogGridProductsFromSupplier
  - CatalogGridProductsInHub, CatalogGridOffers
- Add unified catalog page at /catalog with query params
- Remove SubNavigation from catalog section (kept for other sections)
- Update all links to use new unified catalog paths
- Delete old nested catalog pages (offers/suppliers/hubs flows)
- Add i18n translations for catalog section
2026-01-22 10:57:30 +07:00
Ruslan Bakiev
01f0836173 Move transport icons to bottom of HubCard
Some checks failed
Build Docker Image / build (push) Failing after 7m41s
2026-01-22 10:06:51 +07:00
Ruslan Bakiev
bd176973a8 Add verified badge top-right on SupplierCard, transport icons on HubCard
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-22 10:05:59 +07:00
Ruslan Bakiev
09e5889feb Fix supplier products page to use 3-column grid
All checks were successful
Build Docker Image / build (push) Successful in 3m23s
2026-01-22 09:58:34 +07:00
Ruslan Bakiev
f72cd40b42 Navigation: bg-base-100 with shadow instead of bg-base-200
All checks were successful
Build Docker Image / build (push) Successful in 3m13s
2026-01-22 09:53:47 +07:00
Ruslan Bakiev
5ad9b7834b Sync cupcake theme from orderflow - brighter colors, more rounded
All checks were successful
Build Docker Image / build (push) Successful in 3m9s
2026-01-22 09:47:52 +07:00
Ruslan Bakiev
aabee550d2 Fix theme application - use cupcake instead of removing data-theme
All checks were successful
Build Docker Image / build (push) Successful in 3m13s
2026-01-22 09:39:46 +07:00
Ruslan Bakiev
b5d27a3a20 Add cupcake theme to daisyUI config
All checks were successful
Build Docker Image / build (push) Successful in 3m18s
2026-01-22 09:17:08 +07:00
Ruslan Bakiev
4e49da5f9f Fix route conflict: move offer detail page to /offers/detail/[offerId]
All checks were successful
Build Docker Image / build (push) Successful in 3m18s
- Change theme from cmyk to cupcake
- Move [offerId].vue to detail/[offerId].vue to avoid conflict with [productId]/index.vue
- Update all navigation references to use new /catalog/offers/detail/ path
2026-01-22 09:07:26 +07:00
Ruslan Bakiev
796204b3cd Add grid layout for catalog cards + hover shadow
All checks were successful
Build Docker Image / build (push) Successful in 4m8s
- CatalogPage: added gridColumns prop (1/2/3 columns)
- Card: added hover:shadow-lg on interactive cards
- Products, hubs, suppliers pages now use 3-column grid
- Offers remain full-width (gridColumns=1 default)
2026-01-22 08:54:31 +07:00
Ruslan Bakiev
631effdde4 Redirect search to catalog offers flow instead of /request
All checks were successful
Build Docker Image / build (push) Successful in 4m2s
- GlobalSearchBar now redirects to /catalog/offers/[productId]/[hubId]
- GoodsContent redirects to offers flow
- select-location pages redirect to offers flow
- Added quantity tag to offers page
- Added KYC profile loading and offer detail navigation on offers page
2026-01-21 14:59:50 +07:00
Ruslan Bakiev
16c0a8112e Add offer detail page /catalog/offers/[offerId]
All checks were successful
Build Docker Image / build (push) Successful in 4m28s
- New page shows offer details, supplier info, and full KYC profile
- Updated CalcResultContent to navigate to offer page on card click
2026-01-21 14:53:27 +07:00
Ruslan Bakiev
c1ae984fcc Add KycProfileCard component with full company info
Some checks failed
Build Docker Image / build (push) Has been cancelled
- New KycProfileCard shows full KYC profile data (INN, OGRN, director, capital, activities)
- Added to offer detail page /catalog/suppliers/[supplierId]/[productId]/[hubId]
- Uses kycProfileFull endpoint for authorized detailed company info
2026-01-21 14:52:01 +07:00
Ruslan Bakiev
1b0fae1164 Fix map click in server clustering mode when item not loaded
All checks were successful
Build Docker Image / build (push) Successful in 3m34s
When using server-side clustering, the clicked item's uuid might not be
in the paginated props.items array. Now emits a minimal object with just
uuid so parent pages can still navigate.
2026-01-21 14:40:38 +07:00
Ruslan Bakiev
9787dc2b2a Fix: Update russia.vue to use CreateKycApplicationRussiaDocument
All checks were successful
Build Docker Image / build (push) Successful in 3m26s
2026-01-21 09:46:50 +07:00
Ruslan Bakiev
26f4dbab4c Update generated GraphQL types for kycProfileTeaser/Full
Some checks failed
Build Docker Image / build (push) Failing after 1m37s
2026-01-21 09:44:05 +07:00
Ruslan Bakiev
ccaa0d49f8 Update GraphQL queries: companyTeaserByProfile → kycProfileTeaser
Some checks failed
Build Docker Image / build (push) Failing after 1m59s
2026-01-21 09:40:42 +07:00
Ruslan Bakiev
dac73a49c7 Update KYC operations and run codegen
Some checks failed
Build Docker Image / build (push) Failing after 1m30s
2026-01-21 09:31:02 +07:00
Ruslan Bakiev
253ad024f6 Add price to route card on offer page
Some checks failed
Build Docker Image / build (push) Failing after 1m46s
2026-01-21 09:28:43 +07:00
Ruslan Bakiev
5617b8b916 Add price and supplier info to offer page
Some checks failed
Build Docker Image / build (push) Failing after 1m23s
2026-01-21 09:26:55 +07:00
Ruslan Bakiev
ace458ed7e Add KYC profile integration and SupplierInfoBlock component
Some checks failed
Build Docker Image / build (push) Failing after 2m51s
2026-01-21 09:19:44 +07:00
Ruslan Bakiev
d8befc8b9f Add hoveredId to all catalog pages for map point highlighting
All checks were successful
Build Docker Image / build (push) Successful in 3m46s
When hovering over a card on the left, the corresponding point
on the map now shows a blue highlight.
2026-01-19 13:01:57 +07:00
Ruslan Bakiev
825128e349 Fix catalog map navigation and hover interactions
All checks were successful
Build Docker Image / build (push) Successful in 3m44s
- Add @select handler to hubs/index.vue and suppliers/index.vue
  for map click navigation
- Add hoveredId props to final pages for point highlighting
  on card hover
- Add "Выберите источник" hint on final pages with sources
2026-01-19 12:40:24 +07:00
Ruslan Bakiev
2abbfd8895 fix(catalog): remove hover/select animations from intermediate pages
All checks were successful
Build Docker Image / build (push) Successful in 3m42s
Animation on map only needed on final pages (routes/sources).
Intermediate pages now navigate directly on card click.

Files changed:
- hubs/index.vue - removed hover/select
- suppliers/index.vue - removed hover/select
- suppliers/[]/[]/index.vue - removed hover
- offers/index.vue - removed hover
- offers/[]/index.vue - removed hover
2026-01-19 12:25:58 +07:00
Ruslan Bakiev
b711d5d3b3 fix(catalog): restore price charts on nested pages
All checks were successful
Build Docker Image / build (push) Successful in 4m10s
Keep charts with simplified headers (no text clutter)
2026-01-19 12:19:59 +07:00
Ruslan Bakiev
fd057528dc chore(catalog): simplify nested page headers, remove charts
Some checks failed
Build Docker Image / build (push) Has been cancelled
- Remove chart components and unused price history code
- Simplify headers to show only hint text
- Keep navigation badges in search bar
2026-01-19 12:16:58 +07:00
Ruslan Bakiev
42c8688561 Simplify catalog root pages - show only 'Выберите...' hint
All checks were successful
Build Docker Image / build (push) Successful in 4m9s
2026-01-19 12:09:18 +07:00
Ruslan Bakiev
da29a354ff Add navigation badges to offers and hubs catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 3m52s
- /catalog/offers/[productId] - badge "Товар: Name"
- /catalog/offers/[productId]/[hubId] - badges "Товар", "Хаб"
- /catalog/hubs/[id] - badge "Хаб: Name"
- /catalog/hubs/[id]/[productId] - badges "Хаб", "Товар"

Removed breadcrumb components, replaced with search bar badges
2026-01-19 11:33:36 +07:00
Ruslan Bakiev
5b715ef46f Fix getMockPriceHistory function name in offers page
All checks were successful
Build Docker Image / build (push) Successful in 4m0s
2026-01-19 11:18:43 +07:00
Ruslan Bakiev
0a79b90d1c Replace breadcrumbs with key-value badges in search bar
All checks were successful
Build Docker Image / build (push) Successful in 4m0s
- Add key property to FilterOption interface in CatalogSearchBar
- Display badges in "Key: Value" format (e.g., "Поставщик: Name")
- Remove SuppliersBreadcrumbs from supplier catalog pages
- Add navigationFilters computed with supplier/product/hub badges
- Add handleRemoveFilter to navigate back when badge is clicked
2026-01-19 11:09:58 +07:00
Ruslan Bakiev
bfbab9cef4 Unify catalog cards and fix offer point color
Some checks failed
Build Docker Image / build (push) Failing after 3m51s
- Change point color on /catalog/offers to green (#22c55e)
- Replace custom route card with OfferResultCard on supplier hub page
2026-01-19 10:53:27 +07:00
Ruslan Bakiev
804bd9c95d Fix catalog pages UI issues
All checks were successful
Build Docker Image / build (push) Successful in 5m48s
- Remove counter from /catalog/offers search bar
- Add server-clustering to /catalog/offers/[productId]
- Remove transition animations from Card.vue
- Use client-side clustering for suppliers (data from exchange, not geo)
2026-01-19 10:02:41 +07:00
Ruslan Bakiev
43310f5c28 Enable server-side clustering on all catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 4m37s
- Add clusterNodeType prop to CatalogPage
- Update useClusteredNodes to accept nodeType parameter
- Enable server clustering on /offers and /suppliers pages
- Add hoveredId sync on /offers page
- Remove hover:shadow-lg animation from Card
2026-01-16 17:32:59 +07:00
Ruslan Bakiev
d3bc7e9c09 refactor(webapp): Update to use new geo queries - offersByHub, offerToHub
All checks were successful
Build Docker Image / build (push) Successful in 4m23s
- Rename GetOffersToHub → GetOffersByHub
- Rename GetDeliveryToHub → GetOfferToHub
- Delete FindRoutes.graphql, FindProductRoutes.graphql
- Update catalog pages and CalcResultContent to use new query names
- Regenerate GraphQL types
2026-01-16 16:57:30 +07:00
Ruslan Bakiev
7968a32fd4 Fix map layout: use sticky instead of fixed, add rounded corners and shadow
All checks were successful
Build Docker Image / build (push) Successful in 4m20s
2026-01-16 16:27:39 +07:00
Ruslan Bakiev
62dea50abb Add catalogProducts translations to common.json
All checks were successful
Build Docker Image / build (push) Successful in 4m20s
2026-01-16 16:11:12 +07:00
Ruslan Bakiev
100caffaf4 Add missing catalogProducts translations
All checks were successful
Build Docker Image / build (push) Successful in 4m15s
2026-01-16 15:51:43 +07:00
Ruslan Bakiev
0142c1c375 Run codegen with new geo queries
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
2026-01-16 15:43:04 +07:00
Ruslan Bakiev
2253cd20b0 Update catalog pages to use new geo queries
Some checks failed
Build Docker Image / build (push) Failing after 1m35s
- Replace FindProductRoutesDocument with GetOffersToHubDocument
- Replace FindSupplierProductHubsDocument with GetOffersBySupplierProductDocument + GetHubsNearOfferDocument
- Update all catalog pages to use new query naming convention
- Add new GraphQL operation files, remove deprecated ones
2026-01-16 15:40:06 +07:00
Ruslan Bakiev
e869c2065f Use supplier.uuid for geo query instead of teamUuid
All checks were successful
Build Docker Image / build (push) Successful in 4m12s
2026-01-16 10:52:01 +07:00
Ruslan Bakiev
273990899f Fix offer.lines - read productUuid directly from offer
All checks were successful
Build Docker Image / build (push) Successful in 4m16s
2026-01-16 10:35:13 +07:00
Ruslan Bakiev
cdf4b3069f fix(catalog): fix supplier products and remove old offers page
All checks were successful
Build Docker Image / build (push) Successful in 4m23s
- Fix computed products to read from offer directly instead of offer.lines
- Remove obsolete offers/[uuid].vue (replaced by new flow)
2026-01-16 09:38:17 +07:00
Ruslan Bakiev
3b0418f328 fix(catalog): use supplier.uuid instead of teamUuid for navigation
All checks were successful
Build Docker Image / build (push) Successful in 5m25s
- SupplierCard.vue: use supplier.uuid in link generation
- products/[id].vue: get uuid from supplier profile and use it for navigation

Fixes 404 errors on supplier pages
2026-01-16 09:11:46 +07:00
Ruslan Bakiev
d9d05a4c21 feat(catalog): add search to all catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 4m55s
Add CatalogSearchBar component with filtering to:
- /catalog/offers - search by product name
- /catalog/offers/[productId] - search by hub name/country
- /catalog/hubs/[id] - search by product name
- /catalog/suppliers/[supplierId] - search by product name
- /catalog/suppliers/[supplierId]/[productId] - search by hub name/country
2026-01-16 02:19:41 +07:00
Ruslan Bakiev
45ec9923e3 feat(catalog): add map to all catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 4m47s
All catalog pages now use CatalogPage component with map on the right side:
- /catalog/hubs/[id] - shows hub on map
- /catalog/offers - shows empty map (products have no coords)
- /catalog/offers/[productId] - shows hubs where product can be delivered
- /catalog/suppliers/[supplierId] - shows supplier location
- /catalog/suppliers/[supplierId]/[productId] - shows hubs for supplier's product
- /catalog/suppliers/[supplierId]/[productId]/[hubId] - shows source and destination
2026-01-16 02:00:31 +07:00
Ruslan Bakiev
181dc4ea6b feat: use filtered geo queries for catalog pages
All checks were successful
Build Docker Image / build (push) Successful in 4m57s
- /hubs/[id]: use findProductsForHub to show only deliverable products
- /offers/[productId]: use findHubsForProduct to show only reachable hubs
- /suppliers/[id]/[productId]: use findSupplierProductHubs for supplier-specific hubs
- Add new GraphQL operations for geo queries
2026-01-16 01:42:18 +07:00
Ruslan Bakiev
1e87a14065 feat(catalog): implement step-by-step navigation for offers and suppliers
All checks were successful
Build Docker Image / build (push) Successful in 4m49s
- Transform offers/index.vue to show products list with sparkline charts
- Create nested routes for offers: /offers → /offers/[productId] → /offers/[productId]/[hubId]
- Create nested routes for suppliers: /suppliers → /suppliers/[supplierId] → /suppliers/[supplierId]/[productId] → /suppliers/[supplierId]/[productId]/[hubId]
- Add OffersBreadcrumbs and SuppliersBreadcrumbs components for navigation
- Update HubCard to accept custom linkTo prop
- Key difference: Suppliers calculation uses FindRoutes (single source), Offers uses FindProductRoutes (all sources)
2026-01-16 00:52:47 +07:00
Ruslan Bakiev
210d3e935c feat: вложенные роуты хаб/товар с хлебными крошками
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
- /catalog/hubs/[id] — список товаров с графиками
- /catalog/hubs/[id]/[productId] — страница товара с большим графиком и предложениями
- CatalogBreadcrumbs — компонент хлебных крошек
2026-01-16 00:25:41 +07:00
Ruslan Bakiev
bab0e9e539 refactor: убрать автовыбор продукта, карточки на всю ширину
All checks were successful
Build Docker Image / build (push) Successful in 4m53s
2026-01-16 00:02:17 +07:00
Ruslan Bakiev
25030f0350 feat: добавить мок-данные графиков цен на карточки продуктов
All checks were successful
Build Docker Image / build (push) Successful in 4m43s
2026-01-15 23:50:52 +07:00
Ruslan Bakiev
71663186e2 refactor: убрать ring бордер с карточки продукта
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
2026-01-15 23:39:35 +07:00
Ruslan Bakiev
5b620f77b3 Improve hub page: new RouteStepper, HubProductCard with ApexCharts
All checks were successful
Build Docker Image / build (push) Successful in 4m57s
- Redesign RouteStepper: nodes connected by lines with distance on line
- Add HubProductCard component with sparkline chart background
- Auto-select first product when hub page loads
- Remove placeholder with package-x icon
- Add ApexCharts plugin for charts
- Pass startName/endName to RouteStepper for route visualization
2026-01-15 15:45:26 +07:00
Ruslan Bakiev
97f346ba83 Fix navigation background color: bg-base-100 → bg-base-200
All checks were successful
Build Docker Image / build (push) Successful in 4m10s
2026-01-15 13:03:16 +07:00
Ruslan Bakiev
43158924ab Fix smooth scroll animation and map positioning
All checks were successful
Build Docker Image / build (push) Successful in 4m4s
- Replace v-show with transform: translateY() for smooth header collapse animation
- Wrap MainNav + SubNav in fixed container with dynamic transform
- Remove sticky positioning from MainNavigation and SubNavigation
- Fix map to extend to screen edge (right-0, no rounded corners)
- Add dynamic padding-top to main for fixed header compensation
2026-01-15 12:51:00 +07:00
Ruslan Bakiev
9b738e6841 Remove py-4 from main to fix gap between SubNav and SearchBar
All checks were successful
Build Docker Image / build (push) Successful in 4m12s
2026-01-15 12:32:22 +07:00
Ruslan Bakiev
b9f44ecaf4 Fix SearchBar height and remove padding gaps
All checks were successful
Build Docker Image / build (push) Successful in 4m24s
2026-01-15 12:15:48 +07:00
Ruslan Bakiev
ea7c0b460a Move expand button from separate bar to SearchBar area
All checks were successful
Build Docker Image / build (push) Successful in 4m4s
2026-01-15 11:55:41 +07:00
Ruslan Bakiev
46b1a17a23 Fix smooth scroll in CatalogPage - use pixel values from useScrollCollapse
All checks were successful
Build Docker Image / build (push) Successful in 4m3s
2026-01-15 11:47:56 +07:00
Ruslan Bakiev
03485b77a5 Refactor: use topnav layout + CatalogPage component
All checks were successful
Build Docker Image / build (push) Successful in 4m11s
- Remove catalog.vue layout and useCatalogLayout.ts (broken provide/inject)
- All catalog/clientarea list pages now use topnav layout
- Pages use CatalogPage component for SearchBar + Map functionality
- Clean architecture: layout handles nav, component handles features
2026-01-15 11:18:57 +07:00
Ruslan Bakiev
7ea96a97b3 Refactor catalog layout: replace Teleport with provide/inject
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Create useCatalogLayout composable for data transfer from pages to layout
- Layout now owns SearchBar and CatalogMap components directly
- Pages provide data via provideCatalogLayout()
- Fixes navigation glitches (multiple SearchBars) when switching tabs
- Support custom subNavItems for clientarea pages
- Unify 6 pages to use catalog layout:
  - catalog/offers, suppliers, hubs
  - clientarea/orders, addresses, offers
2026-01-15 10:49:40 +07:00
Ruslan Bakiev
4bd5b882e0 Fix header heights: MainNav 64px, SubNav 54px, SearchBar 56px
All checks were successful
Build Docker Image / build (push) Successful in 4m28s
2026-01-15 10:30:40 +07:00
Ruslan Bakiev
3f5c2d6e60 Migrate offers and suppliers pages to catalog layout with Teleport
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
2026-01-15 10:16:35 +07:00
Ruslan Bakiev
9411eb9874 Change body background to base-300 for better color hierarchy
All checks were successful
Build Docker Image / build (push) Successful in 6m28s
2026-01-15 10:07:56 +07:00
Ruslan Bakiev
e451267c36 Fix search bar top padding on catalog page
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-01-15 10:04:45 +07:00
Ruslan Bakiev
0337cebc63 Simplify OfferResultCard and use daisyUI Steps
All checks were successful
Build Docker Image / build (push) Successful in 4m42s
- Remove sourceName, latitude, longitude props from OfferResultCard
- Remove LocationMiniMap component (not needed, main map is on the side)
- Convert RouteStepper to use daisyUI steps-horizontal
- Update hub page and CalcResultContent to remove unused props
2026-01-15 00:33:17 +07:00
Ruslan Bakiev
f03554893b Update OfferResultCard: add location, mini map, fix price format
All checks were successful
Build Docker Image / build (push) Successful in 4m30s
- Remove distance from right side (shown in stepper)
- Change price format to symbol + formatted number (e.g. $1,200/тонна)
- Add locationName prop for displaying offer location
- Add LocationMiniMap component for showing point on map
- Update hub page and CalcResultContent to pass coordinates
2026-01-15 00:07:21 +07:00
Ruslan Bakiev
de95dbd059 Unify offer cards: RouteStepper + OfferResultCard components
All checks were successful
Build Docker Image / build (push) Successful in 4m36s
- Add RouteStepper component with transport icons (🚛 🚂 🚢)
- Add OfferResultCard with price, distance, route stages
- Update hub page to use OfferResultCard
- Update CalcResultContent to use OfferResultCard
2026-01-14 23:47:42 +07:00
230 changed files with 12244 additions and 4655 deletions

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,11 @@ 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
@@ -41,4 +46,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

@@ -7,7 +7,7 @@
<script setup lang="ts">
useHead({
htmlAttrs: {
'data-theme': 'cmyk',
'data-theme': 'cupcake',
},
script: []
})

View File

@@ -2,10 +2,64 @@
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "silk";
name: "cupcake";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97.788% 0.004 56.375);
--color-base-200: oklch(93.982% 0.007 61.449);
--color-base-300: oklch(91.586% 0.006 53.44);
--color-base-content: oklch(23.574% 0.066 313.189);
--color-primary: oklch(85% 0.138 181.071);
--color-primary-content: oklch(43% 0.078 188.216);
--color-secondary: oklch(89% 0.061 343.231);
--color-secondary-content: oklch(45% 0.187 3.815);
--color-accent: oklch(90% 0.076 70.697);
--color-accent-content: oklch(47% 0.157 37.304);
--color-neutral: oklch(27% 0.006 286.033);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(68% 0.169 237.323);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(69% 0.17 162.48);
--color-success-content: oklch(26% 0.051 172.552);
--color-warning: oklch(79% 0.184 86.047);
--color-warning-content: oklch(28% 0.066 53.813);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 2rem;
--radius-field: 2rem;
--radius-box: 2rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 0.5px;
--depth: 1;
--noise: 0;
}
@layer components {
.glass-topfade {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.18) 45%,
rgba(255, 255, 255, 0) 100%
);
}
.glass-soft {
@apply bg-white/10 border border-white/10 backdrop-blur-md;
}
.glass-bright {
@apply bg-white/30 border border-white/20 backdrop-blur-md;
}
}
@plugin "daisyui/theme" {
name: "silk";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97% 0.0035 67.78);
--color-base-200: oklch(95% 0.0081 61.42);
--color-base-300: oklch(90% 0.0081 61.42);

View File

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

View File

@@ -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>(), {
@@ -67,14 +76,14 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const query = ref('')
const suggestions = ref([])
const suggestions = ref<BankSuggestion[]>([])
const loading = ref(false)
const showDropdown = ref(false)
// Hide dropdown when clicking outside
onMounted(() => {
document.addEventListener('click', (e) => {
if (!e.target?.closest('.relative')) {
document.addEventListener('click', (e: MouseEvent) => {
if (!(e.target as HTMLElement)?.closest('.relative')) {
showDropdown.value = false
}
})
@@ -114,7 +123,7 @@ const onInput = async () => {
}
}
const selectBank = (bank: any) => {
const selectBank = (bank: BankSuggestion) => {
query.value = bank.value
showDropdown.value = false

View File

@@ -44,6 +44,7 @@ const breadcrumbs = computed(() => {
let currentPath = '/clientarea'
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (!segment) continue
currentPath += `/${segment}`
const isLast = i === segments.length - 1

View File

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

View File

@@ -1,86 +1,65 @@
<template>
<div class="space-y-10">
<div class="space-y-6">
<!-- Header -->
<Card padding="lg" class="border border-base-300">
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
</Card>
<!-- Loading -->
<div v-if="pending" class="text-sm text-base-content/60">
Загрузка маршрутов...
</div>
<!-- Error -->
<div v-else-if="error" class="text-sm text-error">
Ошибка загрузки маршрутов: {{ error.message }}
</div>
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
<div v-if="productRouteOptions.length" class="space-y-10">
<div
v-for="(option, optionIndex) in productRouteOptions"
:key="option.sourceUuid || optionIndex"
class="space-y-6"
>
<div class="space-y-1">
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
</div>
<div v-if="option.routes?.length" class="space-y-6">
<Card
v-for="(route, routeIndex) in option.routes"
:key="routeIndex"
padding="lg"
class="border border-base-300"
>
<Stack gap="4">
<div class="flex flex-wrap items-center justify-between gap-2">
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
<Text tone="muted" size="sm">
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
</Text>
</div>
<RouteStagesList :stages="mapRouteStages(route)" />
<div class="divider my-0"></div>
<RequestRoutesMap :routes="[route]" :height="240" />
</Stack>
</Card>
</div>
<Text v-else tone="muted" size="sm">
Маршруты от источника не найдены.
</Text>
</div>
</div>
<template v-if="!productRouteOptions.length && legacyRoutes.length">
<div class="space-y-6">
<Card
v-for="(route, routeIndex) in legacyRoutes"
:key="routeIndex"
padding="lg"
class="border border-base-300"
>
<Stack gap="4">
<div class="flex flex-wrap items-center justify-between gap-2">
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
<Text tone="muted" size="sm">
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
</Text>
</div>
<RouteStagesList :stages="mapRouteStages(route)" />
<div class="divider my-0"></div>
<RequestRoutesMap :routes="[route]" :height="240" />
</Stack>
</Card>
</div>
</template>
<!-- Results -->
<div v-else-if="productRouteOptions.length > 0" class="space-y-4">
<OfferResultCard
v-for="(option, index) in productRouteOptions"
:key="option.sourceUuid ?? index"
:supplier-name="getSupplierName(option.sourceUuid)"
:location-name="getOfferData(option.sourceUuid)?.locationName"
:product-name="productName"
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
:quantity="getOfferData(option.sourceUuid)?.quantity"
:currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit"
:stages="getRouteStages(option)"
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
@select="navigateToOffer(option.sourceUuid)"
/>
</div>
<!-- Legacy routes (fallback) -->
<div v-else-if="legacyRoutes.length > 0" class="space-y-6">
<Card
v-for="(route, routeIndex) in legacyRoutes"
:key="routeIndex"
padding="lg"
class="border border-base-300"
>
<Stack gap="4">
<div class="flex flex-wrap items-center justify-between gap-2">
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
<Text tone="muted" size="sm">
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
</Text>
</div>
<RouteStagesList :stages="mapRouteStages(route)" />
<div class="divider my-0"></div>
<RequestRoutesMap :routes="[route]" :height="240" />
</Stack>
</Card>
</div>
<!-- Empty -->
<div v-else class="text-sm text-base-content/60">
Маршруты не найдены. Возможно, нет связи между точками в графе.
</div>
@@ -88,16 +67,41 @@
</template>
<script setup lang="ts">
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
import { GetNodeDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
interface RouteStage {
fromUuid?: string | null
fromName?: string | null
toName?: string | null
distanceKm?: number | null
travelTimeSeconds?: number | null
transportType?: string | null
}
interface RoutePathType {
totalDistanceKm?: number | null
totalTimeSeconds?: number | null
stages?: (RouteStage | null)[]
}
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
import type { OfferWithRouteType, RouteStageType } from '~/composables/graphql/public/geo-generated'
const route = useRoute()
const localePath = useLocalePath()
const searchStore = useSearchStore()
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
type OfferData = NonNullable<GetOfferQueryResult['getOffer']>
const offersData = ref<Map<string, OfferData>>(new Map())
// Supplier data for KYC profile UUID (by team_uuid)
type SupplierData = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
const suppliersData = ref<Map<string, SupplierData>>(new Map())
const summaryTitle = computed(() => `${productName.value}${locationName.value}`)
const summaryMeta = computed(() => {
@@ -108,10 +112,9 @@ const summaryMeta = computed(() => {
return meta
})
// Determine context (new flow: product + destination; legacy: from param)
// Determine context
const productUuid = computed(() => (route.query.productUuid as string) || searchStore.searchForm.productUuid)
const destinationUuid = computed(() => (route.query.locationUuid as string) || searchStore.searchForm.locationUuid)
const legacyFromUuid = computed(() => route.params.id as string | undefined)
type ProductRouteOption = {
sourceUuid?: string | null
@@ -122,95 +125,68 @@ type ProductRouteOption = {
routes?: RoutePathType[] | null
}
const fetchProductRoutes = async () => {
const fetchOffersByHub = async () => {
if (!productUuid.value || !destinationUuid.value) return null
const { client } = useApolloClient('publicGeo')
const { default: gql } = await import('graphql-tag')
const query = gql`
query FindProductRoutes($productUuid: String!, $toUuid: String!, $limitSources: Int, $limitRoutes: Int) {
findProductRoutes(
productUuid: $productUuid
toUuid: $toUuid
limitSources: $limitSources
limitRoutes: $limitRoutes
) {
sourceUuid
sourceName
sourceLat
sourceLon
distanceKm
routes {
totalDistanceKm
totalTimeSeconds
stages {
fromUuid
fromName
fromLat
fromLon
toUuid
toName
toLat
toLon
distanceKm
travelTimeSeconds
transportType
}
}
}
}
`
// 1. Get hub node to get coordinates
const hubData = await execute(GetNodeDocument, { uuid: destinationUuid.value }, 'public', 'geo')
const hub = hubData?.node
const { data } = await client.query({
query,
variables: {
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
return null
}
// 2. Find offers near hub for this product WITH routes calculated on backend
const offersResponse = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productUuid.value,
toUuid: destinationUuid.value,
limitSources: 5,
limitRoutes: 1
}
})
return data
}
hubUuid: destinationUuid.value, // Pass hubUuid to get routes calculated on backend
radius: 500,
limit: 5
},
'public',
'geo'
)
const fetchLegacyRoutes = async () => {
if (!legacyFromUuid.value || !destinationUuid.value) return null
const { client } = useApolloClient('publicGeo')
const { data } = await client.query({
query: FindRoutesDocument,
variables: {
fromUuid: legacyFromUuid.value,
toUuid: destinationUuid.value,
limit: 3
}
})
return data
const offers = offersResponse?.nearestOffers || []
// Offers already include routes from backend
const offersWithRoutes = offers
.filter((offer): offer is NonNullable<OfferWithRouteType> => 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 }
}
const { data: productRoutesData, pending, error } = await useAsyncData(
() => `product-routes-${productUuid.value}-${destinationUuid.value}-${legacyFromUuid.value || 'none'}`,
() => `offers-by-hub-${productUuid.value}-${destinationUuid.value}`,
async () => {
// Prefer product-based routes; fallback to legacy if no product
if (productUuid.value && destinationUuid.value) {
return await fetchProductRoutes()
}
if (legacyFromUuid.value && destinationUuid.value) {
return await fetchLegacyRoutes()
return await fetchOffersByHub()
}
return null
},
{ watch: [productUuid, destinationUuid, legacyFromUuid] }
{ watch: [productUuid, destinationUuid] }
)
const productRouteOptions = computed(() => {
const options = productRoutesData.value?.findProductRoutes as ProductRouteOption[] | undefined
const options = productRoutesData.value?.offersByHub as ProductRouteOption[] | undefined
return options?.filter(Boolean) || []
})
const legacyRoutes = computed(() => {
const data = productRoutesData.value?.findRoutes
if (!data) return []
return (data as (RoutePathType | null)[]).filter((r): r is RoutePathType => r !== null)
const legacyRoutes = computed<RoutePathType[]>(() => {
return [] // Legacy routes removed
})
const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
@@ -226,6 +202,100 @@ const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
}))
}
// Get route stages for OfferResultCard stepper
const getRouteStages = (option: ProductRouteOption) => {
const route = option.routes?.[0]
if (!route?.stages) return []
return route.stages
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
.map((stage) => ({
transportType: stage.transportType,
distanceKm: stage.distanceKm,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
}))
}
// Get offer data for card
const getOfferData = (uuid?: string | null) => {
if (!uuid) return null
return offersData.value.get(uuid)
}
// Get KYC profile UUID by offer UUID
const getKycProfileUuid = (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?.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
navigateTo(localePath(`/catalog/offers/detail/${offerUuid}`))
}
// Load offer details for prices
const loadOfferDetails = async (options: ProductRouteOption[]) => {
if (options.length === 0) {
offersData.value.clear()
suppliersData.value.clear()
return
}
const newOffersData = new Map<string, OfferData>()
const newSuppliersData = new Map<string, SupplierData>()
const teamUuidsToLoad = new Set<string>()
// First, load all offers
await Promise.all(options.map(async (option) => {
if (!option.sourceUuid) return
try {
const data = await execute(GetOfferDocument, { uuid: option.sourceUuid }, 'public', 'exchange')
if (data?.getOffer) {
newOffersData.set(option.sourceUuid, data.getOffer)
if (data.getOffer.teamUuid) {
teamUuidsToLoad.add(data.getOffer.teamUuid)
}
}
} catch (error) {
console.error('Error loading offer:', option.sourceUuid, error)
}
}))
// Then, load supplier profiles for all team UUIDs
await Promise.all([...teamUuidsToLoad].map(async (teamUuid) => {
try {
const data = await execute(GetSupplierProfileByTeamDocument, { teamUuid }, 'public', 'exchange')
if (data?.getSupplierProfileByTeam) {
newSuppliersData.set(teamUuid, data.getSupplierProfileByTeam)
}
} catch (error) {
console.error('Error loading supplier:', teamUuid, error)
}
}))
offersData.value = newOffersData
suppliersData.value = newSuppliersData
}
// Watch for route options and load offers
watch(productRouteOptions, (options) => {
if (options.length > 0) {
loadOfferDetails(options)
}
}, { immediate: true })
// Formatting helpers
const formatDistance = (km: number | null | undefined) => {
if (!km) return '0'

View File

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

View File

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

View File

@@ -48,13 +48,24 @@ interface CompanyData {
address: string
}
interface CompanySuggestion {
value: string
unrestricted_value: string
data: {
inn: string
kpp?: string
ogrn?: string
address?: { value: string }
}
}
interface Props {
modelValue?: CompanyData
}
interface Emits {
(e: 'update:modelValue', value: CompanyData): void
(e: 'select', company: any): void
(e: 'select', company: CompanySuggestion): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -71,14 +82,14 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const query = ref('')
const suggestions = ref([])
const suggestions = ref<CompanySuggestion[]>([])
const loading = ref(false)
const showDropdown = ref(false)
// Hide dropdown when clicking outside
onMounted(() => {
document.addEventListener('click', (e) => {
if (!e.target?.closest('.relative')) {
document.addEventListener('click', (e: MouseEvent) => {
if (!(e.target as HTMLElement)?.closest('.relative')) {
showDropdown.value = false
}
})
@@ -118,10 +129,10 @@ const onInput = async () => {
}
}
const selectCompany = (company: any) => {
const selectCompany = (company: CompanySuggestion) => {
query.value = company.value
showDropdown.value = false
const companyData: CompanyData = {
companyName: company.value,
companyFullName: company.unrestricted_value,
@@ -130,7 +141,7 @@ const selectCompany = (company: any) => {
ogrn: company.data.ogrn || '',
address: company.data.address?.value || ''
}
emit('update:modelValue', companyData)
emit('select', company)
}

View File

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

View File

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

View File

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

View File

@@ -24,38 +24,40 @@
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
<ProductCard
v-for="product in productsData"
:key="product.uuid"
:product="product"
v-for="(product, index) in productsData"
:key="product?.uuid ?? index"
:product="product!"
selectable
@select="selectProduct(product)"
@select="selectProduct(product!)"
/>
</Grid>
</Stack>
</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
const quantity = searchStore.searchForm.quantity
const query: Record<string, string> = {}
if (quantity) query.quantity = String(quantity)
if (locationUuid) {
// Both product and hub selected -> show offers
navigateTo({
path: '/request',
query: {
productUuid: product.uuid,
product: product.name,
locationUuid,
location: searchStore.searchForm.location,
quantity: searchStore.searchForm.quantity || undefined
}
path: `/catalog`,
query: { product: product.uuid, hub: locationUuid, ...query }
})
return
}

View File

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

View File

@@ -4,13 +4,13 @@
<!-- Company Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Company details</h3>
<h3 class="card-title text-base-content">{{ t('kycRussia.form.companyDetails') }}</h3>
<div class="space-y-4">
<!-- Company search with DADATA -->
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Organization search
{{ t('kycRussia.form.organizationSearch') }}
</label>
<CompanySearchRussia v-model="formData.company" @select="onCompanySelect" />
</div>
@@ -18,7 +18,7 @@
<!-- Company details (auto-filled from DADATA) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">INN</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.inn') }}</label>
<input
v-model="formData.company.inn"
class="input input-bordered w-full"
@@ -26,7 +26,7 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">KPP</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.kpp') }}</label>
<input
v-model="formData.company.kpp"
class="input input-bordered w-full"
@@ -36,7 +36,7 @@
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">OGRN</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.ogrn') }}</label>
<input
v-model="formData.company.ogrn"
class="input input-bordered w-full"
@@ -45,7 +45,7 @@
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Address</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.address') }}</label>
<textarea
v-model="formData.company.address"
class="textarea textarea-bordered w-full min-h-[120px]"
@@ -60,13 +60,13 @@
<!-- Bank Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Bank details</h3>
<h3 class="card-title text-base-content">{{ t('kycRussia.form.bankDetails') }}</h3>
<div class="space-y-4">
<!-- Bank search with DADATA -->
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Bank search
{{ t('kycRussia.form.bankSearch') }}
</label>
<BankSearchRussia v-model="formData.bank" @select="onBankSelect" />
</div>
@@ -74,7 +74,7 @@
<!-- Bank details -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">BIC</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.bic') }}</label>
<input
v-model="formData.bank.bik"
class="input input-bordered w-full"
@@ -82,7 +82,7 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Corr. account</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.corrAccount') }}</label>
<input
v-model="formData.bank.correspondentAccount"
class="input input-bordered w-full"
@@ -97,41 +97,41 @@
<!-- Contact Section -->
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body gap-4">
<h3 class="card-title text-base-content">Contact details</h3>
<h3 class="card-title text-base-content">{{ t('kycRussia.form.contactDetails') }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Contact person *
{{ t('kycRussia.form.contactPerson') }} *
</label>
<input
v-model="formData.contact.person"
type="text"
required
class="input input-bordered w-full"
placeholder="Full name of company representative"
:placeholder="t('kycRussia.form.placeholders.contactPerson')"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">Email *</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.email') }} *</label>
<input
v-model="formData.contact.email"
type="email"
required
class="input input-bordered w-full"
placeholder="email@company.ru"
:placeholder="t('kycRussia.form.placeholders.email')"
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content mb-2">Phone *</label>
<label class="block text-sm font-medium text-base-content mb-2">{{ t('kycRussia.form.phone') }} *</label>
<input
v-model="formData.contact.phone"
type="tel"
required
class="input input-bordered w-full"
placeholder="+7 (xxx) xxx-xx-xx"
:placeholder="t('kycRussia.form.placeholders.phone')"
/>
</div>
</div>
@@ -146,7 +146,7 @@
:disabled="loading || !isFormValid"
class="btn btn-primary"
>
{{ loading ? 'Sending...' : 'Submit for review' }}
{{ loading ? t('kycRussia.form.sending') : t('kycRussia.form.submit') }}
</button>
</div>
</form>
@@ -154,8 +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)
@@ -193,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,
@@ -204,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

@@ -0,0 +1,155 @@
<template>
<Card v-if="kycProfileUuid" padding="md">
<!-- Loading -->
<div v-if="pending" class="flex items-center gap-2">
<Spinner size="sm" />
<Text tone="muted" size="sm">Загрузка данных о компании...</Text>
</div>
<!-- Error or no data -->
<div v-else-if="!profileData" class="text-sm text-base-content/60">
Данные о компании недоступны
</div>
<!-- Profile data -->
<Stack v-else gap="4">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<Text weight="semibold" size="lg">{{ profileData.name }}</Text>
<div class="flex items-center gap-2 mt-1">
<span v-if="profileData.companyType" class="badge badge-outline badge-sm">
{{ profileData.companyType }}
</span>
<span v-if="profileData.registrationYear" class="text-xs text-base-content/60">
с {{ profileData.registrationYear }} г.
</span>
<span v-if="profileData.isActive" class="badge badge-success badge-xs">Активна</span>
<span v-else class="badge badge-error badge-xs">Неактивна</span>
</div>
</div>
</div>
<!-- Details grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- INN -->
<div v-if="profileData.inn">
<Text tone="muted" size="sm">ИНН</Text>
<Text weight="medium">{{ profileData.inn }}</Text>
</div>
<!-- OGRN -->
<div v-if="profileData.ogrn">
<Text tone="muted" size="sm">ОГРН</Text>
<Text weight="medium">{{ profileData.ogrn }}</Text>
</div>
<!-- Director -->
<div v-if="profileData.director">
<Text tone="muted" size="sm">Руководитель</Text>
<Text weight="medium">{{ profileData.director }}</Text>
</div>
<!-- Capital -->
<div v-if="profileData.capital">
<Text tone="muted" size="sm">Уставный капитал</Text>
<Text weight="medium">{{ profileData.capital }}</Text>
</div>
<!-- Address -->
<div v-if="profileData.address" class="md:col-span-2">
<Text tone="muted" size="sm">Адрес</Text>
<Text weight="medium">{{ profileData.address }}</Text>
</div>
<!-- Activities -->
<div v-if="profileData.activities?.length" class="md:col-span-2">
<Text tone="muted" size="sm">Виды деятельности</Text>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="(activity, idx) in profileData.activities.slice(0, 5)"
:key="idx"
class="badge badge-ghost badge-sm"
>
{{ activity }}
</span>
<span v-if="profileData.activities.length > 5" class="text-xs text-base-content/60">
+{{ profileData.activities.length - 5 }}
</span>
</div>
</div>
</div>
<!-- Footer: sources and last updated -->
<div class="flex items-center justify-between text-xs text-base-content/50 pt-2 border-t border-base-200">
<span v-if="profileData.sources?.length">
Источники: {{ profileData.sources.join(', ') }}
</span>
<span v-if="profileData.lastUpdated">
Обновлено: {{ formatDate(profileData.lastUpdated) }}
</span>
</div>
</Stack>
</Card>
</template>
<script setup lang="ts">
import { GetKycProfileFullDocument } from '~/composables/graphql/public/kyc-generated'
const props = defineProps<{
kycProfileUuid?: string | null
}>()
const { execute } = useGraphQL()
const profileData = ref<{
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?: (string | null)[] | null
sources?: (string | null)[] | null
lastUpdated?: string | null
} | null>(null)
const pending = ref(false)
const loadProfile = async () => {
if (!props.kycProfileUuid) return
pending.value = true
try {
const data = await execute(
GetKycProfileFullDocument,
{ profileUuid: props.kycProfileUuid },
'public',
'kyc'
)
profileData.value = data?.kycProfileFull || null
} catch (error) {
console.error('Error loading KYC profile:', error)
} finally {
pending.value = false
}
}
watch(() => props.kycProfileUuid, (uuid) => {
if (uuid) {
loadProfile()
} else {
profileData.value = null
}
}, { immediate: true })
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString('ru-RU')
} catch {
return dateStr
}
}
</script>

View File

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

View File

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

View File

@@ -2,22 +2,22 @@
<Stack gap="8">
<!-- My addresses (for authenticated users) -->
<Stack v-if="isAuthenticated && teamAddresses?.length" gap="4">
<PageHeader title="My addresses">
<PageHeader :title="t('locations.myAddresses')">
<template #actions>
<NuxtLink
:to="localePath('/clientarea/addresses')"
class="btn btn-sm btn-ghost gap-2"
>
<Icon name="lucide:settings" size="16" />
Manage
{{ t('locations.manage') }}
</NuxtLink>
</template>
</PageHeader>
<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)"
@@ -26,7 +26,7 @@
<Stack direction="row" align="center" gap="2">
<Icon name="lucide:map-pin" size="18" class="text-primary" />
<Text size="base" weight="semibold">{{ addr.name }}</Text>
<Pill v-if="addr.isDefault" variant="outline" size="sm">Default</Pill>
<Pill v-if="addr.isDefault" variant="outline" size="sm">{{ t('locations.default') }}</Pill>
</Stack>
<Text tone="muted" size="sm">{{ addr.address }}</Text>
</Stack>
@@ -36,7 +36,7 @@
<!-- Terminals and logistics hubs -->
<Stack gap="4">
<PageHeader title="Terminals and logistics hubs" />
<PageHeader :title="t('locations.terminalsAndHubs')" />
<div v-if="pending" class="flex items-center justify-center p-8">
<span class="loading loading-spinner loading-lg" />
@@ -44,21 +44,21 @@
<Alert v-else-if="error" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">Load error</Heading>
<Button @click="refresh()">Try again</Button>
<Heading :level="4" weight="semibold">{{ t('locations.loadError') }}</Heading>
<Button @click="refresh()">{{ t('locations.tryAgain') }}</Button>
</Stack>
</Alert>
<EmptyState
v-else-if="!locationsData?.length"
title="No locations"
description="Logistics hubs not added yet"
:title="t('locations.noLocations')"
:description="t('locations.noHubsDescription')"
/>
<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,8 +69,19 @@
</template>
<script setup lang="ts">
import { GetNodesDocument } 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()
const { isAuthenticated } = useAuth()
const localePath = useLocalePath()
@@ -83,36 +94,38 @@ const calculateDistance = (lat: number, lng: number) => {
}
// Load logistics hubs
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', GetNodesDocument, {}, 'public', 'geo')
const locationsData = computed(() => {
return (locationsDataRaw.value || []).map((location: any) => ({
...location,
distance: location?.latitude && location?.longitude
? calculateDistance(location.latitude, location.longitude)
: undefined,
}))
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', HubsListDocument, { limit: 100 }, 'public', 'geo')
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

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

View File

@@ -228,15 +228,16 @@ const onMapCreated = (map: MapboxMap) => {
// Click on cluster to zoom in
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const feature = features[0]
if (!feature) return
const clusterId = features[0].properties?.cluster_id
const clusterId = feature.properties?.cluster_id
const source = map.getSource('locations') as mapboxgl.GeoJSONSource
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return
const geometry = features[0].geometry as GeoJSON.Point
const geometry = feature.geometry as GeoJSON.Point
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: zoom || 4
@@ -247,10 +248,11 @@ const onMapCreated = (map: MapboxMap) => {
// Click on individual point
map.on('click', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
if (!features.length) return
const feature = features[0]
if (!feature) return
const featureProps = features[0].properties
const geometry = features[0].geometry as GeoJSON.Point
const featureProps = feature.properties
const geometry = feature.geometry as GeoJSON.Point
const location: Location = {
uuid: featureProps?.uuid,

View File

@@ -38,8 +38,8 @@
</div>
<Stack v-if="autoEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in autoEdges"
:key="edge.toUuid"
v-for="(edge, index) in autoEdges"
:key="edge.toUuid ?? index"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
@@ -70,8 +70,8 @@
</div>
<Stack v-if="railEdges.length > 0" gap="2">
<NuxtLink
v-for="edge in railEdges"
:key="edge.toUuid"
v-for="(edge, index) in railEdges"
:key="edge.toUuid ?? index"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
@@ -292,8 +292,10 @@ const addHubSource = (map: MapboxMapType, id: string, hub: CurrentHub, color: st
})
map.on('click', `${id}-circle`, (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = e.features![0].properties?.name
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = feature.properties?.name
new Popup()
.setLngLat(coordinates)
@@ -390,8 +392,10 @@ const onMapCreated = (map: MapboxMapType) => {
})
const onNeighborsClick = (e: mapboxgl.MapLayerMouseEvent) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features![0].properties
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = feature.properties
const name = featureProps?.name
const distanceKm = featureProps?.distanceKm

View File

@@ -37,8 +37,8 @@
<div class="order-1 lg:order-2">
<Stack gap="2">
<NuxtLink
v-for="edge in edges"
:key="edge.toUuid"
v-for="(edge, index) in edges"
:key="edge.toUuid ?? index"
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
>
@@ -304,8 +304,10 @@ const onMapCreated = (map: MapboxMapType) => {
// Popups on click
map.on('click', 'current-hub-circle', (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = e.features![0].properties?.name
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const name = feature.properties?.name
new Popup()
.setLngLat(coordinates)
@@ -314,8 +316,10 @@ const onMapCreated = (map: MapboxMapType) => {
})
map.on('click', 'neighbors-circles', (e) => {
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features![0].properties
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = feature.properties
const name = featureProps?.name
const distanceKm = featureProps?.distanceKm

View File

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

View File

@@ -47,14 +47,14 @@
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h3 class="text-lg font-semibold text-base-content">Notifications</h3>
<h3 class="text-lg font-semibold text-base-content">{{ t('notifications.title') }}</h3>
<div class="flex items-center gap-3">
<button
v-if="unreadCount > 0"
@click="handleMarkAllAsRead"
class="text-sm text-primary hover:text-primary/80 font-medium"
>
Mark all as read
{{ t('notifications.markAllAsRead') }}
</button>
<button
@click="isOpen = false"
@@ -82,7 +82,7 @@
<svg class="w-12 h-12 text-base-content/30 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-sm text-base-content/60">No notifications</p>
<p class="text-sm text-base-content/60">{{ t('notifications.empty') }}</p>
</div>
<!-- Notification Items -->
@@ -107,7 +107,7 @@
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-base-content line-clamp-2">
{{ notification.content || notification.payload?.body || 'New notification' }}
{{ notification.content || notification.payload?.body || t('notifications.new') }}
</p>
<p class="mt-1 text-xs text-base-content/60">
{{ formatTime(notification.createdAt) }}
@@ -125,7 +125,7 @@
@click="isOpen = false"
class="text-xs text-primary hover:text-primary/80 font-medium"
>
All notifications
{{ t('notifications.viewAll') }}
</NuxtLink>
</div>
</div>
@@ -136,6 +136,8 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
const { t } = useI18n()
const props = defineProps<{
subscriberId: string
}>()
@@ -192,10 +194,10 @@ const formatTime = (dateString: string) => {
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min ago`
if (diffHours < 24) return `${diffHours} h ago`
if (diffDays < 7) return `${diffDays} d ago`
if (diffMins < 1) return t('notifications.time.justNow')
if (diffMins < 60) return t('notifications.time.minutesAgo', { n: diffMins })
if (diffHours < 24) return t('notifications.time.hoursAgo', { n: diffHours })
if (diffDays < 7) return t('notifications.time.daysAgo', { n: diffDays })
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}

View File

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

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ const fetchRouteGeometry = async (stage: RouteStage): Promise<[number, number][]
fromLon: stage.fromLon,
toLat: stage.toLat,
toLon: stage.toLon
}, 'public', 'geo')
}, 'public', 'geo') as Record<string, any>
const geometry = routeData?.[routeField]?.geometry
if (typeof geometry === 'string') {
@@ -363,13 +363,15 @@ const onMapCreated = (map: MapboxMapType) => {
// Click on marker
map.on('click', 'orders-markers-circles', (e) => {
const props = e.features?.[0]?.properties
const feature = e.features?.[0]
if (!feature) return
const props = feature.properties
const orderId = props?.orderId
if (orderId) {
emit('select-order', orderId)
}
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
new Popup()
.setLngLat(coordinates)
.setHTML(`<strong>${props?.name || 'Point'}</strong><br/>${props?.orderName || ''}`)

View File

@@ -90,6 +90,7 @@ const routeMarkers = computed(() => {
if (!stages.length) return
const first = stages[0]
const last = stages[stages.length - 1]
if (!first || !last) return
if (typeof first.fromLat === 'number' && typeof first.fromLon === 'number') {
markers.push({
@@ -183,7 +184,7 @@ const fetchStageGeometry = async (stage: RouteStage, routeIndex: number, stageIn
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
const routeData = await execute(RouteDocument, { fromLat, fromLon, toLat, toLon }, 'public', 'geo')
const routeData = await execute(RouteDocument, { fromLat, fromLon, toLat, toLon }, 'public', 'geo') as Record<string, any>
const geometry = routeData?.[routeField]?.geometry
if (typeof geometry === 'string') {
@@ -341,8 +342,10 @@ const onMapCreated = (map: MapboxMapType) => {
})
map.on('click', 'request-markers-circles', (e) => {
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = e.features?.[0].properties
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const featureProps = feature.properties
const title = featureProps?.name || 'Точка'
const label = featureProps?.label || ''

View File

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

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)
@@ -227,8 +259,10 @@ const onMapCreated = (map: MapboxMapType) => {
})
map.on('click', 'route-points-layer', (e) => {
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const props = e.features?.[0].properties
const feature = e.features?.[0]
if (!feature) return
const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const props = feature.properties
const name = props?.name || t('routeMap.points.service')
new Popup()
@@ -261,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

@@ -38,8 +38,8 @@
</li>
<li>
<NuxtLink
:to="localePath('/catalog/offers')"
:class="{ active: isActive('/catalog/offers') }"
:to="localePath('/catalog?select=product')"
:class="{ active: isCatalogActive('product') }"
class="tooltip tooltip-right"
:data-tip="t('nav.offers')"
>
@@ -48,8 +48,8 @@
</li>
<li>
<NuxtLink
:to="localePath('/catalog/suppliers')"
:class="{ active: isActive('/catalog/suppliers') }"
:to="localePath('/catalog?select=supplier')"
:class="{ active: isCatalogActive('supplier') }"
class="tooltip tooltip-right"
:data-tip="t('nav.suppliers')"
>
@@ -58,8 +58,8 @@
</li>
<li>
<NuxtLink
:to="localePath('/catalog/hubs')"
:class="{ active: isActive('/catalog/hubs') }"
:to="localePath('/catalog?select=hub')"
:class="{ active: isCatalogActive('hub') }"
class="tooltip tooltip-right"
:data-tip="t('nav.hubs')"
>
@@ -169,19 +169,19 @@
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/offers')" :class="{ active: isActive('/catalog/offers') }">
<NuxtLink :to="localePath('/catalog?select=product')" :class="{ active: isCatalogActive('product') }">
<Icon name="lucide:tag" size="18" />
{{ t('nav.offers') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/suppliers')" :class="{ active: isActive('/catalog/suppliers') }">
<NuxtLink :to="localePath('/catalog?select=supplier')" :class="{ active: isCatalogActive('supplier') }">
<Icon name="lucide:building-2" size="18" />
{{ t('nav.suppliers') }}
</NuxtLink>
</li>
<li>
<NuxtLink :to="localePath('/catalog/hubs')" :class="{ active: isActive('/catalog/hubs') }">
<NuxtLink :to="localePath('/catalog?select=hub')" :class="{ active: isCatalogActive('hub') }">
<Icon name="lucide:warehouse" size="18" />
{{ t('nav.hubs') }}
</NuxtLink>
@@ -379,6 +379,24 @@ const isActive = (path: string) => {
return current.startsWith(localePath(path) + '/')
}
// Check if catalog section is active based on query params
const isCatalogActive = (type: 'product' | 'supplier' | 'hub') => {
const catalogPath = localePath('/catalog')
if (!route.path.startsWith(catalogPath)) return false
const { select, product, supplier, hub } = route.query
// If we're in selection mode for this type
if (select === type) return true
// If this type has been selected (in the flow)
if (type === 'product' && (product || select === 'product')) return true
if (type === 'supplier' && supplier) return true
if (type === 'hub' && hub) return true
return false
}
const isExactActive = (path: string) => {
return route.path === localePath(path)
}

View File

@@ -0,0 +1,70 @@
<template>
<div v-if="kycProfileUuid && companyData" class="space-y-2">
<div class="flex items-center gap-2">
<span class="badge badge-outline badge-sm">{{ companyData.companyType || 'Компания' }}</span>
<span v-if="companyData.registrationYear" class="text-xs text-base-content/60">
с {{ companyData.registrationYear }} г.
</span>
<span v-if="companyData.isActive" class="badge badge-success badge-xs">Активна</span>
</div>
<div v-if="companyData.sourcesCount" class="text-xs text-base-content/60">
{{ companyData.sourcesCount }} {{ pluralize(companyData.sourcesCount, 'источник', 'источника', 'источников') }} данных
</div>
</div>
<div v-else-if="kycProfileUuid && pending" class="text-xs text-base-content/60">
Загрузка данных...
</div>
</template>
<script setup lang="ts">
import { GetKycProfileTeaserDocument } from '~/composables/graphql/public/kyc-generated'
const props = defineProps<{
kycProfileUuid?: string | null
}>()
const { execute } = useGraphQL()
const companyData = ref<{
companyType?: string | null
registrationYear?: number | null
isActive?: boolean | null
sourcesCount?: number | null
} | null>(null)
const pending = ref(false)
const loadCompanyData = async () => {
if (!props.kycProfileUuid) return
pending.value = true
try {
const data = await execute(
GetKycProfileTeaserDocument,
{ profileUuid: props.kycProfileUuid },
'public',
'kyc'
)
companyData.value = data?.kycProfileTeaser || null
} catch (error) {
console.error('Error loading company data:', error)
} finally {
pending.value = false
}
}
watch(() => props.kycProfileUuid, (uuid) => {
if (uuid) {
loadCompanyData()
} else {
companyData.value = null
}
}, { immediate: true })
const pluralize = (n: number, one: string, few: string, many: string) => {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
return many
}
</script>

View File

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

View File

@@ -40,6 +40,19 @@
</template>
<script setup lang="ts">
interface TeamMember {
id: string
userId: string
role?: string | null
}
interface Team {
id?: string | null
name: string
createdAt?: string | null
members?: TeamMember[] | null
}
interface Props {
team: Team
}
@@ -49,7 +62,7 @@ const membersCount = computed(() => props.team?.members?.length || 1)
const displayMembers = computed(() => (props.team?.members || []).slice(0, 3))
const remainingMembers = computed(() => Math.max(0, membersCount.value - 3))
const formatDate = (dateString: string) => {
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return ''
try {
return new Date(dateString).toLocaleDateString('ru-RU')

View File

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

View File

@@ -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

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

View File

@@ -45,10 +45,10 @@
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-72 p-2 shadow-lg border border-base-300 mt-1"
>
<li class="menu-title"><span>Quick actions</span></li>
<li><a @click="navigateToAction('/catalog')">Find materials</a></li>
<li><a @click="navigateToAction('/clientarea/orders')">My orders</a></li>
<li><a @click="navigateToAction('/clientarea/profile')">Profile settings</a></li>
<li class="menu-title"><span>{{ t('topbar.quickActions') }}</span></li>
<li><a @click="navigateToAction('/catalog')">{{ t('topbar.findMaterials') }}</a></li>
<li><a @click="navigateToAction('/clientarea/orders')">{{ t('topbar.myOrders') }}</a></li>
<li><a @click="navigateToAction('/clientarea/profile')">{{ t('topbar.profileSettings') }}</a></li>
</ul>
</div>
@@ -110,7 +110,7 @@
<details>
<summary>
<Icon name="lucide:globe" size="16" />
Language
{{ t('topbar.language') }}
</summary>
<ul>
<li v-for="loc in locales" :key="loc.code">
@@ -161,7 +161,7 @@ defineProps<{
}>()
const localePath = useLocalePath()
const { locale, locales } = useI18n()
const { t, locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const locationStore = useLocationStore()

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -48,5 +48,5 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.address.uuid)
const linkable = computed(() => !props.selectable && !!props.address.uuid)
</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

@@ -0,0 +1,51 @@
<template>
<div class="breadcrumbs text-sm">
<ul>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<NuxtLink v-if="crumb.to" :to="crumb.to" class="hover:text-primary">
{{ crumb.label }}
</NuxtLink>
<span v-else class="text-base-content">{{ crumb.label }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
hubId?: string
hubName?: string
productId?: string
productName?: string
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const breadcrumbs = computed(() => {
const crumbs: Array<{ label: string; to?: string }> = []
// Hubs list
crumbs.push({
label: t('breadcrumbs.hubs', 'Hubs'),
to: localePath('/catalog?select=hub')
})
// Hub
if (props.hubId) {
crumbs.push({
label: props.hubName || `#${props.hubId.slice(0, 8)}...`,
to: props.productId ? localePath(`/catalog/hubs/${props.hubId}`) : undefined
})
}
// Product
if (props.productId) {
crumbs.push({
label: props.productName || `#${props.productId.slice(0, 8)}...`
})
}
return crumbs
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<h1 class="text-4xl font-bold mb-4">{{ t('catalog.hero.title') }}</h1>
<p class="text-lg text-base-content/70 mb-8 max-w-lg">
{{ t('catalog.hero.subtitle') }}
</p>
<div class="flex flex-wrap justify-center gap-4">
<button
class="btn btn-lg btn-primary gap-2"
@click="$emit('start-select', 'product')"
>
<Icon name="lucide:package" size="24" />
{{ t('catalog.filters.product') }}
</button>
<button
class="btn btn-lg btn-outline gap-2"
@click="$emit('start-select', 'supplier')"
>
<Icon name="lucide:factory" size="24" />
{{ t('catalog.filters.supplier') }}
</button>
<button
class="btn btn-lg btn-outline gap-2"
@click="$emit('start-select', 'hub')"
>
<Icon name="lucide:map-pin" size="24" />
{{ t('catalog.filters.hub') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits<{
(e: 'start-select', type: string): void
}>()
const { t } = useI18n()
</script>

View File

@@ -4,7 +4,7 @@
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogHubsSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/hubs')"
:to="localePath('/catalog?select=hub')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogHubsSection.actions.view_all') }}</span>
@@ -17,8 +17,8 @@
<Text weight="semibold" class="mb-3">{{ country.name }}</Text>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<HubCard
v-for="hub in country.hubs"
:key="hub.uuid"
v-for="(hub, index) in country.hubs"
:key="hub.uuid ?? index"
:hub="hub"
/>
</Grid>

View File

@@ -20,11 +20,11 @@ import { LngLatBounds } from 'mapbox-gl'
import type { ClusterPointType } 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 {
@@ -44,23 +44,39 @@ const props = withDefaults(defineProps<{
mapId: string
items?: MapItem[]
clusteredPoints?: ClusterPointType[]
clusteredPointsByType?: Partial<Record<'offer' | 'hub' | 'supplier', ClusterPointType[]>>
useServerClustering?: boolean
hoveredItemId?: string | null
hoveredItem?: HoveredItem | null
pointColor?: string
entityType?: 'offer' | 'hub' | 'supplier'
initialCenter?: [number, number]
initialZoom?: number
infoLoading?: boolean
fitPaddingLeft?: number
relatedPoints?: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}>
}>(), {
pointColor: '#10b981',
pointColor: '#f97316',
entityType: 'offer',
initialCenter: () => [37.64, 55.76],
initialZoom: 2,
useServerClustering: false,
infoLoading: false,
fitPaddingLeft: 0,
items: () => [],
clusteredPoints: () => []
clusteredPoints: () => [],
clusteredPointsByType: undefined,
relatedPoints: () => []
})
const emit = defineEmits<{
'select-item': [uuid: string]
'select-item': [uuid: string, properties?: Record<string, any>]
'bounds-change': [bounds: MapBounds]
}>()
@@ -69,6 +85,119 @@ 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 = {
offer: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>`,
hub: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z"/><path d="M6 18h12"/><path d="M6 14h12"/><rect width="12" height="12" x="6" y="10"/></svg>`,
supplier: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M17 18h1"/><path d="M12 18h1"/><path d="M7 18h1"/></svg>`
}
return icons[type]
}
// Load icon into map as image
const loadEntityIcon = async (map: MapboxMapType, type: 'offer' | 'hub' | 'supplier', color: string) => {
const iconName = `entity-icon-${type}`
if (map.hasImage(iconName)) {
map.removeImage(iconName)
}
const svg = createEntityIcon(type, color)
const img = new Image(32, 32)
return new Promise<void>((resolve) => {
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
if (ctx) {
// Draw colored circle background
ctx.beginPath()
ctx.arc(16, 16, 15, 0, 2 * Math.PI)
ctx.fillStyle = color
ctx.fill()
ctx.strokeStyle = 'white'
ctx.lineWidth = 2
ctx.stroke()
// Draw icon on top
ctx.drawImage(img, 4, 4, 24, 24)
}
const imageData = ctx?.getImageData(0, 0, 32, 32)
if (imageData) {
map.addImage(iconName, { width: 32, height: 32, data: imageData.data })
}
resolve()
}
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
})
}
// Standard colors for entity types
const ENTITY_COLORS = {
hub: '#22c55e', // green
supplier: '#3b82f6', // blue
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']
for (const type of types) {
const iconName = `related-icon-${type}`
if (map.hasImage(iconName)) {
map.removeImage(iconName)
}
const svg = createEntityIcon(type, ENTITY_COLORS[type])
const img = new Image(32, 32)
await new Promise<void>((resolve) => {
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.beginPath()
ctx.arc(16, 16, 15, 0, 2 * Math.PI)
ctx.fillStyle = ENTITY_COLORS[type]
ctx.fill()
ctx.strokeStyle = 'white'
ctx.lineWidth = 2
ctx.stroke()
ctx.drawImage(img, 4, 4, 24, 24)
}
const imageData = ctx?.getImageData(0, 0, 32, 32)
if (imageData) {
map.addImage(iconName, { width: 32, height: 32, data: imageData.data })
}
resolve()
}
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
})
}
}
const mapOptions = computed(() => ({
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: props.initialCenter,
@@ -80,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
@@ -106,6 +237,33 @@ const serverClusteredGeoJson = computed(() => ({
}))
}))
const serverClusteredGeoJsonByType = computed(() => {
const build = (points: ClusterPointType[] | 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,
@@ -119,8 +277,39 @@ const hoveredPointGeoJson = computed(() => ({
}] : []
}))
// Related points GeoJSON (for Info mode)
const relatedPointsGeoJson = computed(() => {
if (!props.relatedPoints || props.relatedPoints.length === 0) {
return { type: 'FeatureCollection' as const, features: [] }
}
return {
type: 'FeatureCollection' as const,
features: props.relatedPoints.map(point => ({
type: 'Feature' as const,
properties: {
uuid: point.uuid,
name: point.name,
type: point.type
},
geometry: {
type: 'Point' as const,
coordinates: [point.longitude, point.latitude]
}
}))
}
})
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()
@@ -135,7 +324,7 @@ const emitBoundsChange = (map: MapboxMapType) => {
}
const onMapCreated = (map: MapboxMapType) => {
const initMap = () => {
const initMap = async () => {
map.setFog({
color: 'rgb(186, 210, 235)',
'high-color': 'rgb(36, 92, 223)',
@@ -145,9 +334,13 @@ const onMapCreated = (map: MapboxMapType) => {
})
if (props.useServerClustering) {
initServerClusteringLayers(map)
if (usesTypedClusters.value) {
await initServerClusteringLayersByType(map)
} else {
await initServerClusteringLayers(map)
}
} else {
initClientClusteringLayers(map)
await initClientClusteringLayers(map)
}
// Emit initial bounds
@@ -166,7 +359,10 @@ const onMapCreated = (map: MapboxMapType) => {
}
}
const initClientClusteringLayers = (map: MapboxMapType) => {
const initClientClusteringLayers = async (map: MapboxMapType) => {
// Load entity icon first
await loadEntityIcon(map, props.entityType, props.pointColor)
map.addSource(sourceId.value, {
type: 'geojson',
data: geoJsonData.value,
@@ -203,19 +399,13 @@ const initClientClusteringLayers = (map: MapboxMapType) => {
map.addLayer({
id: 'unclustered-point',
type: 'circle',
type: 'symbol',
source: sourceId.value,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 12,
'circle-color': [
'case',
['==', ['get', 'uuid'], props.hoveredItemId || ''],
'#facc15', // yellow when hovered
props.pointColor
],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
layout: {
'icon-image': `entity-icon-${props.entityType}`,
'icon-size': 1,
'icon-allow-overlap': true
}
})
@@ -226,7 +416,7 @@ const initClientClusteringLayers = (map: MapboxMapType) => {
filter: ['!', ['has', 'point_count']],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.5],
'text-offset': [0, 1.8],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
@@ -239,20 +429,22 @@ const initClientClusteringLayers = (map: MapboxMapType) => {
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties?.cluster_id
const feature = features[0]
if (!feature) return
const clusterId = feature.properties?.cluster_id
const source = map.getSource(sourceId.value) as mapboxgl.GeoJSONSource
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return
const geometry = features[0].geometry as GeoJSON.Point
const geometry = feature.geometry as GeoJSON.Point
map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 })
})
})
map.on('click', 'unclustered-point', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
if (!features.length) return
emit('select-item', features[0].properties?.uuid)
const feature = features[0]
if (!feature) return
emit('select-item', feature.properties?.uuid)
})
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
@@ -260,24 +452,117 @@ const initClientClusteringLayers = (map: MapboxMapType) => {
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
// Hovered point layer (on top of everything) - "target" effect with border
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
// Outer ring (white)
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'
}
})
// Inner point (same as entity color)
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 (for Info mode - icons by type)
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' // default
],
'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
}
})
// Click handlers for related points
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 = ''
})
// Auto-fit bounds to all items
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
}
}
const initServerClusteringLayers = (map: MapboxMapType) => {
const initServerClusteringLayers = async (map: MapboxMapType) => {
// Load entity icon first
await loadEntityIcon(map, props.entityType, props.pointColor)
map.addSource(sourceId.value, {
type: 'geojson',
data: serverClusteredGeoJson.value
})
// Clusters (count > 1)
// Clusters (count > 1) - circle with count
map.addLayer({
id: 'server-clusters',
type: 'circle',
@@ -304,22 +589,16 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
paint: { 'text-color': '#ffffff' }
})
// Individual points (count == 1)
// Individual points (count == 1) - icon with entity type
map.addLayer({
id: 'server-points',
type: 'circle',
type: 'symbol',
source: sourceId.value,
filter: ['==', ['get', 'count'], 1],
paint: {
'circle-radius': 12,
'circle-color': [
'case',
['==', ['get', 'id'], props.hoveredItemId || ''],
'#facc15', // yellow when hovered
props.pointColor
],
'circle-stroke-width': 3,
'circle-stroke-color': '#ffffff'
layout: {
'icon-image': `entity-icon-${props.entityType}`,
'icon-size': 1,
'icon-allow-overlap': true
}
})
@@ -330,7 +609,7 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
filter: ['==', ['get', 'count'], 1],
layout: {
'text-field': ['get', 'name'],
'text-offset': [0, 1.5],
'text-offset': [0, 1.8],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
},
@@ -344,20 +623,23 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
// Click on cluster to zoom in
map.on('click', 'server-clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-clusters'] })
if (!features.length) return
const expansionZoom = features[0].properties?.expansionZoom
const geometry = features[0].geometry as GeoJSON.Point
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
})
})
// Click on individual point
// Click on individual point - emit full properties
map.on('click', 'server-points', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['server-points'] })
if (!features.length) return
emit('select-item', features[0].properties?.id)
const feature = features[0]
if (!feature) return
const props = feature.properties || {}
emit('select-item', props.id, props)
})
map.on('mouseenter', 'server-clusters', () => { map.getCanvas().style.cursor = 'pointer' })
@@ -365,33 +647,301 @@ const initServerClusteringLayers = (map: MapboxMapType) => {
map.on('mouseenter', 'server-points', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'server-points', () => { map.getCanvas().style.cursor = '' })
// Hovered point layer (on top of everything) - "target" effect with border
map.addSource(hoveredSourceId.value, {
type: 'geojson',
data: hoveredPointGeoJson.value
})
// Outer ring (white)
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'
}
})
// Inner point (same as entity color)
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 (for Info mode - icons by type)
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' // default
],
'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
}
})
// Click handlers for related points
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 = ''
})
}
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': '#3b82f6',
'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
@@ -401,8 +951,71 @@ watch(() => props.hoveredItem, () => {
}
}, { deep: true })
// 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
const map = mapRef.value
// Reload icon with new color and type
await loadEntityIcon(map, newType, newColor)
// Update cluster circle colors
if (props.useServerClustering) {
if (usesTypedClusters.value) {
return
}
if (map.getLayer('server-clusters')) {
map.setPaintProperty('server-clusters', 'circle-color', newColor)
}
if (map.getLayer('server-points')) {
map.setLayoutProperty('server-points', 'icon-image', `entity-icon-${newType}`)
}
} else {
if (map.getLayer('clusters')) {
map.setPaintProperty('clusters', 'circle-color', newColor)
}
if (map.getLayer('unclustered-point')) {
map.setLayoutProperty('unclustered-point', 'icon-image', `entity-icon-${newType}`)
}
}
// Update hovered point color
if (map.getLayer('hovered-point-layer')) {
map.setPaintProperty('hovered-point-layer', 'circle-color', newColor)
}
})
// 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()
@@ -412,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

@@ -23,8 +23,8 @@
<!-- Hubs Tab -->
<template v-if="activeTab === 'hubs'">
<HubCard
v-for="hub in hubs"
:key="hub.uuid"
v-for="(hub, index) in hubs"
:key="hub.uuid ?? index"
:hub="hub"
selectable
:is-selected="selectedId === hub.uuid"
@@ -37,16 +37,20 @@
<!-- Offers Tab -->
<template v-if="activeTab === 'offers'">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
selectable
compact
:is-selected="selectedId === offer.uuid"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
: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>
@@ -54,8 +58,8 @@
<!-- Suppliers Tab -->
<template v-if="activeTab === 'suppliers'">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
v-for="(supplier, index) in suppliers"
:key="supplier.uuid ?? index"
:supplier="supplier"
selectable
:is-selected="selectedId === supplier.uuid"
@@ -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

@@ -15,7 +15,7 @@
<div v-if="filters && filters.length > 0" class="p-4 border-b border-base-300">
<CatalogFilters
:filters="filters"
:model-value="selectedFilter"
:model-value="selectedFilter ?? 'all'"
@update:model-value="$emit('update:selectedFilter', $event)"
/>
</div>

View File

@@ -4,7 +4,7 @@
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogOffersSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/offers')"
:to="localePath('/catalog?select=product')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogOffersSection.actions.view_all') }}</span>
@@ -13,23 +13,30 @@
</Stack>
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<OfferCard
v-for="offer in offers"
:key="offer.uuid"
:offer="offer"
<OfferResultCard
v-for="(offer, index) in offersWithPrice"
:key="offer.uuid ?? index"
: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

@@ -15,7 +15,13 @@
class="badge badge-sm badge-primary gap-1 cursor-pointer hover:badge-error transition-colors"
@click="$emit('remove-filter', filter.id)"
>
{{ filter.label }}
<template v-if="filter.key">
<span class="opacity-70">{{ filter.key }}:</span>
<span>{{ filter.label }}</span>
</template>
<template v-else>
{{ filter.label }}
</template>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
@@ -96,6 +102,7 @@
interface FilterOption {
id: string
label: string
key?: string // Optional key for "Key: Value" format badges
}
interface SortOption {

View File

@@ -4,7 +4,7 @@
<Stack direction="row" align="center" justify="between">
<Heading :level="2">{{ t('catalogSuppliersSection.header.title') }}</Heading>
<NuxtLink
:to="localePath('/catalog/suppliers')"
:to="localePath('/catalog?select=supplier')"
class="btn btn-sm btn-ghost"
>
<span>{{ t('catalogSuppliersSection.actions.view_all') }}</span>
@@ -14,8 +14,8 @@
<Grid :cols="1" :md="2" :lg="3" :gap="4">
<SupplierCard
v-for="supplier in suppliers"
:key="supplier.uuid"
v-for="(supplier, index) in suppliers"
:key="supplier.uuid ?? index"
:supplier="supplier"
/>
</Grid>

View File

@@ -0,0 +1,80 @@
<template>
<div class="flex flex-col gap-4">
<!-- View toggle -->
<div class="join join-horizontal">
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mapViewMode === 'offers' }"
@click="setMapViewMode('offers')"
>
<Icon name="lucide:shopping-bag" size="16" />
<span class="hidden sm:inline">{{ $t('catalog.views.offers') }}</span>
</button>
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mapViewMode === 'hubs' }"
@click="setMapViewMode('hubs')"
>
<Icon name="lucide:warehouse" size="16" />
<span class="hidden sm:inline">{{ $t('catalog.views.hubs') }}</span>
</button>
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mapViewMode === 'suppliers' }"
@click="setMapViewMode('suppliers')"
>
<Icon name="lucide:factory" size="16" />
<span class="hidden sm:inline">{{ $t('catalog.views.suppliers') }}</span>
</button>
</div>
<!-- Selected item info -->
<div v-if="selectedItem" class="card bg-base-100 shadow-lg">
<div class="card-body p-4">
<div class="flex items-start justify-between gap-2">
<div>
<h3 class="card-title text-base">{{ selectedItem.name }}</h3>
<p v-if="selectedItem.country" class="text-sm text-base-content/70">
{{ selectedItem.country }}
</p>
</div>
<button class="btn btn-ghost btn-sm btn-circle" @click="emit('close-selected')">
<Icon name="lucide:x" size="16" />
</button>
</div>
<div class="card-actions justify-end mt-2">
<button class="btn btn-primary btn-sm" @click="emit('view-details', selectedItem)">
{{ $t('common.viewDetails') }}
</button>
</div>
</div>
</div>
<!-- Hint when nothing selected -->
<div v-else class="text-sm text-base-content/60">
<p>{{ $t('catalog.explore.subtitle') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
interface SelectedItem {
uuid: string
name?: string | null
country?: string | null
latitude?: number | null
longitude?: number | null
}
defineProps<{
selectedItem?: SelectedItem | null
}>()
const emit = defineEmits<{
'close-selected': []
'view-details': [item: SelectedItem]
}>()
const { mapViewMode, setMapViewMode } = useCatalogSearch()
</script>

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/hubs/${hub.uuid}`) : undefined"
:to="linkable ? resolvedLink : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
@@ -16,16 +16,35 @@
]"
>
<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">
<Icon v-if="hasTransport('auto')" name="lucide:truck" size="14" class="text-base-content/50" />
<Icon v-if="hasTransport('rail')" name="lucide:train-front" size="14" class="text-base-content/50" />
<Icon v-if="hasTransport('sea')" name="lucide:ship" size="14" class="text-base-content/50" />
<Icon v-if="hasTransport('air')" name="lucide:plane" size="14" class="text-base-content/50" />
</div>
</div>
</Card>
@@ -40,13 +59,19 @@ interface Hub {
name?: string | null
country?: string | null
countryCode?: string | null
latitude?: number | null
longitude?: number | null
distance?: string
distanceKm?: number | null
transportTypes?: (string | null)[] | null
}
const props = defineProps<{
hub: Hub
origin?: { latitude: number; longitude: number } | null
selectable?: boolean
isSelected?: boolean
linkTo?: string
}>()
defineEmits<{
@@ -57,7 +82,8 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.hub.uuid)
const linkable = computed(() => !props.selectable && !!(props.linkTo || props.hub.uuid))
const resolvedLink = computed(() => props.linkTo || localePath(`/catalog/hubs/${props.hub.uuid}`))
// ISO code to emoji flag
const isoToEmoji = (code: string): string => {
@@ -70,4 +96,34 @@ const countryFlag = computed(() => {
}
return '🌍'
})
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

@@ -0,0 +1,504 @@
<template>
<div class="flex flex-col h-full">
<!-- Header with close button -->
<div class="flex-shrink-0 p-4 border-b border-white/10">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="flex items-center justify-center w-6 h-6 rounded-full"
:style="{ backgroundColor: badgeColor }"
>
<Icon :name="entityIcon" size="14" class="text-white" />
</div>
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
</div>
<div class="flex items-center gap-2">
<button
v-if="(entityType === 'hub' || entityType === 'supplier') && entity?.uuid"
class="rounded-full glass-bright border border-white/30 shadow-lg p-1.5 transition-transform hover:scale-105"
@click="emit('pin', entityType, { uuid: entity?.uuid, name: entity?.name })"
aria-label="Pin"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
<Icon name="lucide:x" size="16" />
</button>
</div>
</div>
</div>
<!-- Content (scrollable) -->
<div class="flex-1 overflow-y-auto p-4">
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md text-white" />
</div>
<!-- Content -->
<div v-else-if="entity" class="flex flex-col gap-4">
<!-- Entity Info Header (text, not card) -->
<div class="mb-2">
<!-- Location for hub/supplier -->
<p v-if="entityLocation" class="text-sm text-white/70 flex items-center gap-1">
<Icon name="lucide:map-pin" size="14" />
{{ entityLocation }}
</p>
<!-- Price for offer -->
<p v-if="entityType === 'offer' && entity?.pricePerUnit" class="text-sm text-white/70 flex items-center gap-1">
<Icon name="lucide:tag" size="14" />
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
</p>
<!-- Supplier for offer (clickable name) -->
<button
v-if="entityType === 'offer' && entity?.teamUuid"
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
@click="emit('open-info', 'supplier', entity.teamUuid)"
>
<Icon name="lucide:factory" size="14" />
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
<span v-else>{{ supplierDisplayName || $t('catalog.info.supplier') }}</span>
</button>
</div>
<!-- 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 }}
<span v-if="loadingProducts" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedProducts.length > 0" class="text-white/50">({{ relatedProducts.length }})</span>
</h3>
<div v-if="!loadingProducts && relatedProducts.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.empty.noProducts') }}
</div>
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
<div
v-for="(product, index) in relatedProducts"
:key="product.uuid ?? index"
class="relative group"
>
<ProductCard
: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.locationName || offer.locationCountry || offer.country || offer.locationName"
: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' && !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') }}
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedSuppliers.length > 0" class="text-white/50">({{ relatedSuppliers.length }})</span>
</h3>
<div v-if="!loadingSuppliers && relatedSuppliers.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.info.noSuppliers') }}
</div>
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
<div
v-for="(supplier, index) in relatedSuppliers"
:key="supplier.uuid ?? index"
class="relative group"
>
<SupplierCard
: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>
<!-- Hubs Section (for supplier/offer) -->
<section v-if="entityType === 'supplier' || entityType === 'offer'">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:warehouse" size="16" />
{{ $t('catalog.info.nearestHubs') }}
<span v-if="loadingHubs" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedHubs.length > 0" class="text-white/50">({{ relatedHubs.length }})</span>
</h3>
<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="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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { InfoEntityType } from '~/composables/useCatalogSearch'
import type {
InfoEntity,
InfoProductItem,
InfoHubItem,
InfoSupplierItem,
InfoOfferItem
} from '~/composables/useCatalogInfo'
import type { RouteStageType } from '~/composables/graphql/public/geo-generated'
const props = defineProps<{
entityType: InfoEntityType
entityId: string
entity: InfoEntity | null
relatedProducts?: InfoProductItem[]
relatedHubs?: InfoHubItem[]
relatedSuppliers?: InfoSupplierItem[]
relatedOffers?: InfoOfferItem[]
selectedProduct?: string | null
currentTab?: string
loading?: boolean
loadingProducts?: boolean
loadingHubs?: boolean
loadingSuppliers?: boolean
loadingOffers?: boolean
}>()
const emit = defineEmits<{
'close': []
'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()
const { entityColors } = useCatalogSearch()
// Safe accessors for optional arrays
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(() => {
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
})
// Entity location (address, city, country)
const entityLocation = computed(() => {
if (!props.entity) return null
const parts = [props.entity.address, props.entity.city, props.entity.country].filter(Boolean)
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'
? t('catalog.info.productsHere')
: t('catalog.info.productsFromSupplier')
})
// Badge color
const badgeColor = computed(() => {
if (props.entityType === 'hub') return entityColors.hub
if (props.entityType === 'supplier') return entityColors.supplier
if (props.entityType === 'offer') return entityColors.offer
return '#666'
})
const entityIcon = computed(() => {
if (props.entityType === 'hub') return 'lucide:warehouse'
if (props.entityType === 'supplier') return 'lucide:factory'
if (props.entityType === 'offer') return 'lucide:shopping-bag'
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: InfoProductItem) => {
emit('select-product', product.uuid)
}
const onOfferSelect = (offer: InfoOfferItem) => {
if (offer.uuid) {
emit('select-offer', { uuid: offer.uuid, productUuid: offer.productUuid })
}
}
const onHubSelect = (hub: InfoHubItem) => {
if (hub.uuid) {
emit('open-info', 'hub', hub.uuid)
}
}
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<RouteStageType> => 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

@@ -0,0 +1,13 @@
<template>
<div class="flex flex-col h-full">
<!-- Header: белое стекло (negative margins to expand beyond parent padding) -->
<div class="sticky top-0 z-10 -mx-4 -mt-4 px-4 pt-4 pb-3 rounded-t-xl bg-white/90 backdrop-blur-md border-b border-white/20">
<slot name="header" />
</div>
<!-- Content: тёмный (negative margins to expand beyond parent padding) -->
<div class="flex-1 -mx-4 -mb-4 px-4 pt-3 pb-4 overflow-y-auto">
<slot />
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,227 @@
<template>
<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>
</div>
<!-- Supplier info -->
<SupplierInfoBlock v-if="kycProfileUuid" :kyc-profile-uuid="kycProfileUuid" class="mb-3" />
<!-- 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">
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[]
totalTimeSeconds?: number | null
kycProfileUuid?: string | null
grouped?: boolean
}>(), {
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 == null) return null
const currSymbol = getCurrencySymbol(props.currency)
const unitName = getUnitName(props.unit)
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 '$'
case 'EUR': return '€'
case 'RUB': return '₽'
case 'CNY': return '¥'
default: return '$'
}
}
const getUnitName = (unit?: string | null) => {
switch (unit?.toLowerCase()) {
case 'т':
case 't':
case 'ton':
case 'tonne':
return t('catalogOfferCard.labels.default_unit')
case 'кг':
case 'kg':
return t('catalogOfferCard.labels.unit_kg')
default:
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,51 @@
<template>
<div class="breadcrumbs text-sm">
<ul>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<NuxtLink v-if="crumb.to" :to="crumb.to" class="hover:text-primary">
{{ crumb.label }}
</NuxtLink>
<span v-else class="text-base-content">{{ crumb.label }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
productId?: string
productName?: string
hubId?: string
hubName?: string
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const breadcrumbs = computed(() => {
const crumbs: Array<{ label: string; to?: string }> = []
// Catalog root
crumbs.push({
label: t('breadcrumbs.products', 'Products'),
to: localePath('/catalog?select=product')
})
// Product
if (props.productId) {
crumbs.push({
label: props.productName || `#${props.productId.slice(0, 8)}...`,
to: props.hubId ? localePath(`/catalog?product=${props.productId}`) : undefined
})
}
// Hub
if (props.hubId) {
crumbs.push({
label: props.hubName || `#${props.hubId.slice(0, 8)}...`
})
}
return crumbs
})
</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

@@ -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">
<Stack gap="1">
<Text size="base" weight="semibold">{{ product.name }}</Text>
<Text tone="muted">{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}</Text>
</Stack>
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
</Stack>
<!-- 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>
@@ -30,16 +72,21 @@ import { NuxtLink } from '#components'
interface Product {
uuid?: string | null
name?: string | null
categoryName?: string | null
description?: string | null
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
@@ -48,5 +95,84 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && props.product.uuid)
const linkable = computed(() => !props.selectable && !!props.product.uuid)
// Generate mock price history based on uuid for consistency
const effectivePriceHistory = computed(() => {
if (props.priceHistory && props.priceHistory.length > 0) {
return props.priceHistory
}
if (!props.product.uuid) return []
const seed = props.product.uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const basePrice = 100 + (seed % 200)
return Array.from({ length: 7 }, (_, i) => {
const variation = Math.sin(seed + i * 0.5) * 20 + Math.cos(seed * 0.3 + i) * 10
return Math.round(basePrice + variation)
})
})
// Effective price - use provided or last from history
const effectivePrice = computed(() => {
if (props.currentPrice) return props.currentPrice
if (effectivePriceHistory.value.length > 0) {
return effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
}
return null
})
// Price formatting
const formattedPrice = computed(() => {
if (!effectivePrice.value) return ''
const symbol = getCurrencySymbol(props.currency)
return `${symbol}${effectivePrice.value.toLocaleString()}`
})
const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) {
case 'USD': return '$'
case 'EUR': return '€'
case 'RUB': return '₽'
case 'CNY': return '¥'
default: return '$'
}
}
// Calculate trend from price history
const trend = computed(() => {
if (effectivePriceHistory.value.length < 2) return 0
const first = effectivePriceHistory.value[0]
const last = effectivePriceHistory.value[effectivePriceHistory.value.length - 1]
if (!first || first === 0 || !last) return 0
return Math.round(((last - first) / first) * 100)
})
// Chart configuration
const chartOptions = computed(() => ({
chart: {
type: 'area',
sparkline: { enabled: true },
animations: { enabled: false }
},
stroke: {
curve: 'smooth',
width: 2
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.4,
opacityTo: 0.1
}
},
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
tooltip: { enabled: false },
xaxis: { labels: { show: false } },
yaxis: { labels: { show: false } }
}))
const chartSeries = computed(() => [{
name: 'Price',
data: effectivePriceHistory.value.length > 0 ? effectivePriceHistory.value : [0]
}])
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="flex flex-col gap-4">
<h3 class="font-semibold text-lg">{{ $t('catalog.quote.title') }}</h3>
<!-- Quantity input -->
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.quantity') }}</span>
</label>
<input
v-model="localQuantity"
type="number"
:placeholder="$t('catalog.quote.enterQty')"
class="input input-bordered input-sm"
min="1"
@blur="emit('update-quantity', localQuantity)"
@keyup.enter="emit('update-quantity', localQuantity)"
/>
</div>
<!-- Action buttons -->
<div class="flex gap-2 mt-2">
<button
class="btn btn-primary flex-1"
:disabled="!canSearch"
@click="emit('search')"
>
<Icon name="lucide:search" size="16" />
{{ $t('catalog.quote.search') }}
</button>
<button
v-if="hasAnyFilter"
class="btn btn-ghost btn-sm"
@click="emit('clear-all')"
>
{{ $t('catalog.quote.clear') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
productId?: string
productLabel?: string
hubId?: string
hubLabel?: string
supplierId?: string
supplierLabel?: string
quantity?: string
canSearch: boolean
}>()
const emit = defineEmits<{
'edit-filter': [type: string]
'remove-filter': [type: string]
'update-quantity': [value: string]
'search': []
'clear-all': []
}>()
const localQuantity = ref(props.quantity || '')
watch(() => props.quantity, (newVal) => {
localQuantity.value = newVal || ''
})
const hasAnyFilter = computed(() => {
return !!(props.productId || props.hubId || props.supplierId || props.quantity)
})
</script>

View File

@@ -0,0 +1,149 @@
<template>
<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-white">{{ $t('catalog.headers.offers') }}</h3>
<span class="badge badge-neutral">{{ totalOffers }}</span>
</div>
</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-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 group.offers"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
>
<OfferResultCard
grouped
: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="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
<div
v-else
v-for="offer in group.offers"
:key="offer.uuid"
class="cursor-pointer"
@click="emit('select-offer', offer)"
>
<OfferResultCard
:supplier-name="offer.supplierName"
:location-name="offer.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="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Offer {
uuid: string
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
locationName?: string | null
locationCountry?: string | null
locationCountryCode?: 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
}
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

@@ -0,0 +1,83 @@
<template>
<div class="flex items-center w-full py-2">
<!-- Start node -->
<div class="flex flex-col items-center shrink-0">
<div class="w-3 h-3 rounded-full bg-primary border-2 border-primary"></div>
<Text v-if="startName" size="xs" tone="muted" class="mt-1 max-w-16 truncate text-center">{{ startName }}</Text>
</div>
<!-- Stages (line + transport icon + distance -> node) -->
<template v-for="(stage, i) in stages" :key="i">
<!-- Line segment with transport icon and distance -->
<div class="flex-1 flex flex-col items-center mx-1 min-w-12">
<!-- Line with icon in the middle -->
<div class="flex items-center w-full">
<div class="h-0.5 bg-primary/60 flex-1"></div>
<span class="px-1.5 text-sm" :title="getTransportName(stage.transportType)">
{{ getTransportIcon(stage.transportType) }}
</span>
<div class="h-0.5 bg-primary/60 flex-1"></div>
</div>
<!-- Distance label -->
<Text size="xs" tone="muted" class="mt-0.5 whitespace-nowrap">
{{ formatDistance(stage.distanceKm) }} км
</Text>
</div>
<!-- Intermediate/End node -->
<div class="flex flex-col items-center shrink-0">
<div
class="rounded-full border-2"
:class="i === stages.length - 1 ? 'w-3 h-3 bg-success border-success' : 'w-2.5 h-2.5 bg-base-100 border-primary'"
></div>
<Text v-if="i === stages.length - 1 && endName" size="xs" tone="muted" class="mt-1 max-w-16 truncate text-center">
{{ endName }}
</Text>
</div>
</template>
</div>
</template>
<script setup lang="ts">
export interface RouteStage {
transportType?: string | null
distanceKm?: number | null
}
defineProps<{
stages: RouteStage[]
startName?: string
endName?: string
}>()
const getTransportIcon = (type?: string | null) => {
switch (type) {
case 'rail':
return '🚂'
case 'sea':
return '🚢'
case 'road':
case 'auto':
default:
return '🚛'
}
}
const getTransportName = (type?: string | null) => {
switch (type) {
case 'rail':
return 'Ж/Д'
case 'sea':
return 'Море'
case 'road':
case 'auto':
default:
return 'Авто'
}
}
const formatDistance = (km?: number | null) => {
if (!km) return '0'
return Math.round(km).toLocaleString()
}
</script>

View File

@@ -0,0 +1,197 @@
<template>
<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 mb-3">
<h3 class="font-semibold text-base text-white">{{ title }}</h3>
<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>
<!-- 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-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>
<div v-else class="flex flex-col gap-2">
<!-- Products -->
<template v-if="selectMode === 'product'">
<div
v-for="(item, index) in items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
<ProductCard
:product="item"
selectable
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 items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
<HubCard
:hub="item"
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 items"
:key="item.uuid ?? index"
class="relative group"
@mouseenter="emit('hover', item.uuid ?? null)"
@mouseleave="emit('hover', null)"
>
<SupplierCard
:supplier="item"
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"
ref="loadMoreSentinel"
class="flex items-center justify-center py-4"
>
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SelectMode } from '~/composables/useCatalogSearch'
interface Item {
uuid?: string | null
name?: string | null
country?: string | null
}
const props = defineProps<{
selectMode: SelectMode
products?: Item[]
hubs?: Item[]
suppliers?: Item[]
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
}>()
const emit = defineEmits<{
'select': [type: string, item: Item]
'close': []
'load-more': []
'hover': [uuid: string | null]
'pin': [type: 'product' | 'hub' | 'supplier', item: Item]
}>()
const { t } = useI18n()
const loadMoreSentinel = ref<HTMLElement | null>(null)
// Infinite scroll using IntersectionObserver
let observer: IntersectionObserver | null = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting && props.hasMore && !props.loadingMore) {
emit('load-more')
}
},
{ threshold: 0.1 }
)
})
watch(loadMoreSentinel, (el) => {
if (el && observer) {
observer.observe(el)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
})
const title = computed(() => {
switch (props.selectMode) {
case 'product': return t('catalog.headers.selectProduct')
case 'hub': return t('catalog.headers.selectHub')
case 'supplier': return t('catalog.headers.selectSupplier')
default: return ''
}
})
const items = computed(() => {
switch (props.selectMode) {
case 'product': return props.products || []
case 'hub': return props.hubs || []
case 'supplier': return props.suppliers || []
default: return []
}
})
// Select item and emit
const onSelect = (item: Item) => {
if (props.selectMode && item.uuid) {
emit('select', props.selectMode, item)
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="linkable ? NuxtLink : 'div'"
:to="linkable ? localePath(`/catalog/suppliers/${supplier.teamUuid || supplier.uuid}`) : undefined"
:to="linkable ? localePath(`/catalog?supplier=${supplier.uuid}`) : undefined"
class="block"
:class="{ 'cursor-pointer': selectable }"
@click="selectable && $emit('select')"
@@ -13,29 +13,41 @@
isSelected && 'ring-2 ring-primary ring-offset-2'
]"
>
<div class="flex flex-col gap-1">
<!-- Logo -->
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
<div 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 v-if="supplier.isVerified" class="badge badge-neutral badge-dash text-xs">
{{ t('catalogSupplier.badges.verified') }}
</span>
<span class="badge badge-neutral badge-dash text-xs">
<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>
@@ -69,7 +81,7 @@ defineEmits<{
const localePath = useLocalePath()
const { t } = useI18n()
const linkable = computed(() => !props.selectable && (props.supplier.teamUuid || props.supplier.uuid))
const linkable = computed(() => !props.selectable && !!props.supplier.uuid)
const reliabilityLabel = computed(() => {
if (props.supplier.onTimeRate !== undefined && props.supplier.onTimeRate !== null) {

View File

@@ -0,0 +1,63 @@
<template>
<div class="breadcrumbs text-sm">
<ul>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<NuxtLink v-if="crumb.to" :to="crumb.to" class="hover:text-primary">
{{ crumb.label }}
</NuxtLink>
<span v-else class="text-base-content">{{ crumb.label }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
supplierId?: string
supplierName?: string
productId?: string
productName?: string
hubId?: string
hubName?: string
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const breadcrumbs = computed(() => {
const crumbs: Array<{ label: string; to?: string }> = []
// Suppliers list
crumbs.push({
label: t('breadcrumbs.suppliers', 'Suppliers'),
to: localePath('/catalog?select=supplier')
})
// Supplier
if (props.supplierId) {
const hasNext = props.productId
crumbs.push({
label: props.supplierName || `#${props.supplierId.slice(0, 8)}...`,
to: hasNext ? localePath(`/catalog?supplier=${props.supplierId}`) : undefined
})
}
// Product
if (props.productId) {
const hasNext = props.hubId
crumbs.push({
label: props.productName || `#${props.productId.slice(0, 8)}...`,
to: hasNext ? localePath(`/catalog?supplier=${props.supplierId}&product=${props.productId}`) : undefined
})
}
// Hub
if (props.hubId) {
crumbs.push({
label: props.hubName || `#${props.hubId.slice(0, 8)}...`
})
}
return crumbs
})
</script>

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,71 +1,295 @@
<template>
<header class="sticky top-0 z-40 bg-base-100 border-b border-base-300">
<div class="relative flex items-center h-16 px-4 lg:px-6">
<!-- Left: Logo -->
<div class="flex items-center">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl">Optovia</span>
</NuxtLink>
<header
class="relative overflow-hidden"
:class="headerClasses"
:style="{ height: `${height}px` }"
>
<div class="absolute top-0 left-0 right-0 pointer-events-none glass-topfade" :style="glassStyle" />
<!-- Single row: Logo + Search + Icons -->
<div
class="relative z-10 flex px-4 lg:px-6 gap-4"
:class="isHeroLayout ? 'items-start pt-4' : 'items-center'"
:style="rowStyle"
>
<!-- Left: Logo + AI button + Nav links (top aligned) -->
<div class="flex items-center flex-shrink-0 rounded-full glass-bright">
<div class="flex items-center gap-2 px-4 py-2">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink>
<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 -->
<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>
<button
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="showActiveMode && catalogMode === 'quote' && !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', 'quote')"
>
{{ $t('catalog.modes.quote') }}
</button>
<!-- 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: Main tabs (absolutely centered on page) -->
<nav class="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
<NuxtLink
v-for="tab in visibleTabs"
:key="tab.key"
:to="localePath(tab.path)"
class="px-4 py-2 rounded-full font-medium text-sm transition-colors hover:bg-base-200"
:class="{ 'bg-base-200 text-primary': isActiveTab(tab.key) }"
>
{{ tab.label }}
</NuxtLink>
</nav>
<!-- 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" />
<!-- Right: AI + Globe + Team + User -->
<div class="flex items-center gap-2 ml-auto">
<!-- AI Assistant button -->
<NuxtLink :to="localePath('/clientarea/ai')" class="btn btn-ghost btn-circle">
<Icon name="lucide:bot" size="20" />
</NuxtLink>
<!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle">
<Icon name="lucide:globe" size="20" />
</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">
<!-- Client Area tabs -->
<template v-if="isClientArea">
<div class="flex items-center gap-1 rounded-full glass-bright p-1">
<!-- BUYER tabs -->
<template v-if="currentRole !== 'SELLER'">
<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'"
: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'"
>
{{ loc.code.toUpperCase() }}
{{ $t('cabinetNav.orders') }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<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: Simple segmented input with search inside (white glass) -->
<template v-else-if="catalogMode === 'quote'">
<div class="flex items-center w-full rounded-full glass-bright overflow-hidden">
<!-- Product segment -->
<button
class="btn btn-sm btn-ghost w-full justify-start"
@click="$emit('toggle-theme')"
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')"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.product') }}</div>
<div class="font-medium truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</div>
</button>
<div class="w-px h-8 bg-white/20 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')"
>
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.hub') }}</div>
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
</button>
<div class="w-px h-8 bg-white/20 self-center" />
<!-- 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 -->
<button
class="btn btn-primary btn-circle m-1"
:disabled="!canSearch"
@click="$emit('search')"
>
<Icon name="lucide:search" size="18" />
</button>
</div>
</template>
<!-- Explore mode: Regular pill input + chips (white glass) -->
<template v-else>
<!-- Big pill input -->
<div
class="flex items-center gap-3 w-full px-5 py-3 rounded-full glass-bright focus-within:border-primary 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" />
<!-- Tokens + input inline (no wrap to prevent height change) -->
<div class="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<!-- Active filter tokens (outline style with icon in circle) -->
<div
v-for="token in activeTokens"
:key="token.type"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border-2 cursor-pointer hover:opacity-80 transition-all flex-shrink-0"
:style="{ borderColor: getTokenColor(token.type), color: getTokenColor(token.type) }"
@click.stop="$emit('edit-token', token.type)"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
:style="{ backgroundColor: getTokenColor(token.type) }"
>
<Icon :name="getTokenIcon(token.type)" size="12" class="text-white" />
</span>
<span class="max-w-28 truncate font-medium text-sm">{{ token.label }}</span>
<button
class="hover:text-error ml-0.5"
@click.stop="$emit('remove-token', token.type)"
>
<Icon name="lucide:x" size="14" />
</button>
</div>
<!-- Search input -->
<input
ref="inputRef"
v-model="localSearchQuery"
type="text"
:placeholder="placeholder"
class="flex-1 min-w-32 bg-transparent outline-none text-lg text-base-content placeholder:text-base-content/50"
@input="$emit('update:search-query', localSearchQuery)"
/>
</div>
</div>
</template>
</div>
<!-- Right: Globe + Team + User (top aligned like logo) -->
<div class="flex items-center flex-shrink-0 rounded-full glass-bright">
<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
tabindex="0"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:globe" size="18" />
</button>
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
<div class="flex gap-2 mb-4">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="btn btn-sm"
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<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="btn btn-ghost gap-2">
<Icon name="lucide:building-2" size="18" />
<span class="hidden sm:inline max-w-32 truncate">
<button
tabindex="0"
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:building-2" size="16" />
<span class="hidden lg:inline max-w-24 truncate text-xs">
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
</span>
<Icon name="lucide:chevron-down" size="14" />
<Icon name="lucide:chevron-down" size="12" />
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2 shadow-lg border border-base-300">
<li class="menu-title"><span>{{ $t('common.teams') }}</span></li>
@@ -86,21 +310,26 @@
</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="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
<div
v-else
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
>
{{ userInitials }}
</div>
<div
tabindex="0"
role="button"
class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer"
: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="useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'"
>
{{ userInitials }}
</div>
</div>
<ul
@@ -127,37 +356,34 @@
</div>
</template>
<template v-else>
<button @click="$emit('sign-in')" class="btn btn-primary btn-sm">
<button
@click="$emit('sign-in')"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
: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>
<!-- Mobile tabs (shown below header on small screens) -->
<nav class="md:hidden flex items-center justify-center gap-1 py-2 border-t border-base-300 overflow-x-auto">
<NuxtLink
v-for="tab in visibleTabs"
:key="tab.key"
:to="localePath(tab.path)"
class="px-4 py-2 rounded-full text-sm font-medium hover:bg-base-200"
:class="{ 'bg-base-200 text-primary': isActiveTab(tab.key) }"
>
{{ tab.label }}
</NuxtLink>
</nav>
</header>
</template>
<script setup lang="ts">
const props = defineProps<{
import type { SelectMode } from '~/composables/useCatalogSearch'
import { entityColors } from '~/composables/useCatalogSearch'
import type { CatalogMode } from '~/composables/useCatalogSearch'
const props = withDefaults(defineProps<{
sessionChecked?: boolean
loggedIn?: boolean
userAvatarSvg?: string
userName?: string
userInitials?: string
theme?: 'default' | 'night'
theme?: 'cupcake' | 'night'
userData?: {
id?: string
activeTeam?: { name?: string; teamType?: string }
@@ -165,38 +391,162 @@ const props = defineProps<{
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null
isSeller?: boolean
}>()
defineEmits(['toggle-theme', 'sign-out', 'sign-in', 'switch-team'])
const localePath = useLocalePath()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const route = useRoute()
const { t } = useI18n()
const tabs = computed(() => [
{ key: 'search', label: t('cabinetNav.search'), path: '/', auth: false },
{ key: 'catalog', label: t('cabinetNav.catalog'), path: '/catalog/offers', auth: false },
{ key: 'orders', label: t('cabinetNav.orders'), path: '/clientarea/orders', auth: true },
{ key: 'seller', label: t('cabinetNav.seller'), path: '/clientarea/offers', auth: true, seller: true },
])
const visibleTabs = computed(() => {
return tabs.value.filter(tab => {
if (tab.auth && !props.loggedIn) return false
if (tab.seller && !props.isSeller) return false
return true
})
// 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 }>
selectMode?: SelectMode
searchQuery?: string
// Catalog mode props
catalogMode?: CatalogMode
// Quote mode props
productLabel?: string
hubLabel?: string
quantity?: string
canSearch?: boolean
showModeToggle?: boolean
showActiveMode?: boolean // Whether to show active state on mode toggle
// 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,
collapseProgress: 1
})
const isActiveTab = (key: string) => {
const path = route.path
if (key === 'search') return path === '/' || path === '/en' || path === '/ru'
if (key === 'catalog') return path.startsWith('/catalog') || path.includes('/en/catalog') || path.includes('/ru/catalog')
if (key === 'orders') return path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')
if (key === 'seller') return path.includes('/clientarea/offers')
return false
}
</script>
defineEmits([
'toggle-chat',
'toggle-theme',
'sign-out',
'sign-in',
'switch-team',
'switch-role',
// Search events
'start-select',
'cancel-select',
'edit-token',
'remove-token',
'update:search-query',
'update-quantity',
// Quote mode
'search',
'set-catalog-mode'
])
const localePath = useLocalePath()
const route = useRoute()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const { t } = useI18n()
const { chatOpen } = toRefs(props)
// 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 || '')
const localQuantity = ref(props.quantity || '')
watch(() => props.searchQuery, (val) => {
localSearchQuery.value = val || ''
})
watch(() => props.quantity, (val) => {
localQuantity.value = val || ''
})
const focusInput = () => {
inputRef.value?.focus()
}
const placeholder = computed(() => {
if (props.selectMode === 'product') return t('catalog.search.searchProducts')
if (props.selectMode === 'supplier') return t('catalog.search.searchSuppliers')
if (props.selectMode === 'hub') return t('catalog.search.searchHubs')
if (!props.activeTokens?.length) return t('catalog.search.placeholder')
return t('catalog.search.refine')
})
const selectModeLabel = computed(() => {
if (props.selectMode === 'product') return t('catalog.filters.product')
if (props.selectMode === 'supplier') return t('catalog.filters.supplier')
if (props.selectMode === 'hub') return t('catalog.filters.hub')
return ''
})
const selectModeIcon = computed(() => {
if (props.selectMode === 'product') return 'lucide:package'
if (props.selectMode === 'supplier') return 'lucide:factory'
if (props.selectMode === 'hub') return 'lucide:map-pin'
return 'lucide:search'
})
const getTokenColor = (type: string) => {
return entityColors[type as keyof typeof entityColors] || entityColors.product
}
const getTokenIcon = (type: string) => {
const icons: Record<string, string> = {
product: 'lucide:shopping-bag',
hub: 'lucide:warehouse',
supplier: 'lucide:factory'
}
return icons[type] || 'lucide:tag'
}
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 glassStyle = computed(() => {
if (isHeroLayout.value) {
return { height: `${topRowHeight}px` }
}
return { height: '100%' }
})
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` }
})
// Header background classes
const headerClasses = computed(() => {
if (props.isHomePage && !props.isCollapsed) {
return 'bg-transparent'
}
if (props.isCollapsed) {
return 'bg-transparent backdrop-blur-xl'
}
return 'bg-transparent backdrop-blur-xl'
})
// 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,6 @@
<template>
<nav v-if="items.length > 0" class="sticky top-16 z-30 bg-base-100 border-b border-base-300">
<nav v-if="items.length > 0" class="bg-base-100 shadow-sm">
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
<!-- Collapse button (chevron up) -->
<button
v-if="showCollapseButton"
class="btn btn-ghost btn-xs btn-circle mr-1 flex-shrink-0"
@click="emit('collapse')"
>
<Icon name="lucide:chevron-up" size="16" />
</button>
<NuxtLink
v-for="item in items"
:key="item.path"
@@ -26,11 +17,6 @@
<script setup lang="ts">
const props = defineProps<{
section: 'catalog' | 'orders' | 'seller' | 'settings'
showCollapseButton?: boolean
}>()
const emit = defineEmits<{
collapse: []
}>()
const localePath = useLocalePath()
@@ -39,9 +25,9 @@ const { t } = useI18n()
const sectionItems = computed(() => ({
catalog: [
{ label: 'Предложения', path: '/catalog/offers' },
{ label: t('cabinetNav.suppliers'), path: '/catalog/suppliers' },
{ label: t('cabinetNav.hubs'), path: '/catalog/hubs' },
{ label: 'Предложения', path: '/catalog?select=product' },
{ label: t('cabinetNav.suppliers'), path: '/catalog?select=supplier' },
{ label: t('cabinetNav.hubs'), path: '/catalog?select=hub' },
],
orders: [
{ label: t('cabinetNav.orders'), path: '/clientarea/orders' },

View File

@@ -1,282 +1,432 @@
<template>
<div class="flex flex-col flex-1 min-h-0">
<!-- Loading state -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ $t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
<div class="fixed inset-0 flex flex-col">
<!-- Fullscreen Map -->
<div class="absolute inset-0">
<ClientOnly>
<CatalogMap
ref="mapRef"
:map-id="mapId"
:items="isInfoMode ? [] : (useServerClustering ? [] : itemsWithCoords)"
: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"
/>
</ClientOnly>
</div>
<!-- Content -->
<template v-else>
<!-- Search bar slot (sticky third bar - like navigation) -->
<div v-if="$slots.searchBar" class="sticky z-20 -mx-3 lg:-mx-6 px-3 lg:px-6 py-2 bg-base-100 border-b border-base-300" :class="searchBarTopClass">
<slot name="searchBar" :displayed-count="displayItems.length" :total-count="totalCount" />
<!-- View mode loading indicator -->
<div
v-if="clusterLoading || loading"
class="absolute top-[116px] left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 glass-soft 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>
</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 glass-soft rounded-full px-3 py-1.5 text-white text-sm hover:bg-white/15 transition-colors"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
<span>{{ $t('catalog.list') }}</span>
</button>
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
<label
v-if="selectMode !== null"
class="absolute top-[116px] left-[calc(1rem+32rem+1rem)] z-20 hidden lg:flex items-center gap-2 glass-soft rounded-full px-3 py-1.5 cursor-pointer text-white text-sm hover:bg-white/15 transition-colors"
>
<input
type="checkbox"
:checked="filterByBounds"
class="checkbox checkbox-xs checkbox-primary"
@change="$emit('update:filter-by-bounds', ($event.target as HTMLInputElement).checked)"
/>
<span>{{ $t('catalog.search.filterByMap') }}</span>
</label>
<!-- 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 glass-bright rounded-full p-1">
<button
v-if="showOffersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="mapViewMode === 'offers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
@click="setMapViewMode('offers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
<Icon name="lucide:shopping-bag" size="12" class="text-white" />
</span>
{{ $t('catalog.views.offers') }}
</button>
<button
v-if="showHubsToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
@click="setMapViewMode('hubs')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
<Icon name="lucide:warehouse" size="12" class="text-white" />
</span>
{{ $t('catalog.views.hubs') }}
</button>
<button
v-if="showSuppliersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10'"
@click="setMapViewMode('suppliers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
<Icon name="lucide:factory" size="12" class="text-white" />
</span>
{{ $t('catalog.views.suppliers') }}
</button>
</div>
</div>
<!-- Left panel (slides from left when isPanelOpen is true) -->
<Transition name="slide-left">
<div
v-if="isPanelOpen"
class="absolute top-[116px] left-4 bottom-4 z-30 max-w-[calc(100vw-2rem)] hidden lg:block"
:class="panelWidth"
>
<div class="glass-soft rounded-2xl shadow-lg h-full flex flex-col text-white">
<slot name="panel" />
</div>
</div>
</Transition>
<!-- Mobile bottom sheet -->
<div class="lg:hidden absolute bottom-0 left-0 right-0 z-20">
<!-- Mobile controls: List button + view toggle -->
<div class="flex justify-between px-4 mb-2">
<!-- List button (mobile) -->
<button
class="flex items-center gap-2 glass-soft rounded-full px-3 py-2 text-white text-sm"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
<span>{{ $t('catalog.list') }}</span>
</button>
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 glass-bright rounded-full p-1">
<button
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')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
<Icon name="lucide:shopping-bag" size="12" class="text-white" />
</span>
</button>
<button
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')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
<Icon name="lucide:warehouse" size="12" class="text-white" />
</span>
</button>
<button
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')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
<Icon name="lucide:factory" size="12" class="text-white" />
</span>
</button>
</div>
</div>
<!-- With Map: Split Layout -->
<template v-if="withMap">
<!-- Desktop: side-by-side -->
<div class="hidden lg:flex flex-1 gap-4 min-h-0 py-4">
<!-- Left: List (scrollable) -->
<div class="w-2/5 overflow-y-auto pr-2">
<Stack gap="4">
<slot name="header" />
<slot name="filters" />
<Stack gap="3">
<div
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)"
@mouseenter="emit('update:hoveredId', item.uuid)"
@mouseleave="emit('update:hoveredId', undefined)"
>
<slot name="card" :item="item" />
</div>
</Stack>
<slot name="pagination" />
<Stack v-if="displayItems.length === 0" align="center" gap="2">
<slot name="empty">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot>
</Stack>
</Stack>
<!-- Mobile panel (collapsible) - only when panel is open -->
<Transition name="slide-up">
<div
v-if="isPanelOpen"
class="glass-soft rounded-t-2xl shadow-lg transition-all duration-300 text-white 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>
<!-- Right: Map (fixed position) -->
<div class="w-3/5 relative">
<div class="fixed right-6 w-[calc(60%-3rem)] rounded-lg overflow-hidden" :class="[mapTopClass, mapHeightClass]">
<!-- Search with map checkbox -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly>
<CatalogMap
ref="mapRef"
:map-id="mapId"
:items="useServerClustering ? [] : itemsWithCoords"
:clustered-points="useServerClustering ? clusteredNodes : []"
:use-server-clustering="useServerClustering"
:point-color="pointColor"
:hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
<slot name="panel" />
</div>
</div>
<!-- Mobile: toggle between list and map -->
<div class="lg:hidden flex-1 flex flex-col min-h-0">
<div class="flex-1 overflow-y-auto py-4" v-show="mobileView === 'list'">
<Stack gap="4">
<slot name="header" />
<slot name="filters" />
<Stack gap="3">
<div
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedId }"
@click="onItemClick(item)"
@mouseenter="emit('update:hoveredId', item.uuid)"
@mouseleave="emit('update:hoveredId', undefined)"
>
<slot name="card" :item="item" />
</div>
</Stack>
<slot name="pagination" />
<Stack v-if="displayItems.length === 0" align="center" gap="2">
<slot name="empty">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot>
</Stack>
</Stack>
</div>
<div class="flex-1 relative" v-show="mobileView === 'map'">
<!-- Search with map checkbox (mobile) -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ $t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly>
<CatalogMap
ref="mobileMapRef"
:map-id="`${mapId}-mobile`"
:items="useServerClustering ? [] : itemsWithCoords"
:clustered-points="useServerClustering ? clusteredNodes : []"
:use-server-clustering="useServerClustering"
:point-color="pointColor"
:hovered-item-id="hoveredId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
<!-- Mobile toggle -->
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<div class="btn-group shadow-lg">
<button
class="btn btn-sm"
:class="{ 'btn-active': mobileView === 'list' }"
@click="mobileView = 'list'"
>
{{ $t('common.list') }}
</button>
<button
class="btn btn-sm"
:class="{ 'btn-active': mobileView === 'map' }"
@click="mobileView = 'map'"
>
{{ $t('common.map') }}
</button>
</div>
</div>
</div>
</template>
<!-- Without Map: Simple List -->
<div v-else class="flex-1 overflow-y-auto py-4">
<Stack gap="4">
<slot name="header" />
<slot name="filters" />
<Stack gap="3">
<div
v-for="item in items"
:key="item.uuid"
@click="onItemClick(item)"
>
<slot name="card" :item="item" />
</div>
</Stack>
<slot name="pagination" />
<Stack v-if="items.length === 0" align="center" gap="2">
<slot name="empty">
<Text tone="muted">{{ $t('common.values.not_available') }}</Text>
</slot>
</Stack>
</Stack>
</div>
</template>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
const { mapViewMode, setMapViewMode, selectMode, startSelect, cancelSelect } = useCatalogSearch()
// 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'
: mapViewMode.value === 'suppliers' ? 'supplier'
: 'product'
startSelect(newSelectMode)
}
// Close panel
const closePanel = () => {
cancelSelect()
}
// Point color based on map view mode
const VIEW_MODE_COLORS = {
offers: '#f97316', // orange
hubs: '#22c55e', // green
suppliers: '#3b82f6' // blue
} as const
const activePointColor = computed(() => VIEW_MODE_COLORS[mapViewMode.value] || VIEW_MODE_COLORS.offers)
// Entity type for icons based on view mode
const VIEW_MODE_ENTITY_TYPES = {
offers: 'offer',
hubs: 'hub',
suppliers: 'supplier'
} as const
const activeEntityType = computed(() => VIEW_MODE_ENTITY_TYPES[mapViewMode.value] || VIEW_MODE_ENTITY_TYPES.offers)
// Node type for server clustering based on view mode
const VIEW_MODE_NODE_TYPES = {
offers: 'offer',
hubs: 'logistics',
suppliers: 'supplier'
} as const
const activeClusterNodeType = computed(() => VIEW_MODE_NODE_TYPES[mapViewMode.value] || VIEW_MODE_NODE_TYPES.offers)
// Store current bounds for refetching when view mode changes
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<{
items: MapItem[]
mapItems?: MapItem[] // Optional separate items for map (if different from list items)
loading?: boolean
withMap?: boolean
useServerClustering?: boolean // Use server-side h3 clustering for ALL points
useServerClustering?: boolean
clusterNodeType?: string
useTypedClusters?: boolean
mapId?: string
pointColor?: string
selectedId?: string
hoveredId?: string
hasSubNav?: boolean
totalCount?: number // Total count for search bar counter (can differ from items.length with pagination)
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
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}>
}>(), {
loading: false,
withMap: true,
useServerClustering: false,
useServerClustering: true,
clusterNodeType: 'offer',
useTypedClusters: false,
mapId: 'catalog-map',
pointColor: '#3b82f6',
hasSubNav: true,
totalCount: 0
})
// Inject header collapsed state from layout
const headerCollapsed = inject<Ref<boolean>>('headerCollapsed', ref(false))
// Map positioning - dynamic based on search bar presence and header collapsed state
// Expanded: MainNav (4rem) + SubNav (3rem) = 7rem, with SearchBar = 10rem
// Collapsed: CollapsedBar (2rem), with SearchBar = 5rem
const slots = useSlots()
const hasSearchBar = computed(() => !!slots.searchBar)
// SearchBar position: below header (sticky)
const searchBarTopClass = computed(() => {
if (headerCollapsed.value) {
return 'top-8' // 2rem collapsed bar
}
return 'top-[7rem]' // 4rem MainNav + 3rem SubNav
})
// Map position: below header + searchbar (fixed)
const mapTopClass = computed(() => {
if (headerCollapsed.value) {
return hasSearchBar.value ? 'top-[5rem]' : 'top-8' // collapsed bar + searchbar
}
return hasSearchBar.value ? 'top-[10rem]' : 'top-[7rem]' // full header + searchbar
})
const mapHeightClass = computed(() => {
if (headerCollapsed.value) {
return hasSearchBar.value ? 'h-[calc(100vh-6rem)]' : 'h-[calc(100vh-3rem)]'
}
return hasSearchBar.value ? 'h-[calc(100vh-11rem)]' : 'h-[calc(100vh-8rem)]'
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: () => []
})
const emit = defineEmits<{
'select': [item: MapItem]
'update:selectedId': [uuid: string]
'bounds-change': [bounds: MapBounds]
'update:hoveredId': [uuid: string | undefined]
'update:filter-by-bounds': [value: boolean]
}>()
// Server-side clustering
const { clusteredNodes, fetchClusters } = useClusteredNodes()
const useTypedClusters = computed(() => props.useTypedClusters && props.useServerClustering)
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const clusterProductUuid = computed(() => props.clusterProductUuid ?? undefined)
const clusterHubUuid = computed(() => props.clusterHubUuid ?? undefined)
const clusterSupplierUuid = computed(() => props.clusterSupplierUuid ?? undefined)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
if (props.useServerClustering) {
fetchClusters(bounds)
}
// Server-side clustering (single-type mode)
const { clusteredNodes, fetchClusters, loading: singleClusterLoading, clearNodes } = useClusteredNodes(
undefined,
activeClusterNodeType,
clusterProductUuid,
clusterHubUuid,
clusterSupplierUuid
)
// Server-side clustering (typed mode)
const offerClusters = useClusteredNodes(undefined, ref('offer'), clusterProductUuid, clusterHubUuid, clusterSupplierUuid)
const hubClusters = useClusteredNodes(undefined, ref('logistics'), clusterProductUuid, clusterHubUuid, clusterSupplierUuid)
const supplierClusters = useClusteredNodes(undefined, ref('supplier'), clusterProductUuid, clusterHubUuid, clusterSupplierUuid)
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()
}
// Filtered items when searchWithMap is enabled
const displayItems = computed(() => {
if (!searchWithMap.value || !currentBounds.value) return props.items
return props.items.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
})
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
if (currentBounds.value) {
await fetchClusters(currentBounds.value)
}
})
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)
// Selected item from map click
const selectedMapItem = ref<MapItem | null>(null)
// Mobile panel state
const mobilePanelExpanded = ref(false)
// Info mode - when relatedPoints are present, hide clusters and show only related points
const isInfoMode = computed(() => props.forceInfoMode || (props.relatedPoints && props.relatedPoints.length > 0))
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
if (!props.hoveredId) return null
@@ -285,12 +435,9 @@ const hoveredItem = computed(() => {
return { latitude: Number(item.latitude), longitude: Number(item.longitude) }
})
// Use mapItems if provided, otherwise fall back to items
const itemsForMap = computed(() => props.mapItems || props.items)
// Filter items with valid coordinates for map (client-side mode only)
const itemsWithCoords = computed(() =>
itemsForMap.value.filter(item =>
props.items.filter(item =>
item.latitude != null &&
item.longitude != null &&
!isNaN(Number(item.latitude)) &&
@@ -300,56 +447,75 @@ const itemsWithCoords = computed(() =>
name: item.name || '',
latitude: Number(item.latitude),
longitude: Number(item.longitude),
country: item.country,
orderUuid: item.orderUuid // Preserve orderUuid for hover matching
country: item.country
}))
)
// Mobile view toggle
const mobileView = ref<'list' | 'map'>('list')
// Map refs
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
const mobileMapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
// Handle item click from list
const onItemClick = (item: MapItem) => {
emit('select', item)
emit('update:selectedId', item.uuid)
// Fly to item on map
if (props.withMap && item.latitude && item.longitude) {
mapRef.value?.flyTo(Number(item.latitude), Number(item.longitude), 8)
mobileMapRef.value?.flyTo(Number(item.latitude), Number(item.longitude), 8)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
emit('bounds-change', bounds)
// Don't fetch clusters when in info mode
if (props.useServerClustering && !isInfoMode.value) {
if (useTypedClusters.value) {
fetchActiveClusters()
} else {
fetchClusters(bounds)
}
}
}
// Handle selection from map
const onMapSelect = (uuid: string) => {
const onMapSelect = (uuid: string, properties?: Record<string, any>) => {
const item = props.items.find(i => i.uuid === uuid)
if (item) {
selectedMapItem.value = item
emit('select', item)
emit('update:selectedId', uuid)
} else if (props.useServerClustering) {
// For server clustering, include properties from cluster data
const mapItem: MapItem = {
uuid,
name: properties?.name,
...properties
}
selectedMapItem.value = mapItem
emit('select', mapItem)
}
}
// Watch selectedId and fly to it
watch(() => props.selectedId, (uuid) => {
if (uuid && props.withMap) {
const item = itemsWithCoords.value.find(i => i.uuid === uuid)
if (item) {
mapRef.value?.flyTo(item.latitude, item.longitude, 8)
mobileMapRef.value?.flyTo(item.latitude, item.longitude, 8)
}
}
})
const onViewDetails = (item: MapItem) => {
emit('select', item)
}
// Expose flyTo for external use
const flyTo = (lat: number, lng: number, zoom = 8) => {
mapRef.value?.flyTo(lat, lng, zoom)
mobileMapRef.value?.flyTo(lat, lng, zoom)
}
defineExpose({ flyTo })
defineExpose({ flyTo, currentBounds })
</script>
<style scoped>
/* Drawer slide animation (desktop - left) */
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
/* Drawer slide animation (mobile - up) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>

View File

@@ -1,140 +0,0 @@
<template>
<div class="bg-base-100 py-4 px-4 lg:px-6">
<div class="flex items-center justify-center">
<form
@submit.prevent="handleSearch"
class="flex items-center bg-base-100 rounded-full border border-base-300 shadow-sm hover:shadow-md transition-shadow"
>
<!-- Product field (clickable, navigates to /goods) -->
<div
class="flex flex-col px-4 py-2 min-w-48 pl-6 rounded-l-full hover:bg-base-200/50 border-r border-base-300 cursor-pointer"
@click="goToProductSelection"
>
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.product') }}
</label>
<div class="text-sm" :class="productDisplay ? 'text-base-content' : 'text-base-content/50'">
{{ productDisplay || $t('search.product_placeholder') }}
</div>
</div>
<!-- Quantity field (editable) -->
<div class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 border-r border-base-300">
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.quantity') }}
</label>
<input
v-model="quantity"
type="number"
min="1"
:placeholder="$t('search.quantity_placeholder')"
class="w-full bg-transparent outline-none text-sm"
@change="syncQuantityToStore"
/>
</div>
<!-- Destination field (clickable, navigates to /select-location) -->
<div
class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 cursor-pointer"
@click="goToLocationSelection"
>
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.destination') }}
</label>
<div class="text-sm" :class="locationDisplay ? 'text-base-content' : 'text-base-content/50'">
{{ locationDisplay || $t('search.destination_placeholder') }}
</div>
</div>
<!-- Search button -->
<button
type="submit"
class="btn btn-primary btn-circle ml-2 mr-1"
:disabled="!canSearch"
>
<Icon name="lucide:search" size="18" />
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
search: [params: { productUuid?: string; quantity?: number; locationUuid?: string }]
}>()
const router = useRouter()
const localePath = useLocalePath()
const searchStore = useSearchStore()
// Read from searchStore
const productDisplay = computed(() => searchStore.searchForm.product || '')
const productUuid = computed(() => searchStore.searchForm.productUuid || '')
const locationDisplay = computed(() => searchStore.searchForm.location || '')
const locationUuid = computed(() => searchStore.searchForm.locationUuid || '')
// Quantity - local state synced with store
const quantity = ref<number | undefined>(
searchStore.searchForm.quantity ? Number(searchStore.searchForm.quantity) : undefined
)
const syncQuantityToStore = () => {
if (quantity.value) {
searchStore.setQuantity(String(quantity.value))
}
}
// Navigation to selection pages
const goToProductSelection = () => {
navigateTo(localePath('/goods'))
}
const goToLocationSelection = () => {
navigateTo(localePath('/select-location') + '?mode=search')
}
// Can search - need at least product selected
const canSearch = computed(() => {
return !!productUuid.value
})
// Search handler - navigate to /request
const handleSearch = () => {
if (!canSearch.value) return
// Sync quantity to store
syncQuantityToStore()
const query: Record<string, string | undefined> = {
productUuid: productUuid.value || undefined,
product: productDisplay.value || undefined,
quantity: quantity.value ? String(quantity.value) : undefined,
locationUuid: locationUuid.value || undefined,
location: locationDisplay.value || undefined
}
// Remove undefined/empty values
Object.keys(query).forEach(key => {
if (!query[key]) delete query[key]
})
router.push({
path: localePath('/request'),
query: query as Record<string, string>
})
emit('search', {
productUuid: productUuid.value,
quantity: quantity.value,
locationUuid: locationUuid.value
})
}
// Watch store changes to sync quantity
watch(() => searchStore.searchForm.quantity, (val) => {
if (val) {
quantity.value = Number(val)
}
}, { immediate: true })
</script>

View File

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

View File

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

View File

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

View File

@@ -35,8 +35,10 @@ const toneMap: Record<string, string> = {
const cardClass = computed(() => {
const paddingClass = paddingMap[props.padding] || paddingMap.medium
const toneClass = toneMap[props.tone] || toneMap.default
const interactiveClass = props.interactive ? 'cursor-pointer hover:shadow-lg' : ''
const baseClass = 'card transition-all duration-200'
const interactiveClass = props.interactive
? 'cursor-pointer hover:shadow-lg transition-shadow duration-200'
: ''
const baseClass = 'card'
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
})
</script>

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ const props = defineProps({
default: '',
},
type: {
type: String,
type: String as () => 'button' | 'submit' | 'reset',
default: 'button',
},
chevron: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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