create-nextblock 0.12.13 → 0.12.15

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 (42) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +0 -13
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +0 -14
  4. package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
  5. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +209 -101
  6. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -3
  7. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
  8. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +1 -1
  9. package/templates/nextblock-template/app/cms/interactions/page.tsx +1 -1
  10. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
  11. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
  12. package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
  13. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
  14. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +2 -2
  15. package/templates/nextblock-template/app/cms/revisions/actions.ts +5 -5
  16. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
  17. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
  18. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
  19. package/templates/nextblock-template/app/cms/users/actions.ts +0 -3
  20. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +0 -2
  21. package/templates/nextblock-template/app/cms/users/page.tsx +0 -2
  22. package/templates/nextblock-template/app/layout.tsx +42 -1
  23. package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
  24. package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +1 -1
  25. package/templates/nextblock-template/app/profile/account-data.ts +1 -1
  26. package/templates/nextblock-template/app/profile/account-types.ts +0 -1
  27. package/templates/nextblock-template/app/profile/page.tsx +0 -1
  28. package/templates/nextblock-template/app/providers.tsx +2 -0
  29. package/templates/nextblock-template/components/PostCommentsSection.tsx +2 -2
  30. package/templates/nextblock-template/components/ProductReviewsSection.tsx +2 -2
  31. package/templates/nextblock-template/components/header-auth.tsx +1 -1
  32. package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
  33. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +46 -43
  34. package/templates/nextblock-template/lib/custom-block-relation-registry.ts +3 -3
  35. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
  36. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
  37. package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
  38. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +72 -37
  39. package/templates/nextblock-template/package.json +1 -1
  40. package/templates/nextblock-template/proxy.ts +141 -8
  41. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  42. package/templates/nextblock-template/components/GitHubLoginButton.tsx +0 -36
@@ -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}
@@ -31,7 +31,7 @@ type RevisionItem = {
31
31
  revision_type: 'snapshot' | 'diff';
32
32
  created_at: string;
33
33
  author_id: string | null;
34
- author?: { full_name?: string | null; github_username?: string | null } | null;
34
+ author?: { full_name?: string | null } | null;
35
35
  };
36
36
 
37
37
  import { Input } from "@nextblock-cms/ui";
@@ -223,7 +223,7 @@ export default function RevisionHistoryButton({ parentType, parentId }: Revision
223
223
  <div className="rounded border divide-y">
224
224
  {revisions.map((rev: RevisionItem, idx) => {
225
225
  const when = rev.created_at ? formatDistanceToNow(new Date(rev.created_at), { addSuffix: true }) : '';
226
- const authorName = rev.author?.full_name || rev.author?.github_username;
226
+ const authorName = rev.author?.full_name;
227
227
  const isCurrent = currentVersion != null && rev.version === currentVersion;
228
228
  const isInitial = rev.version === 1;
229
229
 
@@ -12,7 +12,7 @@ type RevisionListItem = {
12
12
  revision_type: 'snapshot' | 'diff';
13
13
  created_at: string;
14
14
  author_id: string | null;
15
- author?: { full_name?: string | null; github_username?: string | null } | null;
15
+ author?: { full_name?: string | null } | null;
16
16
  };
17
17
 
18
18
  const REVISIONS_PER_PAGE = 10; // Reduced to 10 as requested
@@ -22,7 +22,7 @@ export async function listPageRevisions(pageId: number, page = 1, startDate?: st
22
22
 
23
23
  let query = supabase
24
24
  .from('page_revisions')
25
- .select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name, github_username)', { count: 'exact' })
25
+ .select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
26
26
  .eq('page_id', pageId)
27
27
  .order('version', { ascending: false })
28
28
  .limit(1000); // Fetch up to 1000 recent revisions to allow dense pagination after filtering
@@ -105,7 +105,7 @@ export async function listPageRevisions(pageId: number, page = 1, startDate?: st
105
105
  revision_type: 'snapshot',
106
106
  created_at: pageCreatedAt ?? new Date().toISOString(),
107
107
  author_id: null,
108
- author: { full_name: 'System (Initial)', github_username: 'system' },
108
+ author: { full_name: 'System (Initial)' },
109
109
  has_changes: true, // V1 always counts
110
110
  content: null
111
111
  });
@@ -139,7 +139,7 @@ export async function listPostRevisions(postId: number, page = 1, startDate?: st
139
139
 
140
140
  let query = supabase
141
141
  .from('post_revisions')
142
- .select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name, github_username)', { count: 'exact' })
142
+ .select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
143
143
  .eq('post_id', postId)
144
144
  .order('version', { ascending: false })
145
145
  .limit(1000);
@@ -221,7 +221,7 @@ export async function listPostRevisions(postId: number, page = 1, startDate?: st
221
221
  revision_type: 'snapshot',
222
222
  created_at: postCreatedAt ?? new Date().toISOString(),
223
223
  author_id: null,
224
- author: { full_name: 'System (Initial)', github_username: 'system' },
224
+ author: { full_name: 'System (Initial)' },
225
225
  has_changes: true,
226
226
  content: null
227
227
  });
@@ -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
+ }
@@ -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" />
@@ -38,7 +38,6 @@ type UpdateUserProfilePayload = {
38
38
  full_name?: string | null;
39
39
  avatar_url?: string | null;
40
40
  website?: string | null;
41
- github_username?: string | null;
42
41
  phone?: string | null;
43
42
  };
44
43
 
@@ -97,7 +96,6 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
97
96
  full_name: formData.get("full_name") as string || null,
98
97
  avatar_url: formData.get("avatar_url") as string || null,
99
98
  website: formData.get("website") as string || null,
100
- github_username: formData.get("github_username") as string || null,
101
99
  phone: formData.get("phone") as string || null,
102
100
  };
