create-nextblock 0.12.14 → 0.12.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +10 -3
  3. package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +26 -1
  5. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
  6. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
  7. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
  8. package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
  9. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
  10. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
  11. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
  12. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
  13. package/templates/nextblock-template/app/layout.tsx +42 -1
  14. package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
  15. package/templates/nextblock-template/app/providers.tsx +2 -0
  16. package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
  17. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +16 -11
  18. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
  19. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
  20. package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
  21. package/templates/nextblock-template/package.json +1 -1
  22. package/templates/nextblock-template/proxy.ts +141 -8
  23. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,188 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useTransition } from 'react';
4
+ import {
5
+ Button,
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardFooter,
10
+ CardHeader,
11
+ CardTitle,
12
+ Checkbox,
13
+ RadioGroup,
14
+ RadioGroupItem,
15
+ } from '@nextblock-cms/ui';
16
+ import { toast } from 'react-hot-toast';
17
+ import { Ban, Globe, Info, MapPin, Route } from 'lucide-react';
18
+ import type { LucideIcon } from 'lucide-react';
19
+ import { updateLanguageDetectionSettings } from '../actions';
20
+ import type {
21
+ LanguageDetectionMode,
22
+ LanguageDetectionSettings,
23
+ } from '../../../../../lib/i18n/detection';
24
+
25
+ interface ModeOption {
26
+ value: LanguageDetectionMode;
27
+ title: string;
28
+ description: string;
29
+ icon: LucideIcon;
30
+ }
31
+
32
+ const MODE_OPTIONS: ModeOption[] = [
33
+ {
34
+ value: 'browser',
35
+ title: 'Browser language',
36
+ description:
37
+ "Serve the language the visitor's browser prefers (Accept-Language header). Recommended — it reflects what each person actually reads.",
38
+ icon: Globe,
39
+ },
40
+ {
41
+ value: 'country',
42
+ title: "Visitor's country (IP)",
43
+ description:
44
+ 'Serve the main language of the country the visitor browses from, using the geolocation headers provided by your host (Vercel, Cloudflare, CloudFront).',
45
+ icon: MapPin,
46
+ },
47
+ {
48
+ value: 'browser_then_country',
49
+ title: 'Browser language, then country',
50
+ description:
51
+ "Try the browser's preferred language first; when none of your languages match, fall back to the visitor's country.",
52
+ icon: Route,
53
+ },
54
+ {
55
+ value: 'default',
56
+ title: 'No detection',
57
+ description:
58
+ 'Always serve the default language to new visitors. They can still switch manually with the language switcher (shown when more than one language is active).',
59
+ icon: Ban,
60
+ },
61
+ ];
62
+
63
+ const COUNTRY_MODES: LanguageDetectionMode[] = ['country', 'browser_then_country'];
64
+
65
+ export default function LanguageDetectionPanel({
66
+ initialSettings,
67
+ activeLanguageCount,
68
+ }: {
69
+ initialSettings: LanguageDetectionSettings;
70
+ activeLanguageCount: number;
71
+ }) {
72
+ const [mode, setMode] = useState<LanguageDetectionMode>(initialSettings.mode);
73
+ const [rememberVisitorChoice, setRememberVisitorChoice] = useState<boolean>(
74
+ initialSettings.rememberVisitorChoice,
75
+ );
76
+ const [savedSettings, setSavedSettings] = useState<LanguageDetectionSettings>(initialSettings);
77
+ const [isPending, startTransition] = useTransition();
78
+
79
+ // With fewer than two active languages every mode resolves to the same locale,
80
+ // so the whole panel is a no-op and the public language switcher is hidden.
81
+ const isMoot = activeLanguageCount < 2;
82
+
83
+ const isDirty =
84
+ mode !== savedSettings.mode || rememberVisitorChoice !== savedSettings.rememberVisitorChoice;
85
+
86
+ const handleSave = () => {
87
+ startTransition(async () => {
88
+ const result = await updateLanguageDetectionSettings({ mode, rememberVisitorChoice });
89
+ if (result?.error) {
90
+ toast.error(result.error);
91
+ return;
92
+ }
93
+ setSavedSettings({ mode, rememberVisitorChoice });
94
+ toast.success(result?.success ?? 'Language detection settings saved.');
95
+ });
96
+ };
97
+
98
+ return (
99
+ <Card>
100
+ <CardHeader>
101
+ <CardTitle>Language Detection</CardTitle>
102
+ <CardDescription>
103
+ How the site picks the first language for a new visitor. A language chosen with the
104
+ language switcher overrides detection for the rest of the visit — and for a year when
105
+ &ldquo;Remember the visitor&apos;s language&rdquo; is on. Changes reach new visitors
106
+ within about a minute.
107
+ </CardDescription>
108
+ </CardHeader>
109
+ <CardContent className="space-y-6">
110
+ {isMoot && (
111
+ <p className="flex items-start gap-2 rounded-md border border-dashed p-3 text-sm text-muted-foreground">
112
+ <Info className="mt-0.5 h-4 w-4 shrink-0" />
113
+ <span>
114
+ Language detection takes effect once at least two active languages are configured.
115
+ With a single language every visitor receives that language and the public language
116
+ switcher is hidden — you can still choose a mode here so it applies as soon as you add
117
+ another language.
118
+ </span>
119
+ </p>
120
+ )}
121
+ <RadioGroup
122
+ value={mode}
123
+ onValueChange={(value) => setMode(value as LanguageDetectionMode)}
124
+ className="grid gap-3 md:grid-cols-2"
125
+ >
126
+ {MODE_OPTIONS.map((option) => (
127
+ <label
128
+ key={option.value}
129
+ className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2"
130
+ >
131
+ <RadioGroupItem
132
+ value={option.value}
133
+ className="mt-1"
134
+ aria-label={option.title}
135
+ aria-describedby={`detection-mode-desc-${option.value}`}
136
+ />
137
+ <span className="space-y-1">
138
+ <span className="flex items-center gap-2 text-sm font-medium">
139
+ <option.icon className="h-4 w-4 text-muted-foreground" />
140
+ {option.title}
141
+ </span>
142
+ <span
143
+ id={`detection-mode-desc-${option.value}`}
144
+ className="block text-sm text-muted-foreground"
145
+ >
146
+ {option.description}
147
+ </span>
148
+ </span>
149
+ </label>
150
+ ))}
151
+ </RadioGroup>
152
+
153
+ <div aria-live="polite">
154
+ {COUNTRY_MODES.includes(mode) && (
155
+ <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
156
+ Country detection relies on the geolocation header your hosting platform adds to each
157
+ request (Vercel, Cloudflare, and CloudFront do this automatically). When the header is
158
+ missing, visitors get the default language
159
+ {mode === 'browser_then_country' ? ' unless their browser language matches' : ''}.
160
+ </p>
161
+ )}
162
+ </div>
163
+
164
+ <label className="flex cursor-pointer items-start gap-3">
165
+ <Checkbox
166
+ checked={rememberVisitorChoice}
167
+ onCheckedChange={(checked) => setRememberVisitorChoice(checked === true)}
168
+ className="mt-0.5"
169
+ aria-label="Remember the visitor's language"
170
+ aria-describedby="remember-visitor-desc"
171
+ />
172
+ <span className="space-y-1">
173
+ <span className="block text-sm font-medium">Remember the visitor&apos;s language</span>
174
+ <span id="remember-visitor-desc" className="block text-sm text-muted-foreground">
175
+ Keep the detected or chosen language in a cookie for a year. When off, the language
176
+ only sticks for the browsing session and is detected again on the next visit.
177
+ </span>
178
+ </span>
179
+ </label>
180
+ </CardContent>
181
+ <CardFooter className="justify-end">
182
+ <Button onClick={handleSave} disabled={!isDirty || isPending}>
183
+ {isPending ? 'Saving...' : 'Save Detection Settings'}
184
+ </Button>
185
+ </CardFooter>
186
+ </Card>
187
+ );
188
+ }
@@ -23,6 +23,8 @@ import {
23
23
  } from "@nextblock-cms/ui";
