@open-mercato/core 0.6.6-develop.6407.1.9a4c26cf6a → 0.6.6-develop.6409.1.4bfed821c0

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.
@@ -16,7 +16,15 @@ import { formatCurrency } from "../../../../../components/detail/utils.js";
16
16
  const leafletRuntime = L.default ?? L;
17
17
  const TILE_URL = process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL ?? "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
18
18
  const TILE_ATTRIBUTION = process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_ATTRIBUTION ?? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
19
- const USING_PUBLIC_OSM_TILES = TILE_URL.includes("tile.openstreetmap.org");
19
+ function targetsPublicOsmTileHost(tileUrl) {
20
+ try {
21
+ const { hostname } = new URL(tileUrl);
22
+ return hostname === "openstreetmap.org" || hostname.endsWith(".openstreetmap.org");
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ const USING_PUBLIC_OSM_TILES = targetsPublicOsmTileHost(TILE_URL);
20
28
  let publicOsmTileWarningEmitted = false;
21
29
  function warnOnPublicOsmTilesOnce() {
22
30
  if (publicOsmTileWarningEmitted || !USING_PUBLIC_OSM_TILES) return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport * as L from 'leaflet'\nimport 'leaflet.markercluster'\nimport 'leaflet/dist/leaflet.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.Default.css'\nimport { X } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { formatCurrency } from '../../../../../components/detail/utils'\nimport type {\n DealsMapCanvasDeal,\n DealsMapCanvasProps,\n DealsMapPreview,\n} from './DealsMapCanvas'\n\nconst leafletRuntime = ((L as { default?: typeof L }).default ?? L) as typeof L\n\nconst TILE_URL =\n process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL ?? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'\nconst TILE_ATTRIBUTION =\n process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_ATTRIBUTION ??\n '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors'\n// True whenever the EFFECTIVE tile URL targets OSM's public CDN \u2014 whether from the bundled default\n// (env unset) OR an env value pointed back at the same public host. OSM's tile usage policy\n// prohibits production/commercial traffic against it.\nconst USING_PUBLIC_OSM_TILES = TILE_URL.includes('tile.openstreetmap.org')\n\nlet publicOsmTileWarningEmitted = false\n// Warn once (per session) so deployments point NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or\n// commercial tile service instead of silently shipping against the shared public CDN.\nfunction warnOnPublicOsmTilesOnce(): void {\n if (publicOsmTileWarningEmitted || !USING_PUBLIC_OSM_TILES) return\n publicOsmTileWarningEmitted = true\n // eslint-disable-next-line no-console\n console.warn(\n '[open-mercato] Deals map is using the public OpenStreetMap tile server. ' +\n \"OSM's tile usage policy prohibits production/commercial traffic \u2014 point \" +\n 'NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or commercial tile service before deploying.',\n )\n}\n\nconst WORLD_CENTER: L.LatLngTuple = [20, 0]\nconst WORLD_ZOOM = 2\nconst SINGLE_PIN_ZOOM = 12\nconst FIT_PADDING: L.PointTuple = [32, 32]\n\n// Saturated pin fill per stage tone \u2014 mirrors `Lane.tsx` ACCENT_TONE_CLASS so the same\n// stage reads as the same color on the kanban color bar and on the map pin.\nconst PIN_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-icon',\n error: 'bg-status-error-icon',\n warning: 'bg-status-warning-icon',\n info: 'bg-status-info-icon',\n neutral: 'bg-status-neutral-icon',\n brand: 'bg-brand-violet',\n pink: 'bg-status-pink-icon',\n}\n\n// Stage badge surface for the preview card \u2014 mirrors `Lane.tsx` COUNT_BADGE_TONE_CLASS.\nconst STAGE_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-bg text-status-success-text',\n error: 'bg-status-error-bg text-status-error-text',\n warning: 'bg-status-warning-bg text-status-warning-text',\n info: 'bg-status-info-bg text-status-info-text',\n neutral: 'bg-status-neutral-bg text-status-neutral-text',\n brand: 'bg-brand-violet/14 text-brand-violet',\n pink: 'bg-status-pink-bg text-status-pink-text',\n}\n\nfunction getPinToneClass(tone: FilterOptionTone | null): string {\n if (tone && tone in PIN_TONE_CLASS) return PIN_TONE_CLASS[tone]\n return 'bg-status-neutral-icon'\n}\n\nfunction getStageBadgeClass(tone: FilterOptionTone | null): string {\n if (tone && tone in STAGE_BADGE_TONE_CLASS) return STAGE_BADGE_TONE_CLASS[tone]\n return 'bg-muted text-muted-foreground'\n}\n\n// divIcon html is a raw string (no React) \u2014 only the server-issued deal id is interpolated,\n// never user-entered text, and styling comes exclusively from DS token classes.\nfunction buildMarkerIcon(dealId: string, tone: FilterOptionTone | null, selected: boolean): L.DivIcon {\n const sizePx = selected ? 20 : 14\n const dotClass = selected\n ? `block size-5 rounded-full border-2 border-card shadow-md ring-2 ring-ring ${getPinToneClass(tone)}`\n : `block size-3.5 rounded-full border-2 border-card shadow-md ${getPinToneClass(tone)}`\n return L.divIcon({\n className: `om-deal-map-marker${selected ? ' om-deal-map-marker--selected' : ''}`,\n html: `<span class=\"${dotClass}\" data-deal-id=\"${dealId}\"></span>`,\n iconSize: [sizePx, sizePx],\n iconAnchor: [sizePx / 2, sizePx / 2],\n })\n}\n\nfunction buildClusterIcon(count: number): L.DivIcon {\n return L.divIcon({\n className: 'om-deal-map-cluster',\n 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>`,\n iconSize: [36, 36],\n iconAnchor: [18, 18],\n })\n}\n\nconst shortDateFormatter = new Intl.DateTimeFormat(undefined, {\n month: 'short',\n day: '2-digit',\n})\n\nfunction formatShortDate(value: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return shortDateFormatter.format(date)\n}\n\ntype PreviewCardProps = {\n preview: DealsMapPreview\n onClose: () => void\n}\n\nfunction DealMapPreviewCard({ preview, onClose }: PreviewCardProps): React.ReactElement {\n const t = useT()\n const closeDate = formatShortDate(preview.expectedCloseAt)\n const metaParts: string[] = []\n if (typeof preview.valueAmount === 'number') {\n metaParts.push(formatCurrency(preview.valueAmount, preview.valueCurrency))\n }\n if (typeof preview.probability === 'number') {\n metaParts.push(\n translateWithFallback(t, 'customers.deals.map.preview.probabilityShort', '{value}%', {\n value: Math.min(Math.max(Math.round(preview.probability), 0), 100),\n }),\n )\n }\n if (closeDate) {\n metaParts.push(\n translateWithFallback(t, 'customers.deals.map.preview.closeShort', 'Close {date}', {\n date: closeDate,\n }),\n )\n }\n return (\n <div\n data-map-preview-card={preview.id}\n 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\"\n >\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex min-w-0 flex-col\">\n {preview.companyLabel ? (\n <span className=\"truncate text-sm font-medium leading-normal text-foreground\">\n {preview.companyLabel}\n </span>\n ) : null}\n {preview.locationLine ? (\n <span className=\"truncate text-xs leading-normal text-muted-foreground\">\n {preview.locationLine}\n </span>\n ) : null}\n </div>\n <IconButton\n variant=\"ghost\"\n size=\"sm\"\n type=\"button\"\n onClick={onClose}\n aria-label={translateWithFallback(t, 'customers.deals.map.preview.close', 'Close preview')}\n >\n <X className=\"size-4\" aria-hidden=\"true\" />\n </IconButton>\n </div>\n <h3 className=\"text-base font-semibold leading-normal text-foreground\">{preview.title}</h3>\n {metaParts.length > 0 ? (\n <p className=\"text-sm leading-normal text-muted-foreground\">{metaParts.join(' \u00B7 ')}</p>\n ) : null}\n {preview.stageLabel || preview.ownerName ? (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {preview.stageLabel ? (\n <span\n className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-normal ${getStageBadgeClass(preview.stageTone)}`}\n >\n {preview.stageLabel}\n </span>\n ) : null}\n {preview.ownerName ? (\n <span className=\"inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium leading-normal text-foreground\">\n {preview.ownerName}\n </span>\n ) : null}\n </div>\n ) : null}\n <Button asChild className=\"mt-1 w-full\">\n <Link href={`/backend/customers/deals/${preview.id}`}>\n {translateWithFallback(t, 'customers.deals.map.preview.openDeal', 'Open deal')}\n </Link>\n </Button>\n </div>\n )\n}\n\nexport default function DealsMapCanvasImpl({\n deals,\n legendStages,\n preview,\n selectedDealId,\n onSelect,\n onCenterChange,\n className,\n}: DealsMapCanvasProps): React.ReactElement {\n const t = useT()\n const containerRef = React.useRef<HTMLDivElement | null>(null)\n const mapRef = React.useRef<L.Map | null>(null)\n const clusterRef = React.useRef<L.MarkerClusterGroup | null>(null)\n const markersByIdRef = React.useRef(new Map<string, L.Marker>())\n const dealsByIdRef = React.useRef(new Map<string, DealsMapCanvasDeal>())\n const previousSelectedIdRef = React.useRef<string | null>(null)\n const lastFitSignatureRef = React.useRef<string | null>(null)\n const centerChangeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const onSelectRef = React.useRef(onSelect)\n onSelectRef.current = onSelect\n const onCenterChangeRef = React.useRef(onCenterChange)\n onCenterChangeRef.current = onCenterChange\n\n React.useEffect(() => {\n const node = containerRef.current\n if (!node || mapRef.current) return undefined\n warnOnPublicOsmTilesOnce()\n const map = L.map(node, { zoomControl: false, worldCopyJump: true })\n // Zoom sits top-LEFT so it never overlaps the selection preview card (top-right overlay).\n L.control.zoom({ position: 'topleft' }).addTo(map)\n L.tileLayer(TILE_URL, { attribution: TILE_ATTRIBUTION, maxZoom: 19 }).addTo(map)\n map.setView(WORLD_CENTER, WORLD_ZOOM)\n const cluster = leafletRuntime.markerClusterGroup({\n showCoverageOnHover: false,\n iconCreateFunction: (markerCluster) => buildClusterIcon(markerCluster.getChildCount()),\n })\n map.addLayer(cluster)\n // `moveend` fires on every pan/zoom-end and each one re-renders the view + re-runs the panel's\n // proximity (haversine) sort, so debounce the state update. Read the center synchronously here\n // while the map pane is guaranteed positioned \u2014 deferring getCenter() into the timeout can hit\n // Leaflet's `_leaflet_pos` on a pane that has since been reset (fitBounds) or torn down.\n map.on('moveend', () => {\n const center = map.getCenter()\n if (centerChangeTimerRef.current) clearTimeout(centerChangeTimerRef.current)\n centerChangeTimerRef.current = setTimeout(() => {\n onCenterChangeRef.current({ latitude: center.lat, longitude: center.lng })\n }, 250)\n })\n mapRef.current = map\n clusterRef.current = cluster\n const resizeObserver = new ResizeObserver(() => {\n map.invalidateSize()\n })\n resizeObserver.observe(node)\n return () => {\n resizeObserver.disconnect()\n if (centerChangeTimerRef.current) {\n clearTimeout(centerChangeTimerRef.current)\n centerChangeTimerRef.current = null\n }\n // Cancel any in-flight pan/zoom/fly animation before teardown \u2014 otherwise its\n // CSS transitionend fires after the panes are gone and Leaflet reads `_leaflet_pos`\n // off an undefined map pane.\n map.stop()\n map.remove()\n mapRef.current = null\n clusterRef.current = null\n markersByIdRef.current.clear()\n dealsByIdRef.current.clear()\n previousSelectedIdRef.current = null\n lastFitSignatureRef.current = null\n }\n }, [])\n\n const dealsSignature = React.useMemo(() => deals.map((deal) => deal.id).join('|'), [deals])\n\n React.useEffect(() => {\n const map = mapRef.current\n const cluster = clusterRef.current\n if (!map || !cluster) return\n cluster.clearLayers()\n markersByIdRef.current.clear()\n dealsByIdRef.current.clear()\n const markers: L.Marker[] = []\n for (const deal of deals) {\n const marker = L.marker([deal.latitude, deal.longitude], {\n icon: buildMarkerIcon(deal.id, deal.tone, false),\n keyboard: false,\n })\n marker.on('click', () => onSelectRef.current(deal.id))\n markersByIdRef.current.set(deal.id, marker)\n dealsByIdRef.current.set(deal.id, deal)\n markers.push(marker)\n }\n cluster.addLayers(markers)\n if (lastFitSignatureRef.current !== dealsSignature) {\n lastFitSignatureRef.current = dealsSignature\n // Fit instantly (animate: false). The list pages in over several updates, so animated\n // re-fits would stack overlapping zoom transitions and race Leaflet's pane bookkeeping.\n map.stop()\n if (deals.length === 0) {\n map.setView(WORLD_CENTER, WORLD_ZOOM, { animate: false })\n } else if (deals.length === 1) {\n map.setView([deals[0].latitude, deals[0].longitude], SINGLE_PIN_ZOOM, { animate: false })\n } else {\n const bounds = L.latLngBounds(\n deals.map((deal) => [deal.latitude, deal.longitude] as L.LatLngTuple),\n )\n map.fitBounds(bounds, { padding: FIT_PADDING, animate: false })\n }\n }\n }, [deals, dealsSignature])\n\n React.useEffect(() => {\n const map = mapRef.current\n const cluster = clusterRef.current\n if (!map || !cluster) return\n const previousId = previousSelectedIdRef.current\n const selectionChanged = previousId !== selectedDealId\n if (previousId && previousId !== selectedDealId) {\n const previousMarker = markersByIdRef.current.get(previousId)\n const previousDeal = dealsByIdRef.current.get(previousId)\n if (previousMarker && previousDeal) {\n previousMarker.setIcon(buildMarkerIcon(previousDeal.id, previousDeal.tone, false))\n }\n }\n previousSelectedIdRef.current = selectedDealId\n if (!selectedDealId) return\n const marker = markersByIdRef.current.get(selectedDealId)\n const deal = dealsByIdRef.current.get(selectedDealId)\n if (!marker || !deal) return\n marker.setIcon(buildMarkerIcon(deal.id, deal.tone, true))\n // Only move the camera when the selection itself changed. This effect also depends on\n // `deals` so the selected marker's icon is re-applied after a marker rebuild \u2014 but a deal-set\n // change alone (e.g. a sibling query resolving) must not yank the viewport back to the pin.\n if (!selectionChanged) return\n map.stop()\n const visibleParent = cluster.getVisibleParent(marker)\n if (visibleParent && visibleParent !== marker) {\n cluster.zoomToShowLayer(marker, () => {})\n } else {\n map.flyTo(marker.getLatLng(), Math.max(map.getZoom(), SINGLE_PIN_ZOOM))\n }\n }, [selectedDealId, deals])\n\n // Escape clears the selected-deal preview. Bound at the document level (not the canvas region's\n // onKeyDown) so it fires regardless of whether focus is on the map, a panel card, or the preview\n // card. Registered only while a deal is selected, so it never swallows Escape elsewhere.\n React.useEffect(() => {\n if (!selectedDealId) return undefined\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return\n onSelectRef.current(null)\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [selectedDealId])\n\n return (\n <div\n role=\"region\"\n aria-label={translateWithFallback(t, 'customers.deals.map.canvas.label', 'Deals map')}\n className={`relative ${className ?? ''}`.trim()}\n >\n <div ref={containerRef} data-map-canvas className=\"absolute inset-0 z-0\" />\n {deals.length === 0 ? (\n <div className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6\">\n <div className=\"max-w-sm rounded-xl border border-border bg-card/95 p-4 text-center shadow-sm\">\n <p className=\"text-sm font-medium leading-normal text-foreground\">\n {translateWithFallback(t, 'customers.deals.map.panel.empty.title', 'No deals on the map yet')}\n </p>\n <p className=\"mt-1 text-xs leading-normal text-muted-foreground\">\n {translateWithFallback(\n t,\n 'customers.deals.map.panel.empty.description',\n 'Add latitude and longitude to company or person addresses to plot their deals here.',\n )}\n </p>\n </div>\n </div>\n ) : null}\n {legendStages.length > 0 ? (\n <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\">\n <span className=\"text-overline font-bold uppercase leading-normal text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.map.legend.title', 'Stages')}\n </span>\n {legendStages.map((stage) => (\n <span key={stage.id} className=\"flex items-center gap-2 text-xs leading-normal text-foreground\">\n <span\n className={`size-2.5 shrink-0 rounded-full ${getPinToneClass(stage.tone)}`}\n aria-hidden=\"true\"\n />\n {stage.label}\n </span>\n ))}\n </div>\n ) : null}\n {preview ? <DealMapPreviewCard preview={preview} onClose={() => onSelectRef.current(null)} /> : null}\n </div>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport * as L from 'leaflet'\nimport 'leaflet.markercluster'\nimport 'leaflet/dist/leaflet.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.css'\nimport 'leaflet.markercluster/dist/MarkerCluster.Default.css'\nimport { X } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { formatCurrency } from '../../../../../components/detail/utils'\nimport type {\n DealsMapCanvasDeal,\n DealsMapCanvasProps,\n DealsMapPreview,\n} from './DealsMapCanvas'\n\nconst leafletRuntime = ((L as { default?: typeof L }).default ?? L) as typeof L\n\nconst TILE_URL =\n process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL ?? 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'\nconst TILE_ATTRIBUTION =\n process.env.NEXT_PUBLIC_OM_DEALS_MAP_TILE_ATTRIBUTION ??\n '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors'\n// True whenever the EFFECTIVE tile URL targets OSM's public CDN \u2014 whether from the bundled default\n// (env unset) OR an env value pointed back at the same public host. OSM's tile usage policy\n// prohibits production/commercial traffic against it. Match on the parsed hostname rather than a raw\n// substring so a lookalike host (e.g. `tile.openstreetmap.org.example.com`) is not mistaken for OSM.\nfunction targetsPublicOsmTileHost(tileUrl: string): boolean {\n try {\n const { hostname } = new URL(tileUrl)\n return hostname === 'openstreetmap.org' || hostname.endsWith('.openstreetmap.org')\n } catch {\n return false\n }\n}\n\nconst USING_PUBLIC_OSM_TILES = targetsPublicOsmTileHost(TILE_URL)\n\nlet publicOsmTileWarningEmitted = false\n// Warn once (per session) so deployments point NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or\n// commercial tile service instead of silently shipping against the shared public CDN.\nfunction warnOnPublicOsmTilesOnce(): void {\n if (publicOsmTileWarningEmitted || !USING_PUBLIC_OSM_TILES) return\n publicOsmTileWarningEmitted = true\n // eslint-disable-next-line no-console\n console.warn(\n '[open-mercato] Deals map is using the public OpenStreetMap tile server. ' +\n \"OSM's tile usage policy prohibits production/commercial traffic \u2014 point \" +\n 'NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or commercial tile service before deploying.',\n )\n}\n\nconst WORLD_CENTER: L.LatLngTuple = [20, 0]\nconst WORLD_ZOOM = 2\nconst SINGLE_PIN_ZOOM = 12\nconst FIT_PADDING: L.PointTuple = [32, 32]\n\n// Saturated pin fill per stage tone \u2014 mirrors `Lane.tsx` ACCENT_TONE_CLASS so the same\n// stage reads as the same color on the kanban color bar and on the map pin.\nconst PIN_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-icon',\n error: 'bg-status-error-icon',\n warning: 'bg-status-warning-icon',\n info: 'bg-status-info-icon',\n neutral: 'bg-status-neutral-icon',\n brand: 'bg-brand-violet',\n pink: 'bg-status-pink-icon',\n}\n\n// Stage badge surface for the preview card \u2014 mirrors `Lane.tsx` COUNT_BADGE_TONE_CLASS.\nconst STAGE_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-bg text-status-success-text',\n error: 'bg-status-error-bg text-status-error-text',\n warning: 'bg-status-warning-bg text-status-warning-text',\n info: 'bg-status-info-bg text-status-info-text',\n neutral: 'bg-status-neutral-bg text-status-neutral-text',\n brand: 'bg-brand-violet/14 text-brand-violet',\n pink: 'bg-status-pink-bg text-status-pink-text',\n}\n\nfunction getPinToneClass(tone: FilterOptionTone | null): string {\n if (tone && tone in PIN_TONE_CLASS) return PIN_TONE_CLASS[tone]\n return 'bg-status-neutral-icon'\n}\n\nfunction getStageBadgeClass(tone: FilterOptionTone | null): string {\n if (tone && tone in STAGE_BADGE_TONE_CLASS) return STAGE_BADGE_TONE_CLASS[tone]\n return 'bg-muted text-muted-foreground'\n}\n\n// divIcon html is a raw string (no React) \u2014 only the server-issued deal id is interpolated,\n// never user-entered text, and styling comes exclusively from DS token classes.\nfunction buildMarkerIcon(dealId: string, tone: FilterOptionTone | null, selected: boolean): L.DivIcon {\n const sizePx = selected ? 20 : 14\n const dotClass = selected\n ? `block size-5 rounded-full border-2 border-card shadow-md ring-2 ring-ring ${getPinToneClass(tone)}`\n : `block size-3.5 rounded-full border-2 border-card shadow-md ${getPinToneClass(tone)}`\n return L.divIcon({\n className: `om-deal-map-marker${selected ? ' om-deal-map-marker--selected' : ''}`,\n html: `<span class=\"${dotClass}\" data-deal-id=\"${dealId}\"></span>`,\n iconSize: [sizePx, sizePx],\n iconAnchor: [sizePx / 2, sizePx / 2],\n })\n}\n\nfunction buildClusterIcon(count: number): L.DivIcon {\n return L.divIcon({\n className: 'om-deal-map-cluster',\n 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>`,\n iconSize: [36, 36],\n iconAnchor: [18, 18],\n })\n}\n\nconst shortDateFormatter = new Intl.DateTimeFormat(undefined, {\n month: 'short',\n day: '2-digit',\n})\n\nfunction formatShortDate(value: string | null): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return shortDateFormatter.format(date)\n}\n\ntype PreviewCardProps = {\n preview: DealsMapPreview\n onClose: () => void\n}\n\nfunction DealMapPreviewCard({ preview, onClose }: PreviewCardProps): React.ReactElement {\n const t = useT()\n const closeDate = formatShortDate(preview.expectedCloseAt)\n const metaParts: string[] = []\n if (typeof preview.valueAmount === 'number') {\n metaParts.push(formatCurrency(preview.valueAmount, preview.valueCurrency))\n }\n if (typeof preview.probability === 'number') {\n metaParts.push(\n translateWithFallback(t, 'customers.deals.map.preview.probabilityShort', '{value}%', {\n value: Math.min(Math.max(Math.round(preview.probability), 0), 100),\n }),\n )\n }\n if (closeDate) {\n metaParts.push(\n translateWithFallback(t, 'customers.deals.map.preview.closeShort', 'Close {date}', {\n date: closeDate,\n }),\n )\n }\n return (\n <div\n data-map-preview-card={preview.id}\n 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\"\n >\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex min-w-0 flex-col\">\n {preview.companyLabel ? (\n <span className=\"truncate text-sm font-medium leading-normal text-foreground\">\n {preview.companyLabel}\n </span>\n ) : null}\n {preview.locationLine ? (\n <span className=\"truncate text-xs leading-normal text-muted-foreground\">\n {preview.locationLine}\n </span>\n ) : null}\n </div>\n <IconButton\n variant=\"ghost\"\n size=\"sm\"\n type=\"button\"\n onClick={onClose}\n aria-label={translateWithFallback(t, 'customers.deals.map.preview.close', 'Close preview')}\n >\n <X className=\"size-4\" aria-hidden=\"true\" />\n </IconButton>\n </div>\n <h3 className=\"text-base font-semibold leading-normal text-foreground\">{preview.title}</h3>\n {metaParts.length > 0 ? (\n <p className=\"text-sm leading-normal text-muted-foreground\">{metaParts.join(' \u00B7 ')}</p>\n ) : null}\n {preview.stageLabel || preview.ownerName ? (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {preview.stageLabel ? (\n <span\n className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-normal ${getStageBadgeClass(preview.stageTone)}`}\n >\n {preview.stageLabel}\n </span>\n ) : null}\n {preview.ownerName ? (\n <span className=\"inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium leading-normal text-foreground\">\n {preview.ownerName}\n </span>\n ) : null}\n </div>\n ) : null}\n <Button asChild className=\"mt-1 w-full\">\n <Link href={`/backend/customers/deals/${preview.id}`}>\n {translateWithFallback(t, 'customers.deals.map.preview.openDeal', 'Open deal')}\n </Link>\n </Button>\n </div>\n )\n}\n\nexport default function DealsMapCanvasImpl({\n deals,\n legendStages,\n preview,\n selectedDealId,\n onSelect,\n onCenterChange,\n className,\n}: DealsMapCanvasProps): React.ReactElement {\n const t = useT()\n const containerRef = React.useRef<HTMLDivElement | null>(null)\n const mapRef = React.useRef<L.Map | null>(null)\n const clusterRef = React.useRef<L.MarkerClusterGroup | null>(null)\n const markersByIdRef = React.useRef(new Map<string, L.Marker>())\n const dealsByIdRef = React.useRef(new Map<string, DealsMapCanvasDeal>())\n const previousSelectedIdRef = React.useRef<string | null>(null)\n const lastFitSignatureRef = React.useRef<string | null>(null)\n const centerChangeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const onSelectRef = React.useRef(onSelect)\n onSelectRef.current = onSelect\n const onCenterChangeRef = React.useRef(onCenterChange)\n onCenterChangeRef.current = onCenterChange\n\n React.useEffect(() => {\n const node = containerRef.current\n if (!node || mapRef.current) return undefined\n warnOnPublicOsmTilesOnce()\n const map = L.map(node, { zoomControl: false, worldCopyJump: true })\n // Zoom sits top-LEFT so it never overlaps the selection preview card (top-right overlay).\n L.control.zoom({ position: 'topleft' }).addTo(map)\n L.tileLayer(TILE_URL, { attribution: TILE_ATTRIBUTION, maxZoom: 19 }).addTo(map)\n map.setView(WORLD_CENTER, WORLD_ZOOM)\n const cluster = leafletRuntime.markerClusterGroup({\n showCoverageOnHover: false,\n iconCreateFunction: (markerCluster) => buildClusterIcon(markerCluster.getChildCount()),\n })\n map.addLayer(cluster)\n // `moveend` fires on every pan/zoom-end and each one re-renders the view + re-runs the panel's\n // proximity (haversine) sort, so debounce the state update. Read the center synchronously here\n // while the map pane is guaranteed positioned \u2014 deferring getCenter() into the timeout can hit\n // Leaflet's `_leaflet_pos` on a pane that has since been reset (fitBounds) or torn down.\n map.on('moveend', () => {\n const center = map.getCenter()\n if (centerChangeTimerRef.current) clearTimeout(centerChangeTimerRef.current)\n centerChangeTimerRef.current = setTimeout(() => {\n onCenterChangeRef.current({ latitude: center.lat, longitude: center.lng })\n }, 250)\n })\n mapRef.current = map\n clusterRef.current = cluster\n const resizeObserver = new ResizeObserver(() => {\n map.invalidateSize()\n })\n resizeObserver.observe(node)\n return () => {\n resizeObserver.disconnect()\n if (centerChangeTimerRef.current) {\n clearTimeout(centerChangeTimerRef.current)\n centerChangeTimerRef.current = null\n }\n // Cancel any in-flight pan/zoom/fly animation before teardown \u2014 otherwise its\n // CSS transitionend fires after the panes are gone and Leaflet reads `_leaflet_pos`\n // off an undefined map pane.\n map.stop()\n map.remove()\n mapRef.current = null\n clusterRef.current = null\n markersByIdRef.current.clear()\n dealsByIdRef.current.clear()\n previousSelectedIdRef.current = null\n lastFitSignatureRef.current = null\n }\n }, [])\n\n const dealsSignature = React.useMemo(() => deals.map((deal) => deal.id).join('|'), [deals])\n\n React.useEffect(() => {\n const map = mapRef.current\n const cluster = clusterRef.current\n if (!map || !cluster) return\n cluster.clearLayers()\n markersByIdRef.current.clear()\n dealsByIdRef.current.clear()\n const markers: L.Marker[] = []\n for (const deal of deals) {\n const marker = L.marker([deal.latitude, deal.longitude], {\n icon: buildMarkerIcon(deal.id, deal.tone, false),\n keyboard: false,\n })\n marker.on('click', () => onSelectRef.current(deal.id))\n markersByIdRef.current.set(deal.id, marker)\n dealsByIdRef.current.set(deal.id, deal)\n markers.push(marker)\n }\n cluster.addLayers(markers)\n if (lastFitSignatureRef.current !== dealsSignature) {\n lastFitSignatureRef.current = dealsSignature\n // Fit instantly (animate: false). The list pages in over several updates, so animated\n // re-fits would stack overlapping zoom transitions and race Leaflet's pane bookkeeping.\n map.stop()\n if (deals.length === 0) {\n map.setView(WORLD_CENTER, WORLD_ZOOM, { animate: false })\n } else if (deals.length === 1) {\n map.setView([deals[0].latitude, deals[0].longitude], SINGLE_PIN_ZOOM, { animate: false })\n } else {\n const bounds = L.latLngBounds(\n deals.map((deal) => [deal.latitude, deal.longitude] as L.LatLngTuple),\n )\n map.fitBounds(bounds, { padding: FIT_PADDING, animate: false })\n }\n }\n }, [deals, dealsSignature])\n\n React.useEffect(() => {\n const map = mapRef.current\n const cluster = clusterRef.current\n if (!map || !cluster) return\n const previousId = previousSelectedIdRef.current\n const selectionChanged = previousId !== selectedDealId\n if (previousId && previousId !== selectedDealId) {\n const previousMarker = markersByIdRef.current.get(previousId)\n const previousDeal = dealsByIdRef.current.get(previousId)\n if (previousMarker && previousDeal) {\n previousMarker.setIcon(buildMarkerIcon(previousDeal.id, previousDeal.tone, false))\n }\n }\n previousSelectedIdRef.current = selectedDealId\n if (!selectedDealId) return\n const marker = markersByIdRef.current.get(selectedDealId)\n const deal = dealsByIdRef.current.get(selectedDealId)\n if (!marker || !deal) return\n marker.setIcon(buildMarkerIcon(deal.id, deal.tone, true))\n // Only move the camera when the selection itself changed. This effect also depends on\n // `deals` so the selected marker's icon is re-applied after a marker rebuild \u2014 but a deal-set\n // change alone (e.g. a sibling query resolving) must not yank the viewport back to the pin.\n if (!selectionChanged) return\n map.stop()\n const visibleParent = cluster.getVisibleParent(marker)\n if (visibleParent && visibleParent !== marker) {\n cluster.zoomToShowLayer(marker, () => {})\n } else {\n map.flyTo(marker.getLatLng(), Math.max(map.getZoom(), SINGLE_PIN_ZOOM))\n }\n }, [selectedDealId, deals])\n\n // Escape clears the selected-deal preview. Bound at the document level (not the canvas region's\n // onKeyDown) so it fires regardless of whether focus is on the map, a panel card, or the preview\n // card. Registered only while a deal is selected, so it never swallows Escape elsewhere.\n React.useEffect(() => {\n if (!selectedDealId) return undefined\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return\n onSelectRef.current(null)\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [selectedDealId])\n\n return (\n <div\n role=\"region\"\n aria-label={translateWithFallback(t, 'customers.deals.map.canvas.label', 'Deals map')}\n className={`relative ${className ?? ''}`.trim()}\n >\n <div ref={containerRef} data-map-canvas className=\"absolute inset-0 z-0\" />\n {deals.length === 0 ? (\n <div className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6\">\n <div className=\"max-w-sm rounded-xl border border-border bg-card/95 p-4 text-center shadow-sm\">\n <p className=\"text-sm font-medium leading-normal text-foreground\">\n {translateWithFallback(t, 'customers.deals.map.panel.empty.title', 'No deals on the map yet')}\n </p>\n <p className=\"mt-1 text-xs leading-normal text-muted-foreground\">\n {translateWithFallback(\n t,\n 'customers.deals.map.panel.empty.description',\n 'Add latitude and longitude to company or person addresses to plot their deals here.',\n )}\n </p>\n </div>\n </div>\n ) : null}\n {legendStages.length > 0 ? (\n <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\">\n <span className=\"text-overline font-bold uppercase leading-normal text-muted-foreground\">\n {translateWithFallback(t, 'customers.deals.map.legend.title', 'Stages')}\n </span>\n {legendStages.map((stage) => (\n <span key={stage.id} className=\"flex items-center gap-2 text-xs leading-normal text-foreground\">\n <span\n className={`size-2.5 shrink-0 rounded-full ${getPinToneClass(stage.tone)}`}\n aria-hidden=\"true\"\n />\n {stage.label}\n </span>\n ))}\n </div>\n ) : null}\n {preview ? <DealMapPreviewCard preview={preview} onClose={() => onSelectRef.current(null)} /> : null}\n </div>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -24,12 +24,14 @@ function PortalSignupPage({ params }) {
24
24
  const [email, setEmail] = useState("");
25
25
  const [password, setPassword] = useState("");
26
26
  const [error, setError] = useState(null);
27
+ const [fieldErrors, setFieldErrors] = useState({});
27
28
  const [success, setSuccess] = useState(false);
28
29
  const [submitting, setSubmitting] = useState(false);
29
30
  const handleSubmit = useCallback(
30
31
  async (event) => {
31
32
  event.preventDefault();
32
33
  setError(null);
34
+ setFieldErrors({});
33
35
  if (!tenant.organizationId) {
34
36
  setError(t("portal.org.invalid", "Organization not found."));
35
37
  return;
@@ -45,7 +47,27 @@ function PortalSignupPage({ params }) {
45
47
  setSuccess(true);
46
48
  return;
47
49
  }
48
- setError(result.result?.error || t("portal.signup.error.generic", "Signup failed. Please try again."));
50
+ const details = result.result?.details;
51
+ const detailEntries = details ? Object.entries(details).filter(([, messages]) => (messages?.length ?? 0) > 0) : [];
52
+ if (detailEntries.length > 0) {
53
+ const mapped = {};
54
+ for (const [fieldKey] of detailEntries) {
55
+ if (fieldKey === "displayName") {
56
+ mapped.displayName = t("portal.signup.error.displayName.required", "Full name is required.");
57
+ } else if (fieldKey === "email") {
58
+ mapped.email = t("portal.signup.error.email.invalid", "Please enter a valid email address.");
59
+ } else if (fieldKey === "password") {
60
+ mapped.password = t("portal.signup.error.password.minLength", "Password must be at least 8 characters.");
61
+ }
62
+ }
63
+ const hasUnmappedField = detailEntries.some(([fieldKey]) => !(fieldKey in mapped));
64
+ setFieldErrors(mapped);
65
+ if (hasUnmappedField || Object.keys(mapped).length === 0) {
66
+ setError(result.result?.error || t("portal.signup.error.generic", "Signup failed. Please try again."));
67
+ }
68
+ } else {
69
+ setError(result.result?.error || t("portal.signup.error.generic", "Signup failed. Please try again."));
70
+ }
49
71
  } catch {
50
72
  setError(t("portal.signup.error.generic", "Signup failed. Please try again."));
51
73
  } finally {
@@ -93,15 +115,18 @@ function PortalSignupPage({ params }) {
93
115
  error ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null,
94
116
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
95
117
  /* @__PURE__ */ jsx(Label, { htmlFor: "signup-name", className: "text-overline font-semibold uppercase tracking-wider text-muted-foreground/70", children: t("portal.signup.displayName", "Full Name") }),
96
- /* @__PURE__ */ jsx(Input, { id: "signup-name", type: "text", autoComplete: "name", required: true, placeholder: t("portal.signup.displayName.placeholder", "Jane Smith"), value: displayName, onChange: (e) => setDisplayName(e.target.value), disabled: submitting, className: "rounded-lg" })
118
+ /* @__PURE__ */ jsx(Input, { id: "signup-name", type: "text", autoComplete: "name", required: true, placeholder: t("portal.signup.displayName.placeholder", "Jane Smith"), value: displayName, onChange: (e) => setDisplayName(e.target.value), disabled: submitting, "aria-invalid": fieldErrors.displayName ? true : void 0, "aria-describedby": fieldErrors.displayName ? "signup-name-error" : void 0, className: "rounded-lg" }),
119
+ fieldErrors.displayName && /* @__PURE__ */ jsx("p", { id: "signup-name-error", role: "alert", className: "text-sm text-destructive", children: fieldErrors.displayName })
97
120
  ] }),
98
121
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
99
122
  /* @__PURE__ */ jsx(Label, { htmlFor: "signup-email", className: "text-overline font-semibold uppercase tracking-wider text-muted-foreground/70", children: t("portal.signup.email", "Email") }),
100
- /* @__PURE__ */ jsx(EmailInput, { id: "signup-email", required: true, placeholder: t("portal.signup.email.placeholder", "you@example.com"), value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting, className: "rounded-lg" })
123
+ /* @__PURE__ */ jsx(EmailInput, { id: "signup-email", required: true, placeholder: t("portal.signup.email.placeholder", "you@example.com"), value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting, "aria-invalid": fieldErrors.email ? true : void 0, "aria-describedby": fieldErrors.email ? "signup-email-error" : void 0, className: "rounded-lg" }),
124
+ fieldErrors.email && /* @__PURE__ */ jsx("p", { id: "signup-email-error", role: "alert", className: "text-sm text-destructive", children: fieldErrors.email })
101
125
  ] }),
102
126
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
103
127
  /* @__PURE__ */ jsx(Label, { htmlFor: "signup-password", className: "text-overline font-semibold uppercase tracking-wider text-muted-foreground/70", children: t("portal.signup.password", "Password") }),
104
- /* @__PURE__ */ jsx(PasswordInput, { id: "signup-password", autoComplete: "new-password", required: true, placeholder: t("portal.signup.password.placeholder", "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"), value: password, onChange: (e) => setPassword(e.target.value), disabled: submitting, className: "rounded-lg" })
128
+ /* @__PURE__ */ jsx(PasswordInput, { id: "signup-password", autoComplete: "new-password", required: true, placeholder: t("portal.signup.password.placeholder", "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"), value: password, onChange: (e) => setPassword(e.target.value), disabled: submitting, "aria-invalid": fieldErrors.password ? true : void 0, "aria-describedby": fieldErrors.password ? "signup-password-error" : void 0, className: "rounded-lg" }),
129
+ fieldErrors.password && /* @__PURE__ */ jsx("p", { id: "signup-password-error", role: "alert", className: "text-sm text-destructive", children: fieldErrors.password })
105
130
  ] }),