103
101
 
@@ -126,7 +124,6 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
126
124
  full_name: rawFormData.full_name,
127
125
  avatar_url: rawFormData.avatar_url,
128
126
  website: rawFormData.website,
129
- github_username: rawFormData.github_username,
130
127
  phone: rawFormData.phone,
131
128
  };
132
129
 
@@ -30,7 +30,6 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
30
30
  if (data.full_name !== undefined) formData.append('full_name', data.full_name || '');
31
31
  if (data.avatar_url !== undefined) formData.append('avatar_url', data.avatar_url || '');
32
32
  if (data.website !== undefined) formData.append('website', data.website || '');
33
- if (data.github_username !== undefined) formData.append('github_username', data.github_username || '');
34
33
  if (data.phone !== undefined) formData.append('phone', data.phone || '');
35
34
  if (data.role !== undefined) formData.append('role', data.role);
36
35
 
@@ -48,7 +47,6 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
48
47
  full_name: userToEditProfile?.full_name || '',
49
48
  avatar_url: userToEditProfile?.avatar_url || '',
50
49
  website: userToEditProfile?.website || '',
51
- github_username: userToEditProfile?.github_username || '',
52
50
  phone: userToEditProfile?.phone || '',
53
51
  role: userToEditProfile?.role || 'USER',
54
52
  billing_address: userToEditAddresses.billingAddress,
@@ -129,7 +129,6 @@ export default async function CmsUsersListPage() {
129
129
  <TableHead className="w-[80px]">Avatar</TableHead>
130
130
  <TableHead>Email</TableHead>
131
131
  <TableHead>Full Name</TableHead>
132
- <TableHead>GitHub</TableHead>
133
132
  <TableHead>Role</TableHead>
134
133
  <TableHead>Joined</TableHead>
135
134
  <TableHead className="text-right w-[80px]">Actions</TableHead>
@@ -146,7 +145,6 @@ export default async function CmsUsersListPage() {
146
145
  </TableCell>
147
146
  <TableCell className="font-medium">{authUser.email}</TableCell>
148
147
  <TableCell className="text-muted-foreground">{profile?.full_name || "N/A"}</TableCell>
149
- <TableCell className="text-muted-foreground">{profile?.github_username || "-"}</TableCell>
150
148
  <TableCell>
151
149
  <Badge variant={
152
150
  profile?.role === "ADMIN" ? "destructive" :