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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +10 -3
- package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +26 -1
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
- package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
- package/templates/nextblock-template/app/layout.tsx +42 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/providers.tsx +2 -0
- package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +16 -11
- package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
- package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
- package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +141 -8
- 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
|
+
“Remember the visitor's language” 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'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
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 | `
|
|
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
|
|
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 |
|
|
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 |
|
|
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:
|
|
2619
|
-
Locale -->|Invalid or Missing|
|
|
2620
|
-
|
|
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?}
|