@open-mercato/core 0.6.6-develop.6386.1.391a1afb9e → 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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/deals/map/route.js +433 -0
  3. package/dist/modules/customers/api/deals/map/route.js.map +7 -0
  4. package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
  5. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
  7. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
  8. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
  9. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
  10. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
  11. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
  13. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
  14. package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
  15. package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
  17. package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
  18. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
  19. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
  21. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  24. package/dist/modules/customers/components/AddressEditor.js +34 -2
  25. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  26. package/dist/modules/customers/components/AddressTiles.js +43 -3
  27. package/dist/modules/customers/components/AddressTiles.js.map +2 -2
  28. package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
  29. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  30. package/dist/modules/customers/components/formConfig.js +10 -0
  31. package/dist/modules/customers/components/formConfig.js.map +2 -2
  32. package/dist/modules/customers/data/validators.js +3 -2
  33. package/dist/modules/customers/data/validators.js.map +2 -2
  34. package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
  35. package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
  36. package/package.json +11 -7
  37. package/src/modules/customers/api/deals/map/route.ts +549 -0
  38. package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
  39. package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
  40. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
  41. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
  42. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
  43. package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
  44. package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
  45. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
  46. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
  47. package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
  48. package/src/modules/customers/components/AddressEditor.tsx +35 -0
  49. package/src/modules/customers/components/AddressTiles.tsx +51 -1
  50. package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
  51. package/src/modules/customers/components/formConfig.tsx +10 -0
  52. package/src/modules/customers/data/validators.ts +13 -2
  53. package/src/modules/customers/i18n/de.json +24 -0
  54. package/src/modules/customers/i18n/en.json +24 -0
  55. package/src/modules/customers/i18n/es.json +24 -0
  56. package/src/modules/customers/i18n/pl.json +24 -0
  57. package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