106
131
  /* @__PURE__ */ jsx(Button, { type: "submit", disabled: submitting, className: "mt-1 w-full rounded-lg", children: submitting ? t("portal.signup.submitting", "Creating account...") : t("portal.signup.submit", "Create Account") }),
107
132
  /* @__PURE__ */ jsxs("p", { className: "text-center text-sm text-muted-foreground", children: [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/portal/frontend/%5BorgSlug%5D/portal/signup/page.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport { useCallback, useMemo, useState } from 'react'\nimport Link from 'next/link'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { PasswordInput } from '@open-mercato/ui/primitives/password-input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport { SearchX, Check } from 'lucide-react'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { usePortalContext } from '@open-mercato/ui/portal/PortalContext'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { PortalInjectionSpots } from '@open-mercato/ui/backend/injection/spotIds'\n\ntype Props = { params: { orgSlug: string } }\ntype SignupResponse = { ok: boolean; error?: string }\n\nexport default function PortalSignupPage({ params }: Props) {\n const t = useT()\n const orgSlug = params.orgSlug\n const { tenant } = usePortalContext()\n\n const [displayName, setDisplayName] = useState('')\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [success, setSuccess] = useState(false)\n const [submitting, setSubmitting] = useState(false)\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent) => {\n event.preventDefault()\n setError(null)\n\n if (!tenant.organizationId) {\n setError(t('portal.org.invalid', 'Organization not found.'))\n return\n }\n\n setSubmitting(true)\n try {\n const result = await apiCall<SignupResponse>('/api/customer_accounts/signup', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, displayName, organizationId: tenant.organizationId }),\n })\n\n if (result.status === 202 && result.result?.ok) {\n setSuccess(true)\n return\n }\n\n setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))\n } catch {\n setError(t('portal.signup.error.generic', 'Signup failed. Please try again.'))\n } finally {\n setSubmitting(false)\n }\n },\n [displayName, email, password, tenant.organizationId, t],\n )\n\n const injectionContext = useMemo(\n () => ({ orgSlug }),\n [orgSlug],\n )\n\n if (tenant.loading) {\n return <div className=\"flex items-center justify-center py-20\"><Spinner /></div>\n }\n\n if (tenant.error) {\n return (\n <div className=\"mx-auto w-full max-w-md py-12\">\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<SearchX className=\"h-6 w-6\" aria-hidden />}\n title={t('portal.org.invalid', 'Organization not found.')}\n />\n </div>\n )\n }\n\n if (success) {\n return (\n <div className=\"mx-auto w-full max-w-sm text-center\">\n <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-foreground text-background\">\n <Check className=\"size-6\" />\n </div>\n <h1 className=\"text-2xl font-bold tracking-tight\">{t('portal.signup.success.title', 'Check your email')}</h1>\n <p className=\"mt-1.5 text-sm text-muted-foreground\">{t(\n 'portal.signup.success.description',\n 'If your registration was accepted, check your email for next steps before signing in. Some organizations require an administrator to activate new accounts.',\n )}</p>\n <Button asChild className=\"mt-6 w-full rounded-lg\">\n <Link href={`/${orgSlug}/portal/login`}>{t('portal.signup.success.loginLink', 'Sign In')}</Link>\n </Button>\n </div>\n )\n }\n\n return (\n <div className=\"mx-auto w-full max-w-sm\">\n <div className=\"mb-8 text-center\">\n <h1 className=\"text-2xl font-bold tracking-tight\">{t('portal.signup.title', 'Create Account')}</h1>\n <p className=\"mt-1.5 text-sm text-muted-foreground\">{t('portal.signup.description', 'Sign up for a portal account.')}</p>\n </div>\n\n <InjectionSpot spotId={PortalInjectionSpots.pageBefore('signup')} context={injectionContext} />\n\n <form onSubmit={handleSubmit} className=\"flex flex-col gap-4\">\n {error ? (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n ) : null}\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-name\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.displayName', 'Full Name')}</Label>\n <Input id=\"signup-name\" type=\"text\" autoComplete=\"name\" required placeholder={t('portal.signup.displayName.placeholder', 'Jane Smith')} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={submitting} className=\"rounded-lg\" />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-email\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.email', 'Email')}</Label>\n <EmailInput id=\"signup-email\" required placeholder={t('portal.signup.email.placeholder', 'you@example.com')} value={email} onChange={(e) => setEmail(e.target.value)} disabled={submitting} className=\"rounded-lg\" />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-password\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.password', 'Password')}</Label>\n <PasswordInput id=\"signup-password\" autoComplete=\"new-password\" required placeholder={t('portal.signup.password.placeholder', '\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022')} value={password} onChange={(e) => setPassword(e.target.value)} disabled={submitting} className=\"rounded-lg\" />\n </div>\n\n <Button type=\"submit\" disabled={submitting} className=\"mt-1 w-full rounded-lg\">\n {submitting ? t('portal.signup.submitting', 'Creating account...') : t('portal.signup.submit', 'Create Account')}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t('portal.signup.hasAccount', 'Already have an account?')}{' '}\n <Link href={`/${orgSlug}/portal/login`} className=\"font-medium text-foreground underline underline-offset-4 hover:opacity-80\">\n {t('portal.signup.loginLink', 'Sign in')}\n </Link>\n </p>\n </form>\n\n <InjectionSpot spotId={PortalInjectionSpots.pageAfter('signup')} context={injectionContext} />\n </div>\n )\n}\n"],
