@open-mercato/core 0.6.6-develop.6385.1.9a81faa5f0 → 0.6.6-develop.6387.1.f4896ad565

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/scope.js +26 -0
  3. package/dist/modules/attachments/lib/scope.js.map +7 -0
  4. package/dist/modules/catalog/commands/variants.js +4 -2
  5. package/dist/modules/catalog/commands/variants.js.map +2 -2
  6. package/dist/modules/customers/api/deals/map/route.js +433 -0
  7. package/dist/modules/customers/api/deals/map/route.js.map +7 -0
  8. package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
  9. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
  11. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
  13. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
  14. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
  15. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
  17. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
  18. package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
  19. package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
  21. package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
  22. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
  23. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  26. package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
  27. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  28. package/dist/modules/customers/components/AddressEditor.js +34 -2
  29. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  30. package/dist/modules/customers/components/AddressTiles.js +43 -3
  31. package/dist/modules/customers/components/AddressTiles.js.map +2 -2
  32. package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
  33. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  34. package/dist/modules/customers/components/formConfig.js +10 -0
  35. package/dist/modules/customers/components/formConfig.js.map +2 -2
  36. package/dist/modules/customers/data/validators.js +3 -2
  37. package/dist/modules/customers/data/validators.js.map +2 -2
  38. package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
  39. package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
  40. package/dist/modules/messages/commands/messages.js +0 -1
  41. package/dist/modules/messages/commands/messages.js.map +2 -2
  42. package/dist/modules/messages/lib/attachments.js +13 -7
  43. package/dist/modules/messages/lib/attachments.js.map +2 -2
  44. package/package.json +11 -7
  45. package/src/modules/attachments/lib/scope.ts +42 -0
  46. package/src/modules/catalog/commands/variants.ts +7 -2
  47. package/src/modules/customers/api/deals/map/route.ts +549 -0
  48. package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
  49. package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
  50. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
  51. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
  52. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
  53. package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
  54. package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
  55. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
  56. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
  57. package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
  58. package/src/modules/customers/components/AddressEditor.tsx +35 -0
  59. package/src/modules/customers/components/AddressTiles.tsx +51 -1
  60. package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
  61. package/src/modules/customers/components/formConfig.tsx +10 -0
  62. package/src/modules/customers/data/validators.ts +13 -2
  63. package/src/modules/customers/i18n/de.json +24 -0
  64. package/src/modules/customers/i18n/en.json +24 -0
  65. package/src/modules/customers/i18n/es.json +24 -0
  66. package/src/modules/customers/i18n/pl.json +24 -0
  67. package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
  68. package/src/modules/messages/commands/messages.ts +0 -1
  69. package/src/modules/messages/lib/attachments.ts +15 -7