24
24
  import type { Database } from "@nextblock-cms/db";
25
25
  import DeleteLanguageClientButton from './components/DeleteLanguageButton';
26
+ import LanguageDetectionPanel from './components/LanguageDetectionPanel';
27
+ import { getLanguageDetectionSettings } from './actions';
26
28
 
27
29
  type Language = Database['public']['Tables']['languages']['Row'];
28
30
 
@@ -41,7 +43,10 @@ async function getLanguages(): Promise<Language[]> {
41
43
  }
42
44
 
43
45
  export default async function CmsLanguagesListPage() {
44
- const languages = await getLanguages();
46
+ const [languages, detectionSettings] = await Promise.all([
47
+ getLanguages(),
48
+ getLanguageDetectionSettings(),
49
+ ]);
45
50
  // The following line for searchParams will cause an error during static generation or if window is not defined.
46
51
  // It's better to pass searchParams as props if needed from the page component.
47
52
  // For this specific page, success messages are handled by redirect query params which Next.js makes available in page props.
@@ -144,6 +149,12 @@ export default async function CmsLanguagesListPage() {
144
149
  </Table>
145
150
  </div>
146
151
  )}
152
+ <div className="mt-6">
153
+ <LanguageDetectionPanel
154
+ initialSettings={detectionSettings}
155
+ activeLanguageCount={languages.filter((lang) => lang.is_active !== false).length}
156
+ />
157
+ </div>
147
158
  <div className="mt-6">