5
- "mappings": ";AAwEmE,cAkB7D,YAlB6D;AAvEnE,SAAS,aAAa,SAAS,gBAAgB;AAC/C,OAAO,UAAU;AACjB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,OAAO,wBAAwB;AACxC,SAAS,kBAAkB;AAC3B,SAAS,SAAS,aAAa;AAC/B,SAAS,eAAe;AACxB,SAAS,eAAe;AACxB,SAAS,wBAAwB;AACjC,SAAS,qBAAqB;AAC9B,SAAS,4BAA4B;AAKtB,SAAR,iBAAkC,EAAE,OAAO,GAAU;AAC1D,QAAM,IAAI,KAAK;AACf,QAAM,UAAU,OAAO;AACvB,QAAM,EAAE,OAAO,IAAI,iBAAiB;AAEpC,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,EAAE;AACjD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,EAAE;AAC3C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAC5C,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,KAAK;AAElD,QAAM,eAAe;AAAA,IACnB,OAAO,UAA2B;AAChC,YAAM,eAAe;AACrB,eAAS,IAAI;AAEb,UAAI,CAAC,OAAO,gBAAgB;AAC1B,iBAAS,EAAE,sBAAsB,yBAAyB,CAAC;AAC3D;AAAA,MACF;AAEA,oBAAc,IAAI;AAClB,UAAI;AACF,cAAM,SAAS,MAAM,QAAwB,iCAAiC;AAAA,UAC5E,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,OAAO,UAAU,aAAa,gBAAgB,OAAO,eAAe,CAAC;AAAA,QAC9F,CAAC;AAED,YAAI,OAAO,WAAW,OAAO,OAAO,QAAQ,IAAI;AAC9C,qBAAW,IAAI;AACf;AAAA,QACF;AAEA,iBAAS,OAAO,QAAQ,SAAS,EAAE,+BAA+B,kCAAkC,CAAC;AAAA,MACvG,QAAQ;AACN,iBAAS,EAAE,+BAA+B,kCAAkC,CAAC;AAAA,MAC/E,UAAE;AACA,sBAAc,KAAK;AAAA,MACrB;AAAA,IACF;AAAA,IACA,CAAC,aAAa,OAAO,UAAU,OAAO,gBAAgB,CAAC;AAAA,EACzD;AAEA,QAAM,mBAAmB;AAAA,IACvB,OAAO,EAAE,QAAQ;AAAA,IACjB,CAAC,OAAO;AAAA,EACV;AAEA,MAAI,OAAO,SAAS;AAClB,WAAO,oBAAC,SAAI,WAAU,0CAAyC,8BAAC,WAAQ,GAAE;AAAA,EAC5E;AAEA,MAAI,OAAO,OAAO;AAChB,WACE,oBAAC,SAAI,WAAU,iCACb;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,MAAM,oBAAC,WAAQ,WAAU,WAAU,eAAW,MAAC;AAAA,QAC/C,OAAO,EAAE,sBAAsB,yBAAyB;AAAA;AAAA,IAC1D,GACF;AAAA,EAEJ;AAEA,MAAI,SAAS;AACX,WACE,qBAAC,SAAI,WAAU,uCACb;AAAA,0BAAC,SAAI,WAAU,oGACb,8BAAC,SAAM,WAAU,UAAS,GAC5B;AAAA,MACA,oBAAC,QAAG,WAAU,qCAAqC,YAAE,+BAA+B,kBAAkB,GAAE;AAAA,MACxG,oBAAC,OAAE,WAAU,wCAAwC;AAAA,QACnD;AAAA,QACA;AAAA,MACF,GAAE;AAAA,MACF,oBAAC,UAAO,SAAO,MAAC,WAAU,0BACxB,8BAAC,QAAK,MAAM,IAAI,OAAO,iBAAkB,YAAE,mCAAmC,SAAS,GAAE,GAC3F;AAAA,OACF;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,2BACb;AAAA,yBAAC,SAAI,WAAU,oBACb;AAAA,0BAAC,QAAG,WAAU,qCAAqC,YAAE,uBAAuB,gBAAgB,GAAE;AAAA,MAC9F,oBAAC,OAAE,WAAU,wCAAwC,YAAE,6BAA6B,+BAA+B,GAAE;AAAA,OACvH;AAAA,IAEA,oBAAC,iBAAc,QAAQ,qBAAqB,WAAW,QAAQ,GAAG,SAAS,kBAAkB;AAAA,IAE7F,qBAAC,UAAK,UAAU,cAAc,WAAU,uBACrC;AAAA,cACC,oBAAC,SAAM,SAAQ,eACb,8BAAC,oBAAkB,iBAAM,GAC3B,IACE;AAAA,MAEJ,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,SAAM,SAAQ,eAAc,WAAU,iFAAiF,YAAE,6BAA6B,WAAW,GAAE;AAAA,QACpK,oBAAC,SAAM,IAAG,eAAc,MAAK,QAAO,cAAa,QAAO,UAAQ,MAAC,aAAa,EAAE,yCAAyC,YAAY,GAAG,OAAO,aAAa,UAAU,CAAC,MAAM,eAAe,EAAE,OAAO,KAAK,GAAG,UAAU,YAAY,WAAU,cAAa;AAAA,SAC5P;AAAA,MAEA,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,SAAM,SAAQ,gBAAe,WAAU,iFAAiF,YAAE,uBAAuB,OAAO,GAAE;AAAA,QAC3J,oBAAC,cAAW,IAAG,gBAAe,UAAQ,MAAC,aAAa,EAAE,mCAAmC,iBAAiB,GAAG,OAAO,OAAO,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK,GAAG,UAAU,YAAY,WAAU,cAAa;AAAA,SACrN;AAAA,MAEA,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,SAAM,SAAQ,mBAAkB,WAAU,iFAAiF,YAAE,0BAA0B,UAAU,GAAE;AAAA,QACpK,oBAAC,iBAAc,IAAG,mBAAkB,cAAa,gBAAe,UAAQ,MAAC,aAAa,EAAE,sCAAsC,kDAAkD,GAAG,OAAO,UAAU,UAAU,CAAC,MAAM,YAAY,EAAE,OAAO,KAAK,GAAG,UAAU,YAAY,WAAU,cAAa;AAAA,SACjS;AAAA,MAEA,oBAAC,UAAO,MAAK,UAAS,UAAU,YAAY,WAAU,0BACnD,uBAAa,EAAE,4BAA4B,qBAAqB,IAAI,EAAE,wBAAwB,gBAAgB,GACjH;AAAA,MAEA,qBAAC,OAAE,WAAU,6CACV;AAAA,UAAE,4BAA4B,0BAA0B;AAAA,QAAG;AAAA,QAC5D,oBAAC,QAAK,MAAM,IAAI,OAAO,iBAAiB,WAAU,6EAC/C,YAAE,2BAA2B,SAAS,GACzC;AAAA,SACF;AAAA,OACF;AAAA,IAEA,oBAAC,iBAAc,QAAQ,qBAAqB,UAAU,QAAQ,GAAG,SAAS,kBAAkB;AAAA,KAC9F;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\nimport { useCallback, useMemo, useState } from 'react'\nimport Link from 'next/link'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { PasswordInput } from '@open-mercato/ui/primitives/password-input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport { SearchX, Check } from 'lucide-react'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { usePortalContext } from '@open-mercato/ui/portal/PortalContext'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { PortalInjectionSpots } from '@open-mercato/ui/backend/injection/spotIds'\n\ntype Props = { params: { orgSlug: string } }\ntype SignupResponse = { ok: boolean; error?: string; details?: Record<string, string[]> }\n\nexport default function PortalSignupPage({ params }: Props) {\n const t = useT()\n const orgSlug = params.orgSlug\n const { tenant } = usePortalContext()\n\n const [displayName, setDisplayName] = useState('')\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})\n const [success, setSuccess] = useState(false)\n const [submitting, setSubmitting] = useState(false)\n\n const handleSubmit = useCallback(\n async (event: React.FormEvent) => {\n event.preventDefault()\n setError(null)\n setFieldErrors({})\n\n if (!tenant.organizationId) {\n setError(t('portal.org.invalid', 'Organization not found.'))\n return\n }\n\n setSubmitting(true)\n try {\n const result = await apiCall<SignupResponse>('/api/customer_accounts/signup', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, displayName, organizationId: tenant.organizationId }),\n })\n\n if (result.status === 202 && result.result?.ok) {\n setSuccess(true)\n return\n }\n\n const details = result.result?.details\n const detailEntries = details ? Object.entries(details).filter(([, messages]) => (messages?.length ?? 0) > 0) : []\n if (detailEntries.length > 0) {\n // API detail values are raw untranslated validator strings; each known field maps to one translated message instead.\n const mapped: Record<string, string> = {}\n for (const [fieldKey] of detailEntries) {\n if (fieldKey === 'displayName') {\n mapped.displayName = t('portal.signup.error.displayName.required', 'Full name is required.')\n } else if (fieldKey === 'email') {\n mapped.email = t('portal.signup.error.email.invalid', 'Please enter a valid email address.')\n } else if (fieldKey === 'password') {\n mapped.password = t('portal.signup.error.password.minLength', 'Password must be at least 8 characters.')\n }\n }\n const hasUnmappedField = detailEntries.some(([fieldKey]) => !(fieldKey in mapped))\n setFieldErrors(mapped)\n if (hasUnmappedField || Object.keys(mapped).length === 0) {\n setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))\n }\n } else {\n setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))\n }\n } catch {\n setError(t('portal.signup.error.generic', 'Signup failed. Please try again.'))\n } finally {\n setSubmitting(false)\n }\n },\n [displayName, email, password, tenant.organizationId, t],\n )\n\n const injectionContext = useMemo(\n () => ({ orgSlug }),\n [orgSlug],\n )\n\n if (tenant.loading) {\n return <div className=\"flex items-center justify-center py-20\"><Spinner /></div>\n }\n\n if (tenant.error) {\n return (\n <div className=\"mx-auto w-full max-w-md py-12\">\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<SearchX className=\"h-6 w-6\" aria-hidden />}\n title={t('portal.org.invalid', 'Organization not found.')}\n />\n </div>\n )\n }\n\n if (success) {\n return (\n <div className=\"mx-auto w-full max-w-sm text-center\">\n <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-foreground text-background\">\n <Check className=\"size-6\" />\n </div>\n <h1 className=\"text-2xl font-bold tracking-tight\">{t('portal.signup.success.title', 'Check your email')}</h1>\n <p className=\"mt-1.5 text-sm text-muted-foreground\">{t(\n 'portal.signup.success.description',\n 'If your registration was accepted, check your email for next steps before signing in. Some organizations require an administrator to activate new accounts.',\n )}</p>\n <Button asChild className=\"mt-6 w-full rounded-lg\">\n <Link href={`/${orgSlug}/portal/login`}>{t('portal.signup.success.loginLink', 'Sign In')}</Link>\n </Button>\n </div>\n )\n }\n\n return (\n <div className=\"mx-auto w-full max-w-sm\">\n <div className=\"mb-8 text-center\">\n <h1 className=\"text-2xl font-bold tracking-tight\">{t('portal.signup.title', 'Create Account')}</h1>\n <p className=\"mt-1.5 text-sm text-muted-foreground\">{t('portal.signup.description', 'Sign up for a portal account.')}</p>\n </div>\n\n <InjectionSpot spotId={PortalInjectionSpots.pageBefore('signup')} context={injectionContext} />\n\n <form onSubmit={handleSubmit} className=\"flex flex-col gap-4\">\n {error ? (\n <Alert variant=\"destructive\">\n <AlertDescription>{error}</AlertDescription>\n </Alert>\n ) : null}\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-name\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.displayName', 'Full Name')}</Label>\n <Input id=\"signup-name\" type=\"text\" autoComplete=\"name\" required placeholder={t('portal.signup.displayName.placeholder', 'Jane Smith')} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.displayName ? true : undefined} aria-describedby={fieldErrors.displayName ? 'signup-name-error' : undefined} className=\"rounded-lg\" />\n {fieldErrors.displayName && <p id=\"signup-name-error\" role=\"alert\" className=\"text-sm text-destructive\">{fieldErrors.displayName}</p>}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-email\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.email', 'Email')}</Label>\n <EmailInput id=\"signup-email\" required placeholder={t('portal.signup.email.placeholder', 'you@example.com')} value={email} onChange={(e) => setEmail(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.email ? true : undefined} aria-describedby={fieldErrors.email ? 'signup-email-error' : undefined} className=\"rounded-lg\" />\n {fieldErrors.email && <p id=\"signup-email-error\" role=\"alert\" className=\"text-sm text-destructive\">{fieldErrors.email}</p>}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"signup-password\" className=\"text-overline font-semibold uppercase tracking-wider text-muted-foreground/70\">{t('portal.signup.password', 'Password')}</Label>\n <PasswordInput id=\"signup-password\" autoComplete=\"new-password\" required placeholder={t('portal.signup.password.placeholder', '\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022')} value={password} onChange={(e) => setPassword(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.password ? true : undefined} aria-describedby={fieldErrors.password ? 'signup-password-error' : undefined} className=\"rounded-lg\" />\n {fieldErrors.password && <p id=\"signup-password-error\" role=\"alert\" className=\"text-sm text-destructive\">{fieldErrors.password}</p>}\n </div>\n\n <Button type=\"submit\" disabled={submitting} className=\"mt-1 w-full rounded-lg\">\n {submitting ? t('portal.signup.submitting', 'Creating account...') : t('portal.signup.submit', 'Create Account')}\n </Button>\n\n <p className=\"text-center text-sm text-muted-foreground\">\n {t('portal.signup.hasAccount', 'Already have an account?')}{' '}\n <Link href={`/${orgSlug}/portal/login`} className=\"font-medium text-foreground underline underline-offset-4 hover:opacity-80\">\n {t('portal.signup.loginLink', 'Sign in')}\n </Link>\n </p>\n </form>\n\n <InjectionSpot spotId={PortalInjectionSpots.pageAfter('signup')} context={injectionContext} />\n </div>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6407.1.9a4c26cf6a",
3
+ "version": "0.6.6-develop.6409.1.4bfed821c0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -231,6 +231,11 @@
231
231
  "@mikro-orm/core": "^7.1.4",
