create-nextblock 0.10.2 → 0.10.3

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 +245 -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,118 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useTransition } from 'react';
5
+ import {
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from '@nextblock-cms/ui';
13
+ import { ArrowRight, CheckCircle2, Circle, X } from 'lucide-react';
14
+ import type { OnboardingStatus } from '../../../../lib/onboarding/status';
15
+
16
+ export default function DashboardOnboarding({
17
+ status,
18
+ dismissAction,
19
+ }: {
20
+ status: OnboardingStatus;
21
+ dismissAction: (dismissed: boolean) => Promise<void>;
22
+ }) {
23
+ const [isPending, startTransition] = useTransition();
24
+
25
+ if (status.dismissed) return null;
26
+
27
+ const pct = status.total > 0 ? Math.round((status.completed / status.total) * 100) : 0;
28
+ const allDone = status.completed >= status.total;
29
+
30
+ const dismiss = () => {
31
+ startTransition(async () => {
32
+ await dismissAction(true);
33
+ });
34
+ };
35
+
36
+ return (
37
+ <Card className="border-primary/30 bg-primary/[0.03]">
38
+ <CardHeader className="pb-3">
39
+ <div className="flex items-start justify-between gap-4">
40
+ <div className="space-y-1">
41
+ <CardTitle className="flex items-center gap-2">
42
+ {allDone ? '🎉 Your site is ready' : 'Finish setting up your site'}
43
+ </CardTitle>
44
+ <CardDescription>
45
+ {allDone
46
+ ? 'Every recommended step is complete. You can dismiss this checklist.'
47
+ : 'Complete these steps to get the most out of NextBlock. You can do them in any order.'}
48
+ </CardDescription>
49
+ </div>
50
+ <Button
51
+ variant="ghost"
52
+ size="icon"
53
+ onClick={dismiss}
54
+ disabled={isPending}
55
+ aria-label="Dismiss onboarding checklist"
56
+ className="shrink-0"
57
+ >
58
+ <X className="h-4 w-4" />
59
+ </Button>
60
+ </div>
61
+
62
+ <div className="mt-3 space-y-1.5">
63
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
64
+ <span>
65
+ {status.completed} of {status.total} complete
66
+ </span>
67
+ <span>{pct}%</span>
68
+ </div>
69
+ <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
70
+ <div
71
+ className="h-full rounded-full bg-primary transition-all duration-500 ease-out"
72
+ style={{ width: `${pct}%` }}
73
+ />
74
+ </div>
75
+ </div>
76
+ </CardHeader>
77
+
78
+ <CardContent className="pt-0">
79
+ <ul className="divide-y">
80
+ {status.steps.map((step) => (
81
+ <li key={step.key} className="flex items-center gap-3 py-3">
82
+ {step.done ? (
83
+ <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600" />
84
+ ) : (
85
+ <Circle className="h-5 w-5 shrink-0 text-muted-foreground/50" />
86
+ )}
87
+ <div className="min-w-0 flex-1">
88
+ <div className="flex items-center gap-2">
89
+ <span
90
+ className={`text-sm font-medium ${
91
+ step.done ? 'text-muted-foreground line-through' : ''
92
+ }`}
93
+ >
94
+ {step.title}
95
+ </span>
96
+ {step.optional && (
97
+ <span className="rounded-full border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
98
+ Optional
99
+ </span>
100
+ )}
101
+ </div>
102
+ <p className="text-xs text-muted-foreground">{step.description}</p>
103
+ </div>
104
+ {!step.done && step.key !== 'admin' && (
105
+ <Button asChild variant="outline" size="sm" className="shrink-0">
106
+ <Link href={step.href}>
107
+ Set up
108
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
109
+ </Link>
110
+ </Button>
111
+ )}
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ </CardContent>
116
+ </Card>
117
+ );
118
+ }
@@ -1,26 +1,21 @@
1
- import { Alert, AlertDescription, AlertTitle, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@nextblock-cms/ui"
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@nextblock-cms/ui"
2
2
  import { Calendar, FileText, PenTool, Users, Eye, TrendingUp, DollarSign, Receipt, ShoppingCart } from "lucide-react"
3
3
  import { getDashboardStats } from "./actions"
4
4
  import { MetricCard, SalesLedger, PremiumCTA } from "./components/DashboardComponents"
5
+ import DashboardOnboarding from "./components/DashboardOnboarding"
6
+ import { getOnboardingStatus } from "../../../lib/onboarding/status"
7
+ import { setOnboardingDismissed } from "../../../lib/onboarding/actions"
5
8
  import { formatPrice } from "@nextblock-cms/utils"
6
9
 
7
10
  export default async function CmsDashboardPage() {
8
11
  const stats = await getDashboardStats();
9
- const gtmId = process.env.NEXT_PUBLIC_GTM_ID;
10
- const showWarning = !gtmId && gtmId !== "false";
12
+ const onboarding = await getOnboardingStatus({ isEcommerceActive: stats.isEcommerceActive });
11
13
 
12
14
  const isEcommerce = stats.isEcommerceActive;
13
15
 
14
16
  return (
15
17
  <div className="w-full space-y-8">
16
- {showWarning && (
17
- <Alert variant="destructive">
18
- <AlertTitle>Google Tag Manager Not Configured</AlertTitle>
19
- <AlertDescription>
20
- Missing <code>NEXT_PUBLIC_GTM_ID</code>. Analytics tracking is disabled.
21
- </AlertDescription>
22
- </Alert>
23
- )}
18
+ <DashboardOnboarding status={onboarding} dismissAction={setOnboardingDismissed} />
24
19
 
25
20
  <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
26
21
  <div>
@@ -2,7 +2,7 @@ import 'katex/dist/katex.min.css';
2
2
  import { redirect } from 'next/navigation';
3
3
  import CmsClientLayout from "./CmsClientLayout";
4
4
  import { verifyPackageOnline } from '@nextblock-cms/db/server';
5
- import { evaluateTwoFactor } from '../../lib/auth/twoFactor';
5
+ import { evaluateTwoFactor, getStaffTwoFactorReminder } from '../../lib/auth/twoFactor';
6
6
 
7
7
  export default async function CmsLayout({
8
8
  children,
@@ -16,13 +16,18 @@ export default async function CmsLayout({
16
16
  redirect('/two-factor?redirect_to=/cms/dashboard');
17
17
  }
18
18
 
19
- const [isEcommerceActive, isCortexAiActive] = await Promise.all([
19
+ const [isEcommerceActive, isCortexAiActive, showTwoFactorReminder] = await Promise.all([
20
20
  verifyPackageOnline('ecommerce'),
21
21
  verifyPackageOnline('cortex-ai'),
22
+ getStaffTwoFactorReminder(),
22
23
  ]);
23
24
 
24
25
  return (
25
- <CmsClientLayout isCortexAiActive={isCortexAiActive} isEcommerceActive={isEcommerceActive}>
26
+ <CmsClientLayout
27
+ isCortexAiActive={isCortexAiActive}
28
+ isEcommerceActive={isEcommerceActive}
29
+ showTwoFactorReminder={showTwoFactorReminder}
30
+ >
26
31
  {children}
27
32
  </CmsClientLayout>
28
33
  );
@@ -0,0 +1,60 @@
1
+ // app/cms/settings/email/actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { saveEmailSettings } from '../../../../lib/config/email-settings';
7
+ import { sendEmail } from '../../../actions/email';
8
+
9
+ async function assertAdmin() {
10
+ const supabase = createClient();
11
+ const {
12
+ data: { user },
13
+ } = await supabase.auth.getUser();
14
+ if (!user) {
15
+ throw new Error('You must be logged in to update settings.');
16
+ }
17
+ const { data: profile, error } = await supabase
18
+ .from('profiles')
19
+ .select('role')
20
+ .eq('id', user.id)
21
+ .single();
22
+ if (error || !profile || profile.role !== 'ADMIN') {
23
+ throw new Error('You do not have permission to perform this action.');
24
+ }
25
+ }
26
+
27
+ export async function updateEmailSettings(formData: FormData) {
28
+ await assertAdmin();
29
+
30
+ await saveEmailSettings({
31
+ host: String(formData.get('host') ?? ''),
32
+ port: String(formData.get('port') ?? ''),
33
+ fromEmail: String(formData.get('fromEmail') ?? ''),
34
+ fromName: String(formData.get('fromName') ?? ''),
35
+ secure: formData.get('secure') === 'on' || formData.get('secure') === 'true',
36
+ user: String(formData.get('user') ?? ''),
37
+ pass: String(formData.get('pass') ?? ''),
38
+ });
39
+
40
+ revalidatePath('/cms/settings/email');
41
+ return { success: true as const, message: 'Email settings saved.' };
42
+ }
43
+
44
+ export async function sendTestEmail(formData: FormData) {
45
+ await assertAdmin();
46
+
47
+ const to = String(formData.get('to') ?? '').trim();
48
+ if (!to) {
49
+ throw new Error('Enter a recipient email address.');
50
+ }
51
+
52
+ await sendEmail({
53
+ to,
54
+ subject: 'NextBlock test email',
55
+ text: 'This is a test email from your NextBlock CMS. SMTP is configured correctly.',
56
+ html: '<p>This is a test email from your NextBlock CMS. SMTP is configured correctly. 🎉</p>',
57
+ });
58
+
59
+ return { success: true as const, message: `Test email sent to ${to}.` };
60
+ }
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, useTransition } from 'react';
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ Button,
8
+ Checkbox,
9
+ Input,
10
+ Label,
11
+ Spinner,
12
+ } from '@nextblock-cms/ui';
13
+ import type { EmailSettingsView } from '../../../../../lib/config/email-settings';
14
+ import { updateEmailSettings, sendTestEmail } from '../actions';
15
+ import type { Message } from '../../../../../components/form-message';
16
+ import { useHotkeys } from '../../../../../hooks/use-hotkeys';
17
+
18
+ interface EmailFormProps {
19
+ initialSettings: EmailSettingsView;
20
+ }
21
+
22
+ export default function EmailForm({ initialSettings }: EmailFormProps) {
23
+ const [isPending, startTransition] = useTransition();
24
+ const [isTesting, startTestTransition] = useTransition();
25
+ const [message, setMessage] = useState<Message | null>(null);
26
+
27
+ const [host, setHost] = useState(initialSettings.host);
28
+ const [port, setPort] = useState(initialSettings.port);
29
+ const [fromEmail, setFromEmail] = useState(initialSettings.fromEmail);
30
+ const [fromName, setFromName] = useState(initialSettings.fromName);
31
+ const [secure, setSecure] = useState(initialSettings.secure);
32
+ const [user, setUser] = useState('');
33
+ const [pass, setPass] = useState('');
34
+ const [testTo, setTestTo] = useState('');
35
+
36
+ const formRef = useRef<HTMLFormElement>(null);
37
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
38
+
39
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
40
+ event.preventDefault();
41
+ setMessage(null);
42
+ const formData = new FormData();
43
+ formData.append('host', host);
44
+ formData.append('port', port);
45
+ formData.append('fromEmail', fromEmail);
46
+ formData.append('fromName', fromName);
47
+ formData.append('secure', secure ? 'on' : '');
48
+ // Only send credentials when changed — blank keeps the stored secret.
49
+ if (user.trim()) formData.append('user', user.trim());
50
+ if (pass.trim()) formData.append('pass', pass.trim());
51
+
52
+ startTransition(async () => {
53
+ try {
54
+ const result = await updateEmailSettings(formData);
55
+ setMessage({ success: result.message });
56
+ setUser('');
57
+ setPass('');
58
+ } catch (error) {
59
+ setMessage({ error: error instanceof Error ? error.message : 'Failed to save settings.' });
60
+ }
61
+ });
62
+ };
63
+
64
+ const handleSendTest = () => {
65
+ setMessage(null);
66
+ const formData = new FormData();
67
+ formData.append('to', testTo.trim());
68
+ startTestTransition(async () => {
69
+ try {
70
+ const result = await sendTestEmail(formData);
71
+ setMessage({ success: result.message });
72
+ } catch (error) {
73
+ setMessage({ error: error instanceof Error ? error.message : 'Failed to send test email.' });
74
+ }
75
+ });
76
+ };
77
+
78
+ return (
79
+ <div className="space-y-6">
80
+ {initialSettings.envFallbackActive && (
81
+ <Alert className="py-2 px-4">
82
+ <AlertDescription className="text-xs">
83
+ SMTP is currently read from environment variables (<code>SMTP_*</code>). Saving here
84
+ moves it into the database and takes precedence.
85
+ </AlertDescription>
86
+ </Alert>
87
+ )}
88
+
89
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
90
+ <div className="grid gap-4 sm:grid-cols-2">
91
+ <div className="space-y-2">
92
+ <Label htmlFor="host">SMTP host</Label>
93
+ <Input id="host" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" />
94
+ </div>
95
+ <div className="space-y-2">
96
+ <Label htmlFor="port">Port</Label>
97
+ <Input id="port" value={port} onChange={(e) => setPort(e.target.value)} placeholder="465" />
98
+ </div>
99
+ <div className="space-y-2">
100
+ <Label htmlFor="fromEmail">From email</Label>
101
+ <Input id="fromEmail" type="email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" />
102
+ </div>
103
+ <div className="space-y-2">
104
+ <Label htmlFor="fromName">From name</Label>
105
+ <Input id="fromName" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="My Site" />
106
+ </div>
107
+ <div className="space-y-2">
108
+ <Label htmlFor="user">
109
+ Username{' '}
110
+ {initialSettings.hasUser && (
111
+ <span className="text-xs text-muted-foreground">
112
+ (stored{initialSettings.userLast4 ? ` ••••${initialSettings.userLast4}` : ''} — leave blank to keep)
113
+ </span>
114
+ )}
115
+ </Label>
116
+ <Input id="user" autoComplete="off" value={user} onChange={(e) => setUser(e.target.value)} placeholder={initialSettings.hasUser ? '••••••••' : 'smtp username'} />
117
+ </div>
118
+ <div className="space-y-2">
119
+ <Label htmlFor="pass">
120
+ Password{' '}
121
+ {initialSettings.hasPass && (
122
+ <span className="text-xs text-muted-foreground">(stored — leave blank to keep)</span>
123
+ )}
124
+ </Label>
125
+ <Input id="pass" type="password" autoComplete="new-password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder={initialSettings.hasPass ? '••••••••' : 'smtp password'} />
126
+ </div>
127
+ </div>
128
+
129
+ <label className="flex items-start gap-3">
130
+ <Checkbox checked={secure} onCheckedChange={(c) => setSecure(c === true)} className="mt-1" />
131
+ <span className="text-sm">
132
+ <span className="font-medium">Use implicit TLS (SMTPS)</span>
133
+ <span className="block text-xs text-muted-foreground">
134
+ Enable for port 465. Disable for STARTTLS on ports like 587.
135
+ </span>
136
+ </span>
137
+ </label>
138
+
139
+ <div className="flex items-center gap-4">
140
+ <Button type="submit" disabled={isPending}>
141
+ {isPending ? (<><Spinner className="mr-2 h-4 w-4" /> Saving…</>) : 'Save settings'}
142
+ </Button>
143
+ </div>
144
+ </form>
145
+
146
+ <div className="rounded-lg border p-4 space-y-3">
147
+ <div className="space-y-1">
148
+ <Label htmlFor="testTo">Send a test email</Label>
149
+ <p className="text-xs text-muted-foreground">
150
+ Save your settings first, then send a test message to confirm delivery.
151
+ </p>
152
+ </div>
153
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
154
+ <Input
155
+ id="testTo"
156
+ type="email"
157
+ value={testTo}
158
+ onChange={(e) => setTestTo(e.target.value)}
159
+ placeholder="you@example.com"
160
+ className="sm:max-w-xs"
161
+ />
162
+ <Button type="button" variant="outline" onClick={handleSendTest} disabled={isTesting || !testTo.trim()}>
163
+ {isTesting ? (<><Spinner className="mr-2 h-4 w-4" /> Sending…</>) : 'Send test email'}
164
+ </Button>
165
+ </div>
166
+ </div>
167
+
168
+ {message && (
169
+ <Alert variant={'error' in message ? 'destructive' : 'success'} className="py-2 px-4">
170
+ <AlertDescription>
171
+ {'error' in message
172
+ ? message.error
173
+ : 'success' in message
174
+ ? message.success
175
+ : message.message}
176
+ </AlertDescription>
177
+ </Alert>
178
+ )}
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,28 @@
1
+ // app/cms/settings/email/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
3
+ import { getEmailSettingsView } from '../../../../lib/config/email-settings';
4
+ import EmailForm from './components/EmailForm';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export default async function EmailSettingsPage() {
9
+ const settings = await getEmailSettingsView();
10
+
11
+ return (
12
+ <div className="max-w-4xl mx-auto">
13
+ <Card>
14
+ <CardHeader>
15
+ <CardTitle>Email (SMTP)</CardTitle>
16
+ <CardDescription>
17
+ Configure the SMTP server used for transactional email (verification, password
18
+ resets, 2FA codes, contact notifications). Credentials are encrypted at rest. If left
19
+ blank, NextBlock falls back to the <code>SMTP_*</code> environment variables.
20
+ </CardDescription>
21
+ </CardHeader>
22
+ <CardContent>
23
+ <EmailForm initialSettings={settings} />
24
+ </CardContent>
25
+ </Card>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,60 @@
1
+ 'use server';
2
+
3
+ import { createClient } from '@nextblock-cms/db/server';
4
+ import { revalidatePath } from 'next/cache';
5
+ import {
6
+ getPrivacySettings as readPrivacySettings,
7
+ mergePrivacySettings,
8
+ } from '../../../../lib/privacy/settings';
9
+ import type { PrivacySettings } from '../../../../lib/privacy/types';
10
+
11
+ export interface GoogleAnalyticsSettings {
12
+ gtm_id: string;
13
+ ga_measurement_id: string;
14
+ custom_scripts: string;
15
+ }
16
+
17
+ export async function getGoogleAnalyticsSettings(): Promise<GoogleAnalyticsSettings> {
18
+ const settings = await readPrivacySettings();
19
+ return {
20
+ gtm_id: settings.gtm_id,
21
+ ga_measurement_id: settings.ga_measurement_id,
22
+ custom_scripts: settings.custom_scripts,
23
+ };
24
+ }
25
+
26
+ async function assertAdmin(): Promise<void> {
27
+ const supabase = createClient();
28
+ const {
29
+ data: { user },
30
+ } = await supabase.auth.getUser();
31
+ if (!user) {
32
+ throw new Error('You must be logged in to update settings.');
33
+ }
34
+ const { data: profile } = await supabase
35
+ .from('profiles')
36
+ .select('role')
37
+ .eq('id', user.id)
38
+ .single();
39
+ if (!profile || profile.role !== 'ADMIN') {
40
+ throw new Error('You do not have permission to perform this action.');
41
+ }
42
+ }
43
+
44
+ export async function updateGoogleAnalyticsSettings(formData: FormData) {
45
+ await assertAdmin();
46
+
47
+ // Only the analytics fields are touched; mergePrivacySettings preserves the
48
+ // banner/corporate fields owned by the Privacy & Consent page.
49
+ const patch: Partial<PrivacySettings> = {
50
+ gtm_id: (formData.get('gtm_id')?.toString() ?? '').trim(),
51
+ ga_measurement_id: (formData.get('ga_measurement_id')?.toString() ?? '').trim(),
52
+ custom_scripts: formData.get('custom_scripts')?.toString() ?? '',
53
+ };
54
+
55
+ await mergePrivacySettings(patch);
56
+ // The analytics guard (GTM/GA4 + custom scripts) lives in the root layout.
57
+ revalidatePath('/', 'layout');
58
+
59
+ return { success: true, message: 'Google Analytics settings saved.' };
60
+ }
@@ -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
+ }