@open-mercato/core 0.6.6-develop.6406.1.a0bd770daf → 0.6.6-develop.6408.1.0cbfbd7476
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/api/settings/route.js +142 -0
- package/dist/modules/catalog/api/settings/route.js.map +7 -0
- package/dist/modules/catalog/backend/config/catalog/page.js +6 -2
- package/dist/modules/catalog/backend/config/catalog/page.js.map +2 -2
- package/dist/modules/catalog/components/UnitPriceDisplaySettings.js +89 -0
- package/dist/modules/catalog/components/UnitPriceDisplaySettings.js.map +7 -0
- package/dist/modules/catalog/components/products/ProductUomSection.js +4 -2
- package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
- package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js +25 -0
- package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js.map +7 -0
- package/dist/modules/catalog/lib/settings.js +9 -0
- package/dist/modules/catalog/lib/settings.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +9 -1
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +2 -2
- package/package.json +12 -12
- package/src/modules/catalog/api/settings/route.ts +165 -0
- package/src/modules/catalog/backend/config/catalog/page.tsx +2 -0
- package/src/modules/catalog/components/UnitPriceDisplaySettings.tsx +106 -0
- package/src/modules/catalog/components/products/ProductUomSection.tsx +4 -0
- package/src/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.ts +39 -0
- package/src/modules/catalog/i18n/de.json +8 -0
- package/src/modules/catalog/i18n/en.json +8 -0
- package/src/modules/catalog/i18n/es.json +8 -0
- package/src/modules/catalog/i18n/pl.json +8 -0
- package/src/modules/catalog/lib/settings.ts +8 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +12 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/catalog/lib/settings.ts"],
|
|
4
|
+
"sourcesContent": ["export const CATALOG_SETTINGS_MODULE_ID = 'catalog'\n\n// Controls whether the EU unit-price presentation feature is exposed at all.\n// Default ON so existing tenants keep the current behavior; manufacturers or\n// other non-retail tenants can turn it off to remove the settings from the\n// product form entirely.\nexport const UNIT_PRICE_DISPLAY_ENABLED_KEY = 'unit_price_display_enabled'\nexport const UNIT_PRICE_DISPLAY_ENABLED_DEFAULT = true\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,6BAA6B;AAMnC,MAAM,iCAAiC;AACvC,MAAM,qCAAqC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -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 ?? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
19
|
-
|
|
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;
|
package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map
CHANGED
|
@@ -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 '© <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": ";
|
|
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 '© <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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
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.
|
|
252
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
253
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
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.
|
|
259
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
260
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
263
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
264
|
+
"@open-mercato/shared": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
265
|
+
"@open-mercato/ui": "0.6.6-develop.6408.1.0cbfbd7476",
|
|
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",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
|
+
import {
|
|
7
|
+
runCrudMutationGuardAfterSuccess,
|
|
8
|
+
validateCrudMutationGuard,
|
|
9
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
10
|
+
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
11
|
+
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
12
|
+
import {
|
|
13
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
14
|
+
UNIT_PRICE_DISPLAY_ENABLED_DEFAULT,
|
|
15
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
16
|
+
} from '../../lib/settings'
|
|
17
|
+
|
|
18
|
+
export const metadata = {
|
|
19
|
+
GET: { requireAuth: true, requireFeatures: ['catalog.products.view'] },
|
|
20
|
+
PUT: { requireAuth: true, requireFeatures: ['catalog.settings.manage'] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bodySchema = z.object({
|
|
24
|
+
unitPriceDisplayEnabled: z.boolean(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const responseSchema = z.object({
|
|
28
|
+
unitPriceDisplayEnabled: z.boolean(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
type SettingsContext = {
|
|
32
|
+
container: Awaited<ReturnType<typeof createRequestContainer>>
|
|
33
|
+
auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>
|
|
34
|
+
tenantId: string
|
|
35
|
+
organizationId: string | null
|
|
36
|
+
actorId: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveSettingsContext(req: Request): Promise<SettingsContext> {
|
|
40
|
+
const container = await createRequestContainer()
|
|
41
|
+
const auth = await getAuthFromRequest(req)
|
|
42
|
+
if (!auth || !auth.tenantId) {
|
|
43
|
+
throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
44
|
+
}
|
|
45
|
+
const actorId =
|
|
46
|
+
(typeof auth.sub === 'string' && auth.sub.trim().length > 0 && auth.sub) ||
|
|
47
|
+
(typeof auth.userId === 'string' && auth.userId.trim().length > 0 && auth.userId) ||
|
|
48
|
+
(typeof auth.keyId === 'string' && auth.keyId.trim().length > 0 && auth.keyId) ||
|
|
49
|
+
'system'
|
|
50
|
+
return {
|
|
51
|
+
container,
|
|
52
|
+
auth,
|
|
53
|
+
tenantId: auth.tenantId,
|
|
54
|
+
organizationId: auth.orgId ?? null,
|
|
55
|
+
actorId,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readUnitPriceDisplayEnabled(context: SettingsContext): Promise<boolean> {
|
|
60
|
+
const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
|
|
61
|
+
const value = await configService.getValue<boolean>(
|
|
62
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
63
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
64
|
+
{ defaultValue: UNIT_PRICE_DISPLAY_ENABLED_DEFAULT, scope: { tenantId: context.tenantId } },
|
|
65
|
+
)
|
|
66
|
+
return typeof value === 'boolean' ? value : UNIT_PRICE_DISPLAY_ENABLED_DEFAULT
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function GET(req: Request) {
|
|
70
|
+
try {
|
|
71
|
+
const context = await resolveSettingsContext(req)
|
|
72
|
+
const unitPriceDisplayEnabled = await readUnitPriceDisplayEnabled(context)
|
|
73
|
+
return NextResponse.json({ unitPriceDisplayEnabled })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (isCrudHttpError(err)) {
|
|
76
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
77
|
+
}
|
|
78
|
+
console.error('[catalog/settings.GET] Unexpected error', err)
|
|
79
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function PUT(req: Request) {
|
|
84
|
+
try {
|
|
85
|
+
const context = await resolveSettingsContext(req)
|
|
86
|
+
const body = bodySchema.parse(await req.json())
|
|
87
|
+
|
|
88
|
+
const guardResult = await validateCrudMutationGuard(context.container, {
|
|
89
|
+
tenantId: context.tenantId,
|
|
90
|
+
organizationId: context.organizationId,
|
|
91
|
+
userId: context.actorId,
|
|
92
|
+
resourceKind: 'catalog.settings',
|
|
93
|
+
resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
94
|
+
operation: 'custom',
|
|
95
|
+
requestMethod: req.method,
|
|
96
|
+
requestHeaders: req.headers,
|
|
97
|
+
mutationPayload: { unitPriceDisplayEnabled: body.unitPriceDisplayEnabled },
|
|
98
|
+
})
|
|
99
|
+
if (guardResult && !guardResult.ok) {
|
|
100
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
|
|
104
|
+
await configService.setValue(
|
|
105
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
106
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
107
|
+
body.unitPriceDisplayEnabled,
|
|
108
|
+
{ tenantId: context.tenantId },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
112
|
+
await runCrudMutationGuardAfterSuccess(context.container, {
|
|
113
|
+
tenantId: context.tenantId,
|
|
114
|
+
organizationId: context.organizationId,
|
|
115
|
+
userId: context.actorId,
|
|
116
|
+
resourceKind: 'catalog.settings',
|
|
117
|
+
resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
118
|
+
operation: 'custom',
|
|
119
|
+
requestMethod: req.method,
|
|
120
|
+
requestHeaders: req.headers,
|
|
121
|
+
metadata: guardResult.metadata ?? null,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return NextResponse.json({ unitPriceDisplayEnabled: body.unitPriceDisplayEnabled })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (isCrudHttpError(err)) {
|
|
128
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
129
|
+
}
|
|
130
|
+
if (err instanceof z.ZodError) {
|
|
131
|
+
return NextResponse.json({ error: 'Invalid request', details: err.issues }, { status: 400 })
|
|
132
|
+
}
|
|
133
|
+
console.error('[catalog/settings.PUT] Unexpected error', err)
|
|
134
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const getDoc: OpenApiMethodDoc = {
|
|
139
|
+
summary: 'Read catalog settings',
|
|
140
|
+
tags: ['Catalog'],
|
|
141
|
+
responses: [
|
|
142
|
+
{ status: 200, description: 'Catalog settings', schema: responseSchema },
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const putDoc: OpenApiMethodDoc = {
|
|
147
|
+
summary: 'Update catalog settings',
|
|
148
|
+
tags: ['Catalog'],
|
|
149
|
+
requestBody: { schema: bodySchema },
|
|
150
|
+
responses: [
|
|
151
|
+
{ status: 200, description: 'Updated catalog settings', schema: responseSchema },
|
|
152
|
+
],
|
|
153
|
+
errors: [{ status: 400, description: 'Invalid request body' }],
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const openApi: OpenApiRouteDoc = {
|
|
157
|
+
tag: 'Catalog',
|
|
158
|
+
summary: 'Catalog module settings',
|
|
159
|
+
methods: {
|
|
160
|
+
GET: getDoc,
|
|
161
|
+
PUT: putDoc,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { GET, PUT }
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
2
2
|
import { PriceKindSettings } from '../../../components/PriceKindSettings'
|
|
3
|
+
import { UnitPriceDisplaySettings } from '../../../components/UnitPriceDisplaySettings'
|
|
3
4
|
|
|
4
5
|
export default function CatalogConfigurationPage() {
|
|
5
6
|
return (
|
|
6
7
|
<Page>
|
|
7
8
|
<PageBody className="space-y-8">
|
|
8
9
|
<PriceKindSettings />
|
|
10
|
+
<UnitPriceDisplaySettings />
|
|
9
11
|
</PageBody>
|
|
10
12
|
</Page>
|
|
11
13
|
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
|
|
5
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
6
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
7
|
+
import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
|
+
import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
9
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
10
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
|
+
|
|
12
|
+
type CatalogSettingsResponse = {
|
|
13
|
+
unitPriceDisplayEnabled?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function UnitPriceDisplaySettings() {
|
|
17
|
+
const t = useT()
|
|
18
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
19
|
+
const [enabled, setEnabled] = React.useState<boolean | null>(null)
|
|
20
|
+
const [saving, setSaving] = React.useState(false)
|
|
21
|
+
|
|
22
|
+
const loadError = t('catalog.settings.unitPriceDisplay.errors.load', 'Failed to load catalog settings.')
|
|
23
|
+
|
|
24
|
+
const load = React.useCallback(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const payload = await readApiResultOrThrow<CatalogSettingsResponse>(
|
|
27
|
+
'/api/catalog/settings',
|
|
28
|
+
undefined,
|
|
29
|
+
{ errorMessage: loadError, fallback: { unitPriceDisplayEnabled: true } },
|
|
30
|
+
)
|
|
31
|
+
setEnabled(payload.unitPriceDisplayEnabled !== false)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('catalog.settings.load failed', err)
|
|
34
|
+
flash(loadError, 'error')
|
|
35
|
+
setEnabled(true)
|
|
36
|
+
}
|
|
37
|
+
}, [loadError])
|
|
38
|
+
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
load().catch(() => {})
|
|
41
|
+
}, [load, scopeVersion])
|
|
42
|
+
|
|
43
|
+
const handleChange = React.useCallback(async (next: boolean) => {
|
|
44
|
+
const previous = enabled
|
|
45
|
+
setEnabled(next)
|
|
46
|
+
setSaving(true)
|
|
47
|
+
try {
|
|
48
|
+
// optimistic-lock-exempt: tenant-scoped module-config toggle (single
|
|
49
|
+
// settings row via ModuleConfigService), not a versioned editable entity —
|
|
50
|
+
// no updated_at to lock against and no lost-update concern for this admin preference.
|
|
51
|
+
const call = await apiCall('/api/catalog/settings', {
|
|
52
|
+
method: 'PUT',
|
|
53
|
+
headers: { 'content-type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ unitPriceDisplayEnabled: next }),
|
|
55
|
+
})
|
|
56
|
+
if (!call.ok) {
|
|
57
|
+
await raiseCrudError(call.response, t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.'))
|
|
58
|
+
}
|
|
59
|
+
flash(t('catalog.settings.messages.saved', 'Settings saved.'), 'success')
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('catalog.settings.save failed', err)
|
|
62
|
+
setEnabled(previous)
|
|
63
|
+
const message = err instanceof Error
|
|
64
|
+
? err.message
|
|
65
|
+
: t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.')
|
|
66
|
+
flash(message, 'error')
|
|
67
|
+
} finally {
|
|
68
|
+
setSaving(false)
|
|
69
|
+
}
|
|
70
|
+
}, [enabled, t])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<section className="border bg-card text-card-foreground shadow-sm">
|
|
74
|
+
<div className="border-b px-6 py-4 space-y-1">
|
|
75
|
+
<h2 className="text-lg font-semibold">
|
|
76
|
+
{t('catalog.settings.unitPriceDisplay.title', 'Unit price presentation')}
|
|
77
|
+
</h2>
|
|
78
|
+
<p className="text-sm text-muted-foreground">
|
|
79
|
+
{t(
|
|
80
|
+
'catalog.settings.unitPriceDisplay.description',
|
|
81
|
+
'Controls whether the EU unit price presentation feature is available on the product form.',
|
|
82
|
+
)}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="px-6 py-4">
|
|
86
|
+
{enabled === null ? (
|
|
87
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
88
|
+
<Spinner className="h-4 w-4" />
|
|
89
|
+
{t('catalog.settings.loading', 'Loading…')}
|
|
90
|
+
</div>
|
|
91
|
+
) : (
|
|
92
|
+
<SwitchField
|
|
93
|
+
label={t('catalog.settings.unitPriceDisplay.toggleLabel', 'Enable EU unit price presentation')}
|
|
94
|
+
description={t(
|
|
95
|
+
'catalog.settings.unitPriceDisplay.toggleDescription',
|
|
96
|
+
'When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.',
|
|
97
|
+
)}
|
|
98
|
+
checked={enabled}
|
|
99
|
+
disabled={saving}
|
|
100
|
+
onCheckedChange={(next) => { void handleChange(next) }}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { createProductUnitConversionDraft } from "./productForm";
|
|
24
24
|
import { REFERENCE_UNIT_CODES } from "@open-mercato/shared/lib/units/unitCodes";
|
|
25
25
|
import { toTrimmedOrNull } from "./productFormUtils";
|
|
26
|
+
import { useUnitPriceDisplayEnabled } from "./hooks/useUnitPriceDisplayEnabled";
|
|
26
27
|
|
|
27
28
|
type UnitDictionaryEntry = {
|
|
28
29
|
id?: string;
|
|
@@ -134,6 +135,7 @@ export function ProductUomSection({
|
|
|
134
135
|
embedded = false,
|
|
135
136
|
}: ProductUomSectionProps) {
|
|
136
137
|
const t = useT();
|
|
138
|
+
const { enabled: unitPriceDisplayEnabled } = useUnitPriceDisplayEnabled();
|
|
137
139
|
const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
|
|
138
140
|
const [loadingUnits, setLoadingUnits] = React.useState(false);
|
|
139
141
|
const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
|
|
@@ -473,6 +475,7 @@ export function ProductUomSection({
|
|
|
473
475
|
</div>
|
|
474
476
|
</div>
|
|
475
477
|
|
|
478
|
+
{unitPriceDisplayEnabled ? (
|
|
476
479
|
<div className="space-y-3">
|
|
477
480
|
<div className="flex items-center gap-2">
|
|
478
481
|
<Checkbox
|
|
@@ -564,6 +567,7 @@ export function ProductUomSection({
|
|
|
564
567
|
</p>
|
|
565
568
|
) : null}
|
|
566
569
|
</div>
|
|
570
|
+
) : null}
|
|
567
571
|
|
|
568
572
|
<div className="space-y-3">
|
|
569
573
|
<div className="flex items-center justify-between gap-2">
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
5
|
+
|
|
6
|
+
type CatalogSettingsResponse = {
|
|
7
|
+
unitPriceDisplayEnabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type UseUnitPriceDisplayEnabledResult = {
|
|
11
|
+
enabled: boolean
|
|
12
|
+
isLoading: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the tenant-scoped catalog setting that controls whether the EU unit
|
|
17
|
+
* price presentation feature is available on the product form. Defaults to
|
|
18
|
+
* enabled while loading (and on failure) so the common retail case is never
|
|
19
|
+
* hidden by a transient error.
|
|
20
|
+
*/
|
|
21
|
+
export function useUnitPriceDisplayEnabled(): UseUnitPriceDisplayEnabledResult {
|
|
22
|
+
const query = useQuery({
|
|
23
|
+
queryKey: ['catalog', 'settings', 'unitPriceDisplayEnabled'],
|
|
24
|
+
queryFn: async () => {
|
|
25
|
+
const result = await readApiResultOrThrow<CatalogSettingsResponse>(
|
|
26
|
+
'/api/catalog/settings',
|
|
27
|
+
undefined,
|
|
28
|
+
{ errorMessage: 'Failed to load catalog settings.', fallback: { unitPriceDisplayEnabled: true } },
|
|
29
|
+
)
|
|
30
|
+
return result.unitPriceDisplayEnabled !== false
|
|
31
|
+
},
|
|
32
|
+
staleTime: 60_000,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
enabled: query.data ?? true,
|
|
37
|
+
isLoading: query.isLoading,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Aktion",
|
|
615
615
|
"catalog.search.status.inactive": "Inaktiv",
|
|
616
616
|
"catalog.search.variant.default": "Standard",
|
|
617
|
+
"catalog.settings.loading": "Wird geladen…",
|
|
618
|
+
"catalog.settings.messages.saved": "Einstellungen gespeichert.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Legt fest, ob die EU-Grundpreisdarstellung im Produktformular verfügbar ist.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "Katalogeinstellungen konnten nicht geladen werden.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "Katalogeinstellungen konnten nicht gespeichert werden.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Grundpreisdarstellung",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "Wenn deaktiviert, werden die EU-Grundpreiseinstellungen in jedem Produktformular ausgeblendet. Deaktivieren Sie dies für Hersteller oder andere Mandanten, die nicht an Verbraucher verkaufen.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "EU-Grundpreisdarstellung aktivieren",
|
|
617
625
|
"catalog.stats.active": "Aktiv",
|
|
618
626
|
"catalog.stats.categories": "Kategorien",
|
|
619
627
|
"catalog.stats.products": "Produkte",
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Promotion",
|
|
615
615
|
"catalog.search.status.inactive": "Inactive",
|
|
616
616
|
"catalog.search.variant.default": "Default",
|
|
617
|
+
"catalog.settings.loading": "Loading…",
|
|
618
|
+
"catalog.settings.messages.saved": "Settings saved.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Controls whether the EU unit price presentation feature is available on the product form.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "Failed to load catalog settings.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "Failed to save catalog settings.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Unit price presentation",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "Enable EU unit price presentation",
|
|
617
625
|
"catalog.stats.active": "Active",
|
|
618
626
|
"catalog.stats.categories": "Categories",
|
|
619
627
|
"catalog.stats.products": "Products",
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Promoción",
|
|
615
615
|
"catalog.search.status.inactive": "Inactivo",
|
|
616
616
|
"catalog.search.variant.default": "Predeterminado",
|
|
617
|
+
"catalog.settings.loading": "Cargando…",
|
|
618
|
+
"catalog.settings.messages.saved": "Ajustes guardados.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Controla si la función de presentación del precio unitario de la UE está disponible en el formulario de producto.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "No se pudieron cargar los ajustes del catálogo.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "No se pudieron guardar los ajustes del catálogo.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Presentación del precio unitario",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "Cuando está desactivado, los ajustes del precio unitario de la UE se ocultan en todos los formularios de producto. Desactívalo para fabricantes u otros inquilinos que no venden a consumidores.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "Activar la presentación del precio unitario de la UE",
|
|
617
625
|
"catalog.stats.active": "Activos",
|
|
618
626
|
"catalog.stats.categories": "Categorías",
|
|
619
627
|
"catalog.stats.products": "Productos",
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Promocja",
|
|
615
615
|
"catalog.search.status.inactive": "Nieaktywny",
|
|
616
616
|
"catalog.search.variant.default": "Domyślny",
|
|
617
|
+
"catalog.settings.loading": "Ładowanie…",
|
|
618
|
+
"catalog.settings.messages.saved": "Ustawienia zapisane.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Określa, czy funkcja prezentacji ceny jednostkowej UE jest dostępna w formularzu produktu.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "Nie udało się wczytać ustawień katalogu.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "Nie udało się zapisać ustawień katalogu.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Prezentacja ceny jednostkowej",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "Gdy wyłączone, ustawienia ceny jednostkowej UE są ukryte w każdym formularzu produktu. Wyłącz tę opcję dla firm produkcyjnych lub innych najemców, którzy nie sprzedają konsumentom.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "Włącz prezentację ceny jednostkowej UE",
|
|
617
625
|
"catalog.stats.active": "Aktywne",
|
|
618
626
|
"catalog.stats.categories": "Kategorie",
|
|
619
627
|
"catalog.stats.products": "Produkty",
|