create-nextblock 0.8.11 → 0.9.5

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 (54) hide show
  1. package/bin/create-nextblock.js +101 -35
  2. package/docker-template/.dockerignore +23 -0
  3. package/docker-template/.env.docker.example +56 -0
  4. package/docker-template/Dockerfile +85 -0
  5. package/docker-template/docker/db/init/99-jwt.sql +6 -0
  6. package/docker-template/docker/db/init/99-roles.sql +25 -0
  7. package/docker-template/docker/kong/kong.yml +112 -0
  8. package/docker-template/docker/migrate/run-migrations.sh +51 -0
  9. package/docker-template/docker-compose.yml +219 -0
  10. package/docker-template/scripts/docker-setup.mjs +242 -0
  11. package/package.json +1 -1
  12. package/scripts/sync-template.js +29 -0
  13. package/templates/nextblock-template/.dockerignore +23 -0
  14. package/templates/nextblock-template/Dockerfile +85 -0
  15. package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
  16. package/templates/nextblock-template/app/actions.ts +58 -8
  17. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
  18. package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +9 -9
  19. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
  20. package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
  21. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
  22. package/templates/nextblock-template/app/layout.tsx +57 -3
  23. package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
  24. package/templates/nextblock-template/app/page.tsx +6 -0
  25. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
  26. package/templates/nextblock-template/app/setup/SetupWizard.tsx +771 -0
  27. package/templates/nextblock-template/app/setup/layout.tsx +13 -0
  28. package/templates/nextblock-template/app/setup/page.tsx +103 -0
  29. package/templates/nextblock-template/components/AppShell.tsx +12 -0
  30. package/templates/nextblock-template/components/header-auth.tsx +24 -62
  31. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -0
  32. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -0
  33. package/templates/nextblock-template/docker/kong/kong.yml +112 -0
  34. package/templates/nextblock-template/docker/migrate/run-migrations.sh +51 -0
  35. package/templates/nextblock-template/docker-compose.yml +219 -0
  36. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
  37. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
  38. package/templates/nextblock-template/docs/README.md +2 -0
  39. package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
  40. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
  41. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +5 -5
  42. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +2 -2
  43. package/templates/nextblock-template/lib/setup/actions.ts +370 -0
  44. package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
  45. package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
  46. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
  47. package/templates/nextblock-template/lib/setup/schema-apply.ts +379 -0
  48. package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
  49. package/templates/nextblock-template/lib/setup/types.ts +18 -0
  50. package/templates/nextblock-template/next.config.js +9 -0
  51. package/templates/nextblock-template/package.json +6 -2
  52. package/templates/nextblock-template/proxy.ts +143 -49
  53. package/templates/nextblock-template/scripts/docker-setup.mjs +242 -0
  54. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -1,13 +1,14 @@
1
1
  "use server";
2
2
 
3
3
  import { encodedRedirect } from "@nextblock-cms/utils/server";
4
- import { createClient } from "@nextblock-cms/db/server";
4
+ import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
5
5
  import { headers } from "next/headers";
6
6
  import { redirect } from "next/navigation";
7
7
  import { resolvePostAuthRedirect } from "../lib/auth-redirects";
8
8
  import { createEmailChallenge, evaluateTwoFactor } from "../lib/auth/twoFactor";
9
9
  import { REMEMBER_INTENT_COOKIE, setSecureCookie } from "../lib/auth/cookies";
10
10
  import { sendTwoFactorCodeEmail } from "./actions/twoFactorEmail";
11
+ import { getSystemConfiguration } from "../lib/setup/system-config";
11
12
 
12
13
  export const signUpAction = async (formData: FormData) => {
13
14
  const email = formData.get("email")?.toString();
@@ -29,7 +30,48 @@ export const signUpAction = async (formData: FormData) => {
29
30
  );
30
31
  }
31
32
 
