create-nextblock 0.10.2 → 0.10.4

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 (50) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/email.ts +4 -3
  3. package/templates/nextblock-template/app/actions/formActions.ts +51 -42
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +281 -0
  5. package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +4 -0
  6. package/templates/nextblock-template/app/checkout/success/actions.ts +2 -1
  7. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +64 -20
  8. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -0
  9. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +118 -0
  10. package/templates/nextblock-template/app/cms/dashboard/page.tsx +6 -11
  11. package/templates/nextblock-template/app/cms/layout.tsx +8 -3
  12. package/templates/nextblock-template/app/cms/settings/email/actions.ts +60 -0
  13. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -0
  14. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -0
  15. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -0
  16. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -0
  17. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -0
  18. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +5 -6
  19. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +0 -48
  20. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +4 -3
  21. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -0
  22. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -0
  23. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -0
  24. package/templates/nextblock-template/app/cms/settings/security/actions.ts +3 -0
  25. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +2 -2
  26. package/templates/nextblock-template/app/cms/settings/security/page.tsx +20 -0
  27. package/templates/nextblock-template/app/layout.tsx +5 -1
  28. package/templates/nextblock-template/app/setup/SetupWizard.tsx +15 -158
  29. package/templates/nextblock-template/app/setup/page.tsx +0 -8
  30. package/templates/nextblock-template/components/AppShell.tsx +0 -7
  31. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -1
  32. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -0
  33. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +25 -20
  34. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +13 -2
  35. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +11 -0
  36. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +59 -8
  37. package/templates/nextblock-template/docs/README.md +3 -0
  38. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +11 -13
  39. package/templates/nextblock-template/lib/auth/twoFactor.ts +41 -0
  40. package/templates/nextblock-template/lib/config/email-settings.ts +217 -0
  41. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -0
  42. package/templates/nextblock-template/lib/onboarding/status.ts +136 -0
  43. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -0
  44. package/templates/nextblock-template/lib/privacy/settings.ts +12 -0
  45. package/templates/nextblock-template/lib/privacy/types.ts +3 -1
  46. package/templates/nextblock-template/lib/setup/actions.ts +6 -21
  47. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  48. package/templates/nextblock-template/next-env.d.ts +1 -1
  49. package/templates/nextblock-template/package.json +1 -1
  50. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, useTransition } from 'react';
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ Button,
8
+ Input,
9
+ Label,
10
+ Spinner,
11
+ Textarea,
12
+ } from '@nextblock-cms/ui';
13
+ import { Message } from '../../../../../components/form-message';
14
+ import { useHotkeys } from '../../../../../hooks/use-hotkeys';
15
+ import {
16
+ updateGoogleAnalyticsSettings,
17
+ type GoogleAnalyticsSettings,
18
+ } from '../actions';
19
+
20
+ interface GoogleAnalyticsFormProps {
21
+ initialSettings: GoogleAnalyticsSettings;
22
+ }
23
+
24
+ export default function GoogleAnalyticsForm({ initialSettings }: GoogleAnalyticsFormProps) {
25
+ const [isPending, startTransition] = useTransition();
26
+ const [message, setMessage] = useState<Message | null>(null);
27
+
28
+ const [gtmId, setGtmId] = useState(initialSettings.gtm_id);
29
+ const [gaId, setGaId] = useState(initialSettings.ga_measurement_id);
30
+ const [customScripts, setCustomScripts] = useState(initialSettings.custom_scripts);
31
+
32
+ const formRef = useRef<HTMLFormElement>(null);
33
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
34
+
35
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
36
+ event.preventDefault();
37
+ setMessage(null);
38
+
39
+ const formData = new FormData();
40
+ formData.append('gtm_id', gtmId);
41
+ formData.append('ga_measurement_id', gaId);
42
+ formData.append('custom_scripts', customScripts);
43
+
44
+ startTransition(async () => {
45
+ try {
46
+ const result = await updateGoogleAnalyticsSettings(formData);
47
+ setMessage({ success: result.message });
48
+ } catch (error) {
49
+ setMessage({
50
+ error: error instanceof Error ? error.message : 'An unknown error occurred.',
51
+ });
52
+ }
53
+ });
54
+ };
55
+
56
+ return (
57
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-8">
58
+ <section className="space-y-4">
59
+ <div>
60
+ <h3 className="text-sm font-semibold">Analytics &amp; tracking</h3>
61
+ <p className="text-xs text-slate-500">
62
+ These load <strong>only</strong> after a visitor consents to analytics in the
63
+ Law 25 banner (managed under Privacy &amp; Consent), so the default page weight
64
+ stays at zero tracking bytes.
65
+ </p>
66
+ </div>
67
+ <div className="space-y-2">
68
+ <Label htmlFor="gtm_id">Google Tag Manager ID</Label>
69
+ <Input
70
+ id="gtm_id"
71
+ value={gtmId}
72
+ onChange={(e) => setGtmId(e.target.value)}
73
+ placeholder="GTM-XXXXXXX"
74
+ />
75
+ </div>
76
+ <div className="space-y-2">
77
+ <Label htmlFor="ga_measurement_id">GA4 Measurement ID</Label>
78
+ <Input
79
+ id="ga_measurement_id"
80
+ value={gaId}
81
+ onChange={(e) => setGaId(e.target.value)}
82
+ placeholder="G-XXXXXXXXXX"
83
+ />
84
+ <p className="text-xs text-slate-500">
85
+ Loads Google Analytics 4 directly via <code>gtag</code>. If you also configure
86
+ GA4 inside your GTM container, set only one of these to avoid double-counting.
87
+ </p>
88
+ </div>
89
+ <div className="space-y-2">
90
+ <Label htmlFor="custom_scripts">Custom consented scripts (optional)</Label>
91
+ <Textarea
92
+ id="custom_scripts"
93
+ value={customScripts}
94
+ onChange={(e) => setCustomScripts(e.target.value)}
95
+ placeholder="<!-- Additional marketing/analytics <script> tags, injected only after consent -->"
96
+ rows={4}
97
+ className="font-mono text-xs"
98
+ />
99
+ </div>
100
+ </section>
101
+
102
+ <div className="flex items-center gap-4">
103
+ <Button type="submit" disabled={isPending}>
104
+ {isPending ? (
105
+ <>
106
+ <Spinner className="mr-2 h-4 w-4 animate-spin" /> Saving...
107
+ </>
108
+ ) : (
109
+ 'Save Google Analytics Settings'
110
+ )}
111
+ </Button>
112
+ {message && (
113
+ <Alert
114
+ variant={'success' in message ? 'success' : 'destructive'}
115
+ className="py-2 px-4 w-auto inline-flex items-center"
116
+ >
117
+ <AlertDescription>
118
+ {'success' in message
119
+ ? message.success
120
+ : 'error' in message
121
+ ? message.error
122
+ : ''}
123
+ </AlertDescription>
124
+ </Alert>
125
+ )}
126
+ </div>
127
+ </form>
128
+ );
129
+ }
@@ -0,0 +1,26 @@
1
+ // app/cms/settings/google-analytics/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
3
+ import { getGoogleAnalyticsSettings } from './actions';
4
+ import GoogleAnalyticsForm from './components/GoogleAnalyticsForm';
5
+
6
+ export default async function GoogleAnalyticsSettingsPage() {
7
+ const settings = await getGoogleAnalyticsSettings();
8
+
9
+ return (
10
+ <div className="max-w-4xl mx-auto">
11
+ <Card>
12
+ <CardHeader>
13
+ <CardTitle>Google Analytics</CardTitle>
14
+ <CardDescription>
15
+ Configure Google Tag Manager and Google Analytics 4. These tags load only
16
+ after a visitor accepts analytics in the Law 25 consent banner (managed under
17
+ Privacy &amp; Consent), so the default page weight stays at zero tracking bytes.
18
+ </CardDescription>
19
+ </CardHeader>
20
+ <CardContent>
21
+ <GoogleAnalyticsForm initialSettings={settings} />
22
+ </CardContent>
23
+ </Card>
24
+ </div>
25
+ );
26
+ }
@@ -4,7 +4,7 @@ import { createClient } from '@nextblock-cms/db/server';
4
4
  import { revalidatePath } from 'next/cache';
