create-mercato-app 0.6.2-develop.3406.1.2b403f40da → 0.6.2-develop.3446.1.bd060c6017

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.2-develop.3406.1.2b403f40da",
3
+ "version": "0.6.2-develop.3446.1.bd060c6017",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -88,6 +88,13 @@ NEXT_PUBLIC_UMES_DEVTOOLS=true
88
88
  # Enables checkout summary/help wrappers used by integration tests only.
89
89
  NEXT_PUBLIC_OM_EXAMPLE_CHECKOUT_TEST_INJECTIONS_ENABLED=false
90
90
 
91
+ # Public date/time display format overrides (date-fns tokens).
92
+ # Leave unset, or set to "system", to use the locale-derived default.
93
+ # Examples: NEXT_PUBLIC_OM_DATE_FORMAT=dd.MM.yyyy
94
+ # NEXT_PUBLIC_OM_DATE_TIME_FORMAT=dd.MM.yyyy HH:mm
95
+ # NEXT_PUBLIC_OM_DATE_FORMAT=
96
+ # NEXT_PUBLIC_OM_DATE_TIME_FORMAT=
97
+
91
98
  # Mock payment gateway webhook secret used by the example mock provider.
92
99
  # Required for webhook verification in standalone integration and local test flows.
93
100
  # MOCK_GATEWAY_WEBHOOK_SECRET=open-mercato-mock-dev-webhook-secret
@@ -78,16 +78,18 @@
78
78
  "@uiw/react-markdown-preview": "^5.2.0",
79
79
  "@uiw/react-md-editor": "^4.1.0",
80
80
  "@xyflow/react": "^12.6.0",
81
- "ai": "^6.0.168",
81
+ "ai": "^6.0.177",
82
82
  "awilix": "^12.0.5",
83
83
  "bcryptjs": "^3.0.3",
84
84
  "class-variance-authority": "^0.7.1",
85
85
  "clsx": "^2.1.1",
86
+ "date-fns": "4.1.0",
87
+ "date-fns-tz": "^3.2.0",
86
88
  "dotenv": "^17.4.2",
87
89
  "lucide-react": "^1.8.0",
88
90
  "mammoth": "^1.9.0",
89
91
  "newrelic": "^13.19.1",
90
- "next": "16.2.4",
92
+ "next": "16.2.6",
91
93
  "pg": "8.20.0",
92
94
  "pdfjs-dist": "^5.4.149",
93
95
  "react": "19.2.5",
@@ -97,11 +99,11 @@
97
99
  "reflect-metadata": "^0.2.2",
98
100
  "remark-gfm": "^4.0.1",
99
101
  "resend": "^6.12.0",
100
- "sanitize-html": "^2.17.2",
102
+ "sanitize-html": "^2.17.4",
101
103
  "semver": "^7.7.4",
102
104
  "tailwind-merge": "^3.5.0",
103
105
  "zod": "4.3.6",
104
- "@stripe/react-stripe-js": "^6.2.0",
106
+ "@stripe/react-stripe-js": "^6.3.0",
105
107
  "@stripe/stripe-js": "^9.3.1",
106
108
  "@open-mercato/gateway-stripe": "{{PACKAGE_VERSION}}",
107
109
  "@open-mercato/sync-akeneo": "{{PACKAGE_VERSION}}"
@@ -76,9 +76,9 @@ const {
76
76
  stripAnsi,
77
77
  wrapListLines,
78
78
  } = await import(resolveSplashHelpersImport())
79
- const { resolveSpawnCommand } = await import(resolveSpawnUtilsImport())
79
+ const { resolveProjectBinary, resolveSpawnCommand } = await import(resolveSpawnUtilsImport())
80
80
 
81
- const command = process.platform === 'win32' ? 'mercato.cmd' : 'mercato'
81
+ const command = resolveProjectBinary(process.platform === 'win32' ? 'mercato.cmd' : 'mercato')
82
82
  const classic = process.argv.includes('--classic') || isEnabledEnvFlag(process.env.OM_DEV_CLASSIC)
83
83
  const verbose = !classic && (process.argv.includes('--verbose') || process.env.MERCATO_DEV_OUTPUT === 'verbose')