148
159
  <Alert variant="warning">
149
160
  <ShieldAlert className="h-4 w-4" />
@@ -33,6 +33,13 @@ import {
33
33
  resolveSupabaseUrl,
34
34
  } from '../lib/setup/env-status';
35
35
  import { resolveMediaBaseUrl } from '../lib/storage/provider';
36
+ import {
37
+ LANGUAGE_DETECTION_SETTING_KEY,
38
+ LANGUAGE_DETECTION_CACHE_TAG,
39
+ DEFAULT_LANGUAGE_DETECTION_SETTINGS,
40
+ normalizeLanguageDetectionSettings,
41
+ type LanguageDetectionSettings,
42
+ } from '../lib/i18n/detection';
36
43
 
37
44
  const defaultUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000';
38
45
 
@@ -83,6 +90,25 @@ const getCachedLanguages = unstable_cache(
83
90
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
84
91
  );
85
92
 
93
+ const getCachedLanguageDetectionSettings = unstable_cache(
94
+ async (): Promise<LanguageDetectionSettings> => {
95
+ const supabase = createStaticSupabaseClient();
96
+ const { data, error } = await supabase
97
+ .from('site_settings')
98
+ .select('value')
99
+ .eq('key', LANGUAGE_DETECTION_SETTING_KEY)
100
+ .maybeSingle();
101
+
102
+ // Absent row or read error = defaults (browser detection, remembered choice).
103
+ if (error) {
104
+ return { ...DEFAULT_LANGUAGE_DETECTION_SETTINGS };
105
+ }
106
+ return normalizeLanguageDetectionSettings(data?.value);
107
+ },
108
+ ['public-language-detection'],
109
+ { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: [LANGUAGE_DETECTION_CACHE_TAG] }
110
+ );
111
+
86
112
  const getCachedCopyrightSettings = unstable_cache(
87
113
  async (): Promise<Record<string, string>> => {
88
114
  const supabase = createStaticSupabaseClient();
@@ -274,6 +300,7 @@ async function loadLayoutData() {
274
300
  globalCss: '',
275
301
  privacySettings: DEFAULT_PRIVACY_SETTINGS,
276
302
  footerAttributionEnabled: true,
303
+ rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
277
304
  };
278
305
  }
279
306
 
@@ -300,6 +327,7 @@ async function loadLayoutData() {
300
327
  translationsResult,
301
328
  isEcommerceActive,
302
329
  privacySettings,
330
+ languageDetectionSettings,
303
331
  ] = await Promise.all([
304
332
  supabase.auth.getUser(),
305
333
  getCachedLanguages().catch(() => getActiveLanguagesServerSide().catch(() => [])),
@@ -311,9 +339,19 @@ async function loadLayoutData() {
311
339
  getCachedTranslations().catch(() => []),
312
340
  verifyPackageOnline('ecommerce').catch(() => false),
313
341
  getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
342
+ getCachedLanguageDetectionSettings().catch(() => ({
343
+ ...DEFAULT_LANGUAGE_DETECTION_SETTINGS,
344
+ })),
314
345
  ]);
315
346
 
316
- const availableLanguages: Language[] = availableLanguagesResult;
347
+ // Serve only active languages, matching the proxy's detection set (is_active
348
+ // null counts as active). getCachedLanguages / getActiveLanguagesServerSide
349
+ // both return every row, so filtering here keeps the public switcher and
350
+ // LanguageProvider in step with the locales the proxy will actually honor —
351
+ // otherwise picking a deactivated language would ping-pong against the proxy.
352
+ const availableLanguages: Language[] = availableLanguagesResult.filter(
353
+ (lang) => lang.is_active !== false,
354
+ );
317
355
  const availableCurrencies: StoreCurrency[] = currenciesResult;
318
356
  const defaultLanguage: Language | null =
319
357
  availableLanguages.find((lang) => lang.is_default) ?? availableLanguages[0] ?? null;
@@ -368,6 +406,7 @@ async function loadLayoutData() {
368
406
  globalCss,
369
407
  privacySettings,
370
408
  footerAttributionEnabled,
409
+ rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
371
410
  };
372
411
  }
373
412
 
@@ -449,6 +488,7 @@ export default async function RootLayout({
449
488
  globalCss,
450
489
  privacySettings,
451
490
  footerAttributionEnabled,
491
+ rememberVisitorChoice,
452
492
  } = await loadLayoutData();
453
493
  const draft = await draftMode();
454
494
  // GTM container id comes solely from the privacy settings row (site_settings).
@@ -502,6 +542,7 @@ export default async function RootLayout({
502
542
  initialCurrencyCode={serverCurrencyCode}
503
543
  initialAvailableLanguages={availableLanguages}
504
544
  initialDefaultLanguage={defaultLanguage}
545
+ rememberVisitorChoice={rememberVisitorChoice}
505
546
  translations={translations}
506
547
  nonce={nonce}
507
548
  >
@@ -111,8 +111,12 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
111
111
  if (!preferredLocale) {
112
112
  try {
113
113
  const hdrs = await headers();
114
- const al = hdrs.get("accept-language");
115
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
114
+ // Proxy-detected locale first: it honors the CMS language-detection settings.
115
+ preferredLocale = hdrs.get("x-user-locale") || undefined;
116
+ if (!preferredLocale) {
117
+ const al = hdrs.get("accept-language");
118
+ if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
119
+ }
116
120
  } catch {
117
121
  // ignore
118
122
  }
@@ -206,8 +210,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
206
210
  if (!preferredLocale) {
207
211
  try {
208
212
  const hdrs = await headers();
209
- const al = hdrs.get("accept-language");
210
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
213
+ // Proxy-detected locale first: it honors the CMS language-detection settings.
214
+ preferredLocale = hdrs.get("x-user-locale") || undefined;
215
+ if (!preferredLocale) {
216
+ const al = hdrs.get("accept-language");
217
+ if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
218
+ }
211
219
  } catch {
212
220
  // ignore
213
221
  }
@@ -44,6 +44,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
44
44
  initialCurrencyCode,
45
45
  initialAvailableLanguages,
46
46
  initialDefaultLanguage,
47
+ rememberVisitorChoice,
47
48
  translations,
48
49
  nonce
49
50
  } = props;
@@ -54,6 +55,7 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
54
55
  serverLocale={serverLocale}
55
56
  initialAvailableLanguages={initialAvailableLanguages}
56
57
  initialDefaultLanguage={initialDefaultLanguage}
58
+ rememberVisitorChoice={rememberVisitorChoice}
57
59
  >
58
60
  <CurrencyProvider
59
61
  initialCurrencies={initialCurrencies}
@@ -27,13 +27,15 @@ interface LanguageProviderProps {
27
27
  serverLocale?: string; // Locale determined on the server (from X-User-Locale header)
28
28
  initialAvailableLanguages?: Language[]; // Languages fetched on the server
29
29
  initialDefaultLanguage?: Language | null; // Default language determined on the server
30
+ rememberVisitorChoice?: boolean; // CMS language-detection setting: persist locale for a year vs session-only
30
31
  }
31
32
 
32
33
  export const LanguageProvider = ({
33
34
  children,
34
35
  serverLocale,
35
36
  initialAvailableLanguages,
36
- initialDefaultLanguage
37
+ initialDefaultLanguage,
38
+ rememberVisitorChoice = true
37
39
  }: LanguageProviderProps) => {
38
40
 
39
41
  const [currentLocale, _setCurrentLocale] = useState<string>(() => {
@@ -124,10 +126,18 @@ export const LanguageProvider = ({
124
126
  if (isMounted) { // Check mount status before final state updates
125
127
  _setCurrentLocale(effectiveLocale);
126
128
  if (typeof window !== 'undefined') {
127
- localStorage.setItem(LANGUAGE_STORAGE_KEY, effectiveLocale);
129
+ // Mirror the cookie's persistence: when "remember" is off, keep no
130
+ // durable client-side record (and purge any left from a prior session)
131
+ // so the setting's "session only" promise holds on the client too.
132
+ if (rememberVisitorChoice) {
133
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, effectiveLocale);
134
+ } else {
135
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
136
+ }
128
137
  document.documentElement.lang = effectiveLocale;
129
138
  }
130
- Cookies.set(LANGUAGE_COOKIE_KEY, effectiveLocale, { path: '/', expires: 365, sameSite: 'Lax' });
139
+ // Omitting `expires` makes it a session cookie, so detection re-runs next session.
140
+ Cookies.set(LANGUAGE_COOKIE_KEY, effectiveLocale, { path: '/', ...(rememberVisitorChoice ? { expires: 365 } : {}), sameSite: 'Lax' });
131
141
  setIsLoadingLanguages(false); // Done loading/initializing
132
142
  }
133
143
  };
@@ -137,7 +147,7 @@ export const LanguageProvider = ({
137
147
  return () => {
138
148
  isMounted = false; // Cleanup function to set isMounted to false
139
149
  };
140
- }, [serverLocale, initialAvailableLanguages, initialDefaultLanguage, clientSelectedLocale]);
150
+ }, [serverLocale, initialAvailableLanguages, initialDefaultLanguage, clientSelectedLocale, rememberVisitorChoice]);
141
151
 
142
152
  const setCurrentLocaleCallback = useCallback(async (localeCode: string) => { // Add async here
143
153
  let localeToSet = DEFAULT_FALLBACK_LOCALE;
@@ -159,10 +169,15 @@ export const LanguageProvider = ({
159
169
 
160
170
  _setCurrentLocale(localeToSet);
161
171
  if (typeof window !== 'undefined') {
162
- localStorage.setItem(LANGUAGE_STORAGE_KEY, localeToSet);
172
+ // Match the cookie's persistence (see the init effect): no durable record when off.
173
+ if (rememberVisitorChoice) {
174
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, localeToSet);
175
+ } else {
176
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
177
+ }
163
178
  document.documentElement.lang = localeToSet;
164
179
  }
165
- Cookies.set(LANGUAGE_COOKIE_KEY, localeToSet, { path: '/', expires: 365, sameSite: 'Lax' });
180
+ Cookies.set(LANGUAGE_COOKIE_KEY, localeToSet, { path: '/', ...(rememberVisitorChoice ? { expires: 365 } : {}), sameSite: 'Lax' });
166
181
 
167
182
  // The LanguageSwitcher component is responsible for navigation (push or refresh).
168
183
  // We only set the clientSelectedLocale here to ensure the main useEffect
@@ -172,7 +187,7 @@ export const LanguageProvider = ({
172
187
  setClientSelectedLocale(localeToSet);
173
188
  }
174
189
  // router.refresh(); // REMOVED: LanguageSwitcher will handle navigation/refresh.
175
- }, [availableLanguages]);
190
+ }, [availableLanguages, rememberVisitorChoice]);
176
191
 
177
192
  useEffect(() => {
178
193
  if (currentLocale && typeof window !== 'undefined') {
@@ -269,7 +269,7 @@ The following architectural invariants are enforced at workspace level and must
269
269
 
270
270
  **Editor Capabilities** — The `@nextblock-cms/editor` library (version `0.2.24`) exports `Editor`, `NotionEditor`, `EditorToolbar`, `EditorBubbleMenu`, `EditorFloatingMenu`, `EnhancedFloatingMenu`, `SlashCommandList`, `DragHandle`, `HtmlContent`, and `editorExtensions`. Feature set includes Tiptap StarterKit rich text, syntax-highlighted code blocks, tables, task lists, slash commands, drag handles, image handling, character counting, typography, mathematics, emoji, mentions, inline alert and call-to-action widgets, and custom HTML-preserving extensions for `div`, `style`, `script`, `svg`, `span`, and catch-all attribute preservation. A media-picker bridge is exposed via `setOpenImagePicker()`.
271
271
 
272
- **Translation & Localization** — The system supports two locales at present (`en`, `fr` defined as `SUPPORTED_LOCALES` in `apps/nextblock/proxy.ts`), backed by `languages` and `translations` tables from migration `00000000000001_setup_cms_core.sql`. Content revision history is stored as snapshot + JSON Patch diff (enum `revision_type: snapshot, diff`) per migration `00000000000002_setup_content_tables.sql`.
272
+ **Translation & Localization** — The set of served locales is the active rows of the `languages` table (managed at `/cms/settings/languages`; the proxy reads them with a 60-second in-memory cache and only falls back to the hardcoded `FALLBACK_LOCALES` `en`/`fr` when the DB is unreadable), backed by `languages` and `translations` tables from migration `00000000000001_setup_cms_core.sql`. First-visit language detection is admin-configurable (see F-007): browser `Accept-Language`, IP-country via host geo headers, combined, or always-default — implemented in `apps/nextblock/lib/i18n/detection.ts` and stored in `site_settings.language_detection_settings`. Content revision history is stored as snapshot + JSON Patch diff (enum `revision_type: snapshot, diff`) per migration `00000000000002_setup_content_tables.sql`.
273
273
 
274
274
  **Page Lifecycle** — Pages move through `draft`, `published`, and `archived` statuses (enum `page_status`).
275
275
 
@@ -619,7 +619,11 @@ Media management combines a Cloudflare R2-backed object store with an image-proc
619
619
 
620
620
  **Description**
621
621
 
622
- Two locales are currently supported `en` and `fr` defined as `SUPPORTED_LOCALES` in `apps/nextblock/proxy.ts` and seeded in migration `00000000000008_seed_platform_defaults.sql` (with `en` marked as the single default). The `languages` table enforces a single `is_default` row; the `translations` table stores translation keys with JSONB-per-locale values. Localized content entities (pages, posts, products) are clustered via `translation_group_id` UUIDs. Locale propagation uses the `NEXT_USER_LOCALE` cookie (`maxAge: 31_536_000` seconds, one year) and the `X-User-Locale` request header, both set by the request proxy. The client-side provider chain `LanguageProvider → TranslationsProvider` in `apps/nextblock/app/providers.tsx` bridges server-resolved locale into React context.
622
+ Served locales are the active rows of the `languages` table, managed at `/cms/settings/languages` (the seed provides `en` and `fr`, with `en` as the single default; `apps/nextblock/proxy.ts` keeps `FALLBACK_LOCALES = ['en','fr']` only as a safety net when the DB is unreadable). The `languages` table enforces a single `is_default` row; the `translations` table stores translation keys with JSONB-per-locale values. Localized content entities (pages, posts, products) are clustered via `translation_group_id` UUIDs.
623
+
624
+ **First-visit language detection** is admin-configurable on `/cms/settings/languages` and stored as the non-sensitive `site_settings` key `language_detection_settings` (`{ mode, rememberVisitorChoice }`). Modes: `browser` (default — full `Accept-Language` q-value parsing), `country` (visitor's country from host geo headers `x-vercel-ip-country` / `cf-ipcountry` / `cloudfront-viewer-country` / `x-country-code`, mapped through `apps/nextblock/lib/i18n/country-languages.ts`), `browser_then_country`, and `default` (no detection). The pure resolution helpers live in `apps/nextblock/lib/i18n/detection.ts`; the proxy applies them for any request without a valid locale cookie, resolving against the active languages + default language read from the DB with a 60-second in-memory cache.
625
+
626
+ Locale propagation uses the `NEXT_USER_LOCALE` cookie and the `X-User-Locale` request header, both set by the request proxy. When `rememberVisitorChoice` is `true` (default) the cookie persists for one year (`maxAge: 31_536_000` seconds); when `false` it is a session cookie, so detection re-runs each new browser session (the client `LanguageProvider` mirrors the same expiry for manual switcher choices). A valid cookie always beats detection. The client-side provider chain `LanguageProvider → TranslationsProvider` in `apps/nextblock/app/providers.tsx` bridges server-resolved locale into React context.
623
627
 
624
628
  **Business Value:** Unlocks multi-market deployments without requiring adopters to integrate a separate i18n library. **User Benefits:** Language switching persists across sessions; same content IDs preserve relationships across translations. **Technical Context:** Implemented at schema level (migration `00000000000001_setup_cms_core.sql`), proxy level, and provider level.
625
629
 
@@ -1290,7 +1294,8 @@ This subsection provides the detailed, testable requirements that operationalize
1290
1294
  | F-005-RQ-002 | Editor MUST preserve custom HTML constructs (`div`, `style`, `script`, `svg`, `span`) and unrecognized attributes | Must-Have | High |
1291
1295
  | F-006-RQ-001 | Media uploads MUST record metadata (`object_key`, `file_type`, `size_bytes`, `width`, `height`, `blur_data_url`, `variants`) in the `media` table | Must-Have | Medium |
1292
1296
  | F-006-RQ-002 | `recordMediaUpload` MUST reject non-`ADMIN`/non-`WRITER` callers | Must-Have | Low |
1293
- | F-007-RQ-001 | Locale MUST propagate via `NEXT_USER_LOCALE` cookie (1-year max age) and `X-User-Locale` header | Must-Have | Low |
1297
+ | F-007-RQ-001 | Locale MUST propagate via `NEXT_USER_LOCALE` cookie (1-year max age when `rememberVisitorChoice` is on; session cookie when off) and `X-User-Locale` header | Must-Have | Low |
1298
+ | F-007-RQ-002 | First-visit locale MUST honor the admin-configured detection mode (`browser`, `country`, `browser_then_country`, `default`) from `site_settings.language_detection_settings` | Must-Have | Medium |
1294
1299
  | F-008-RQ-001 | Content revisions MUST use the hybrid snapshot/diff model keyed by `revision_type` enum | Must-Have | Medium |
1295
1300
  | F-009-RQ-001 | Navigation items MUST support `HEADER`, `FOOTER`, `SIDEBAR` locations with hierarchy and ordering | Must-Have | Low |
1296
1301
 
@@ -1303,7 +1308,7 @@ This subsection provides the detailed, testable requirements that operationalize
1303
1308
  | F-004-RQ-001 | Block type string, content object | Validated block content or ZodError | Schema validation synchronous |
1304
1309
  | F-005-RQ-002 | HTML or Tiptap JSON | Rendered editor content preserving DOM | No HTML loss across round-trip |
1305
1310
  | F-006-RQ-001 | Multipart file or presigned PUT | `media` row with metadata | Upload completes within browser timeout |
1306
- | F-007-RQ-001 | `lang` URL parameter or header | Locale cookie + `X-User-Locale` header | Proxy overhead < 5 ms |
1311
+ | F-007-RQ-001 | Locale cookie, `Accept-Language`, host geo-country header | Locale cookie + `X-User-Locale` header | Proxy overhead < 5 ms on cache hit; one Supabase round-trip per isolate per 60 s on miss (loaded concurrently with the auth lookup, shared across concurrent misses) |
1307
1312
 
1308
1313
  #### 2.2.1.3 Validation Rules — Content Delivery
1309
1314
 
@@ -1312,7 +1317,7 @@ This subsection provides the detailed, testable requirements that operationalize
1312
1317
  | F-001-RQ-002 | Image MIME must be in supported list | Only R2-served URLs accepted by next/image loader |
1313
1318
  | F-004-RQ-001 | Block `type` MUST exist in registry; content MUST pass Zod schema | Schema prevents XSS via typed fields |
1314
1319
  | F-006-RQ-001 | `size_bytes` must be non-negative; `object_key` unique | RLS: ADMIN/WRITER write; public read |
1315
- | F-007-RQ-001 | Locale MUST be in `SUPPORTED_LOCALES` (`en`, `fr`) | Cookie uses default security attributes |
1320
+ | F-007-RQ-001 | Locale MUST be an active `languages` row (hardcoded `FALLBACK_LOCALES` `en`/`fr` only when the DB is unreadable) | Cookie uses default security attributes |
1316
1321
  | F-008-RQ-001 | Snapshot version MUST be unique per `page_id` | Writes RLS-restricted to ADMIN/WRITER |
1317
1322
  | F-009-RQ-001 | `menu_location` MUST be one of the three enum values | Writes RLS-restricted to ADMIN/WRITER |
1318
1323
 
@@ -1612,7 +1617,7 @@ The following matrix links features to the sections of the technical specificati
1612
1617
  | Build Status | `ecommerce:build` Nx standalone target currently not green (per §1.3.3.1) | F-013–F-022 |
1613
1618
  | Freemius Reconciliation | Webhook events acknowledged only; DB reconciliation pending (per §1.3.3.1) | F-017, F-021 |
1614
1619
  | Postal Code Shipping | Schema present; runtime resolver does not consume (per §1.3.3.1) | F-019 |
1615
- | Locale Count | Only `en` and `fr` in current release (per §1.3.3.4) | F-007, F-009, F-013 |
1620
+ | Locale Count | Seed provides `en` and `fr`; additional locales are added as `languages` rows at `/cms/settings/languages` | F-007, F-009, F-013 |
1616
1621
  | Module Boundaries | `libs/ui` MUST NOT depend on `apps/nextblock` | F-028 |
1617
1622
 
1618
1623
  ### 2.4.2 Performance Requirements
@@ -1674,7 +1679,7 @@ The following matrix links features to the sections of the technical specificati
1674
1679
  | FX override | `FX_API_BASE_URL` toggle allows switching provider without code change | F-018 |
1675
1680
  | Sandbox dataset | `SANDBOX_RESET_SQL` in `/api/cron/reset-sandbox/route.ts` must be regenerated as schema evolves | F-025, F-026 |
1676
1681
  | Package alignment | `@nextblock-cms/ecom` package name vs. `@nextblock-cms/ecommerce` alias (per §1.3.3.1) requires coordination when republished | F-013–F-022 |
1677
- | Locale expansion | Adding locales beyond `en`/`fr` requires updates to `SUPPORTED_LOCALES` and seed data | F-007 |
1682
+ | Locale expansion | Locales are DB-driven: add an active `languages` row (plus translations/content); `FALLBACK_LOCALES` in `proxy.ts` is only a DB-unreachable safety net | F-007 |
1678
1683
  | Block registry updates | New block types must satisfy F-024 contract and register in `blockRegistry.ts` | F-004, F-024 |
1679
1684
  | RLS policy review | Migration `00000000000006_setup_rls_and_grants.sql` should be audited on new table introduction | All DB-backed features |
1680
1685
 
@@ -2254,7 +2259,7 @@ The workspace implements a layered caching strategy aligned with the performance
2254
2259
  | `unstable_cache` (package activation) | 60 s | Avoids DB hit on every F-022 license check | `libs/db/src/lib/package-validation.ts` |
2255
2260
  | Next.js ISR (public layout) | 60 s (`PUBLIC_LAYOUT_REVALIDATE_SECONDS`) | Public content revalidation | `apps/nextblock/app/layout.tsx` |
2256
2261
  | Image cache TTL | 31,536,000 s (1 year) | Immutable optimized-image responses | `next.config.js` |
2257
- | Locale cookie | 31,536,000 s (1 year) | Persistent locale preference | `proxy.ts` |
2262
+ | Locale cookie | 31,536,000 s (1 year), or session cookie when "remember visitor's language" is off | Persistent locale preference | `proxy.ts` |
2258
2263
  | On-demand revalidation | N/A | Per-path invalidation via `REVALIDATE_SECRET_TOKEN` | `/api/revalidate` (F-027) |
2259
2264
  | bfcache-compatible HTML | `max-age=0, must-revalidate` | Preserves back-forward cache | `proxy.ts` |
2260
2265
 
@@ -2615,9 +2620,9 @@ flowchart TB
2615
2620
  Supabase --> SyncSession[supabase.auth.getSession<br/>Sync Cookies]
2616
2621
  SyncSession --> Locale{Read<br/>NEXT_USER_LOCALE<br/>Cookie}
2617
2622
 
2618
- Locale -->|Valid: en or fr| SetLocaleHeader[Set X-User-Locale]
2619
- Locale -->|Invalid or Missing| DefaultEn[Default to 'en']
2620
- DefaultEn --> SetLocaleHeader
2623
+ Locale -->|Valid: active language| SetLocaleHeader[Set X-User-Locale]
2624
+ Locale -->|Invalid or Missing| Detect[Detect per settings:<br/>browser / country /<br/>browser_then_country / default]
2625
+ Detect --> SetLocaleHeader
2621
2626
 
2622
2627
  SetLocaleHeader --> GetUser[supabase.auth.getUser]
2623
2628
  GetUser --> CmsGuard{Path starts<br/>with /cms?}