232
232
  "@mikro-orm/decorators": "^7.1.4",
233
233
  "@mikro-orm/postgresql": "^7.1.4",
234
+ "@types/html-to-text": "^9.0.4",
235
+ "@types/leaflet": "^1.9.12",
236
+ "@types/leaflet.markercluster": "^1.5.5",
237
+ "@types/sanitize-html": "^2.13.0",
238
+ "@types/semver": "^7.5.8",
234
239
  "@xyflow/react": "^12.11.0",
235
240
  "ai": "^6.0.208",
236
241
  "date-fns": "4.4.0",
@@ -248,28 +253,23 @@
248
253
  "zod": "^4.4.3"
249
254
  },
250
255
  "peerDependencies": {
251
- "@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
252
- "@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
253
- "@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6409.1.4bfed821c0",
257
+ "@open-mercato/shared": "0.6.6-develop.6409.1.4bfed821c0",
258
+ "@open-mercato/ui": "0.6.6-develop.6409.1.4bfed821c0",
254
259
  "react": "^19.0.0",
255
260
  "react-dom": "^19.0.0"
256
261
  },
257
262
  "devDependencies": {
258
- "@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
259
- "@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
260
- "@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
263
+ "@open-mercato/ai-assistant": "0.6.6-develop.6409.1.4bfed821c0",
264
+ "@open-mercato/shared": "0.6.6-develop.6409.1.4bfed821c0",
265
+ "@open-mercato/ui": "0.6.6-develop.6409.1.4bfed821c0",
261
266
  "@testing-library/dom": "^10.4.1",
262
267
  "@testing-library/jest-dom": "^6.9.1",
263
268
  "@testing-library/react": "^16.3.1",
264
269
  "@types/chance": "^1.1.8",
265
- "@types/html-to-text": "^9.0.4",
266
270
  "@types/jest": "^30.0.0",
267
- "@types/leaflet": "^1.9.12",
268
- "@types/leaflet.markercluster": "^1.5.5",
269
271
  "@types/react": "^19.2.17",
270
272
  "@types/react-dom": "^19.2.3",
271
- "@types/sanitize-html": "^2.13.0",
272
- "@types/semver": "^7.5.8",
273
273
  "chance": "^1.1.13",
274
274
  "jest": "^30.4.2",
275
275
  "jest-environment-jsdom": "^30.4.1",
@@ -29,8 +29,18 @@ const TILE_ATTRIBUTION =
29
29
  '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
30
30
  // True whenever the EFFECTIVE tile URL targets OSM's public CDN — whether from the bundled default
31
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')
32
+ // prohibits production/commercial traffic against it. Match on the parsed hostname rather than a raw
33
+ // substring so a lookalike host (e.g. `tile.openstreetmap.org.example.com`) is not mistaken for OSM.
34
+ function targetsPublicOsmTileHost(tileUrl: string): boolean {
35
+ try {
36
+ const { hostname } = new URL(tileUrl)
37
+ return hostname === 'openstreetmap.org' || hostname.endsWith('.openstreetmap.org')
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ const USING_PUBLIC_OSM_TILES = targetsPublicOsmTileHost(TILE_URL)
34
44
 
35
45
  let publicOsmTileWarningEmitted = false
36
46
  // Warn once (per session) so deployments point NEXT_PUBLIC_OM_DEALS_MAP_TILE_URL at a self-hosted or
@@ -17,7 +17,7 @@ import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
17
17
  import { PortalInjectionSpots } from '@open-mercato/ui/backend/injection/spotIds'
18
18
 
19
19
  type Props = { params: { orgSlug: string } }
20
- type SignupResponse = { ok: boolean; error?: string }
20
+ type SignupResponse = { ok: boolean; error?: string; details?: Record<string, string[]> }
21
21
 
22
22
  export default function PortalSignupPage({ params }: Props) {
23
23
  const t = useT()
@@ -28,6 +28,7 @@ export default function PortalSignupPage({ params }: Props) {
28
28
  const [email, setEmail] = useState('')
29
29
  const [password, setPassword] = useState('')
30
30
  const [error, setError] = useState<string | null>(null)
31
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
31
32
  const [success, setSuccess] = useState(false)
32
33
  const [submitting, setSubmitting] = useState(false)
33
34
 
@@ -35,6 +36,7 @@ export default function PortalSignupPage({ params }: Props) {
35
36
  async (event: React.FormEvent) => {
36
37
  event.preventDefault()
37
38
  setError(null)
39
+ setFieldErrors({})
38
40
 
39
41
  if (!tenant.organizationId) {
40
42
  setError(t('portal.org.invalid', 'Organization not found.'))
@@ -54,7 +56,28 @@ export default function PortalSignupPage({ params }: Props) {
54
56
  return
55
57
  }
56
58
 
57
- setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))
59
+ const details = result.result?.details
60
+ const detailEntries = details ? Object.entries(details).filter(([, messages]) => (messages?.length ?? 0) > 0) : []
61
+ if (detailEntries.length > 0) {
62
+ // API detail values are raw untranslated validator strings; each known field maps to one translated message instead.
63
+ const mapped: Record<string, string> = {}
64
+ for (const [fieldKey] of detailEntries) {
65
+ if (fieldKey === 'displayName') {
66
+ mapped.displayName = t('portal.signup.error.displayName.required', 'Full name is required.')
67
+ } else if (fieldKey === 'email') {
68
+ mapped.email = t('portal.signup.error.email.invalid', 'Please enter a valid email address.')
69
+ } else if (fieldKey === 'password') {
70
+ mapped.password = t('portal.signup.error.password.minLength', 'Password must be at least 8 characters.')
71
+ }
72
+ }
73
+ const hasUnmappedField = detailEntries.some(([fieldKey]) => !(fieldKey in mapped))
74
+ setFieldErrors(mapped)
75
+ if (hasUnmappedField || Object.keys(mapped).length === 0) {
76
+ setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))
77
+ }
78
+ } else {
79
+ setError(result.result?.error || t('portal.signup.error.generic', 'Signup failed. Please try again.'))
80
+ }
58
81
  } catch {
59
82
  setError(t('portal.signup.error.generic', 'Signup failed. Please try again.'))
60
83
  } finally {
@@ -122,17 +145,20 @@ export default function PortalSignupPage({ params }: Props) {
122
145
 
123
146
  <div className="flex flex-col gap-1.5">
124
147
  <Label htmlFor="signup-name" className="text-overline font-semibold uppercase tracking-wider text-muted-foreground/70">{t('portal.signup.displayName', 'Full Name')}</Label>
125
- <Input id="signup-name" type="text" autoComplete="name" required placeholder={t('portal.signup.displayName.placeholder', 'Jane Smith')} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={submitting} className="rounded-lg" />
148
+ <Input id="signup-name" type="text" autoComplete="name" required placeholder={t('portal.signup.displayName.placeholder', 'Jane Smith')} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.displayName ? true : undefined} aria-describedby={fieldErrors.displayName ? 'signup-name-error' : undefined} className="rounded-lg" />
149
+ {fieldErrors.displayName && <p id="signup-name-error" role="alert" className="text-sm text-destructive">{fieldErrors.displayName}</p>}
126
150
  </div>
127
151
 
128
152
  <div className="flex flex-col gap-1.5">
129
153
  <Label htmlFor="signup-email" className="text-overline font-semibold uppercase tracking-wider text-muted-foreground/70">{t('portal.signup.email', 'Email')}</Label>
130
- <EmailInput id="signup-email" required placeholder={t('portal.signup.email.placeholder', 'you@example.com')} value={email} onChange={(e) => setEmail(e.target.value)} disabled={submitting} className="rounded-lg" />
154
+ <EmailInput id="signup-email" required placeholder={t('portal.signup.email.placeholder', 'you@example.com')} value={email} onChange={(e) => setEmail(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.email ? true : undefined} aria-describedby={fieldErrors.email ? 'signup-email-error' : undefined} className="rounded-lg" />
155
+ {fieldErrors.email && <p id="signup-email-error" role="alert" className="text-sm text-destructive">{fieldErrors.email}</p>}
131
156
  </div>
132
157
 
133
158
  <div className="flex flex-col gap-1.5">
134
159
  <Label htmlFor="signup-password" className="text-overline font-semibold uppercase tracking-wider text-muted-foreground/70">{t('portal.signup.password', 'Password')}</Label>
135
- <PasswordInput id="signup-password" autoComplete="new-password" required placeholder={t('portal.signup.password.placeholder', '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022')} value={password} onChange={(e) => setPassword(e.target.value)} disabled={submitting} className="rounded-lg" />
160
+ <PasswordInput id="signup-password" autoComplete="new-password" required placeholder={t('portal.signup.password.placeholder', '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022')} value={password} onChange={(e) => setPassword(e.target.value)} disabled={submitting} aria-invalid={fieldErrors.password ? true : undefined} aria-describedby={fieldErrors.password ? 'signup-password-error' : undefined} className="rounded-lg" />
161
+ {fieldErrors.password && <p id="signup-password-error" role="alert" className="text-sm text-destructive">{fieldErrors.password}</p>}
136
162
  </div>
137
163
 
138
164
  <Button type="submit" disabled={submitting} className="mt-1 w-full rounded-lg">
@@ -98,7 +98,10 @@
98
98
  "portal.signup.displayName.placeholder": "Ihr Name",
99
99
  "portal.signup.email": "E-Mail",
100
100
  "portal.signup.email.placeholder": "sie@example.com",
101
+ "portal.signup.error.displayName.required": "Der vollständige Name ist erforderlich.",
102
+ "portal.signup.error.email.invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
101
103
  "portal.signup.error.generic": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
104
+ "portal.signup.error.password.minLength": "Das Passwort muss mindestens 8 Zeichen lang sein.",
102
105
  "portal.signup.hasAccount": "Bereits ein Konto?",
103
106
  "portal.signup.loginLink": "Anmelden",
104
107
  "portal.signup.password": "Passwort",
@@ -98,7 +98,10 @@
98
98
  "portal.signup.displayName.placeholder": "Your name",
99
99
  "portal.signup.email": "Email",
100
100
  "portal.signup.email.placeholder": "you@example.com",
101
+ "portal.signup.error.displayName.required": "Full name is required.",
102
+ "portal.signup.error.email.invalid": "Please enter a valid email address.",
101
103
  "portal.signup.error.generic": "Registration failed. Please try again.",
104
+ "portal.signup.error.password.minLength": "Password must be at least 8 characters.",
102
105
  "portal.signup.hasAccount": "Already have an account?",
103
106
  "portal.signup.loginLink": "Log in",
104
107
  "portal.signup.password": "Password",
@@ -98,7 +98,10 @@
98
98
  "portal.signup.displayName.placeholder": "Tu nombre",
99
99
  "portal.signup.email": "Correo electrónico",
100
100
  "portal.signup.email.placeholder": "tu@ejemplo.com",
101
+ "portal.signup.error.displayName.required": "El nombre completo es obligatorio.",
102
+ "portal.signup.error.email.invalid": "Introduce una dirección de correo electrónico válida.",
101
103
  "portal.signup.error.generic": "Error en el registro. Inténtalo de nuevo.",
104
+ "portal.signup.error.password.minLength": "La contraseña debe tener al menos 8 caracteres.",
102
105
  "portal.signup.hasAccount": "¿Ya tienes cuenta?",
103
106
  "portal.signup.loginLink": "Inicia sesión",
104
107
  "portal.signup.password": "Contraseña",
@@ -98,7 +98,10 @@
98
98
  "portal.signup.displayName.placeholder": "Twoje imię",
99
99
  "portal.signup.email": "Email",
100
100
  "portal.signup.email.placeholder": "ty@example.com",
101
+ "portal.signup.error.displayName.required": "Imię i nazwisko jest wymagane.",
102
+ "portal.signup.error.email.invalid": "Podaj prawidłowy adres e-mail.",
101
103
  "portal.signup.error.generic": "Rejestracja nie powiodła się. Spróbuj ponownie.",
104
+ "portal.signup.error.password.minLength": "Hasło musi mieć co najmniej 8 znaków.",
102
105
  "portal.signup.hasAccount": "Masz już konto?",
103
106
  "portal.signup.loginLink": "Zaloguj się",
104
107
  "portal.signup.password": "Hasło",