84
84
  const rawPassthrough = classic || verbose
@@ -506,10 +506,10 @@ function spawnMercato(args) {
506
506
  return child
507
507
  }
508
508
 
509
- function waitForExit(child) {
509
+ function waitForExit(child, label = 'Child process') {
510
510
  return new Promise((resolve) => {
511
511
  child.on('exit', (code, signal) => {
512
- resolve({ code, signal })
512
+ resolve({ label, code, signal })
513
513
  })
514
514
  })
515
515
  }
@@ -535,6 +535,32 @@ function resolveChildExitCode(result, fallback = 1) {
535
535
  return fallback
536
536
  }
537
537
 
538
+ function formatChildExitStatus(result) {
539
+ if (typeof result?.code === 'number') {
540
+ return `exit code ${result.code}`
541
+ }
542
+ if (result?.signal) {
543
+ return `signal ${result.signal}`
544
+ }
545
+ return 'an unknown status'
546
+ }
547
+
548
+ function resolveUnexpectedExitCode(result) {
549
+ const exitCode = resolveChildExitCode(result, 1)
550
+ return exitCode === 0 ? 1 : exitCode
551
+ }
552
+
553
+ function reportUnexpectedChildExit(result) {
554
+ const message = `❌ ${result?.label ?? 'Child process'} exited unexpectedly with ${formatChildExitStatus(result)}`
555
+ console.error(message)
556
+ rememberRawLog(message)
557
+ publishRuntimeFailure(message, {
558
+ progressCurrent: splashState.progressCurrent >= runtimeProgressCurrent ? splashState.progressCurrent : runtimeProgressCurrent,
559
+ progressLabel: splashState.progressLabel || startupProgress.label,
560
+ failureLines: [...collectRuntimeFailureLines(), message].slice(-10),
561
+ })
562
+ }
563
+
538
564
  function joinBaseUrl(baseUrl, pathname) {
539
565
  return `${String(baseUrl ?? '').replace(/\/$/, '')}${pathname}`
540
566
  }
@@ -1660,15 +1686,16 @@ async function runClassicRuntime() {
1660
1686
 
1661
1687
  const watch = spawnMercato(['generate', 'watch', '--skip-initial'])
1662
1688
  const server = spawnMercato(['server', 'dev'])
1663
- const result = await Promise.race([waitForExit(watch), waitForExit(server)])
1689
+ const result = await Promise.race([
1690
+ waitForExit(watch, 'Generator watch'),
1691
+ waitForExit(server, 'App runtime'),
1692
+ ])
1664
1693
  if (isGracefulShutdownResult(result)) {
1665
1694
  return
1666
1695
  }
1667
1696
 
1668
- // Unexpected child exit MUST surface as non-zero even if the child reported
1669
- // code 0 — hiding a broken runtime as success masks failures from scripts/CI.
1670
- const childCode = resolveChildExitCode(result, 1)
1671
- shutdown(childCode === 0 ? 1 : childCode)
1697
+ reportUnexpectedChildExit(result)
1698
+ shutdown(resolveUnexpectedExitCode(result))
1672
1699
  }
1673
1700
 
1674
1701
  if (classic) {
@@ -1683,10 +1710,11 @@ printRuntimePackagesSummary()
1683
1710
  const watch = startFilteredChild(['generate', 'watch', '--skip-initial'], 'Generator watch', classifyWatchLine)
1684
1711
  const server = startFilteredChild(['server', 'dev'], 'App runtime', classifyServerLine)
1685
1712
 
1686
- const result = await Promise.race([waitForExit(watch), waitForExit(server)])
1713
+ const result = await Promise.race([
1714
+ waitForExit(watch, 'Generator watch'),
1715
+ waitForExit(server, 'App runtime'),
1716
+ ])
1687
1717
  if (!isGracefulShutdownResult(result)) {
1688
- // Unexpected child exit MUST surface as non-zero even if the child reported
1689
- // code 0 — hiding a broken runtime as success masks failures from scripts/CI.
1690
- const childCode = resolveChildExitCode(result, 1)
1691
- shutdown(childCode === 0 ? 1 : childCode)
1718
+ reportUnexpectedChildExit(result)
1719
+ shutdown(resolveUnexpectedExitCode(result))
1692
1720
  }
@@ -9,11 +9,6 @@ const backendRoutes = [
9
9
  ]
10
10
 
11
11
  const mockRegisterBackendRouteManifests = jest.fn()
12
- const mockParseBooleanWithDefault = jest.fn(() => false)
13
- const mockCookies = jest.fn()
14
- const mockHeaders = jest.fn()
15
- const mockResolveTranslations = jest.fn()
16
- const mockGetAuthFromCookies = jest.fn()
17
12
 
18
13
  jest.mock('@/.mercato/generated/backend-routes.generated', () => ({
19
14
  backendRoutes,
@@ -25,12 +20,12 @@ jest.mock('@open-mercato/shared/modules/registry', () => ({
25
20
  }))
26
21
 
27
22
  jest.mock('next/headers', () => ({
28
- cookies: (...args: unknown[]) => mockCookies(...args),
29
- headers: (...args: unknown[]) => mockHeaders(...args),
23
+ cookies: jest.fn(),
24
+ headers: jest.fn(),
30
25
  }))
31
26
 
32
27
  jest.mock('@open-mercato/shared/lib/auth/server', () => ({
33
- getAuthFromCookies: (...args: unknown[]) => mockGetAuthFromCookies(...args),
28
+ getAuthFromCookies: jest.fn(),
34
29
  }))
35
30
 
36
31
  jest.mock('@open-mercato/ui/backend/AppShell', () => ({
@@ -38,7 +33,7 @@ jest.mock('@open-mercato/ui/backend/AppShell', () => ({
38
33
  }))
39
34
 
40
35
  jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
41
- resolveTranslations: (...args: unknown[]) => mockResolveTranslations(...args),
36
+ resolveTranslations: jest.fn(),
42
37
  }))
43
38
 
44
39
  jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
@@ -54,7 +49,7 @@ jest.mock('@open-mercato/shared/lib/version', () => ({
54
49
  }))
55
50
 
56
51
  jest.mock('@open-mercato/shared/lib/boolean', () => ({
57
- parseBooleanWithDefault: (...args: unknown[]) => mockParseBooleanWithDefault(...args),
52
+ parseBooleanWithDefault: jest.fn(() => true),
58
53
  }))
59
54
 
60
55
  jest.mock('@open-mercato/ui/backend/injection/PageInjectionBoundary', () => ({
@@ -78,11 +73,6 @@ describe('Backend layout route registry', () => {
78
73
  beforeEach(() => {
79
74
  jest.resetModules()
80
75
  mockRegisterBackendRouteManifests.mockClear()
81
- mockParseBooleanWithDefault.mockClear()
82
- mockCookies.mockReset()
83
- mockHeaders.mockReset()
84
- mockResolveTranslations.mockReset()
85
- mockGetAuthFromCookies.mockReset()
86
76
  })
87
77
 
88
78
  it('registers backend route manifests at module load', async () => {
@@ -92,35 +82,4 @@ describe('Backend layout route registry', () => {
92
82
 
93
83
  expect(mockRegisterBackendRouteManifests).toHaveBeenCalledWith(backendRoutes)
94
84
  })
95
-
96
- it('defaults the demo mode flag to false when DEMO_MODE is unset', async () => {
97
- const originalDemoMode = process.env.DEMO_MODE
98
- delete process.env.DEMO_MODE
99
- mockGetAuthFromCookies.mockResolvedValue(null)
100
- mockCookies.mockResolvedValue({ get: () => undefined })
101
- mockHeaders.mockResolvedValue({ get: () => null })
102
- mockResolveTranslations.mockResolvedValue({
103
- translate: (_key: string, fallback: string) => fallback,
104
- locale: 'en',
105
- dict: {},
106
- })
107
-
108
- try {
109
- await jest.isolateModulesAsync(async () => {
110
- const { default: BackendLayout } = await import('../layout')
111
- await BackendLayout({
112
- children: React.createElement('div'),
113
- params: Promise.resolve({}),
114
- })
115
- })
116
-
117
- expect(mockParseBooleanWithDefault).toHaveBeenCalledWith(undefined, false)
118
- } finally {
119
- if (originalDemoMode === undefined) {
120
- delete process.env.DEMO_MODE
121
- } else {
122
- process.env.DEMO_MODE = originalDemoMode
123
- }
124
- }
125
- })
126
85
  })
@@ -80,7 +80,7 @@ export default async function BackendLayout({
80
80
 
81
81
  const collapsedCookie = cookieStore.get('om_sidebar_collapsed')?.value
82
82
  const initialCollapsed = collapsedCookie === '1'
83
- const demoModeEnabled = parseBooleanWithDefault(process.env.DEMO_MODE, false)
83
+ const demoModeEnabled = parseBooleanWithDefault(process.env.DEMO_MODE, true)
84
84
  const deployEnv = process.env.DEPLOY_ENV
85
85
  const baseProductName = translate('appShell.productName', 'Open Mercato')
86
86
  const productName = deployEnv && deployEnv !== 'local'
@@ -97,18 +97,4 @@ describe('RootLayout', () => {
97
97
  expect(appProviders?.props.demoModeEnabled).toBe(false)
98
98
  expect(appProviders?.props.noticeBarsEnabled).toBe(true)
99
99
  })
100
-
101
- it('defaults demo mode off when DEMO_MODE is unset', async () => {
102
- delete process.env.DEMO_MODE
103
- delete process.env.OM_INTEGRATION_TEST
104
-
105
- const { default: RootLayout } = await import('../layout')
106
- const { AppProviders } = await import('@/components/AppProviders')
107
- const tree = await RootLayout({ children: 'child' })
108
- const appProviders = findElementByType(tree, AppProviders)
109
-
110
- expect(appProviders).not.toBeNull()
111
- expect(appProviders?.props.demoModeEnabled).toBe(false)
112
- expect(appProviders?.props.noticeBarsEnabled).toBe(true)
113
- })
114
100
  })
@@ -244,36 +244,47 @@ TODO: Fix that latter to have reference by the package names
244
244
  --sidebar-border: oklch(0.922 0 0);
245
245
  --sidebar-ring: oklch(0.708 0 0);
246
246
 
247
- /* ═══ Design System: Semantic Status Tokens — Light Mode ═══ */
248
- /* Error (hue ~25° aligned with --destructive) */
249
- --status-error-bg: oklch(0.965 0.015 25);
250
- --status-error-text: oklch(0.365 0.120 25);
251
- --status-error-border: oklch(0.830 0.060 25);
252
- --status-error-icon: oklch(0.577 0.245 27.325); /* = --destructive */
253
-
254
- /* Success (hue ~160° — aligned with --chart-emerald) */
255
- --status-success-bg: oklch(0.965 0.015 160);
256
- --status-success-text: oklch(0.350 0.080 160);
257
- --status-success-border: oklch(0.830 0.050 160);
258
- --status-success-icon: oklch(0.596 0.145 163.225); /* = --chart-emerald */
259
-
260
- /* Warning (hue ~80° aligned with --chart-amber) */
261
- --status-warning-bg: oklch(0.970 0.020 80);
262
- --status-warning-text: oklch(0.370 0.090 60);
263
- --status-warning-border: oklch(0.830 0.070 80);
264
- --status-warning-icon: oklch(0.700 0.160 70);
265
-
266
- /* Info (hue ~277° indigo, bridges to brand violet) */
267
- --status-info-bg: oklch(0.962 0.018 272.314); /* indigo-50 #EEF2FF */
268
- --status-info-text: oklch(0.359 0.144 278.697); /* indigo-800 #3730A3 */
269
- --status-info-border: oklch(0.870 0.065 274.039);/* indigo-200 #C7D2FE */
270
- --status-info-icon: oklch(0.511 0.262 276.966); /* indigo-600 #4F46E5 = --chart-indigo */
271
-
272
- /* Neutral (achromatic aligned with --muted) */
273
- --status-neutral-bg: oklch(0.965 0 0);
274
- --status-neutral-text: oklch(0.445 0 0);
275
- --status-neutral-border: oklch(0.850 0 0);
276
- --status-neutral-icon: oklch(0.556 0 0); /* = --muted-foreground */
247
+ /* ═══ Design System: Semantic Status Tokens — Light Mode ═══
248
+ Token values match the Tailwind v4 default palette so the
249
+ Alert / Notification / Toast primitive (Figma `169:2358`) is
250
+ pixel-perfect against the Figma `state/{x}/{base,light,lighter}`
251
+ variables (`#dc2626` / `#fecaca` / `#fef2f2` for error etc.).
252
+ Variable token mapping:
253
+ state/{x}/base → status-{x}-icon (Tailwind 600)
254
+ state/{x}/light → status-{x}-border (Tailwind 200)
255
+ state/{x}/lighter → status-{x}-bg (Tailwind 50)
256
+ status-{x}-text uses Tailwind 900 for high contrast on the
257
+ light/lighter tints. */
258
+
259
+ /* Error — Tailwind red (Figma state/error/*) */
260
+ --status-error-bg: oklch(0.971 0.013 17.38); /* red-50 #fef2f2 */
261
+ --status-error-text: oklch(0.396 0.141 25.723); /* red-900 #7f1d1d */
262
+ --status-error-border: oklch(0.885 0.062 18.334);/* red-200 #fecaca */
263
+ --status-error-icon: oklch(0.577 0.245 27.325); /* red-600 #dc2626 = --destructive */
264
+
265
+ /* Success — Tailwind green (Figma state/success/*) */
266
+ --status-success-bg: oklch(0.982 0.018 155.826); /* green-50 #f0fdf4 */
267
+ --status-success-text: oklch(0.393 0.095 152.535); /* green-900 #14532d */
268
+ --status-success-border: oklch(0.925 0.084 155.995);/* green-200 #bbf7d0 */
269
+ --status-success-icon: oklch(0.627 0.194 149.214); /* green-600 #16a34a */
270
+
271
+ /* Warning — Tailwind amber (Figma state/warning/*) */
272
+ --status-warning-bg: oklch(0.987 0.022 95.277); /* amber-50 #fffbeb */
273
+ --status-warning-text: oklch(0.414 0.112 45.904); /* amber-900 #78350f */
274
+ --status-warning-border: oklch(0.924 0.12 95.746); /* amber-200 #fde68a */
275
+ --status-warning-icon: oklch(0.666 0.179 58.318); /* amber-600 #d97706 */
276
+
277
+ /* Info — Tailwind indigo (Figma state/information/*) */
278
+ --status-info-bg: oklch(0.962 0.018 272.314); /* indigo-50 #eef2ff */
279
+ --status-info-text: oklch(0.359 0.144 278.697); /* indigo-900 #312e81 */
280
+ --status-info-border: oklch(0.870 0.065 274.039);/* indigo-200 #c7d2fe */
281
+ --status-info-icon: oklch(0.511 0.262 276.966); /* indigo-600 #4f46e5 = --chart-indigo */
282
+
283
+ /* Neutral — Tailwind neutral (Figma state/faded/*) */
284
+ --status-neutral-bg: oklch(0.97 0 0); /* neutral-100 #f5f5f5 */
285
+ --status-neutral-text: oklch(0.371 0 0); /* neutral-700 #404040 */
286
+ --status-neutral-border: oklch(0.922 0 0);/* neutral-200 #e5e5e5 (≈ Figma #ebebeb) */
287
+ --status-neutral-icon: oklch(0.556 0 0); /* neutral-500 #737373 (≈ Figma #7b7b7b) */
277
288
 
278
289
  /* Pink (hue ~340° — Figma SPEC-048 ACCENT / PINK Stage badges; Tailwind pink-500/-200/-700/-50) */
279
290
  --status-pink-bg: oklch(0.965 0.020 340); /* pink-50 #FDF2F8 */
@@ -358,36 +369,41 @@ TODO: Fix that latter to have reference by the package names
358
369
  --sidebar-border: oklch(1 0 0 / 10%);
359
370
  --sidebar-ring: oklch(0.556 0 0);
360
371
 
361
- /* ═══ Design System: Semantic Status Tokens — Dark Mode ═══ */
362
- /* Error */
363
- --status-error-bg: oklch(0.220 0.025 25);
364
- --status-error-text: oklch(0.850 0.090 25);
365
- --status-error-border: oklch(0.400 0.060 25);
366
- --status-error-icon: oklch(0.704 0.191 22.216); /* = dark --destructive */
367
-
368
- /* Success */
369
- --status-success-bg: oklch(0.220 0.025 160);
370
- --status-success-text: oklch(0.850 0.080 160);
371
- --status-success-border: oklch(0.400 0.050 160);
372
- --status-success-icon: oklch(0.696 0.170 162.480); /* = dark --chart-emerald */
373
-
374
- /* Warning */
375
- --status-warning-bg: oklch(0.225 0.025 80);
376
- --status-warning-text: oklch(0.870 0.080 80);
377
- --status-warning-border: oklch(0.420 0.060 80);
378
- --status-warning-icon: oklch(0.820 0.160 84.429); /* = dark --chart-amber */
379
-
380
- /* Info (indigo bridges to brand violet in dark mode) */
381
- --status-info-bg: oklch(0.220 0.040 276);
382
- --status-info-text: oklch(0.785 0.115 274.713); /* indigo-300 #A5B4FC */
383
- --status-info-border: oklch(0.359 0.144 278.697);/* indigo-800 #3730A3 */
384
- --status-info-icon: oklch(0.585 0.233 277.117); /* indigo-500 #6366F1 */
385
-
386
- /* Neutral */
387
- --status-neutral-bg: oklch(0.230 0 0);
388
- --status-neutral-text: oklch(0.750 0 0);
389
- --status-neutral-border: oklch(0.380 0 0);
390
- --status-neutral-icon: oklch(0.708 0 0); /* = dark --muted-foreground */
372
+ /* ═══ Design System: Semantic Status Tokens — Dark Mode ═══
373
+ Dark-mode counterparts mirror the Tailwind v4 dark palette
374
+ (`*-950` for bg, `*-300` for text, `*-800` for border, `*-500`
375
+ for icon) so the Alert primitive stays pixel-perfect against
376
+ the Figma dark variants. */
377
+
378
+ /* Error — Tailwind red (dark) */
379
+ --status-error-bg: oklch(0.258 0.092 26.042); /* red-950 #450a0a */
380
+ --status-error-text: oklch(0.808 0.114 19.571); /* red-300 #fca5a5 */
381
+ --status-error-border: oklch(0.444 0.177 26.899);/* red-800 #991b1b */
382
+ --status-error-icon: oklch(0.637 0.237 25.331); /* red-500 #ef4444 */
383
+
384
+ /* Success — Tailwind green (dark) */
385
+ --status-success-bg: oklch(0.266 0.065 152.934); /* green-950 #052e16 */
386
+ --status-success-text: oklch(0.871 0.15 154.449); /* green-300 #86efac */
387
+ --status-success-border: oklch(0.448 0.119 151.328);/* green-800 #166534 */
388
+ --status-success-icon: oklch(0.723 0.219 149.579); /* green-500 #22c55e */
389
+
390
+ /* Warning — Tailwind amber (dark) */
391
+ --status-warning-bg: oklch(0.279 0.077 45.635); /* amber-950 #451a03 */
392
+ --status-warning-text: oklch(0.879 0.169 91.605); /* amber-300 #fcd34d */
393
+ --status-warning-border: oklch(0.473 0.137 46.201);/* amber-800 #92400e */
394
+ --status-warning-icon: oklch(0.769 0.188 70.08); /* amber-500 #f59e0b */
395
+
396
+ /* Info — Tailwind indigo (dark) */
397
+ --status-info-bg: oklch(0.257 0.09 281.288); /* indigo-950 #1e1b4b */
398
+ --status-info-text: oklch(0.785 0.115 274.713); /* indigo-300 #a5b4fc */
399
+ --status-info-border: oklch(0.398 0.195 277.366);/* indigo-800 #3730a3 */
400
+ --status-info-icon: oklch(0.585 0.233 277.117); /* indigo-500 #6366f1 */
401
+
402
+ /* Neutral — Tailwind neutral (dark) */
403
+ --status-neutral-bg: oklch(0.205 0 0); /* neutral-900 #171717 */
404
+ --status-neutral-text: oklch(0.872 0 0); /* neutral-300 #d4d4d4 */
405
+ --status-neutral-border: oklch(0.371 0 0);/* neutral-700 #404040 */
406
+ --status-neutral-icon: oklch(0.708 0 0); /* neutral-400 #a3a3a3 = dark --muted-foreground */
391
407
 
392
408
  /* Pink (dark mode) — Figma SPEC-048 ACCENT / PINK Stage badges */
393
409
  --status-pink-bg: oklch(0.225 0.045 340);
@@ -22,7 +22,7 @@ export default async function RootLayout({
22
22
  }>) {
23
23
  const locale = await detectLocale()
24
24
  const dict = await loadDictionary(locale)
25
- const demoModeEnabled = process.env.DEMO_MODE === 'true'
25
+ const demoModeEnabled = process.env.DEMO_MODE !== 'false'
26
26
  const noticeBarsEnabled = process.env.OM_INTEGRATION_TEST !== 'true'
27
27
  return (
28
28
  <html lang={locale} suppressHydrationWarning>
@@ -14,6 +14,8 @@ type AiAssistantShellIntegrationProps = {
14
14
  children: React.ReactNode
15
15
  }
16
16
 
17
+ const AiAssistantIntegrationFallback: AiAssistantIntegrationComponent = ({ children }) => <>{children}</>
18
+
17
19
  export function AiAssistantShellIntegration({
18
20
  tenantId,
19
21
  organizationId,
@@ -23,10 +25,16 @@ export function AiAssistantShellIntegration({
23
25
 
24
26
  React.useEffect(() => {
25
27
  let cancelled = false
26
- void import('@open-mercato/ai-assistant/frontend').then((module) => {
27
- if (cancelled) return
28
- setIntegrationComponent(() => module.AiAssistantIntegration)
29
- })
28
+ void import('@open-mercato/ai-assistant/frontend')
29
+ .then((module) => {
30
+ if (cancelled) return
31
+ setIntegrationComponent(() => module.AiAssistantIntegration)
32
+ })
33
+ .catch((error) => {
34
+ if (cancelled) return
35
+ console.error('Failed to load AI assistant integration', error)
36
+ setIntegrationComponent(() => AiAssistantIntegrationFallback)
37
+ })
30
38
  return () => {
31
39
  cancelled = true
32
40
  }
@@ -14,7 +14,7 @@ async function setGermanLocale(page: Page) {
14
14
  ])
15
15
  }
16
16
 
17
- test.describe('TC-APP-001: Template metadata', () => {
17
+ test.describe('TC-APP-001: App metadata', () => {
18
18
  test('home page exposes localized app metadata', async ({ page }) => {
19
19
  await setGermanLocale(page)
20
20
  await page.goto('/', { waitUntil: 'domcontentloaded' })
@@ -8,6 +8,7 @@ import { pushWithFlash } from '@open-mercato/ui/backend/utils/flash'
8
8
  import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
9
9
  import type { TodoListItem } from '../../../../types'
10
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
11
12
 
12
13
  type TodoItem = TodoListItem
13
14
  type TodoCustomFieldValues = Record<`cf_${string}`, unknown>
@@ -80,13 +81,8 @@ export default function EditTodoPage({ params }: { params?: { id?: string } }) {
80
81
  const item = data?.items?.[0]
81
82
  if (!item) throw new Error(t('example.todos.form.error.notFound'))
82
83
  // Map to form initial values
83
- const cfInit: Partial<TodoCustomFieldValues> = {}
84
84
  const extended = item as TodoItem & Record<string, unknown>
85
- for (const [key, value] of Object.entries(extended)) {
86
- if (key.startsWith('cf_')) {
87
- cfInit[key as keyof TodoCustomFieldValues] = value
88
- }
89
- }
85
+ const cfInit = extractCustomFieldEntries(extended) as Partial<TodoCustomFieldValues>
90
86
  const init: TodoFormValues = {
91
87
  id: item.id,
92
88
  title: item.title,
@@ -0,0 +1,60 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
4
+
5
+ /**
6
+ * Regression guard: `OM_INTEGRATION_TEST=true` must keep both the metadata-driven
7
+ * and the per-handler rate-limit paths bypassed.
8
+ *
9
+ * `readRateLimitConfig()` flips `RATE_LIMIT_ENABLED=false` whenever
10
+ * `OM_INTEGRATION_TEST=true`, which the integration runner exports via
11
+ * `packages/cli/src/lib/testing/integration.ts`. Without that bypass every
12
+ * Playwright run shares a single client IP across workers and burns through
13
+ * per-route point budgets — flakiness that scales with worker count.
14
+ *
15
+ * The metadata-path case depends on the dev-only `ratelimit_probe` module at
16
+ * `apps/mercato/src/modules/ratelimit_probe/`. That module ships only with
17
+ * the open-mercato monorepo and is intentionally absent from
18
+ * `create-mercato-app` scaffolds, so the metadata path self-skips when
19
+ * `/api/ratelimit_probe/ping` responds with 404.
20
+ */
21
+ test.describe('per-route rate limits leak into OM_INTEGRATION_TEST runs', () => {
22
+ test('metadata.rateLimit path (apps/mercato root handler): 5 POSTs to /api/ratelimit_probe/ping all return 200 with points=3', async ({ request }) => {
23
+ const probe = await request.post(`${BASE_URL}/api/ratelimit_probe/ping`, {
24
+ headers: { 'Content-Type': 'application/json' },
25
+ data: {},
26
+ });
27
+ test.skip(
28
+ probe.status() === 404,
29
+ 'ratelimit_probe module not registered (expected for create-mercato-app scaffolds)',
30
+ );
31
+
32
+ const statuses: number[] = [probe.status()];
33
+ for (let i = 0; i < 4; i += 1) {
34
+ const res = await request.post(`${BASE_URL}/api/ratelimit_probe/ping`, {
35
+ headers: { 'Content-Type': 'application/json' },
36
+ data: {},
37
+ });
38
+ statuses.push(res.status());
39
+ }
40
+ // With the OM_INTEGRATION_TEST bypass: all 5 calls return 200.
41
+ // Without it: rate limiter fires on the 4th call → [200, 200, 200, 429, 429].
42
+ expect(statuses, 'OM_INTEGRATION_TEST should bypass metadata.rateLimit').toEqual([200, 200, 200, 200, 200]);
43
+ });
44
+
45
+ test('manual checkRateLimit path (sales/quotes/accept): 11th POST 429s with points=10', async ({ request }) => {
46
+ // The rate limiter runs before token validation at sales/api/quotes/accept/route.ts:50-59,
47
+ // so an obviously invalid token still consumes points.
48
+ const statuses: number[] = [];
49
+ for (let i = 0; i < 12; i += 1) {
50
+ const res = await request.post(`${BASE_URL}/api/sales/quotes/fake-token-for-rate-limit-probe/accept`, {
51
+ headers: { 'Content-Type': 'application/json' },
52
+ data: {},
53
+ });
54
+ statuses.push(res.status());
55
+ }
56
+ // With the OM_INTEGRATION_TEST bypass: zero 429s (invalid token → 400/404, but never 429).
57
+ // Without it: 429 from the 11th call onward.
58
+ expect(statuses.filter((s) => s === 429), 'OM_INTEGRATION_TEST should bypass per-handler checkRateLimit').toEqual([]);
59
+ });
60
+ });
@@ -68,6 +68,10 @@ if (enabledModules.some((entry) => entry.id === 'example')) {
68
68
  enabledModules.push({ id: 'example_customers_sync', from: '@app' })
69
69
  }
70
70
 
71
+ if (parseBooleanWithDefault(process.env.OM_ENABLE_STORAGE_S3, false)) {
72
+ enabledModules.push({ id: 'storage_s3', from: '@open-mercato/storage-s3' })
73
+ }
74
+
71
75
  const enterpriseModulesEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES, false)
72
76
  const enterpriseSsoEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SSO, false)
73
77
  const enterpriseSecurityEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY, false)