5
5
  import {
6
6
  getPrivacySettings as readPrivacySettings,
7
- savePrivacySettings,
7
+ mergePrivacySettings,
8
8
  } from '../../../../lib/privacy/settings';
9
9
  import type { PrivacySettings } from '../../../../lib/privacy/types';
10
10
 
@@ -33,11 +33,10 @@ async function assertAdmin(): Promise<void> {
33
33
  export async function updatePrivacySettings(formData: FormData) {
34
34
  await assertAdmin();
35
35
 
36
- const settings: PrivacySettings = {
36
+ // Analytics fields (GTM/GA4/custom scripts) are owned by the Google Analytics
37
+ // settings page; merge only the consent + corporate fields so they aren't clobbered.
38
+ const patch: Partial<PrivacySettings> = {
37
39
  banner_enabled: formData.get('banner_enabled') === 'true',
38
- gtm_id: (formData.get('gtm_id')?.toString() ?? '').trim(),
39
- ga_measurement_id: (formData.get('ga_measurement_id')?.toString() ?? '').trim(),
40
- custom_scripts: formData.get('custom_scripts')?.toString() ?? '',
41
40
  corporate: {
42
41
  legal_name: (formData.get('legal_name')?.toString() ?? '').trim(),
43
42
  address: (formData.get('address')?.toString() ?? '').trim(),
@@ -45,7 +44,7 @@ export async function updatePrivacySettings(formData: FormData) {
45
44
  },
46
45
  };
47
46
 
48
- await savePrivacySettings(settings);
47
+ await mergePrivacySettings(patch);
49
48
  // Footer (corporate identity) and the analytics guard live in the root layout.
50
49
  revalidatePath('/', 'layout');
51
50
 
@@ -26,9 +26,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
26
26
  const [message, setMessage] = useState<Message | null>(null);
27
27
 
28
28
  const [bannerEnabled, setBannerEnabled] = useState(initialSettings.banner_enabled);
29
- const [gtmId, setGtmId] = useState(initialSettings.gtm_id);
30
- const [gaId, setGaId] = useState(initialSettings.ga_measurement_id);
31
- const [customScripts, setCustomScripts] = useState(initialSettings.custom_scripts);
32
29
  const [legalName, setLegalName] = useState(initialSettings.corporate.legal_name);
33
30
  const [address, setAddress] = useState(initialSettings.corporate.address);
34
31
  const [supportEmail, setSupportEmail] = useState(initialSettings.corporate.support_email);
@@ -42,9 +39,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
42
39
 
43
40
  const formData = new FormData();
44
41
  formData.append('banner_enabled', String(bannerEnabled));
45
- formData.append('gtm_id', gtmId);
46
- formData.append('ga_measurement_id', gaId);
47
- formData.append('custom_scripts', customScripts);
48
42
  formData.append('legal_name', legalName);
49
43
  formData.append('address', address);
50
44
  formData.append('support_email', supportEmail);
@@ -84,48 +78,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
84
78
 
85
79
  <Separator />
86
80
 
87
- {/* Analytics */}
88
- <section className="space-y-4">
89
- <div>
90
- <h3 className="text-sm font-semibold">Analytics &amp; tracking</h3>
91
- <p className="text-xs text-slate-500">
92
- These load <strong>only</strong> after a visitor consents to analytics, so
93
- the default page weight stays at zero tracking bytes.
94
- </p>
95
- </div>
96
- <div className="space-y-2">
97
- <Label htmlFor="gtm_id">Google Tag Manager ID</Label>
98
- <Input
99
- id="gtm_id"
100
- value={gtmId}
101
- onChange={(e) => setGtmId(e.target.value)}
102
- placeholder="GTM-XXXXXXX"
103
- />
104
- </div>
105
- <div className="space-y-2">
106
- <Label htmlFor="ga_measurement_id">GA4 Measurement ID (optional)</Label>
107
- <Input
108
- id="ga_measurement_id"
109
- value={gaId}
110
- onChange={(e) => setGaId(e.target.value)}
111
- placeholder="G-XXXXXXXXXX"
112
- />
113
- </div>
114
- <div className="space-y-2">
115
- <Label htmlFor="custom_scripts">Custom consented scripts (optional)</Label>
116
- <Textarea
117
- id="custom_scripts"
118
- value={customScripts}
119
- onChange={(e) => setCustomScripts(e.target.value)}
120
- placeholder="<!-- Additional marketing/analytics <script> tags, injected only after consent -->"
121
- rows={4}
122
- className="font-mono text-xs"
123
- />
124
- </div>
125
- </section>
126
-
127
- <Separator />
128
-
129
81
  {/* Corporate identity */}
130
82
  <section className="space-y-4">
131
83
  <div>
@@ -12,9 +12,10 @@ export default async function PrivacySettingsPage() {
12
12
  <CardHeader>
13
13
  <CardTitle>Privacy &amp; Consent (Law 25 / CASL)</CardTitle>
14
14
  <CardDescription>
15
- Control the Quebec Law 25 consent banner, the analytics that load only
16
- after consent, and the corporate identity appended to the CASL-compliant
17
- footer. Analytics scripts download zero bytes until a visitor opts in.
15
+ Control the Quebec Law 25 consent banner and the corporate identity appended
16
+ to the CASL-compliant footer. Analytics tags (Google Tag Manager / GA4) are
17
+ configured under Settings &rarr; Google Analytics and download zero bytes until
18
+ a visitor opts in here.
18
19
  </CardDescription>
19
20
  </CardHeader>
20
21
  <CardContent>
@@ -0,0 +1,44 @@
1
+ // app/cms/settings/registration/actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath } from 'next/cache';
6
+ import {
7
+ getSystemConfiguration,
8
+ updateSystemConfiguration,
9
+ } from '../../../../lib/setup/system-config';
10
+
11
+ export async function getRegistrationSettings(): Promise<{ autoAcceptSignups: boolean }> {
12
+ const config = await getSystemConfiguration();
13
+ return { autoAcceptSignups: config.auto_accept_signups };
14
+ }
15
+
16
+ async function assertAdmin() {
17
+ const supabase = createClient();
18
+ const {
19
+ data: { user },
20
+ } = await supabase.auth.getUser();
21
+ if (!user) {
22
+ throw new Error('You must be logged in to update settings.');
23
+ }
24
+ const { data: profile, error } = await supabase
25
+ .from('profiles')
26
+ .select('role')
27
+ .eq('id', user.id)
28
+ .single();
29
+ if (error || !profile || profile.role !== 'ADMIN') {
30
+ throw new Error('You do not have permission to perform this action.');
31
+ }
32
+ }
33
+
34
+ export async function updateRegistrationSettings(formData: FormData) {
35
+ await assertAdmin();
36
+
37
+ const autoAcceptSignups =
38
+ formData.get('autoAcceptSignups') === 'on' || formData.get('autoAcceptSignups') === 'true';
39
+
40
+ await updateSystemConfiguration({ auto_accept_signups: autoAcceptSignups });
41
+
42
+ revalidatePath('/cms/settings/registration');
43
+ return { success: true as const, message: 'Registration settings saved.' };
44
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, useTransition } from 'react';
4
+ import { Alert, AlertDescription, Button, Checkbox, Spinner } from '@nextblock-cms/ui';
5
+ import { updateRegistrationSettings } from '../actions';
6
+ import type { Message } from '../../../../../components/form-message';
7
+ import { useHotkeys } from '../../../../../hooks/use-hotkeys';
8
+
9
+ interface RegistrationFormProps {
10
+ initialSettings: { autoAcceptSignups: boolean };
11
+ }
12
+
13
+ export default function RegistrationForm({ initialSettings }: RegistrationFormProps) {
14
+ const [isPending, startTransition] = useTransition();
15
+ const [message, setMessage] = useState<Message | null>(null);
16
+ const [autoAccept, setAutoAccept] = useState(initialSettings.autoAcceptSignups);
17
+
18
+ const formRef = useRef<HTMLFormElement>(null);
19
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
20
+
21
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
22
+ event.preventDefault();
23
+ setMessage(null);
24
+ const formData = new FormData();
25
+ formData.append('autoAcceptSignups', autoAccept ? 'on' : '');
26
+
27
+ startTransition(async () => {
28
+ try {
29
+ const result = await updateRegistrationSettings(formData);
30
+ setMessage({ success: result.message });
31
+ } catch (error) {
32
+ setMessage({ error: error instanceof Error ? error.message : 'Failed to save settings.' });
33
+ }
34
+ });
35
+ };
36
+
37
+ return (
38
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
39
+ <label className="flex items-start gap-3 rounded-lg border p-4">
40
+ <Checkbox checked={autoAccept} onCheckedChange={(c) => setAutoAccept(c === true)} className="mt-1" />
41
+ <span className="text-sm">
42
+ <span className="font-medium">Auto-approve new registrations (skip email verification)</span>
43
+ <span className="block text-xs text-muted-foreground">
44
+ New sign-ups become active immediately, even without SMTP configured. Convenient for
45
+ local / self-hosted use. Leave off for public production sites so new accounts must
46
+ confirm their email address.
47
+ </span>
48
+ </span>
49
+ </label>
50
+
51
+ <div className="flex items-center gap-4">
52
+ <Button type="submit" disabled={isPending}>
53
+ {isPending ? (<><Spinner className="mr-2 h-4 w-4" /> Saving…</>) : 'Save settings'}
54
+ </Button>
55
+ {message && (
56
+ <Alert variant={'error' in message ? 'destructive' : 'success'} className="py-2 px-4 w-auto inline-flex items-center">
57
+ <AlertDescription>
58
+ {'error' in message ? message.error : 'success' in message ? message.success : message.message}
59
+ </AlertDescription>
60
+ </Alert>
61
+ )}
62
+ </div>
63
+ </form>
64
+ );
65
+ }
@@ -0,0 +1,27 @@
1
+ // app/cms/settings/registration/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
3
+ import { getRegistrationSettings } from './actions';
4
+ import RegistrationForm from './components/RegistrationForm';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export default async function RegistrationSettingsPage() {
9
+ const settings = await getRegistrationSettings();
10
+
11
+ return (
12
+ <div className="max-w-4xl mx-auto">
13
+ <Card>
14
+ <CardHeader>
15
+ <CardTitle>Sign-ups &amp; Registration</CardTitle>
16
+ <CardDescription>
17
+ Control how new public registrations are handled. Email verification requires a
18
+ configured SMTP server (Settings → Configuration → Email).
19
+ </CardDescription>
20
+ </CardHeader>
21
+ <CardContent>
22
+ <RegistrationForm initialSettings={settings} />
23
+ </CardContent>
24
+ </Card>
25
+ </div>
26
+ );
27
+ }
@@ -106,6 +106,9 @@ export async function updateAutoAcceptSignups(formData: FormData) {
106
106
  // --- Global policy (admin only) -------------------------------------------------
107
107
 
108
108
  export async function updateGlobalSecuritySettings(formData: FormData) {
109
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
110
+ throw new Error('Security settings are disabled in the sandbox environment.');
111
+ }
109
112
  const { supabase, user } = await requireUser();
110
113
  const { data: profile } = await supabase
111
114
  .from('profiles')
@@ -503,8 +503,8 @@ function AdminPolicyCard({
503
503
  <div className="space-y-1">
504
504
  <Label htmlFor="enforce_staff_2fa">Encourage staff to enable 2FA</Label>
505
505
  <p className="text-xs text-slate-500">
506
- Surfaces a reminder for ADMIN/WRITER accounts that haven&rsquo;t set up a
507
- second factor.
506
+ Shows a reminder banner across the CMS to ADMIN/WRITER accounts that
507
+ haven&rsquo;t set up a second factor. On by default.
508
508
  </p>
509
509
  </div>
510
510
  </div>
@@ -1,8 +1,28 @@
1
1
  // app/cms/settings/security/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
2
3
  import { getSecurityPanelData } from './actions';
3
4
  import SecurityPanel from './components/SecurityPanel';
4
5
 
5
6
  export default async function SecuritySettingsPage() {
7
+ // The sandbox/demo runs on a single shared account, so per-account security (2FA,
8
+ // trusted devices, policy) is intentionally disabled there.
9
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
10
+ return (
11
+ <div className="max-w-4xl mx-auto">
12
+ <Card>
13
+ <CardHeader>
14
+ <CardTitle>Security &amp; 2FA</CardTitle>
15
+ <CardDescription>
16
+ Security settings are disabled in the sandbox/demo environment, which runs on a
17
+ shared account and resets daily. They are available in a real installation.
18
+ </CardDescription>
19
+ </CardHeader>
20
+ <CardContent />
21
+ </Card>
22
+ </div>
23
+ );
24
+ }
25
+
6
26
  const data = await getSecurityPanelData();
7
27
 
8
28
  return (
@@ -440,7 +440,10 @@ export default async function RootLayout({
440
440
  privacySettings,
441
441
  } = await loadLayoutData();
442
442
  const draft = await draftMode();
443
- const resolvedGtmId = privacySettings.gtm_id || process.env.NEXT_PUBLIC_GTM_ID || '';
443
+ // GTM container id comes solely from the privacy settings row (site_settings).
444
+ // There is intentionally no NEXT_PUBLIC_GTM_ID env fallback — analytics is
445
+ // configured in the CMS (Settings -> Privacy), not via build-time env.
446
+ const resolvedGtmId = privacySettings.gtm_id || '';
444
447
  const visualEditingEnabled =
445
448
  draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
446
449
  const isVercelDeployment = process.env.VERCEL === '1';
@@ -519,6 +522,7 @@ export default async function RootLayout({
519
522
  <DeferredSpeedInsights />
520
523
  <ConsentGatedAnalytics
521
524
  gtmId={resolvedGtmId}
525
+ gaMeasurementId={privacySettings.ga_measurement_id || ''}
522
526
  customScripts={privacySettings.custom_scripts}
523
527
  nonce={nonce}
524
528
  />