32
- const { error } = await supabase.auth.signUp({
33
+ // Auto-accept mode (system_configuration.auto_accept_signups): create an already-
34
+ // confirmed account via the service role so the user is active immediately, with no
35
+ // outbound verification email — regardless of SMTP / project email-confirmation
36
+ // settings. Falls through to the standard signUp flow if the service role isn't
37
+ // available. NOTE: keep supabase.auth calls outside any try/catch so the redirect
38
+ // they (and encodedRedirect) throw internally is never swallowed.
39
+ const { auto_accept_signups: autoAcceptSignups } = await getSystemConfiguration();
40
+ if (autoAcceptSignups) {
41
+ let admin: ReturnType<typeof getServiceRoleSupabaseClient> | null = null;
42
+ try {
43
+ admin = getServiceRoleSupabaseClient();
44
+ } catch {
45
+ admin = null; // service role missing — use the standard flow below
46
+ }
47
+
48
+ if (admin) {
49
+ const { error: createError } = await admin.auth.admin.createUser({
50
+ email,
51
+ password,
52
+ email_confirm: true,
53
+ });
54
+
55
+ if (createError) {
56
+ if (createError.message.toLowerCase().includes("already")) {
57
+ return encodedRedirect("error", "/sign-up", "auth.signup_existing_account_hint");
58
+ }
59
+ return encodedRedirect("error", "/sign-up", createError.message);
60
+ }
61
+
62
+ const { error: signInError } = await supabase.auth.signInWithPassword({
63
+ email,
64
+ password,
65
+ });
66
+ if (signInError) {
67
+ return encodedRedirect("error", "/sign-in", signInError.message);
68
+ }
69
+
70
+ return redirect("/post-sign-in");
71
+ }
72
+ }
73
+
74
+ const { data, error } = await supabase.auth.signUp({
33
75
  email,
34
76
  password,
35
77
  options: {
@@ -57,13 +99,21 @@ export const signUpAction = async (formData: FormData) => {
57
99
  }
58
100
 
59
101
  return encodedRedirect("error", "/sign-up", error.message);
60
- } else {
61
- return encodedRedirect(
62
- "success",
63
- "/sign-up",
64
- "auth.signup_check_email_profile",
65
- );
66
102
  }
103
+
104
+ // When sign-up returns a session, the account is already confirmed — self-hosted GoTrue with
105
+ // autoconfirm (no SMTP), or any project without email confirmation. The user is already signed
106
+ // in, so send them into the app instead of telling them to check an email that was never sent.
107
+ // (The first account becomes ADMIN and lands in the dashboard; everyone else routes normally.)
108
+ if (data.session) {
109
+ return redirect("/post-sign-in");
110
+ }
111
+
112
+ return encodedRedirect(
113
+ "success",
114
+ "/sign-up",
115
+ "auth.signup_check_email_profile",
116
+ );
67
117
  };
68
118
 
69
119
  export const signInAction = async (formData: FormData) => {
@@ -7816,6 +7816,89 @@ npm run dev</code></pre>
7816
7816
  ), 0 FROM target_posts tp WHERE tp.slug = 'comment-configurer-nextblock';
7817
7817
 
7818
7818
 
7819
+ -- >>> FROM: 00000000000030_setup_system_configuration.sql <<<
7820
+ -- 00000000000030_setup_system_configuration.sql
7821
+ -- First-Boot Setup Wizard: global system configuration.
7822
+ --
7823
+ -- Adds a dedicated, RLS-locked \`system_configuration\` table that holds settings the
7824
+ -- browser /setup wizard manages and that don't belong in the public key-value
7825
+ -- \`site_settings\` store. It is a singleton (exactly one row, id = 1).
7826
+ --
7827
+ -- Shape:
7828
+ -- auto_accept_signups boolean -- when true, new public sign-ups skip outbound email
7829
+ -- verification (the signup route uses a service-role
7830
+ -- admin.createUser({ email_confirm: true }) path).
7831
+ -- settings jsonb -- forward-compatible catch-all for future feature
7832
+ -- toggles ({} by default). Do NOT store true secrets
7833
+ -- here (Turnstile/AI secrets keep living in their
7834
+ -- existing site_settings sensitive keys).
7835
+ --
7836
+ -- Access is locked to the ADMIN role for normal clients (NextBlock has no separate
7837
+ -- "super-admin" tier — ADMIN is the top level). The service_role retains full access
7838
+ -- so the wizard can seed/read it before any admin exists.
7839
+
7840
+ CREATE TABLE IF NOT EXISTS public.system_configuration (
7841
+ id integer PRIMARY KEY DEFAULT 1,
7842
+ auto_accept_signups boolean NOT NULL DEFAULT false,
7843
+ settings jsonb NOT NULL DEFAULT '{}'::jsonb,
7844
+ updated_at timestamptz NOT NULL DEFAULT now(),
7845
+ CONSTRAINT system_configuration_singleton CHECK (id = 1)
7846
+ );
7847
+
7848
+ COMMENT ON TABLE public.system_configuration IS
7849
+ 'Singleton (id = 1) of global setup-wizard configuration. ADMIN-only via RLS; never store secrets in settings.';
7850
+
7851
+ -- Seed the single row so reads always find it.
7852
+ INSERT INTO public.system_configuration (id, auto_accept_signups, settings)
7853
+ VALUES (1, false, '{}'::jsonb)
7854
+ ON CONFLICT (id) DO NOTHING;
7855
+
7856
+ ALTER TABLE public.system_configuration ENABLE ROW LEVEL SECURITY;
7857
+
7858
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.system_configuration TO authenticated;
7859
+ GRANT ALL ON public.system_configuration TO service_role;
7860
+
7861
+ -- ADMIN-only for every operation by authenticated clients.
7862
+ DROP POLICY IF EXISTS system_configuration_admin_select ON public.system_configuration;
7863
+ CREATE POLICY system_configuration_admin_select
7864
+ ON public.system_configuration
7865
+ FOR SELECT
7866
+ TO authenticated
7867
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN');
7868
+
7869
+ DROP POLICY IF EXISTS system_configuration_admin_insert ON public.system_configuration;
7870
+ CREATE POLICY system_configuration_admin_insert
7871
+ ON public.system_configuration
7872
+ FOR INSERT
7873
+ TO authenticated
7874
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');
7875
+
7876
+ DROP POLICY IF EXISTS system_configuration_admin_update ON public.system_configuration;
7877
+ CREATE POLICY system_configuration_admin_update
7878
+ ON public.system_configuration
7879
+ FOR UPDATE
7880
+ TO authenticated
7881
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN')
7882
+ WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');
7883
+
7884
+ DROP POLICY IF EXISTS system_configuration_admin_delete ON public.system_configuration;
7885
+ CREATE POLICY system_configuration_admin_delete
7886
+ ON public.system_configuration
7887
+ FOR DELETE
7888
+ TO authenticated
7889
+ USING ((SELECT public.get_current_user_role()) = 'ADMIN');
7890
+
7891
+ -- Service role bypasses the ADMIN checks (used by the wizard before an admin exists,
7892
+ -- and by the signup route to read auto_accept_signups as an anonymous visitor).
7893
+ DROP POLICY IF EXISTS system_configuration_service_role_all ON public.system_configuration;
7894
+ CREATE POLICY system_configuration_service_role_all
7895
+ ON public.system_configuration
7896
+ FOR ALL
7897
+ TO service_role
7898
+ USING (true)
7899
+ WITH CHECK (true);
7900
+
7901
+
7819
7902
  -- Step D: Anchor preserved profiles
7820
7903
  INSERT INTO public.profiles (id, updated_at, full_name, avatar_url, website, role)
7821
7904
  SELECT preserved_user.id, NULL, NULL, NULL, NULL, 'ADMIN'
@@ -1,7 +1,7 @@
1
1
  // app/api/upload/presigned-url/route.ts
2
2
  import { NextRequest, NextResponse } from "next/server";
3
- import { createClient } from "@nextblock-cms/db/server"; // Server client for auth
4
- import { getS3Client } from "@nextblock-cms/utils/server";
3
+ import { createClient } from "@nextblock-cms/db/server"; // Server client for auth
4
+ import { getS3PresignClient } from "@nextblock-cms/utils/server";
5
5
  import { PutObjectCommand } from "@aws-sdk/client-s3";
6
6
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
7
7
 
@@ -32,13 +32,13 @@ export async function POST(request: NextRequest) {
32
32
  }
33
33
 
34
34
  try {
35
- const s3Client = await getS3Client();
36
- if (!s3Client) {
37
- console.error('R2 client is not configured. Check your R2 environment variables.');
38
- return NextResponse.json({ error: 'File uploads are not configured on this server.' }, { status: 500 });
39
- }
40
-
41
- const { filename, contentType, size, folder: rawFolder } = await request.json();
35
+ const s3Client = await getS3PresignClient();
36
+ if (!s3Client) {
37
+ console.error('R2 client is not configured. Check your R2 environment variables.');
38
+ return NextResponse.json({ error: 'File uploads are not configured on this server.' }, { status: 500 });
39
+ }
40
+
41
+ const { filename, contentType, size, folder: rawFolder } = await request.json();
42
42
 
43
43
  if (!filename || !contentType || !size) {
44
44
  return NextResponse.json({ error: "Missing filename, contentType, or size" }, { status: 400 });
@@ -25,6 +25,11 @@ import { getRequestOrigin } from '../../../lib/visual-editing/edit-info';
25
25
 
26
26
  export const dynamicParams = true;
27
27
  export const revalidate = 3600;
28
+ // Render per-request: the shared root layout reads cookies()/headers()/draftMode() (auth + locale),
29
+ // so attempting a statically-cached render throws DYNAMIC_SERVER_USAGE (500). Matches the sibling
30
+ // content routes /[slug] and /product/[slug], which already force dynamic for the same reason.
31
+ export const dynamic = 'force-dynamic';
32
+ export const fetchCache = 'force-no-store';
28
33
 
29
34
  interface ResolvedPostParams {
30
35
  slug: string;
@@ -24,6 +24,10 @@ import {
24
24
  revokeTrustedDevice,
25
25
  type TrustedDeviceRow,
26
26
  } from '../../../../lib/auth/trustedDevices';
27
+ import {
28
+ getSystemConfiguration,
29
+ updateSystemConfiguration,
30
+ } from '../../../../lib/setup/system-config';
27
31
 
28
32
  export interface SecurityPanelData {
29
33
  email: string;
@@ -33,6 +37,7 @@ export interface SecurityPanelData {
33
37
  isAdmin: boolean;
34
38
  globalSettings: SecuritySettings;
35
39
  trustedDevices: TrustedDeviceRow[];
40
+ autoAcceptSignups: boolean;
36
41
  }
37
42
 
38
43
  async function requireUser() {
@@ -70,6 +75,31 @@ export async function getSecurityPanelData(): Promise<SecurityPanelData> {
70
75
  isAdmin: profile?.role === 'ADMIN',
71
76
  globalSettings: await readSecuritySettings(),
72
77
  trustedDevices: await listTrustedDevices(user.id),
78
+ autoAcceptSignups: (await getSystemConfiguration()).auto_accept_signups,
79
+ };
80
+ }
81
+
82
+ // --- Sign-up policy (admin only) ------------------------------------------------
83
+
84
+ export async function updateAutoAcceptSignups(formData: FormData) {
85
+ const { supabase, user } = await requireUser();
86
+ const { data: profile } = await supabase
87
+ .from('profiles')
88
+ .select('role')
89
+ .eq('id', user.id)
90
+ .single();
91
+ if (profile?.role !== 'ADMIN') {
92
+ throw new Error('Only administrators can change the sign-up policy.');
93
+ }
94
+
95
+ const enabled = formData.get('auto_accept_signups') === 'true';
96
+ await updateSystemConfiguration({ auto_accept_signups: enabled });
97
+ revalidatePath('/cms/settings/security');
98
+ return {
99
+ success: true,
100
+ message: enabled
101
+ ? 'New sign-ups will be auto-approved without email verification.'
102
+ : 'New sign-ups now require email verification.',
73
103
  };
74
104
  }
75
105
 
@@ -28,6 +28,7 @@ import {
28
28
  revokeTrustedDeviceAction,
29
29
  sendEmailEnrollmentCode,
30
30
  startTotpEnrollment,
31
+ updateAutoAcceptSignups,
31
32
  updateGlobalSecuritySettings,
32
33
  verifyEmailEnrollment,
33
34
  verifyTotpEnrollment,
@@ -369,10 +370,78 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
369
370
  onSave={(formData) => run(() => updateGlobalSecuritySettings(formData))}
370
371
  />
371
372
  )}
373
+
374
+ {/* Sign-up policy (admin only) */}
375
+ {data.isAdmin && (
376
+ <SignupPolicyCard
377
+ initial={data.autoAcceptSignups}
378
+ isPending={isPending}
379
+ onSave={(formData) => run(() => updateAutoAcceptSignups(formData))}
380
+ />
381
+ )}
372
382
  </>
373
383
  );
374
384
  }
375
385
 
386
+ function SignupPolicyCard({
387
+ initial,
388
+ isPending,
389
+ onSave,
390
+ }: {
391
+ initial: boolean;
392
+ isPending: boolean;
393
+ onSave: (formData: FormData) => void;
394
+ }) {
395
+ const [enabled, setEnabled] = useState(initial);
396
+
397
+ return (
398
+ <Card>
399
+ <CardHeader>
400
+ <CardTitle>Sign-up Policy (Admin)</CardTitle>
401
+ <CardDescription>
402
+ Controls how new public registrations are handled across the whole site.
403
+ </CardDescription>
404
+ </CardHeader>
405
+ <CardContent>
406
+ <form
407
+ onSubmit={(e) => {
408
+ e.preventDefault();
409
+ const formData = new FormData();
410
+ formData.append('auto_accept_signups', String(enabled));
411
+ onSave(formData);
412
+ }}
413
+ className="space-y-6"
414
+ >
415
+ <div className="flex items-start gap-3">
416
+ <Checkbox
417
+ id="auto_accept_signups"
418
+ checked={enabled}
419
+ onCheckedChange={(checked) => setEnabled(checked === true)}
420
+ className="mt-1"
421
+ />
422
+ <div className="space-y-1">
423
+ <Label htmlFor="auto_accept_signups">
424
+ Auto-approve registrations (skip outbound email verification)
425
+ </Label>
426
+ <p className="text-xs text-slate-500">
427
+ New accounts become active immediately, even without SMTP configured. Convenient
428
+ for local / self-hosted use; leave off for public production sites.
429
+ </p>
430
+ </div>
431
+ </div>
432
+
433
+ <Separator />
434
+
435
+ <Button type="submit" disabled={isPending}>
436
+ {isPending ? <Spinner className="mr-2 h-4 w-4 animate-spin" /> : null}
437
+ Save Policy
438
+ </Button>
439
+ </form>
440
+ </CardContent>
441
+ </Card>
442
+ );
443
+ }
444
+
376
445
  function AdminPolicyCard({
377
446
  initial,
378
447
  isPending,
@@ -25,6 +25,7 @@ import { verifyPackageOnline } from '@nextblock-cms/db/server';
25
25
  import { unstable_cache } from 'next/cache';
26
26
  import { createStaticSupabaseClient, getSiteSettings } from './lib/site-settings';
27
27
  import { DEFAULT_OG_IMAGE } from './lib/seo';
28
+ import { isSupabaseConfigured } from '../lib/setup/env-status';
28
29
 
29
30
  const defaultUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000';
30
31
 
@@ -230,11 +231,40 @@ const getCachedActiveLogo = unstable_cache(
230
231
  );
231
232
 
232
233
  async function loadLayoutData() {
233
- const supabase = createSupabaseServerClient();
234
-
235
234
  const headerList = await headers();
236
- const cookieStore = await cookies();
237
235
  const nonce = headerList.get('x-nonce') || '';
236
+ const requestPath = headerList.get('x-nextblock-path') || '';
237
+
238
+ // Skip the public-chrome data loading when there's nothing to render it on: an
239
+ // unconfigured instance, OR the standalone /setup wizard. On /setup, AppShell shows no
240
+ // header/footer anyway, and the DB schema may not exist yet (configured-but-pre-migrate)
241
+ // — querying it just produces noisy "table not found" errors for data nobody displays.
242
+ if (!isSupabaseConfigured() || requestPath.startsWith('/setup')) {
243
+ return {
244
+ user: null,
245
+ profile: null,
246
+ serverDeterminedLocale: DEFAULT_LOCALE_FOR_LAYOUT,
247
+ availableCurrencies: [] as StoreCurrency[],
248
+ serverCurrencyCode: null,
249
+ availableLanguages: [] as Language[],
250
+ defaultLanguage: null,
251
+ translations: [] as Awaited<ReturnType<typeof getCachedTranslations>>,
252
+ copyrightText: '',
253
+ nonce,
254
+ hasSupabaseEnv: false,
255
+ headerNavItems: [] as NavigationItem[],
256
+ footerNavItems: [] as NavigationItem[],
257
+ logo: null as HeaderLogo | null,
258
+ canAccessCms: false,
259
+ siteTitle: 'NextBlock',
260
+ isEcommerceActive: false,
261
+ globalCss: '',
262
+ privacySettings: DEFAULT_PRIVACY_SETTINGS,
263
+ };
264
+ }
265
+
266
+ const supabase = createSupabaseServerClient();
267
+ const cookieStore = await cookies();
238
268
 
239
269
  const xUserLocaleHeader = headerList.get('x-user-locale');
240
270
  const nextUserLocaleCookie = cookieStore.get('NEXT_USER_LOCALE')?.value;
@@ -417,6 +447,22 @@ export default async function RootLayout({
417
447
  ? (await import('@vercel/toolbar/next')).VercelToolbar
418
448
  : null;
419
449
 
450
+ // Expose the PUBLIC Supabase values (url + anon key — both safe to ship to the
451
+ // browser) at runtime. In production the client uses the build-time-inlined
452
+ // NEXT_PUBLIC_* and ignores this; it only matters in local dev, where the wizard
453
+ // writes those vars at runtime and the already-loaded browser bundle would otherwise
454
+ // hold stale empties until a dev-server restart. Read from server process.env here,
455
+ // so it's always fresh.
456
+ const publicEnvBootstrap = (() => {
457
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
458
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
459
+ if (!url || !anonKey) return '';
460
+ return `window.__NEXTBLOCK_PUBLIC_ENV__=${JSON.stringify({ url, anonKey }).replace(
461
+ /</g,
462
+ '\\u003c',
463
+ )};`;
464
+ })();
465
+
420
466
  return (
421
467
  <html lang={serverDeterminedLocale} suppressHydrationWarning>
422
468
  <head>
@@ -424,6 +470,14 @@ export default async function RootLayout({
424
470
  {globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
425
471
  </head>
426
472
  <body className="min-h-screen">
473
+ {publicEnvBootstrap && (
474
+ <Script
475
+ id="nextblock-public-env"
476
+ strategy={TRUSTED_TYPES_SCRIPT_STRATEGY}
477
+ nonce={nonce}
478
+ dangerouslySetInnerHTML={{ __html: publicEnvBootstrap }}
479
+ />
480
+ )}
427
481
  {/* In development this loads after hydration to avoid browser-hidden nonce comparisons. */}
428
482
  <Script
429
483
  id="trusted-types-bootstrap"
@@ -23,13 +23,16 @@ export interface SiteSettings {
23
23
  * route `generateMetadata` functions.
24
24
  */
25
25
  export function createStaticSupabaseClient() {
26
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
26
+ // Fall back to a dummy host when unconfigured (fresh clone, pre-/setup) so the
27
+ // root layout can still render rather than crashing the whole app on boot. Callers
28
+ // (getSiteSettings + the getCached* helpers) already swallow failures and use
29
+ // fallbacks, and loadLayoutData short-circuits before reaching here when there is
30
+ // no Supabase env at all.
31
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy.supabase.co';
27
32
  const supabaseKey =
28
- process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
29
-
30
- if (!supabaseUrl || !supabaseKey) {
31
- throw new Error('Missing Supabase environment variables for public layout data');
32
- }
33
+ process.env.SUPABASE_SERVICE_ROLE_KEY ||
34
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
35
+ 'dummy-anon-key';
33
36
 
34
37
  return createSupabaseJsClient<Database>(supabaseUrl, supabaseKey, {
35
38
  auth: {
@@ -53,6 +56,13 @@ export const getSiteSettings = unstable_cache(
53
56
  siteKeywords: DEFAULT_SITE_KEYWORDS,
54
57
  };
55
58
 
59
+ // Unconfigured instance (pre-/setup): the static client would point at the dummy
60
+ // host, so the fetch fails with a noisy DNS error before falling back. Skip it and
61
+ // return SEO defaults directly. (generateMetadata calls this even on /setup.)
62
+ if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
63
+ return fallback;
64
+ }
65
+
56
66
  try {
57
67
  const supabase = createStaticSupabaseClient();
58
68
  const { data, error } = await supabase
@@ -61,7 +71,12 @@ export const getSiteSettings = unstable_cache(
61
71
  .in('key', ['site_title', 'site_description', 'site_keywords']);
62
72
 
63
73
  if (error || !data) {
64
- console.error('Error fetching cached site settings:', error);
74
+ // PGRST205 = table not found: the schema isn't migrated yet (e.g. mid-setup,
75
+ // right after the connection is saved but before `npm run db:migrate`). That's an
76
+ // expected transient state — don't shout about it. Real errors still log.
77
+ if (error && error.code !== 'PGRST205') {
78
+ console.error('Error fetching cached site settings:', error);
79
+ }
65
80
  return fallback;
66
81
  }
67
82
 
@@ -63,6 +63,12 @@ async function getPreferredLocale() {
63
63
  }
64
64
 
65
65
  export async function generateMetadata(): Promise<Metadata> {
66
+ // Unconfigured instance (pre-/setup): skip all DB work so metadata generation
67
+ // can't crash the boot. The proxy redirects unconfigured traffic to /setup anyway.
68
+ if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
69
+ return { title: 'NextBlock' };
70
+ }
71
+
66
72
  const preferredLocale = await getPreferredLocale();
67
73
  const homepageSlug = await getHomepageSlugForLocale(preferredLocale);
68
74
  const pageData = await getPageDataBySlug(homepageSlug, preferredLocale);
@@ -74,6 +74,11 @@ function resolveCategoryName(category: any, languageCode?: string | null) {
74
74
  }
75
75
 
76
76
  export async function generateStaticParams() {
77
+ // Unconfigured instance (pre-/setup): no DB to read product slugs from.
78
+ if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
79
+ return [];
80
+ }
81
+
77
82
  const supabase = getSsgSupabaseClient();
78
83
  const { data: products } = await getProducts(supabase);
79
84
  const productRows = ((products || []) as any[]).filter(