@@ -0,0 +1,213 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { MapPin, MapPinOff } from 'lucide-react'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@open-mercato/ui/primitives/select'
14
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
16
+ import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
17
+ import { formatCurrency } from '../../../../../components/detail/utils'
18
+ import type { MapDeal, StageMeta } from './DealsMapView'
19
+ import type { MapCenter } from './DealsMapCanvas'
20
+
21
+ const STAGE_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {
22
+ success: 'bg-status-success-bg text-status-success-text',
23
+ error: 'bg-status-error-bg text-status-error-text',
24
+ warning: 'bg-status-warning-bg text-status-warning-text',
25
+ info: 'bg-status-info-bg text-status-info-text',
26
+ neutral: 'bg-status-neutral-bg text-status-neutral-text',
27
+ brand: 'bg-brand-violet/14 text-brand-violet',
28
+ pink: 'bg-status-pink-bg text-status-pink-text',
29
+ }
30
+
31
+ function getStageBadgeClass(tone: FilterOptionTone | null): string {
32
+ if (tone && tone in STAGE_BADGE_TONE_CLASS) return STAGE_BADGE_TONE_CLASS[tone]
33
+ return 'bg-muted text-muted-foreground'
34
+ }
35
+
36
+ type PanelSort = 'proximity' | 'listOrder'
37
+
38
+ function haversineKm(
39
+ a: { latitude: number; longitude: number },
40
+ b: { latitude: number; longitude: number },
41
+ ): number {
42
+ const toRadians = (value: number) => (value * Math.PI) / 180
43
+ const earthRadiusKm = 6371
44
+ const dLat = toRadians(b.latitude - a.latitude)
45
+ const dLng = toRadians(b.longitude - a.longitude)
46
+ const sinLat = Math.sin(dLat / 2)
47
+ const sinLng = Math.sin(dLng / 2)
48
+ const h =
49
+ sinLat * sinLat +
50
+ Math.cos(toRadians(a.latitude)) * Math.cos(toRadians(b.latitude)) * sinLng * sinLng
51
+ return 2 * earthRadiusKm * Math.asin(Math.min(1, Math.sqrt(h)))
52
+ }
53
+
54
+ type DealsLocationPanelProps = {
55
+ deals: MapDeal[]
56
+ // Total deals matching the active filters that resolve to a location (the located-only endpoint's
57
+ // `total`). May exceed `deals.length` when the client cap truncates the rendered set.
58
+ locatedCount: number
59
+ stageMetaById: Map<string, StageMeta>
60
+ mapCenter: MapCenter | null
61
+ selectedDealId: string | null
62
+ onSelect: (dealId: string | null) => void
63
+ }
64
+
65
+ export function DealsLocationPanel({
66
+ deals,
67
+ locatedCount,
68
+ stageMetaById,
69
+ mapCenter,
70
+ selectedDealId,
71
+ onSelect,
72
+ }: DealsLocationPanelProps): React.ReactElement {
73
+ const t = useT()
74
+ const [panelSort, setPanelSort] = React.useState<PanelSort>('proximity')
75
+
76
+ // The map endpoint is located-only, so every deal here carries a location. The guard keeps the
77
+ // proximity comparator total-order-safe against the nullable `MapDeal.location` type.
78
+ const visibleDeals = React.useMemo(() => {
79
+ if (panelSort !== 'proximity' || !mapCenter) return deals
80
+ return deals.slice().sort((a, b) => {
81
+ if (!a.location) return 1
82
+ if (!b.location) return -1
83
+ return haversineKm(mapCenter, a.location) - haversineKm(mapCenter, b.location)
84
+ })
85
+ }, [deals, panelSort, mapCenter])
86
+
87
+ const noAddressLabel = translateWithFallback(
88
+ t,
89
+ 'customers.deals.map.panel.noAddress',
90
+ 'No address coordinates',
91
+ )
92
+
93
+ return (
94
+ <div className="flex max-h-[60vh] w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card lg:max-h-none lg:w-80">
95
+ <div className="flex flex-col gap-1 border-b border-border px-4 py-3">
96
+ <div className="flex items-baseline justify-between gap-2">
97
+ <h2 className="text-sm font-semibold leading-normal text-foreground">
98
+ {translateWithFallback(t, 'customers.deals.map.panel.title', 'Deals with location')}
99
+ </h2>
100
+ <span className="text-xs leading-normal text-muted-foreground">
101
+ {translateWithFallback(t, 'customers.deals.map.panel.count', '{count} located', {
102
+ count: locatedCount,
103
+ })}
104
+ </span>
105
+ </div>
106
+ <p className="text-xs leading-normal text-muted-foreground">
107
+ {translateWithFallback(
108
+ t,
109
+ 'customers.deals.map.panel.hint',
110
+ 'Click a deal or a map pin to preview it.',
111
+ )}
112
+ </p>
113
+ <div className="mt-2 flex items-center gap-2">
114
+ <Select value={panelSort} onValueChange={(value) => setPanelSort(value as PanelSort)}>
115
+ <SelectTrigger
116
+ size="sm"
117
+ className="w-auto min-w-36"
118
+ aria-label={translateWithFallback(t, 'customers.deals.map.panel.sort.label', 'Sort deals panel')}
119
+ >
120
+ <SelectValue />
121
+ </SelectTrigger>
122
+ <SelectContent>
123
+ <SelectItem value="proximity">
124
+ {translateWithFallback(t, 'customers.deals.map.panel.sort.proximity', 'Closest first')}
125
+ </SelectItem>
126
+ <SelectItem value="listOrder">
127
+ {translateWithFallback(t, 'customers.deals.map.panel.sort.listOrder', 'List order')}
128
+ </SelectItem>
129
+ </SelectContent>
130
+ </Select>
131
+ </div>
132
+ </div>
133
+
134
+ <div className="flex flex-1 flex-col gap-2 overflow-y-auto p-3">
135
+ {deals.length === 0 ? (
136
+ <EmptyState
137
+ variant="subtle"
138
+ size="sm"
139
+ icon={<MapPinOff className="size-6" aria-hidden="true" />}
140
+ title={translateWithFallback(
141
+ t,
142
+ 'customers.deals.map.panel.empty.title',
143
+ 'No deals on the map yet',
144
+ )}
145
+ description={translateWithFallback(
146
+ t,
147
+ 'customers.deals.map.panel.empty.description',
148
+ 'Add latitude and longitude to company or person addresses to plot their deals here.',
149
+ )}
150
+ />
151
+ ) : null}
152
+ {visibleDeals.map((deal) => {
153
+ const stageMeta = deal.pipelineStageId ? stageMetaById.get(deal.pipelineStageId) ?? null : null
154
+ const isSelected = deal.id === selectedDealId
155
+ const locationLine = deal.location
156
+ ? [deal.location.city, deal.location.region].filter(Boolean).join(', ') ||
157
+ deal.location.country ||
158
+ null
159
+ : null
160
+ const valueLabel =
161
+ typeof deal.valueAmount === 'number' ? formatCurrency(deal.valueAmount, deal.valueCurrency) : null
162
+ // Compose the full card content for screen readers — the bare title alone dropped the
163
+ // company, value, stage and location that sighted users read off the card.
164
+ const cardAriaLabel =
165
+ [deal.companyLabel, deal.title, valueLabel, stageMeta?.label, locationLine ?? noAddressLabel]
166
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
167
+ .join(', ') || deal.title
168
+ return (
169
+ <Button
170
+ key={deal.id}
171
+ type="button"
172
+ variant="ghost"
173
+ data-map-panel-card={deal.id}
174
+ aria-label={cardAriaLabel}
175
+ aria-pressed={isSelected}
176
+ onClick={() => onSelect(deal.id)}
177
+ className={`h-auto w-full flex-col items-stretch justify-start gap-1 whitespace-normal rounded-lg border border-border p-3 text-left shadow-none ${
178
+ isSelected ? 'bg-muted' : 'bg-card'
179
+ }`}
180
+ >
181
+ <div className="flex items-baseline justify-between gap-2">
182
+ <span className="truncate text-sm font-medium leading-normal text-foreground">
183
+ {deal.companyLabel ?? '—'}
184
+ </span>
185
+ {valueLabel ? (
186
+ <span className="shrink-0 text-sm font-semibold leading-normal text-foreground">
187
+ {valueLabel}
188
+ </span>
189
+ ) : null}
190
+ </div>
191
+ <span className="truncate text-sm leading-normal text-muted-foreground">{deal.title}</span>
192
+ <div className="flex items-center justify-between gap-2">
193
+ <span className="flex min-w-0 items-center gap-1.5 text-xs leading-normal text-muted-foreground">
194
+ <MapPin className="size-3.5 shrink-0" aria-hidden="true" />
195
+ <span className="truncate">{locationLine ?? noAddressLabel}</span>
196
+ </span>
197
+ {stageMeta ? (
198
+ <span
199
+ className={`inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-normal ${getStageBadgeClass(stageMeta.tone)}`}
200
+ >
201
+ {stageMeta.label}
202
+ </span>
203
+ ) : null}
204
+ </div>
205
+ </Button>
206
+ )
207
+ })}
208
+ </div>
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export default DealsLocationPanel
@@ -0,0 +1,79 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
5
+ import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
6
+
7
+ export type MapCenter = {
8
+ latitude: number
9
+ longitude: number
10
+ }
11
+
12
+ export type DealsMapCanvasDeal = {
13
+ id: string
14
+ latitude: number
15
+ longitude: number
16
+ tone: FilterOptionTone | null
17
+ }
18
+
19
+ export type DealsMapLegendStage = {
20
+ id: string
21
+ label: string
22
+ tone: FilterOptionTone | null
23
+ }
24
+
25
+ export type DealsMapPreview = {
26
+ id: string
27
+ title: string
28
+ companyLabel: string | null
29
+ locationLine: string | null
30
+ valueAmount: number | null
31
+ valueCurrency: string | null
32
+ probability: number | null
33
+ expectedCloseAt: string | null
34
+ stageLabel: string | null
35
+ stageTone: FilterOptionTone | null
36
+ ownerName: string | null
37
+ }
38
+
39
+ export type DealsMapCanvasProps = {
40
+ deals: DealsMapCanvasDeal[]
41
+ legendStages: DealsMapLegendStage[]
42
+ preview: DealsMapPreview | null
43
+ selectedDealId: string | null
44
+ onSelect: (dealId: string | null) => void
45
+ onCenterChange: (center: MapCenter) => void
46
+ className?: string
47
+ }
48
+
49
+ type DealsMapCanvasComponent = (props: DealsMapCanvasProps) => React.ReactElement
50
+
51
+ /**
52
+ * Lazy shell around the Leaflet implementation. Leaflet touches `window` at import time and ships
53
+ * its own CSS, so the impl chunk (JS + stylesheets) is imported client-side only — on first mount,
54
+ * inside an effect — which keeps it out of SSR while rendering a prop-sized placeholder until it
55
+ * resolves. A single dynamic `import()` drives both the lazy load and the fallback.
56
+ */
57
+ export function DealsMapCanvas(props: DealsMapCanvasProps): React.ReactElement {
58
+ const [Impl, setImpl] = React.useState<DealsMapCanvasComponent | null>(null)
59
+ React.useEffect(() => {
60
+ let cancelled = false
61
+ void import('./DealsMapCanvasImpl').then((mod) => {
62
+ if (!cancelled) setImpl(() => mod.default)
63
+ })
64
+ return () => {
65
+ cancelled = true
66
+ }
67
+ }, [])
68
+
69
+ if (!Impl) {
70
+ return (
71
+ <div className={`flex items-center justify-center bg-muted/30 ${props.className ?? ''}`.trim()}>
72
+ <Spinner className="size-6 text-muted-foreground" />
73
+ </div>
74
+ )
75
+ }
76
+ return <Impl {...props} />
77
+ }
78
+
79
+ export default DealsMapCanvas