@@ -0,0 +1,407 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import * as L from 'leaflet'
6
+ import 'leaflet.markercluster'
7
+ import 'leaflet/dist/leaflet.css'
8
+ import 'leaflet.markercluster/dist/MarkerCluster.css'
9
+ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
10
+ import { X } from 'lucide-react'
11
+ import { Button } from '@open-mercato/ui/primitives/button'
12
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
13
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
14
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
15
+ import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
16
+ import { formatCurrency } from '../../../../../components/detail/utils'
17
+ import type {
18
+ DealsMapCanvasDeal,
19
+ DealsMapCanvasProps,
20
+ DealsMapPreview,
21
+ } from './DealsMapCanvas'
22
+
23
+ const leafletRuntime = ((L as { default?: typeof L }).default ?? L) as typeof L
24
+
25
+ const TILE_URL =
26
+ process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL ?? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
27
+ const TILE_ATTRIBUTION =
28
+ process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_ATTRIBUTION ??
29
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
30
+ // True whenever the EFFECTIVE tile URL targets OSM's public CDN — whether from the bundled default
31
+ // (env unset) OR an env value pointed back at the same public host. OSM's tile usage policy
32
+ // prohibits production/commercial traffic against it.
33
+ const USING_PUBLIC_OSM_TILES = TILE_URL.includes('tile.openstreetmap.org')
34
+
35
+ let publicOsmTileWarningEmitted = false
36
+ // Warn once (per session) so deployments point NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or
37
+ // commercial tile service instead of silently shipping against the shared public CDN.
38
+ function warnOnPublicOsmTilesOnce(): void {
39
+ if (publicOsmTileWarningEmitted || !USING_PUBLIC_OSM_TILES) return
40
+ publicOsmTileWarningEmitted = true
41
+ // eslint-disable-next-line no-console
42
+ console.warn(
43
+ '[open-mercato] Deals map is using the public OpenStreetMap tile server. ' +
44
+ "OSM's tile usage policy prohibits production/commercial traffic — point " +
45
+ 'NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or commercial tile service before deploying.',
46
+ )
47
+ }
48
+
49
+ const WORLD_CENTER: L.LatLngTuple = [20, 0]
50
+ const WORLD_ZOOM = 2
51
+ const SINGLE_PIN_ZOOM = 12
52
+ const FIT_PADDING: L.PointTuple = [32, 32]
53
+
54
+ // Saturated pin fill per stage tone — mirrors `Lane.tsx` ACCENT_TONE_CLASS so the same
55
+ // stage reads as the same color on the kanban color bar and on the map pin.
56
+ const PIN_TONE_CLASS: Record<FilterOptionTone, string> = {
57
+ success: 'bg-status-success-icon',
58
+ error: 'bg-status-error-icon',
59
+ warning: 'bg-status-warning-icon',
60
+ info: 'bg-status-info-icon',
61
+ neutral: 'bg-status-neutral-icon',
62
+ brand: 'bg-brand-violet',
63
+ pink: 'bg-status-pink-icon',
64
+ }
65
+
66
+ // Stage badge surface for the preview card — mirrors `Lane.tsx` COUNT_BADGE_TONE_CLASS.
67
+ const STAGE_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {
68
+ success: 'bg-status-success-bg text-status-success-text',
69
+ error: 'bg-status-error-bg text-status-error-text',
70
+ warning: 'bg-status-warning-bg text-status-warning-text',
71
+ info: 'bg-status-info-bg text-status-info-text',
72
+ neutral: 'bg-status-neutral-bg text-status-neutral-text',
73
+ brand: 'bg-brand-violet/14 text-brand-violet',
74
+ pink: 'bg-status-pink-bg text-status-pink-text',
75
+ }
76
+
77
+ function getPinToneClass(tone: FilterOptionTone | null): string {
78
+ if (tone && tone in PIN_TONE_CLASS) return PIN_TONE_CLASS[tone]
79
+ return 'bg-status-neutral-icon'
80
+ }
81
+
82
+ function getStageBadgeClass(tone: FilterOptionTone | null): string {
83
+ if (tone && tone in STAGE_BADGE_TONE_CLASS) return STAGE_BADGE_TONE_CLASS[tone]
84
+ return 'bg-muted text-muted-foreground'
85
+ }
86
+
87
+ // divIcon html is a raw string (no React) — only the server-issued deal id is interpolated,
88
+ // never user-entered text, and styling comes exclusively from DS token classes.
89
+ function buildMarkerIcon(dealId: string, tone: FilterOptionTone | null, selected: boolean): L.DivIcon {
90
+ const sizePx = selected ? 20 : 14
91
+ const dotClass = selected
92
+ ? `block size-5 rounded-full border-2 border-card shadow-md ring-2 ring-ring ${getPinToneClass(tone)}`
93
+ : `block size-3.5 rounded-full border-2 border-card shadow-md ${getPinToneClass(tone)}`
94
+ return L.divIcon({
95
+ className: `om-deal-map-marker${selected ? ' om-deal-map-marker--selected' : ''}`,
96
+ html: `<span class="${dotClass}" data-deal-id="${dealId}"></span>`,
97
+ iconSize: [sizePx, sizePx],
98
+ iconAnchor: [sizePx / 2, sizePx / 2],
99
+ })
100
+ }
101
+
102
+ function buildClusterIcon(count: number): L.DivIcon {
103
+ return L.divIcon({
104
+ className: 'om-deal-map-cluster',
105
+ html: `<span class="flex size-9 items-center justify-center rounded-full border-2 border-card bg-brand-violet text-xs font-bold text-brand-violet-foreground shadow-md">${count}</span>`,
106
+ iconSize: [36, 36],
107
+ iconAnchor: [18, 18],
108
+ })
109
+ }
110
+
111
+ const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
112
+ month: 'short',
113
+ day: '2-digit',
114
+ })
115
+
116
+ function formatShortDate(value: string | null): string | null {
117
+ if (!value) return null
118
+ const date = new Date(value)
119
+ if (Number.isNaN(date.getTime())) return null
120
+ return shortDateFormatter.format(date)
121
+ }
122
+
123
+ type PreviewCardProps = {
124
+ preview: DealsMapPreview
125
+ onClose: () => void
126
+ }
127
+
128
+ function DealMapPreviewCard({ preview, onClose }: PreviewCardProps): React.ReactElement {
129
+ const t = useT()
130
+ const closeDate = formatShortDate(preview.expectedCloseAt)
131
+ const metaParts: string[] = []
132
+ if (typeof preview.valueAmount === 'number') {
133
+ metaParts.push(formatCurrency(preview.valueAmount, preview.valueCurrency))
134
+ }
135
+ if (typeof preview.probability === 'number') {
136
+ metaParts.push(
137
+ translateWithFallback(t, 'customers.deals.map.preview.probabilityShort', '{value}%', {
138
+ value: Math.min(Math.max(Math.round(preview.probability), 0), 100),
139
+ }),
140
+ )
141
+ }
142
+ if (closeDate) {
143
+ metaParts.push(
144
+ translateWithFallback(t, 'customers.deals.map.preview.closeShort', 'Close {date}', {
145
+ date: closeDate,
146
+ }),
147
+ )
148
+ }
149
+ return (
150
+ <div
151
+ data-map-preview-card={preview.id}
152
+ className="absolute right-3 top-3 z-10 flex w-80 max-w-full flex-col gap-2 rounded-xl border border-border bg-popover p-4 shadow-lg"
153
+ >
154
+ <div className="flex items-start justify-between gap-2">
155
+ <div className="flex min-w-0 flex-col">
156
+ {preview.companyLabel ? (
157
+ <span className="truncate text-sm font-medium leading-normal text-foreground">
158
+ {preview.companyLabel}
159
+ </span>
160
+ ) : null}
161
+ {preview.locationLine ? (
162
+ <span className="truncate text-xs leading-normal text-muted-foreground">
163
+ {preview.locationLine}
164
+ </span>
165
+ ) : null}
166
+ </div>
167
+ <IconButton
168
+ variant="ghost"
169
+ size="sm"
170
+ type="button"
171
+ onClick={onClose}
172
+ aria-label={translateWithFallback(t, 'customers.deals.map.preview.close', 'Close preview')}
173
+ >
174
+ <X className="size-4" aria-hidden="true" />
175
+ </IconButton>
176
+ </div>
177
+ <h3 className="text-base font-semibold leading-normal text-foreground">{preview.title}</h3>
178
+ {metaParts.length > 0 ? (
179
+ <p className="text-sm leading-normal text-muted-foreground">{metaParts.join(' · ')}</p>
180
+ ) : null}
181
+ {preview.stageLabel || preview.ownerName ? (
182
+ <div className="flex flex-wrap items-center gap-1.5">
183
+ {preview.stageLabel ? (
184
+ <span
185
+ className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-normal ${getStageBadgeClass(preview.stageTone)}`}
186
+ >
187
+ {preview.stageLabel}
188
+ </span>
189
+ ) : null}
190
+ {preview.ownerName ? (
191
+ <span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium leading-normal text-foreground">
192
+ {preview.ownerName}
193
+ </span>
194
+ ) : null}
195
+ </div>
196
+ ) : null}
197
+ <Button asChild className="mt-1 w-full">
198
+ <Link href={`/backend/customers/deals/${preview.id}`}>
199
+ {translateWithFallback(t, 'customers.deals.map.preview.openDeal', 'Open deal')}
200
+ </Link>
201
+ </Button>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ export default function DealsMapCanvasImpl({
207
+ deals,
208
+ legendStages,
209
+ preview,
210
+ selectedDealId,
211
+ onSelect,
212
+ onCenterChange,
213
+ className,
214
+ }: DealsMapCanvasProps): React.ReactElement {
215
+ const t = useT()
216
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
217
+ const mapRef = React.useRef<L.Map | null>(null)
218
+ const clusterRef = React.useRef<L.MarkerClusterGroup | null>(null)
219
+ const markersByIdRef = React.useRef(new Map<string, L.Marker>())
220
+ const dealsByIdRef = React.useRef(new Map<string, DealsMapCanvasDeal>())
221
+ const previousSelectedIdRef = React.useRef<string | null>(null)
222
+ const lastFitSignatureRef = React.useRef<string | null>(null)
223
+ const centerChangeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
224
+
225
+ const onSelectRef = React.useRef(onSelect)
226
+ onSelectRef.current = onSelect
227
+ const onCenterChangeRef = React.useRef(onCenterChange)
228
+ onCenterChangeRef.current = onCenterChange
229
+
230
+ React.useEffect(() => {
231
+ const node = containerRef.current
232
+ if (!node || mapRef.current) return undefined
233
+ warnOnPublicOsmTilesOnce()
234
+ const map = L.map(node, { zoomControl: false, worldCopyJump: true })
235
+ // Zoom sits top-LEFT so it never overlaps the selection preview card (top-right overlay).
236
+ L.control.zoom({ position: 'topleft' }).addTo(map)
237
+ L.tileLayer(TILE_URL, { attribution: TILE_ATTRIBUTION, maxZoom: 19 }).addTo(map)
238
+ map.setView(WORLD_CENTER, WORLD_ZOOM)
239
+ const cluster = leafletRuntime.markerClusterGroup({
240
+ showCoverageOnHover: false,
241
+ iconCreateFunction: (markerCluster) => buildClusterIcon(markerCluster.getChildCount()),
242
+ })
243
+ map.addLayer(cluster)
244
+ // `moveend` fires on every pan/zoom-end and each one re-renders the view + re-runs the panel's
245
+ // proximity (haversine) sort, so debounce the state update. Read the center synchronously here
246
+ // while the map pane is guaranteed positioned — deferring getCenter() into the timeout can hit
247
+ // Leaflet's `_leaflet_pos` on a pane that has since been reset (fitBounds) or torn down.
248
+ map.on('moveend', () => {
249
+ const center = map.getCenter()
250
+ if (centerChangeTimerRef.current) clearTimeout(centerChangeTimerRef.current)
251
+ centerChangeTimerRef.current = setTimeout(() => {
252
+ onCenterChangeRef.current({ latitude: center.lat, longitude: center.lng })
253
+ }, 250)
254
+ })
255
+ mapRef.current = map
256
+ clusterRef.current = cluster
257
+ const resizeObserver = new ResizeObserver(() => {
258
+ map.invalidateSize()
259
+ })
260
+ resizeObserver.observe(node)
261
+ return () => {
262
+ resizeObserver.disconnect()
263
+ if (centerChangeTimerRef.current) {
264
+ clearTimeout(centerChangeTimerRef.current)
265
+ centerChangeTimerRef.current = null
266
+ }
267
+ // Cancel any in-flight pan/zoom/fly animation before teardown — otherwise its
268
+ // CSS transitionend fires after the panes are gone and Leaflet reads `_leaflet_pos`
269
+ // off an undefined map pane.
270
+ map.stop()
271
+ map.remove()
272
+ mapRef.current = null
273
+ clusterRef.current = null
274
+ markersByIdRef.current.clear()
275
+ dealsByIdRef.current.clear()
276
+ previousSelectedIdRef.current = null
277
+ lastFitSignatureRef.current = null
278
+ }
279
+ }, [])
280
+
281
+ const dealsSignature = React.useMemo(() => deals.map((deal) => deal.id).join('|'), [deals])
282
+
283
+ React.useEffect(() => {
284
+ const map = mapRef.current
285
+ const cluster = clusterRef.current
286
+ if (!map || !cluster) return
287
+ cluster.clearLayers()
288
+ markersByIdRef.current.clear()
289
+ dealsByIdRef.current.clear()
290
+ const markers: L.Marker[] = []
291
+ for (const deal of deals) {
292
+ const marker = L.marker([deal.latitude, deal.longitude], {
293
+ icon: buildMarkerIcon(deal.id, deal.tone, false),
294
+ keyboard: false,
295
+ })
296
+ marker.on('click', () => onSelectRef.current(deal.id))
297
+ markersByIdRef.current.set(deal.id, marker)
298
+ dealsByIdRef.current.set(deal.id, deal)
299
+ markers.push(marker)
300
+ }
301
+ cluster.addLayers(markers)
302
+ if (lastFitSignatureRef.current !== dealsSignature) {
303
+ lastFitSignatureRef.current = dealsSignature
304
+ // Fit instantly (animate: false). The list pages in over several updates, so animated
305
+ // re-fits would stack overlapping zoom transitions and race Leaflet's pane bookkeeping.
306
+ map.stop()
307
+ if (deals.length === 0) {
308
+ map.setView(WORLD_CENTER, WORLD_ZOOM, { animate: false })
309
+ } else if (deals.length === 1) {
310
+ map.setView([deals[0].latitude, deals[0].longitude], SINGLE_PIN_ZOOM, { animate: false })
311
+ } else {
312
+ const bounds = L.latLngBounds(
313
+ deals.map((deal) => [deal.latitude, deal.longitude] as L.LatLngTuple),
314
+ )
315
+ map.fitBounds(bounds, { padding: FIT_PADDING, animate: false })
316
+ }
317
+ }
318
+ }, [deals, dealsSignature])
319
+
320
+ React.useEffect(() => {
321
+ const map = mapRef.current
322
+ const cluster = clusterRef.current
323
+ if (!map || !cluster) return
324
+ const previousId = previousSelectedIdRef.current
325
+ const selectionChanged = previousId !== selectedDealId
326
+ if (previousId && previousId !== selectedDealId) {
327
+ const previousMarker = markersByIdRef.current.get(previousId)
328
+ const previousDeal = dealsByIdRef.current.get(previousId)
329
+ if (previousMarker && previousDeal) {
330
+ previousMarker.setIcon(buildMarkerIcon(previousDeal.id, previousDeal.tone, false))
331
+ }
332
+ }
333
+ previousSelectedIdRef.current = selectedDealId
334
+ if (!selectedDealId) return
335
+ const marker = markersByIdRef.current.get(selectedDealId)
336
+ const deal = dealsByIdRef.current.get(selectedDealId)
337
+ if (!marker || !deal) return
338
+ marker.setIcon(buildMarkerIcon(deal.id, deal.tone, true))
339
+ // Only move the camera when the selection itself changed. This effect also depends on
340
+ // `deals` so the selected marker's icon is re-applied after a marker rebuild — but a deal-set
341
+ // change alone (e.g. a sibling query resolving) must not yank the viewport back to the pin.
342
+ if (!selectionChanged) return
343
+ map.stop()
344
+ const visibleParent = cluster.getVisibleParent(marker)
345
+ if (visibleParent && visibleParent !== marker) {
346
+ cluster.zoomToShowLayer(marker, () => {})
347
+ } else {
348
+ map.flyTo(marker.getLatLng(), Math.max(map.getZoom(), SINGLE_PIN_ZOOM))
349
+ }
350
+ }, [selectedDealId, deals])
351
+
352
+ // Escape clears the selected-deal preview. Bound at the document level (not the canvas region's
353
+ // onKeyDown) so it fires regardless of whether focus is on the map, a panel card, or the preview
354
+ // card. Registered only while a deal is selected, so it never swallows Escape elsewhere.
355
+ React.useEffect(() => {
356
+ if (!selectedDealId) return undefined
357
+ const onKeyDown = (event: KeyboardEvent) => {
358
+ if (event.key !== 'Escape') return
359
+ onSelectRef.current(null)
360
+ }
361
+ document.addEventListener('keydown', onKeyDown)
362
+ return () => document.removeEventListener('keydown', onKeyDown)
363
+ }, [selectedDealId])
364
+
365
+ return (
366
+ <div
367
+ role="region"
368
+ aria-label={translateWithFallback(t, 'customers.deals.map.canvas.label', 'Deals map')}
369
+ className={`relative ${className ?? ''}`.trim()}
370
+ >
371
+ <div ref={containerRef} data-map-canvas className="absolute inset-0 z-0" />
372
+ {deals.length === 0 ? (
373
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
374
+ <div className="max-w-sm rounded-xl border border-border bg-card/95 p-4 text-center shadow-sm">
375
+ <p className="text-sm font-medium leading-normal text-foreground">
376
+ {translateWithFallback(t, 'customers.deals.map.panel.empty.title', 'No deals on the map yet')}
377
+ </p>
378
+ <p className="mt-1 text-xs leading-normal text-muted-foreground">
379
+ {translateWithFallback(
380
+ t,
381
+ 'customers.deals.map.panel.empty.description',
382
+ 'Add latitude and longitude to company or person addresses to plot their deals here.',
383
+ )}
384
+ </p>
385
+ </div>
386
+ </div>
387
+ ) : null}
388
+ {legendStages.length > 0 ? (
389
+ <div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5 rounded-lg border border-border bg-card/95 p-3">
390
+ <span className="text-overline font-bold uppercase leading-normal text-muted-foreground">
391
+ {translateWithFallback(t, 'customers.deals.map.legend.title', 'Stages')}
392
+ </span>
393
+ {legendStages.map((stage) => (
394
+ <span key={stage.id} className="flex items-center gap-2 text-xs leading-normal text-foreground">
395
+ <span
396
+ className={`size-2.5 shrink-0 rounded-full ${getPinToneClass(stage.tone)}`}
397
+ aria-hidden="true"
398
+ />
399
+ {stage.label}
400
+ </span>
401
+ ))}
402
+ </div>
403
+ ) : null}
404
+ {preview ? <DealMapPreviewCard preview={preview} onClose={() => onSelectRef.current(null)} /> : null}
405
+ </div>
406
+ )
407
+ }