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
@@ -35,14 +35,6 @@ export interface StoragePrefill {
35
35
  secretAccessKey: string;
36
36
  }
37
37
 
38
- interface SmtpPrefill {
39
- host: string;
40
- port: string;
41
- user: string;
42
- fromEmail: string;
43
- fromName: string;
44
- }
45
-
46
38
  export interface SupabaseEnvDetected {
47
39
  NEXT_PUBLIC_SUPABASE_URL: boolean;
48
40
  SUPABASE_URL: boolean;
@@ -58,20 +50,19 @@ interface Props {
58
50
  writable: boolean;
59
51
  siteUrl: string;
60
52
  storagePrefill: StoragePrefill;
61
- smtpPrefill: SmtpPrefill;
62
- turnstilePrefill: { siteKey: string };
63
53
  /** Which Supabase env vars the running deployment can actually see (read-only channels). */
64
54
  supabaseEnvDetected: SupabaseEnvDetected;
65
55
  }
66
56
 
67
- type StepId = 'connection' | 'storage' | 'email' | 'bot' | 'signups' | 'admin';
57
+ // Email (SMTP), bot protection, and sign-up policy are no longer collected here — they
58
+ // moved to the CMS (Settings → Configuration) and are nudged from the dashboard onboarding
59
+ // checklist. A fresh install only needs the database connection, media storage, and the
60
+ // first administrator account.
61
+ type StepId = 'connection' | 'storage' | 'admin';
68
62
 
69
63
  const STEP_TITLES: Record<StepId, string> = {
70
64
  connection: 'Database connection',
71
65
  storage: 'Media storage',
72
- email: 'Email (SMTP)',
73
- bot: 'Bot protection',
74
- signups: 'Sign-ups',
75
66
  admin: 'Administrator account',
76
67
  };
77
68
 
@@ -89,8 +80,6 @@ export default function SetupWizard({
89
80
  writable,
90
81
  siteUrl,
91
82
  storagePrefill,
92
- smtpPrefill,
93
- turnstilePrefill,
94
83
  supabaseEnvDetected,
95
84
  }: Props) {
96
85
  const [isPending, startTransition] = useTransition();
@@ -110,15 +99,8 @@ export default function SetupWizard({
110
99
  // "Start from a clean database" — only offered/honored on a local fresh install.
111
100
  const [resetFirst, setResetFirst] = useState(true);
112
101
 
113
- // Storage / SMTP / bot / signups / admin.
102
+ // Storage / admin.
114
103
  const [storage, setStorage] = useState(storagePrefill);
115
- const [smtp, setSmtp] = useState({ ...smtpPrefill, pass: '' });
116
- const [turnstileEnabled, setTurnstileEnabled] = useState(false);
117
- const [turnstile, setTurnstile] = useState({
118
- siteKey: turnstilePrefill.siteKey,
119
- secretKey: '',
120
- });
121
- const [autoAccept, setAutoAccept] = useState(false); // off by default (defense-in-depth)
122
104
  const [admin, setAdmin] = useState({ email: '', password: '', fullName: '' });
123
105
 
124
106
  const steps = useMemo<StepId[]>(() => {
@@ -131,7 +113,7 @@ export default function SetupWizard({
131
113
  if (channel !== 'vercel') {
132
114
  list.push('storage');
133
115
  }
134
- list.push('email', 'bot', 'signups', 'admin');
116
+ list.push('admin');
135
117
  return list;
136
118
  }, [configured, channel]);
137
119
 
@@ -205,12 +187,6 @@ export default function SetupWizard({
205
187
  // (baseUrl only differs if a channel prefilled a separate custom domain).
206
188
  put('NEXT_PUBLIC_R2_PUBLIC_URL', storage.publicUrl);
207
189
  put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl || storage.publicUrl);
208
- put('SMTP_HOST', smtp.host);
209
- put('SMTP_PORT', smtp.port);
210
- put('SMTP_USER', smtp.user);
211
- put('SMTP_PASS', smtp.pass);
212
- put('SMTP_FROM_EMAIL', smtp.fromEmail);
213
- put('SMTP_FROM_NAME', smtp.fromName);
214
190
  }
215
191
 
216
192
  setMessage(null);
@@ -218,12 +194,8 @@ export default function SetupWizard({
218
194
  startTransition(async () => {
219
195
  const result = await completeSetup({
220
196
  admin,
221
- autoAcceptSignups: autoAccept,
222
197
  envValues,
223
198
  resetFirst: resetFirst && writable,
224
- turnstile: turnstileEnabled
225
- ? { provider: 'turnstile', siteKey: turnstile.siteKey, secretKey: turnstile.secretKey }
226
- : { provider: 'none', siteKey: '', secretKey: '' },
227
199
  });
228
200
 
229
201
  if (!result.ok) {
@@ -400,122 +372,6 @@ export default function SetupWizard({
400
372
  <StorageStep storage={storage} setStorage={setStorage} channel={channel} />
401
373
  )}
402
374
 
403
- {current === 'email' && (
404
- <div className="space-y-4">
405
- {channel === 'docker' && (
406
- <p className="text-xs text-muted-foreground">
407
- Docker auto-confirms sign-ups when SMTP is not configured, so this is optional.
408
- </p>
409
- )}
410
- <div className="grid gap-4 sm:grid-cols-2">
411
- <Field label="SMTP host" htmlFor="smtpHost">
412
- <Input
413
- id="smtpHost"
414
- value={smtp.host}
415
- onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
416
- />
417
- </Field>
418
- <Field label="Port" htmlFor="smtpPort">
419
- <Input
420
- id="smtpPort"
421
- value={smtp.port}
422
- onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
423
- />
424
- </Field>
425
- <Field label="Username" htmlFor="smtpUser">
426
- <Input
427
- id="smtpUser"
428
- value={smtp.user}
429
- onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
430
- />
431
- </Field>
432
- <Field label="Password" htmlFor="smtpPass">
433
- <Input
434
- id="smtpPass"
435
- type="password"
436
- value={smtp.pass}
437
- onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })}
438
- />
439
- </Field>
440
- <Field label="From email" htmlFor="smtpFromEmail">
441
- <Input
442
- id="smtpFromEmail"
443
- value={smtp.fromEmail}
444
- onChange={(e) => setSmtp({ ...smtp, fromEmail: e.target.value })}
445
- />
446
- </Field>
447
- <Field label="From name" htmlFor="smtpFromName">
448
- <Input
449
- id="smtpFromName"
450
- value={smtp.fromName}
451
- onChange={(e) => setSmtp({ ...smtp, fromName: e.target.value })}
452
- />
453
- </Field>
454
- </div>
455
- {!writable && channel !== 'docker' && (
456
- <p className="text-xs text-muted-foreground">
457
- On this platform, set SMTP_* as environment variables in your hosting dashboard.
458
- </p>
459
- )}
460
- </div>
461
- )}
462
-
463
- {current === 'bot' && (
464
- <div className="space-y-4">
465
- <label className="flex items-start gap-3">
466
- <Checkbox
467
- checked={turnstileEnabled}
468
- onCheckedChange={(c) => setTurnstileEnabled(c === true)}
469
- className="mt-1"
470
- />
471
- <span className="text-sm">
472
- <span className="font-medium">Enable Cloudflare Turnstile</span>
473
- <span className="block text-xs text-muted-foreground">
474
- Protects sign-up / sign-in forms. Stored securely in the database.
475
- </span>
476
- </span>
477
- </label>
478
- {turnstileEnabled && (
479
- <div className="grid gap-4 sm:grid-cols-2">
480
- <Field label="Site key" htmlFor="tsSite">
481
- <Input
482
- id="tsSite"
483
- value={turnstile.siteKey}
484
- onChange={(e) => setTurnstile({ ...turnstile, siteKey: e.target.value })}
485
- />
486
- </Field>
487
- <Field label="Secret key" htmlFor="tsSecret">
488
- <Input
489
- id="tsSecret"
490
- type="password"
491
- value={turnstile.secretKey}
492
- onChange={(e) => setTurnstile({ ...turnstile, secretKey: e.target.value })}
493
- />
494
- </Field>
495
- </div>
496
- )}
497
- </div>
498
- )}
499
-
500
- {current === 'signups' && (
501
- <label className="flex items-start gap-3">
502
- <Checkbox
503
- checked={autoAccept}
504
- onCheckedChange={(c) => setAutoAccept(c === true)}
505
- className="mt-1"
506
- />
507
- <span className="text-sm">
508
- <span className="font-medium">
509
- Auto-approve local registrations (skip outbound email verification)
510
- </span>
511
- <span className="block text-xs text-muted-foreground">
512
- New sign-ups become active immediately, even without SMTP configured. Convenient
513
- for local / self-hosted use; leave off for public production sites.
514
- </span>
515
- </span>
516
- </label>
517
- )}
518
-
519
375
  {current === 'admin' && (
520
376
  <div className="space-y-4">
521
377
  <Field label="Full name" htmlFor="adminName">
@@ -546,6 +402,13 @@ export default function SetupWizard({
546
402
  verification email needed). Finishing also applies the database schema and saved
547
403
  settings, so it can take up to a minute.
548
404
  </p>
405
+ <Alert className="py-2 px-4">
406
+ <AlertDescription className="text-xs">
407
+ Next, your dashboard will guide you through finishing setup — branding, copyright,
408
+ email (SMTP), payment providers, and bot protection are all configured there, no
409
+ environment variables required.
410
+ </AlertDescription>
411
+ </Alert>
549
412
 
550
413
  {writable && (
551
414
  <label className="flex items-start gap-3 rounded-lg border p-3">
@@ -606,14 +469,8 @@ function stepDescription(step: StepId, channel: DeployChannel): string {
606
469
  : channel === 'vercel'
607
470
  ? 'Using Supabase Storage (S3-compatible) for media.'
608
471
  : 'Bring your own Cloudflare R2 bucket for media storage.';
609
- case 'email':
610
- return 'Optional. Used for verification emails, password resets, and 2FA codes.';
611
- case 'bot':
612
- return 'Optional. Add Cloudflare Turnstile to your auth forms.';
613
- case 'signups':
614
- return 'Choose how new public registrations are handled.';
615
472
  case 'admin':
616
- return 'Create the first administrator account.';
473
+ return 'Create the first administrator account — the last step. Email, payments, branding, and the rest are configured from your dashboard.';
617
474
  default:
618
475
  return '';
619
476
  }
@@ -91,14 +91,6 @@ export default async function SetupPage() {
91
91
  writable={isLocalWritableEnv()}
92
92
  siteUrl={process.env.NEXT_PUBLIC_URL ?? ''}
93
93
  storagePrefill={buildStoragePrefill(channel, supabaseUrl)}
94
- smtpPrefill={{
95
- host: process.env.SMTP_HOST ?? '',
96
- port: process.env.SMTP_PORT ?? '465',
97
- user: process.env.SMTP_USER ?? '',
98
- fromEmail: process.env.SMTP_FROM_EMAIL ?? '',
99
- fromName: process.env.SMTP_FROM_NAME ?? '',
100
- }}
101
- turnstilePrefill={{ siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '' }}
102
94
  supabaseEnvDetected={{
103
95
  NEXT_PUBLIC_SUPABASE_URL: Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL),
104
96
  SUPABASE_URL: Boolean(process.env.SUPABASE_URL),
@@ -3,7 +3,6 @@
3
3
  import type { Database } from '@nextblock-cms/db';
4
4
  import { cn } from '@nextblock-cms/utils';
5
5
  import { usePathname } from 'next/navigation';
6
- import Link from 'next/link';
7
6
  import { createContext, useContext, useMemo, type ReactNode } from 'react';
8
7
  import Header from './Header';
9
8
  import FooterNavigation from './FooterNavigation';
@@ -148,12 +147,6 @@ export function AppShell({
148
147
  )}
149
148
  <div className="flex flex-row items-center gap-4">
150
149
  <p className="text-muted-foreground">{copyrightText}</p>
151
- <Link href="/privacy-policy" className="hover:underline">
152
- Privacy Policy
153
- </Link>
154
- <Link href="/terms-of-service" className="hover:underline">
155
- Terms of Service
156
- </Link>
157
150
  <ThemeSwitcher />
158
151
  </div>
159
152
  </div>
@@ -17,6 +17,7 @@ import ClientTextBlockRenderer from "./blocks/renderers/ClientTextBlockRenderer"
17
17
  import { getCachedCustomBlockDefinitionBySlug } from "../lib/custom-block-definitions";
18
18
  import { CachedDynamicLayoutEngine } from "./renderers/CachedDynamicLayoutEngine";
19
19
  import { resolveBlockRelations } from "../lib/resolve-block-relations";
20
+ import { substitutePrivacyMergeTags } from "../lib/privacy/contact-emails";
20
21
 
21
22
  const ECOMMERCE_BLOCK_TYPES = new Set([
22
23
  "product_grid",
@@ -143,9 +144,16 @@ async function renderLoadedBlock({
143
144
 
144
145
  // Keep common LCP-adjacent text blocks out of the dynamic renderer manifest.
145
146
  if (block.block_type === 'text') {
147
+ // Top-level text blocks bypass the server TextBlockRenderer, so resolve any
148
+ // merge tags (e.g. {{privacy_email}} on the Privacy/Terms pages) here.
149
+ const textContent = block.content as { html_content?: string } | null;
150
+ const rawHtml = typeof textContent?.html_content === 'string' ? textContent.html_content : '';
151
+ const html = rawHtml.includes('{{')
152
+ ? await substitutePrivacyMergeTags(rawHtml)
153
+ : rawHtml;
146
154
  return (
147
155
  <ClientTextBlockRenderer
148
- content={block.content as any}
156
+ content={{ ...(textContent as any), html_content: html }}
149
157
  languageId={languageId}
150
158
  visualEditAttributes={visualEditAttributes}
151
159
  />
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentType } from "react";
4
+
5
+ interface DeferredGoogleAnalyticsProps {
6
+ gaId?: string;
7
+ nonce?: string;
8
+ }
9
+
10
+ const interactionEvents: Array<keyof WindowEventMap> = [
11
+ "pointerdown",
12
+ "keydown",
13
+ "scroll",
14
+ "touchstart",
15
+ ];
16
+
17
+ type GoogleAnalyticsComponent = ComponentType<{
18
+ gaId: string;
19
+ nonce?: string;
20
+ }>;
21
+
22
+ export function DeferredGoogleAnalytics({
23
+ gaId,
24
+ nonce,
25
+ }: DeferredGoogleAnalyticsProps) {
26
+ const [GoogleAnalytics, setGoogleAnalytics] =
27
+ useState<GoogleAnalyticsComponent | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!gaId) {
31
+ return;
32
+ }
33
+
34
+ let isMounted = true;
35
+
36
+ function removeListeners() {
37
+ interactionEvents.forEach((eventName) => {
38
+ window.removeEventListener(eventName, enableGa);
39
+ });
40
+ }
41
+
42
+ function enableGa() {
43
+ removeListeners();
44
+
45
+ void import("@next/third-parties/google").then(({ GoogleAnalytics }) => {
46
+ if (isMounted) {
47
+ setGoogleAnalytics(() => GoogleAnalytics as GoogleAnalyticsComponent);
48
+ }
49
+ });
50
+ }
51
+
52
+ interactionEvents.forEach((eventName) => {
53
+ window.addEventListener(eventName, enableGa, {
54
+ once: true,
55
+ passive: true,
56
+ });
57
+ });
58
+
59
+ return () => {
60
+ isMounted = false;
61
+ removeListeners();
62
+ };
63
+ }, [gaId]);
64
+
65
+ if (!gaId || !GoogleAnalytics) {
66
+ return null;
67
+ }
68
+
69
+ return <GoogleAnalytics gaId={gaId} nonce={nonce} />;
70
+ }
@@ -2,17 +2,18 @@ import React from "react";
2
2
  import { headers } from 'next/headers';
3
3
  import ClientTextBlockRenderer from "./ClientTextBlockRenderer";
4
4
  import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
5
+ import { substitutePrivacyMergeTags } from "../../../lib/privacy/contact-emails";
5
6
 
6
7
  export type TextBlockContent = {
7
8
  html_content?: string;
8
9
  };
9
10
 
10
- interface TextBlockRendererProps {
11
- content: TextBlockContent;
12
- languageId: number;
13
- visualEditAttributes?: VisualEditAttributes;
14
- renderContext?: 'prose' | 'section';
15
- }
11
+ interface TextBlockRendererProps {
12
+ content: TextBlockContent;
13
+ languageId: number;
14
+ visualEditAttributes?: VisualEditAttributes;
15
+ renderContext?: 'prose' | 'section';
16
+ }
16
17
 
17
18
  function addNonceToInlineScripts(html: string, nonce: string): string {
18
19
  if (!html || !nonce) return html || '';
@@ -24,23 +25,27 @@ function addNonceToInlineScripts(html: string, nonce: string): string {
24
25
  }
25
26
 
26
27
  const TextBlockRenderer: React.FC<TextBlockRendererProps> = async ({
27
- content,
28
- languageId,
29
- visualEditAttributes,
30
- renderContext = 'prose',
31
- }) => {
28
+ content,
29
+ languageId,
30
+ visualEditAttributes,
31
+ renderContext = 'prose',
32
+ }) => {
32
33
  const hdrs = await headers();
33
34
  const nonce = hdrs.get('x-nonce') || '';
34
- const htmlWithNonce = content.html_content ? addNonceToInlineScripts(content.html_content, nonce) : '';
35
+ let html = content.html_content || '';
36
+ if (html.includes('{{')) {
37
+ html = await substitutePrivacyMergeTags(html);
38
+ }
39
+ const htmlWithNonce = html ? addNonceToInlineScripts(html, nonce) : '';
35
40
  const patchedContent = { ...content, html_content: htmlWithNonce };
36
41
  return (
37
- <ClientTextBlockRenderer
38
- content={patchedContent}
39
- languageId={languageId}
40
- visualEditAttributes={visualEditAttributes}
41
- renderContext={renderContext}
42
- />
43
- );
44
- };
42
+ <ClientTextBlockRenderer
43
+ content={patchedContent}
44
+ languageId={languageId}
45
+ visualEditAttributes={visualEditAttributes}
46
+ renderContext={renderContext}
47
+ />
48
+ );
49
+ };
45
50
 
46
51
  export default TextBlockRenderer;
@@ -5,10 +5,12 @@
5
5
  // shows zero analytics bytes, preserving the Lighthouse budget.
6
6
  import { useEffect, useRef, useState } from 'react';
7
7
  import { DeferredGoogleTagManager } from '../DeferredGoogleTagManager';
8
+ import { DeferredGoogleAnalytics } from '../DeferredGoogleAnalytics';
8
9
  import { CONSENT_CHANGE_EVENT, readConsent } from '../../lib/privacy/consent-client';
9
10
 
10
11
  interface ConsentGatedAnalyticsProps {
11
12
  gtmId?: string;
13
+ gaMeasurementId?: string;
12
14
  customScripts?: string;
13
15
  nonce?: string;
14
16
  }
@@ -29,6 +31,7 @@ function injectCustomScripts(markup: string, nonce?: string) {
29
31
 
30
32
  export function ConsentGatedAnalytics({
31
33
  gtmId,
34
+ gaMeasurementId,
32
35
  customScripts,
33
36
  nonce,
34
37
  }: ConsentGatedAnalyticsProps) {
@@ -54,6 +57,14 @@ export function ConsentGatedAnalytics({
54
57
  }
55
58
  }, [analyticsAllowed, customScripts, nonce]);
56
59
 
57
- if (!analyticsAllowed || !gtmId) return null;
58
- return <DeferredGoogleTagManager gtmId={gtmId} nonce={nonce} />;
60
+ if (!analyticsAllowed) return null;
61
+ if (!gtmId && !gaMeasurementId) return null;
62
+ return (
63
+ <>
64
+ {gtmId && <DeferredGoogleTagManager gtmId={gtmId} nonce={nonce} />}
65
+ {gaMeasurementId && (
66
+ <DeferredGoogleAnalytics gaId={gaMeasurementId} nonce={nonce} />
67
+ )}
68
+ </>
69
+ );
59
70
  }
@@ -109,6 +109,17 @@ repo expects at least:
109
109
  - `CRON_SECRET`, `DRAFT_MODE_SECRET`, `REVALIDATE_SECRET_TOKEN` — auto-generated
110
110
  by `npm run setup`
111
111
 
112
+ > **Supabase key aliases.** The names above are the local-dev canon, but the app also
113
+ > accepts the *new-style* names the hosted/Vercel Supabase Marketplace integration
114
+ > injects: `SUPABASE_URL` (non-prefixed), `SUPABASE_PUBLISHABLE_KEY` /
115
+ > `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (anon-equivalent), and `SUPABASE_SECRET_KEY`
116
+ > (service-role-equivalent). Resolution lives in `apps/nextblock/lib/setup/env-status.ts`
117
+ > (`resolveSupabaseUrl` / `resolveSupabaseAnonKey` / `resolveSupabaseServiceKey`); read
118
+ > Supabase env through those (or the same inline alias chain in published libs) rather
119
+ > than a single raw name. `DRAFT_MODE_SECRET` / `REVALIDATE_SECRET_TOKEN` are optional in
120
+ > production — when unset they are derived from the service-role key (see
121
+ > `apps/nextblock/lib/app-secrets.ts`). See [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md).
122
+
112
123
  Captured by `npm run setup` and needed for a complete CMS:
113
124
 
114
125
  - R2 credentials for media storage. The app builds and serves without them, but
@@ -115,11 +115,62 @@ package activation state.
115
115
 
116
116
  ## Publishing and Release Notes
117
117
 
118
- Inside the monorepo, CLI release work is still tied to the source workspace:
119
-
120
- - library builds and publishes happen from the workspace
121
- - template sync happens before CLI packaging
122
- - the CLI package itself is versioned in `apps/create-nextblock/package.json`
123
-
124
- If a generated project looks stale, check the sync script and template output
125
- before assuming the source app is missing the feature.
118
+ A generated project installs the libraries from **npm**, so a feature only reaches
119
+ scaffolds after the libs are republished. (The monorepo's own Vercel deploy builds the
120
+ libs from source, so it sees changes immediately — only `npm create` scaffolds need a
121
+ republish.)
122
+
123
+ ### Release commands
124
+
125
+ - `npm run release:all -- <version>` build **and publish every package** at one
126
+ synchronized version, in dependency order: `utils → ui → sdk → db → editor → ecom`,
127
+ then `release-cli.js` (which stamps the root + template + `create-nextblock`, re-syncs
128
+ the template, and publishes the CLI). Pass an explicit semver (e.g. `0.10.2`);
129
+ `--dry-run` prints the plan only.
130
+ - `npm run build:<lib>` (`build:utils|ui|db|editor|sdk|ecom`) and
131
+ `node tools/scripts/release-lib.js <lib> <version>` — build + publish a **single** lib
132
+ (`<lib>` is the nx project name, so use `ecommerce`, which maps to the published
133
+ `@nextblock-cms/ecom`). `npx nx build <lib>` only *compiles*, it does not publish.
134
+
135
+ ### npm 2FA / OTP (and capturing a log)
136
+
137
+ Publishing requires a one-time password if the npm account has 2FA. **Piping the command
138
+ output breaks the interactive OTP prompt** — `npm run release:all -- … 2>&1 | Tee-Object …`
139
+ (or `| tee`) fails with `npm error code EOTP` because npm no longer has a TTY. Either:
140
+
141
+ - set an npm **Automation** token (`npm config set //registry.npmjs.org/:_authToken …`),
142
+ which bypasses 2FA — then piping to a log file is fine; or
143
+ - capture with PowerShell `Start-Transcript -Path release.log; npm run release:all -- … ;
144
+ Stop-Transcript`, which records the session while npm keeps its terminal.
145
+
146
+ `release:all` has no "already published" guard: if it dies partway, re-running the same
147
+ version re-publishes from the top and 403s on the first already-published lib. Finish a
148
+ partial release by running the **remaining** libs individually
149
+ (`node tools/scripts/release-lib.js <lib> <version>` … then `release-cli.js <version>`),
150
+ or bump to a fresh version and re-run the whole thing.
151
+
152
+ ### Library build gotchas (dts / tsconfig)
153
+
154
+ Each lib emits its `.d.ts` via `vite-plugin-dts` running tsc on `tsconfig.lib.json`. When a
155
+ lib imports a sibling (`ui`/`db` import `utils`; `ecom` imports all), how you wire the
156
+ tsconfig decides whether the build log is clean:
157
+
158
+ - A **composite** lib (`ui`, `db` — `db` inherits it) must **list the imported sibling's
159
+ sources in `include`** (e.g. `"../utils/src/**/*.ts"`) and keep `"references": []`.
160
+ Mirror `libs/editor`, which always built clean this way. A composite project
161
+ `reference` to the sibling triggers `TS6305` ("output not built" — vite never produces
162
+ the `tsc -b` out-tsc output); empty `references` *without* the `include` triggers
163
+ `TS6307` ("file not listed"). Both are non-fatal log noise but should stay at zero.
164
+ - A **non-composite** lib (`ecom`, which extends `tsconfig.base.json`) just needs
165
+ `"references": []` — no `include` of siblings.
166
+ - A **strict** lib (`db`/`sdk` set `noPropertyAccessFromIndexSignature`) compiles the
167
+ sibling's *source* under its strict rules, so `libs/utils` must stay strict-clean
168
+ (bracket-access undeclared keys, e.g. `process.env['R2_BUCKET_NAME']`).
169
+
170
+ `vite-plugin-dts` `entryRoot: 'src'` keeps emission to the lib's own `src`, so listing
171
+ sibling sources does **not** leak their `.d.ts` into the tarball. The published `bin` path
172
+ in `apps/create-nextblock/package.json` should have **no leading `./`** (`bin/…`, not
173
+ `./bin/…`) or npm "auto-corrects" it with a publish warning.
174
+
175
+ If a generated project looks stale, check the sync script and template output (and whether
176
+ the libs were actually republished) before assuming the source app is missing the feature.
@@ -17,6 +17,7 @@ library surfaces rather than historical planning notes.
17
17
  - Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
18
18
  - Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
19
19
  - Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
20
+ - One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
20
21
 
21
22
  ## Audience Guide
22
23
 
@@ -26,7 +27,9 @@ library surfaces rather than historical planning notes.
26
27
  - Custom block work: read `03`, then `10`.
27
28
  - AI / Cortex work: read `08`.
28
29
  - CLI or template work: read `06`.
30
+ - Publishing the libraries / scaffold CLI: read `06`.
29
31
  - Running everything locally without cloud accounts: read `11`.
32
+ - One-click cloud deploy and the browser setup wizard: read `12`.
30
33
  - AI agents: start with this index, then move directly to the subsystem file that
31
34
  matches the task. Treat `apps/nextblock`, `libs/*`, and
32
35
  `libs/db/src/supabase/migrations` as the final authority if a doc and code ever