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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.12.14",
3
+ "version": "0.12.16",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -25,10 +25,11 @@ export default function TwoFactorForm({
25
25
  type === 'email' && pendingEmailCode ? `Enter the code we sent to ${email}.` : null,
26
26
  );
27
27
 
28
- const submit = () => {
28
+ const submit = (codeToSubmit: string = code) => {
29
+ if (codeToSubmit.length !== 6) return;
29
30
  setError(null);
30
31
  const formData = new FormData();
31
- formData.append('code', code);
32
+ formData.append('code', codeToSubmit);
32
33
  formData.append('redirect_to', redirectTo);
33
34
  startTransition(async () => {
34
35
  try {
@@ -85,7 +86,13 @@ export default function TwoFactorForm({
85
86
  autoFocus
86
87
  maxLength={6}
87
88
  value={code}
88
- onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
89
+ onChange={(e) => {
90
+ const next = e.target.value.replace(/\D/g, '').slice(0, 6);
91
+ setCode(next);
92
+ // Auto-submit the moment a full 6-digit code is entered (typed, pasted,
93
+ // or filled by the OS one-time-code autofill) — no button press needed.
94
+ if (next.length === 6 && !isPending) submit(next);
95
+ }}
89
96
  placeholder="000000"
90
97
  className="tracking-[0.5em] text-center text-lg"
91
98
  />
@@ -78,8 +78,12 @@ export async function generateMetadata(
78
78
  if (!preferredLocale) {
79
79
  try {
80
80
  const hdrs = await headers();
81
- const al = hdrs.get("accept-language");
82
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
81
+ // Proxy-detected locale first: it honors the CMS language-detection settings.
82
+ preferredLocale = hdrs.get("x-user-locale") || undefined;
83
+ if (!preferredLocale) {
84
+ const al = hdrs.get("accept-language");
85
+ if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
86
+ }
83
87
  } catch {
84
88
  // ignore header lookup errors
85
89
  }
@@ -153,8 +157,12 @@ export default async function DynamicPage({ params: paramsPromise }: PageProps)
153
157
  if (!preferredLocale) {
154
158
  try {
155
159
  const hdrs = await headers();
156
- const al = hdrs.get("accept-language");
157
- if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
160
+ // Proxy-detected locale first: it honors the CMS language-detection settings.
161
+ preferredLocale = hdrs.get("x-user-locale") || undefined;
162
+ if (!preferredLocale) {
163
+ const al = hdrs.get("accept-language");
164
+ if (al) preferredLocale = al.split(",")[0]?.split("-")[0];
165
+ }
158
166
  } catch {
159
167
  // ignore header lookup errors
160
168
  }
@@ -5706,6 +5706,30 @@ WHERE key IN (
5706
5706
  );
5707
5707
 
5708
5708
 
5709
+ -- >>> FROM: 00000000000011_language_detection_admin_only.sql <<<
5710
+ -- Restrict writes to the language-detection settings row to ADMIN only.
5711
+ --
5712
+ -- \`site_settings.language_detection_settings\` is a non-sensitive, anon-READABLE
5713
+ -- key (the request proxy reads it with the anon client to pick a visitor's first
5714
+ -- language). The CMS surfaces it under /cms/settings (ADMIN-only) and the server
5715
+ -- action guards with an ADMIN check, but the baseline write policies let ADMIN
5716
+ -- *or* WRITER write any non-sensitive key — so a WRITER could change site-wide
5717
+ -- detection directly via PostgREST. This migration adds the key to the ADMIN-only
5718
+ -- write group (INSERT/UPDATE/DELETE) to match the UI boundary, while leaving the
5719
+ -- SELECT policy untouched so the proxy's anon read keeps working.
5720
+ --
5721
+ -- Forward-only; recreates the three write policies idempotently.
5722
+
5723
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
5724
+ CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
5725
+
5726
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
5727
+ CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
5728
+
5729
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
5730
+ CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
5731
+
5732
+
5709
5733
  -- Step D: Record the applied migrations in history (truncated in Step B) so
5710
5734
  -- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
5711
5735
  INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
@@ -5719,7 +5743,8 @@ WHERE key IN (
5719
5743
  ('00000000000007', 'home_live_demo_promo_copy_fix'),
5720
5744
  ('00000000000008', 'setup_article_reorder'),
5721
5745
  ('00000000000009', 'home_live_demo_promo_contrast'),
5722
- ('00000000000010', 'drop_github_username')
5746
+ ('00000000000010', 'drop_github_username'),
5747
+ ('00000000000011', 'language_detection_admin_only')
5723
5748
  ON CONFLICT (version) DO NOTHING;
5724
5749
 
5725
5750
  -- Step E: Anchor preserved profiles
@@ -157,6 +157,7 @@ export default function FeatureImageField({
157
157
  </DialogHeader>
158
158
  <div className="p-1">
159
159
  <MediaUploadForm
160
+ compact
160
161
  returnJustData={true}
161
162
  defaultFolder={uploadFolder}
162
163
  onUploadSuccess={(newlyUploadedMedia) => {
@@ -158,12 +158,13 @@ export default function MediaPickerDialog({
158
158
  </DialogTrigger>
159
159
  )}
160
160
  <DialogContent className="sm:max-w-[650px] md:max-w-[800px] lg:max-w-[1000px] max-h-[90vh] flex flex-col">
161
- <DialogHeader>
161
+ <DialogHeader className="shrink-0 pb-1">
162
162
  <DialogTitle>{title}</DialogTitle>
163
163
  </DialogHeader>
164
164
 
165
- <div className="p-1">
165
+ <div className="flex-1 min-h-0 overflow-y-auto pr-1">
166
166
  <MediaUploadForm
167
+ compact
167
168
  returnJustData={true}
168
169
  defaultFolder={defaultFolder}
169
170
  onUploadSuccess={(newMedia) => {
@@ -171,39 +172,40 @@ export default function MediaPickerDialog({
171
172
  handleSelect(newMedia);
172
173
  }}
173
174
  />
174
- </div>
175
175
 
176
- <Separator className="my-4" />
177
-
178
- <div className="flex flex-col flex-grow overflow-hidden">
179
- <h3 className="text-lg font-medium mb-3 text-center">Or Select from Library</h3>
180
- <div className="relative mb-2">
181
- <Input
182
- type="search"
183
- placeholder="Search library..."
184
- value={searchTerm}
185
- onChange={(e) => setSearchTerm(e.target.value)}
186
- className="pl-10"
187
- />
188
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
189
- </div>
190
- {isLoading && filtered.length === 0 ? (
191
- <div className="flex-grow flex items-center justify-center">
192
- <p>Loading media...</p>
193
- </div>
194
- ) : loadError && filtered.length === 0 ? (
195
- <div className="flex-grow flex flex-col items-center justify-center gap-3 text-center">
196
- <p className="max-w-sm text-sm text-muted-foreground">{loadError}</p>
197
- <Button type="button" variant="outline" size="sm" onClick={() => void fetchLibrary()}>
198
- Retry
199
- </Button>
200
- </div>
201
- ) : filtered.length === 0 ? (
202
- <div className="flex-grow flex items-center justify-center">
203
- <p>No media found.</p>
176
+ <Separator className="my-3" />
177
+
178
+ <div className="flex flex-col">
179
+ <h3 className="text-base font-medium mb-2 text-center">Or Select from Library</h3>
180
+ <div className="sticky top-0 z-10 -mx-1 mb-2 bg-background px-1 pb-1">
181
+ <div className="relative">
182
+ <Input
183
+ type="search"
184
+ placeholder="Search library..."
185
+ value={searchTerm}
186
+ onChange={(e) => setSearchTerm(e.target.value)}
187
+ className="pl-10"
188
+ />
189
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
190
+ </div>
204
191
  </div>
205
- ) : (
206
- <div className="flex flex-wrap gap-3 overflow-y-auto min-h-0 pr-2 pb-2">
192
+ {isLoading && filtered.length === 0 ? (
193
+ <div className="flex items-center justify-center py-10">
194
+ <p>Loading media...</p>
195
+ </div>
196
+ ) : loadError && filtered.length === 0 ? (
197
+ <div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
198
+ <p className="max-w-sm text-sm text-muted-foreground">{loadError}</p>
199
+ <Button type="button" variant="outline" size="sm" onClick={() => void fetchLibrary()}>
200
+ Retry
201
+ </Button>
202
+ </div>
203
+ ) : filtered.length === 0 ? (
204
+ <div className="flex items-center justify-center py-10">
205
+ <p>No media found.</p>
206
+ </div>
207
+ ) : (
208
+ <div className="flex flex-wrap gap-3 pb-2">
207
209
  {filtered.map((media: Media) => {
208
210
  const previewPath = resolveMediaPreviewPath(media);
209
211
  const previewSrc = previewPath ? resolveMediaPreviewSrc(previewPath) : null;
@@ -241,8 +243,9 @@ export default function MediaPickerDialog({
241
243
  </button>
242
244
  );
243
245
  })}
244
- </div>
245
- )}
246
+ </div>
247
+ )}
248
+ </div>
246
249
  </div>
247
250
  </DialogContent>
248
251
  </Dialog>
@@ -21,11 +21,13 @@ interface MediaUploadFormProps {
21
21
  // And will use onUploadSuccess instead of router.refresh().
22
22
  returnJustData?: boolean;
23
23
  defaultFolder?: string; // Optional pre-populated folder
24
+ // If true, tightens spacing/heights so the form fits inside a modal on shorter screens.
25
+ compact?: boolean;
24
26
  }
25
27
 
26
28
  import { useUploadFolder } from "../UploadFolderContext";
27
29
 
28
- export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder }: MediaUploadFormProps) {
30
+ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder, compact }: MediaUploadFormProps) {
29
31
  const router = useRouter();
30
32
  const [isPending, startTransition] = useTransition();
31
33
  const [file, setFile] = useState<File | null>(null);
@@ -292,26 +294,27 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
292
294
  };
293
295
 
294
296
  return (
295
- <div className="p-6 border rounded-lg shadow-sm bg-card mb-6">
296
- <div role="group" aria-label="Upload new media" className="space-y-4">
297
+ <div className={`border rounded-lg shadow-sm bg-card ${compact ? "p-4" : "p-6 mb-6"}`}>
298
+ <div role="group" aria-label="Upload new media" className={compact ? "space-y-3" : "space-y-4"}>
297
299
  <div>
298
- <Label htmlFor="media-file" className="text-base font-medium">Upload New Media</Label>
299
- <div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-3">
300
- <div>
301
- <Label htmlFor="media-folder" className="text-sm">Folder (e.g., uploads/images/)</Label>
300
+ <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
301
+ <Label htmlFor="media-file" className="text-base font-medium">Upload New Media</Label>
302
+ <div className="flex items-center gap-2">
303
+ <Label htmlFor="media-folder" className="whitespace-nowrap text-sm text-muted-foreground">Upload Folder:</Label>
302
304
  <Input
303
305
  id="media-folder"
304
306
  placeholder="uploads/"
305
307
  value={folder}
306
308
  onChange={(e) => setFolder(e.target.value)}
307
309
  onKeyDown={handleFolderKeyDown}
310
+ className="h-9 w-44 sm:w-56"
308
311
  />
309
312
  </div>
310
313
  </div>
311
314
  <div className="mt-2 flex items-center justify-center w-full">
312
315
  <label
313
316
  htmlFor="media-file-input"
314
- className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
317
+ className={`flex flex-col items-center justify-center w-full ${compact ? "h-28" : "h-40"} border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
315
318
  isDraggingOver ? "border-primary bg-primary-foreground/20" : "border-input"
316
319
  }`}
317
320
  onDrop={handleDrop}
@@ -319,8 +322,8 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
319
322
  onDragEnter={handleDragEnter}
320
323
  onDragLeave={handleDragLeave}
321
324
  >
322
- <div className="flex flex-col items-center justify-center pt-5 pb-6 pointer-events-none"> {/* pointer-events-none for children */}
323
- <UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
325
+ <div className={`flex flex-col items-center justify-center pointer-events-none ${compact ? "pt-3 pb-4" : "pt-5 pb-6"}`}> {/* pointer-events-none for children */}
326
+ <UploadCloud className={`text-muted-foreground ${compact ? "w-8 h-8 mb-2" : "w-10 h-10 mb-3"}`} />
324
327
  <p className="mb-2 text-sm text-muted-foreground">
325
328
  <span className="font-semibold">Click to upload</span> or drag and drop
326
329
  </p>
@@ -332,7 +335,7 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
332
335
  {previewUrl && file && file.type.startsWith("image/") && (
333
336
  <div className="mt-4">
334
337
  <Label>Preview:</Label>
335
- <Image src={previewUrl} alt="Preview" width={300} height={192} className="mt-2 rounded-md max-h-48 w-auto object-contain border" />
338
+ <Image src={previewUrl} alt="Preview" width={300} height={192} className={`mt-2 rounded-md w-auto object-contain border ${compact ? "max-h-32" : "max-h-48"}`} />
336
339
  </div>
337
340
  )}
338
341
  {file && <p className="text-sm mt-2 text-muted-foreground">Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
@@ -3,32 +3,80 @@
3
3
  import React from 'react';
4
4
  import { ProductForm } from '@nextblock-cms/ecommerce';
5
5
  import MediaPickerDialog from '../media/components/MediaPickerDialog';
6
- type ProductFormClientShellProps = React.ComponentProps<typeof ProductForm>;
6
+ import DraftStatusActions from '../components/DraftStatusActions';
7
+
8
+ type ProductFormProps = React.ComponentProps<typeof ProductForm>;
9
+ type ProductUpdateAction = NonNullable<ProductFormProps['updateAction']>;
10
+
11
+ type ProductFormClientShellProps = ProductFormProps & {
12
+ /** Present only in edit mode; drives the "Unpublished Draft" toolbar. */
13
+ productId?: string;
14
+ /** Draft existence as computed on the server for the current render. */
15
+ serverHasDraft?: boolean;
16
+ };
7
17
 
8
18
  const productFormSkeletonRows = ['details', 'description', 'media', 'inventory'];
9
19
 
10
- export default function ProductFormClientShell(props: ProductFormClientShellProps) {
20
+ export default function ProductFormClientShell({
21
+ productId,
22
+ serverHasDraft = false,
23
+ updateAction,
24
+ ...props
25
+ }: ProductFormClientShellProps) {
11
26
  const [isMounted, setIsMounted] = React.useState(false);
27
+ // The draft toolbar is gated on the server-computed `hasDraft`, but the form
28
+ // autosave writes a draft WITHOUT revalidating the route (revalidating would
29
+ // re-init the form and loop the autosave — see updateProductAction). Track
30
+ // draft existence on the client so the toolbar can appear the moment an
31
+ // autosave persists a draft, without a server refetch.
32
+ const [hasDraft, setHasDraft] = React.useState(serverHasDraft);
12
33
 
13
34
  React.useEffect(() => {
14
35
  setIsMounted(true);
15
36
  }, []);
16
37
 
17
- if (!isMounted) {
18
- return <ProductFormShellSkeleton />;
19
- }
38
+ // Keep in sync with the server whenever it reports a draft (e.g. block edits
39
+ // in BlockEditorArea call router.refresh(), which re-runs the page). Only ever
40
+ // latch ON here — publish/discard reload the whole page, which resets state.
41
+ React.useEffect(() => {
42
+ if (serverHasDraft) {
43
+ setHasDraft(true);
44
+ }
45
+ }, [serverHasDraft]);
46
+
47
+ // Reveal the toolbar as soon as a form autosave succeeds. The autosave already
48
+ // upserted a product_drafts row, so a draft now exists.
49
+ const wrappedUpdateAction = React.useCallback(
50
+ async (data: Parameters<ProductUpdateAction>[0]) => {
51
+ const result = await updateAction!(data);
52
+ setHasDraft(true);
53
+ return result;
54
+ },
55
+ [updateAction]
56
+ );
20
57
 
21
58
  return (
22
- <ProductForm
23
- {...props}
24
- mediaPickerNode={
25
- <MediaPickerDialog
26
- triggerLabel="+ Add Image"
27
- triggerVariant="outline"
28
- defaultFolder="uploads/products/"
59
+ <>
60
+ {productId ? (
61
+ <DraftStatusActions parentId={productId} parentType="product" hasDraft={hasDraft} />
62
+ ) : null}
63
+ {isMounted ? (
64
+ <ProductForm
65
+ {...props}
66
+ hasOpenDraft={hasDraft}
67
+ updateAction={updateAction ? wrappedUpdateAction : undefined}
68
+ mediaPickerNode={
69
+ <MediaPickerDialog
70
+ triggerLabel="+ Add Image"
71
+ triggerVariant="outline"
72
+ defaultFolder="uploads/products/"
73
+ />
74
+ }
29
75
  />
30
- }
31
- />
76
+ ) : (
77
+ <ProductFormShellSkeleton />
78
+ )}
79
+ </>
32
80
  );
33
81
  }
34
82
 
@@ -13,7 +13,6 @@ import {
13
13
  DropdownMenuTrigger,
14
14
  } from '@nextblock-cms/ui';
15
15
  import ProductFormClientShell from '../../ProductFormClientShell';
16
- import DraftStatusActions from '../../../components/DraftStatusActions';
17
16
  import {
18
17
  getCmsProduct,
19
18
  getEnabledPaymentProviders,
@@ -191,7 +190,6 @@ export default async function EditProductPage({
191
190
 
192
191
  return (
193
192
  <div className="space-y-8 w-full max-w-[1400px] mx-auto px-6 py-8">
194
- <DraftStatusActions parentId={product.id} parentType="product" hasDraft={hasDraft} />
195
193
  <CortexAiPageContextRegistrar
196
194
  context={{
197
195
  contentType: 'product',
@@ -279,6 +277,8 @@ export default async function EditProductPage({
279
277
  </div>
280
278
 
281
279
  <ProductFormClientShell
280
+ productId={product.id}
281
+ serverHasDraft={hasDraft}
282
282
  initialData={normalizedInitialData}
283
283
  isEdit
284
284
  availableLanguagesProp={languages}
@@ -2,9 +2,15 @@
2
2
  "use server";
3
3
 
4
4
  import { createClient } from "@nextblock-cms/db/server";
5
- import { revalidatePath } from "next/cache";
5
+ import { revalidatePath, updateTag } from "next/cache";
6
6
  import { redirect } from "next/navigation";
7
7
  import type { Database } from "@nextblock-cms/db";
8
+ import {
9
+ LANGUAGE_DETECTION_SETTING_KEY,
10
+ LANGUAGE_DETECTION_CACHE_TAG,
11
+ normalizeLanguageDetectionSettings,
12
+ type LanguageDetectionSettings,
13
+ } from "../../../../lib/i18n/detection";
8
14
 
9
15
  type Language = Database["public"]["Tables"]["languages"]["Row"];
10
16
 
@@ -260,3 +266,49 @@ export async function deleteLanguage(languageId: number) {
260
266
  revalidatePath("/");
261
267
  redirect("/cms/settings/languages?success=Language deleted successfully. All associated content has also been removed.");
262
268
  }
269
+
270
+ // --- Language detection settings (site_settings.language_detection_settings) ---
271
+
272
+ export async function getLanguageDetectionSettings(): Promise<LanguageDetectionSettings> {
273
+ const supabase = createClient();
274
+ const { data, error } = await supabase
275
+ .from("site_settings")
276
+ .select("value")
277
+ .eq("key", LANGUAGE_DETECTION_SETTING_KEY)
278
+ .maybeSingle();
279
+
280
+ if (error) {
281
+ console.error("Error fetching language detection settings:", error);
282
+ }
283
+ // Absent row or error = defaults (browser detection, remembered choice).
284
+ return normalizeLanguageDetectionSettings(error ? null : data?.value);
285
+ }
286
+
287
+ export async function updateLanguageDetectionSettings(
288
+ input: LanguageDetectionSettings,
289
+ ): Promise<{ success?: string; error?: string }> {
290
+ const supabase = createClient();
291
+
292
+ if (!(await verifyAdmin(supabase))) {
293
+ return { error: "Unauthorized: Admin role required." };
294
+ }
295
+
296
+ // Never trust the client payload shape — coerce to a valid settings object.
297
+ const settings = normalizeLanguageDetectionSettings(input);
298
+
299
+ const { error } = await supabase
300
+ .from("site_settings")
301
+ .upsert({ key: LANGUAGE_DETECTION_SETTING_KEY, value: settings });
302
+
303
+ if (error) {
304
+ console.error("Error saving language detection settings:", error);
305
+ return { error: `Failed to save detection settings: ${error.message}` };
306
+ }
307
+
308
+ updateTag(LANGUAGE_DETECTION_CACHE_TAG);
309
+ revalidatePath("/cms/settings/languages");
310
+ revalidatePath("/", "layout");
311
+ // The proxy caches detection config in-memory for up to a minute per worker,
312
+ // so the change isn't instant for new visitors — set that expectation here.
313
+ return { success: "Language detection settings saved. Changes reach new visitors within about a minute." };
314
+ }