create-nextblock 0.14.2 → 0.14.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 (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -1,24 +1,40 @@
1
- // app/cms/settings/global-css/page.tsx
2
- import { getGlobalCss } from './actions';
3
- import GlobalCssForm from './components/GlobalCssForm';
4
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
5
-
6
- export default async function GlobalCssSettingsPage() {
7
- const css = await getGlobalCss();
8
-
9
- return (
10
- <div className="max-w-4xl mx-auto">
11
- <Card>
12
- <CardHeader>
13
- <CardTitle>Global CSS</CardTitle>
14
- <CardDescription>
15
- Inject custom CSS rules dynamically across the entire application front-end.
16
- </CardDescription>
17
- </CardHeader>
18
- <CardContent>
19
- <GlobalCssForm initialCss={css} />
20
- </CardContent>
21
- </Card>
22
- </div>
23
- );
24
- }
1
+ // app/cms/settings/global-css/page.tsx
2
+ import { getGlobalCss } from './actions';
3
+ import { getSiteThemes } from './theme-actions';
4
+ import GlobalCssForm from './components/GlobalCssForm';
5
+ import ThemeManager from './components/ThemeManager';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
7
+
8
+ export default async function GlobalCssSettingsPage() {
9
+ const [css, themes] = await Promise.all([getGlobalCss(), getSiteThemes()]);
10
+
11
+ return (
12
+ <div className="mx-auto max-w-6xl space-y-6">
13
+ <Card>
14
+ <CardHeader>
15
+ <CardTitle>Themes</CardTitle>
16
+ <CardDescription>
17
+ Recolour the themes visitors can pick from the switcher, or add your own. Changes apply
18
+ site-wide without a redeploy.
19
+ </CardDescription>
20
+ </CardHeader>
21
+ <CardContent>
22
+ <ThemeManager initialThemes={themes} />
23
+ </CardContent>
24
+ </Card>
25
+
26
+ <Card>
27
+ <CardHeader>
28
+ <CardTitle>Global CSS</CardTitle>
29
+ <CardDescription>
30
+ Inject custom CSS rules dynamically across the entire application front-end. Loaded after
31
+ the themes above, so it can override any of them.
32
+ </CardDescription>
33
+ </CardHeader>
34
+ <CardContent>
35
+ <GlobalCssForm initialCss={css} />
36
+ </CardContent>
37
+ </Card>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,259 @@
1
+ // app/cms/settings/global-css/theme-actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath, revalidateTag } from 'next/cache';
6
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
7
+ import { isValidThemeSlug, type SiteTheme } from '../../../../lib/themes/buildThemeCss';
8
+ import { isThemeTokenKey, isValidTokenValue, THEME_TOKEN_KEYS } from '../../../../lib/themes/tokens';
9
+
10
+ const THEME_COLUMNS =
11
+ 'id, slug, name, description, icon, color_scheme, tokens, extra_css, is_system, is_default, is_active, sort_order';
12
+
13
+ /** Theme edits are ADMIN-only — RLS enforces it too, this is the friendly error. */
14
+ async function requireAdmin() {
15
+ const supabase = createClient();
16
+ const {
17
+ data: { user },
18
+ } = await supabase.auth.getUser();
19
+ if (!user) {
20
+ return { supabase, error: 'You must be logged in to manage themes.' as const };
21
+ }
22
+
23
+ const { data: profile, error: profileError } = await supabase
24
+ .from('profiles')
25
+ .select('role')
26
+ .eq('id', user.id)
27
+ .single();
28
+
29
+ if (profileError || !profile || profile.role !== 'ADMIN') {
30
+ return { supabase, error: 'Only administrators can manage themes.' as const };
31
+ }
32
+ return { supabase, error: null };
33
+ }
34
+
35
+ function revalidateThemes() {
36
+ revalidateTag('public-layout-site-themes', 'max');
37
+ revalidatePath('/', 'layout');
38
+ }
39
+
40
+ export async function getSiteThemes(): Promise<SiteTheme[]> {
41
+ const supabase = createClient();
42
+ const { data, error } = await supabase.from('site_themes').select(THEME_COLUMNS).order('sort_order');
43
+ if (error || !data) return [];
44
+ return data as unknown as SiteTheme[];
45
+ }
46
+
47
+ /**
48
+ * Keep only known tokens with well-formed values. Anything else is dropped
49
+ * rather than rejected, so a partially-filled form still saves what it can.
50
+ */
51
+ function sanitizeTokens(input: unknown): Record<string, string> {
52
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
53
+ const out: Record<string, string> = {};
54
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
55
+ if (typeof value !== 'string') continue;
56
+ const trimmed = value.trim();
57
+ if (isThemeTokenKey(key) && isValidTokenValue(key, trimmed)) {
58
+ out[key] = trimmed;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export interface ThemeInput {
65
+ name: string;
66
+ slug?: string;
67
+ description?: string | null;
68
+ icon?: string;
69
+ color_scheme?: 'light' | 'dark';
70
+ tokens?: Record<string, string>;
71
+ extra_css?: string | null;
72
+ is_active?: boolean;
73
+ is_default?: boolean;
74
+ sort_order?: number;
75
+ }
76
+
77
+ export async function createTheme(input: ThemeInput): Promise<SettingsActionResult & { slug?: string }> {
78
+ const { supabase, error: authError } = await requireAdmin();
79
+ if (authError) return { ok: false, error: authError };
80
+
81
+ const slug = (input.slug ?? input.name ?? '')
82
+ .toLowerCase()
83
+ .trim()
84
+ .replace(/[^a-z0-9]+/g, '-')
85
+ .replace(/^-+|-+$/g, '');
86
+
87
+ if (!isValidThemeSlug(slug)) {
88
+ return {
89
+ ok: false,
90
+ error: 'Theme id must be 2-40 characters, lowercase letters, numbers and dashes, starting with a letter.',
91
+ };
92
+ }
93
+ if (!input.name?.trim()) {
94
+ return { ok: false, error: 'Theme name is required.' };
95
+ }
96
+
97
+ const { error } = await supabase.from('site_themes').insert({
98
+ slug,
99
+ name: input.name.trim(),
100
+ description: input.description?.trim() || null,
101
+ icon: input.icon || 'Palette',
102
+ color_scheme: input.color_scheme === 'dark' ? 'dark' : 'light',
103
+ tokens: sanitizeTokens(input.tokens),
104
+ extra_css: input.extra_css?.trim() || null,
105
+ is_active: input.is_active ?? true,
106
+ is_default: false,
107
+ is_system: false,
108
+ sort_order: input.sort_order ?? 100,
109
+ });
110
+
111
+ if (error) {
112
+ if (error.code === '23505') {
113
+ return { ok: false, error: `A theme with the id "${slug}" already exists.` };
114
+ }
115
+ console.error('Error creating theme:', error);
116
+ return { ok: false, error: 'Failed to create theme.' };
117
+ }
118
+
119
+ revalidateThemes();
120
+ return { ok: true, message: `Theme "${input.name.trim()}" created.`, slug };
121
+ }
122
+
123
+ export async function updateTheme(id: string, input: ThemeInput): Promise<SettingsActionResult> {
124
+ const { supabase, error: authError } = await requireAdmin();
125
+ if (authError) return { ok: false, error: authError };
126
+
127
+ if (!input.name?.trim()) {
128
+ return { ok: false, error: 'Theme name is required.' };
129
+ }
130
+
131
+ // `slug` and `is_system` are intentionally not updatable: the slug is the CSS
132
+ // class and the value persisted in each visitor's localStorage by next-themes,
133
+ // so renaming it would silently reset everyone's chosen theme.
134
+ const patch: Record<string, unknown> = {
135
+ name: input.name.trim(),
136
+ description: input.description?.trim() || null,
137
+ icon: input.icon || 'Palette',
138
+ color_scheme: input.color_scheme === 'dark' ? 'dark' : 'light',
139
+ extra_css: input.extra_css?.trim() || null,
140
+ };
141
+ if (input.tokens !== undefined) patch.tokens = sanitizeTokens(input.tokens);
142
+ if (input.is_active !== undefined) patch.is_active = input.is_active;
143
+ if (input.sort_order !== undefined) patch.sort_order = input.sort_order;
144
+
145
+ const { error } = await supabase.from('site_themes').update(patch).eq('id', id);
146
+
147
+ if (error) {
148
+ console.error('Error updating theme:', error);
149
+ return { ok: false, error: 'Failed to update theme.' };
150
+ }
151
+
152
+ revalidateThemes();
153
+ return { ok: true, message: 'Theme saved.' };
154
+ }
155
+
156
+ export async function setDefaultTheme(id: string): Promise<SettingsActionResult> {
157
+ const { supabase, error: authError } = await requireAdmin();
158
+ if (authError) return { ok: false, error: authError };
159
+
160
+ // A hidden theme cannot be the site default.
161
+ const { error } = await supabase
162
+ .from('site_themes')
163
+ .update({ is_default: true, is_active: true })
164
+ .eq('id', id);
165
+
166
+ if (error) {
167
+ console.error('Error setting default theme:', error);
168
+ return { ok: false, error: 'Failed to set the default theme.' };
169
+ }
170
+
171
+ revalidateThemes();
172
+ return { ok: true, message: 'Default theme updated.' };
173
+ }
174
+
175
+ export async function deleteTheme(id: string): Promise<SettingsActionResult> {
176
+ const { supabase, error: authError } = await requireAdmin();
177
+ if (authError) return { ok: false, error: authError };
178
+
179
+ const { data: theme, error: readError } = await supabase
180
+ .from('site_themes')
181
+ .select('slug, name, is_system, is_default')
182
+ .eq('id', id)
183
+ .single();
184
+
185
+ if (readError || !theme) {
186
+ return { ok: false, error: 'Theme not found.' };
187
+ }
188
+ if (theme.is_system) {
189
+ return {
190
+ ok: false,
191
+ error: `"${theme.name}" is a system theme. Light and Dark are what "System" resolves to, so they cannot be deleted — but you can recolour them freely.`,
192
+ };
193
+ }
194
+ if (theme.is_default) {
195
+ return { ok: false, error: 'Make another theme the default before deleting this one.' };
196
+ }
197
+
198
+ const { error } = await supabase.from('site_themes').delete().eq('id', id);
199
+ if (error) {
200
+ console.error('Error deleting theme:', error);
201
+ return { ok: false, error: 'Failed to delete theme.' };
202
+ }
203
+
204
+ revalidateThemes();
205
+ return { ok: true, message: `Theme "${theme.name}" deleted. Visitors using it fall back to the default.` };
206
+ }
207
+
208
+ export async function duplicateTheme(id: string): Promise<SettingsActionResult & { slug?: string }> {
209
+ const { supabase, error: authError } = await requireAdmin();
210
+ if (authError) return { ok: false, error: authError };
211
+
212
+ const { data: source, error: readError } = await supabase
213
+ .from('site_themes')
214
+ .select(THEME_COLUMNS)
215
+ .eq('id', id)
216
+ .single();
217
+
218
+ if (readError || !source) {
219
+ return { ok: false, error: 'Theme not found.' };
220
+ }
221
+
222
+ const theme = source as unknown as SiteTheme;
223
+ // Find a free slug: my-theme-copy, my-theme-copy-2, ...
224
+ const { data: existing } = await supabase.from('site_themes').select('slug');
225
+ const taken = new Set((existing ?? []).map((row) => row.slug));
226
+ let slug = `${theme.slug}-copy`.slice(0, 40);
227
+ let n = 2;
228
+ while (taken.has(slug)) {
229
+ slug = `${theme.slug}-copy-${n}`.slice(0, 40);
230
+ n += 1;
231
+ }
232
+
233
+ const { error } = await supabase.from('site_themes').insert({
234
+ slug,
235
+ name: `${theme.name} copy`,
236
+ description: theme.description,
237
+ icon: theme.icon,
238
+ color_scheme: theme.color_scheme,
239
+ tokens: sanitizeTokens(theme.tokens),
240
+ extra_css: theme.extra_css,
241
+ is_active: true,
242
+ is_default: false,
243
+ is_system: false,
244
+ sort_order: (theme.sort_order ?? 0) + 1,
245
+ });
246
+
247
+ if (error) {
248
+ console.error('Error duplicating theme:', error);
249
+ return { ok: false, error: 'Failed to duplicate theme.' };
250
+ }
251
+
252
+ revalidateThemes();
253
+ return { ok: true, message: `Created "${theme.name} copy".`, slug };
254
+ }
255
+
256
+ /** Exposed so the client form can render inputs for exactly what the server accepts. */
257
+ export async function getThemeTokenKeys(): Promise<string[]> {
258
+ return THEME_TOKEN_KEYS;
259
+ }
@@ -13,6 +13,12 @@ import { ConsentGatedAnalytics } from '../components/privacy/ConsentGatedAnalyti
13
13
  import { ConsentBanner } from '../components/privacy/ConsentBanner';
14
14
  import { getPrivacySettings } from '../lib/privacy/settings';
15
15
  import { DEFAULT_PRIVACY_SETTINGS } from '../lib/privacy/types';
16
+ import {
17
+ activeThemeSlugs,
18
+ buildThemeCss,
19
+ defaultThemeSlug,
20
+ type SiteTheme,
21
+ } from '../lib/themes/buildThemeCss';
16
22
  import { DeferredSpeedInsights } from '../components/DeferredSpeedInsights';
17
23
  import { DeferredVisualEditing } from '../components/visual-editing/DeferredVisualEditing';
18
24
  import {
@@ -169,6 +175,25 @@ const getCachedGlobalCss = unstable_cache(
169
175
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
170
176
  );
171
177
 
178
+ const getCachedSiteThemes = unstable_cache(
179
+ async (): Promise<SiteTheme[]> => {
180
+ const supabase = createStaticSupabaseClient();
181
+ const { data, error } = await supabase
182
+ .from('site_themes')
183
+ .select('id, slug, name, description, icon, color_scheme, tokens, extra_css, is_system, is_default, is_active, sort_order')
184
+ .order('sort_order');
185
+
186
+ if (error || !data) {
187
+ // A missing table (pre-migration install) must not take the site down —
188
+ // libs/ui/src/styles/theme.css still ships a working fallback palette.
189
+ return [];
190
+ }
191
+ return data as unknown as SiteTheme[];
192
+ },
193
+ ['public-layout-site-themes'],
194
+ { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: ['public-layout-site-themes'] }
195
+ );
196
+
172
197
  const getCachedTranslations = unstable_cache(
173
198
  async () => {
174
199
  const supabase = createStaticSupabaseClient();
@@ -298,6 +323,7 @@ async function loadLayoutData() {
298
323
  siteTitle: 'NextBlock',
299
324
  isEcommerceActive: false,
300
325
  globalCss: '',
326
+ siteThemes: [] as SiteTheme[],
301
327
  privacySettings: DEFAULT_PRIVACY_SETTINGS,
302
328
  footerAttributionEnabled: true,
303
329
  rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
@@ -324,6 +350,7 @@ async function loadLayoutData() {
324
350
  currenciesResult,
325
351
  copyrightSettingsResult,
326
352
  globalCssResult,
353
+ siteThemesResult,
327
354
  translationsResult,
328
355
  isEcommerceActive,
329
356
  privacySettings,
@@ -336,6 +363,7 @@ async function loadLayoutData() {
336
363
  en: '(c) {year} Nextblock CMS. All rights reserved.',
337
364
  })),
338
365
  getCachedGlobalCss().catch(() => ''),
366
+ getCachedSiteThemes().catch(() => [] as SiteTheme[]),
339
367
  getCachedTranslations().catch(() => []),
340
368
  verifyPackageOnline('ecommerce').catch(() => false),
341
369
  getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
@@ -369,6 +397,7 @@ async function loadLayoutData() {
369
397
  const copyrightText = templateForLocale.replace('{year}', new Date().getFullYear().toString());
370
398
 
371
399
  const globalCss = typeof globalCssResult === 'string' ? globalCssResult : '';
400
+ const siteThemes = Array.isArray(siteThemesResult) ? siteThemesResult : [];
372
401
  const translations = Array.isArray(translationsResult) ? translationsResult : [];
373
402
 
374
403
  const hasSupabaseEnv = isSupabaseConfigured();
@@ -404,6 +433,7 @@ async function loadLayoutData() {
404
433
  siteTitle,
405
434
  isEcommerceActive,
406
435
  globalCss,
436
+ siteThemes,
407
437
  privacySettings,
408
438
  footerAttributionEnabled,
409
439
  rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
@@ -486,10 +516,25 @@ export default async function RootLayout({
486
516
  siteTitle,
487
517
  isEcommerceActive,
488
518
  globalCss,
519
+ siteThemes,
489
520
  privacySettings,
490
521
  footerAttributionEnabled,
491
522
  rememberVisitorChoice,
492
523
  } = await loadLayoutData();
524
+ // Themes are rendered server-side into <head> so the palette is correct on the
525
+ // very first paint, before next-themes' blocking script adds the html class.
526
+ const themeCss = buildThemeCss(siteThemes);
527
+ const themeSlugs = activeThemeSlugs(siteThemes);
528
+ const initialTheme = defaultThemeSlug(siteThemes);
529
+ const themeCatalog = siteThemes
530
+ .filter((theme) => theme.is_active)
531
+ .sort((a, b) => a.sort_order - b.sort_order)
532
+ .map((theme) => ({
533
+ slug: theme.slug,
534
+ name: theme.name,
535
+ icon: theme.icon,
536
+ colorScheme: theme.color_scheme,
537
+ }));
493
538
  const draft = await draftMode();
494
539
  // GTM container id comes solely from the privacy settings row (site_settings).
495
540
  // There is intentionally no NEXT_PUBLIC_GTM_ID env fallback — analytics is
@@ -517,6 +562,7 @@ export default async function RootLayout({
517
562
  <html lang={serverDeterminedLocale} suppressHydrationWarning>
518
563
  <head>
519
564
  <meta name="viewport" content="width=device-width, initial-scale=1" />
565
+ {themeCss && <style id="nb-theme-tokens" dangerouslySetInnerHTML={{ __html: themeCss }} />}
520
566
  {globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
521
567
  </head>
522
568
  <body className="min-h-screen">
@@ -545,6 +591,9 @@ export default async function RootLayout({
545
591
  rememberVisitorChoice={rememberVisitorChoice}
546
592
  translations={translations}
547
593
  nonce={nonce}
594
+ themeSlugs={themeSlugs}
595
+ initialTheme={initialTheme}
596
+ themeCatalog={themeCatalog}
548
597
  >
549
598
  <ToasterProvider />
550
599
  <AppShell
@@ -1,4 +1,5 @@
1
1
  import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
2
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
2
3
  import { getHomepageTranslationGroupId } from './homepage';
3
4
 
4
5
  /**
@@ -164,7 +165,8 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
164
165
  supabase
165
166
  .from('pages')
166
167
  .select('slug, updated_at, language_id, translation_group_id')
167
- .eq('status', 'published'),
168
+ .eq('status', 'published')
169
+ .or(buildPublishedAtOrFilter()),
168
170
  fetchLanguageMap(supabase),
169
171
  getHomepageTranslationGroupId(supabase),
170
172
  ]);
@@ -200,13 +202,12 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
200
202
  export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
201
203
  const supabase = getSsgSupabaseClient();
202
204
  try {
203
- const nowIso = new Date().toISOString();
204
205
  const [{ data: posts, error }, languageMap] = await Promise.all([
205
206
  supabase
206
207
  .from('posts')
207
208
  .select('slug, updated_at, language_id, translation_group_id')
208
209
  .eq('status', 'published')
209
- .or(`published_at.is.null,published_at.lte.${nowIso}`),
210
+ .or(buildPublishedAtOrFilter()),
210
211
  fetchLanguageMap(supabase),
211
212
  ]);
212
213
 
@@ -238,7 +239,8 @@ export async function fetchAllActiveProducts(): Promise<SitemapEntry[]> {
238
239
  supabase
239
240
  .from('products')
240
241
  .select('slug, updated_at, created_at, language_id, translation_group_id')
241
- .eq('status', 'active'),
242
+ .eq('status', 'active')
243
+ .or(buildPublishedAtOrFilter()),
242
244
  fetchLanguageMap(supabase),
243
245
  ]);
244
246
 
@@ -1,6 +1,7 @@
1
1
  import 'server-only';
2
2
 
3
3
  import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
4
5
  import {
5
6
  getDefaultCurrency,
6
7
  inferCurrencyCodeFromLocale,
@@ -1099,6 +1100,7 @@ export async function searchCatalogProducts(body: unknown, request: Request) {
1099
1100
  .from('products')
1100
1101
  .select(PRODUCT_SELECT, { count: 'exact' })
1101
1102
  .eq('status', 'active')
1103
+ .or(buildPublishedAtOrFilter())
1102
1104
  .order('created_at', { ascending: false })
1103
1105
  .range(pagination.offset, pagination.offset + pagination.limit - 1);
1104
1106
 
@@ -1192,7 +1194,7 @@ async function selectRowsByField(
1192
1194
 
1193
1195
  let query = client.from(table).select(select).in(field, values);
1194
1196
  if (table === 'products') {
1195
- query = query.eq('status', 'active');
1197
+ query = query.eq('status', 'active').or(buildPublishedAtOrFilter());
1196
1198
  }
1197
1199
 
1198
1200
  const { data } = await query;
@@ -1251,6 +1253,7 @@ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
1251
1253
  .from('products')
1252
1254
  .select(PRODUCT_SELECT)
1253
1255
  .eq('status', 'active')
1256
+ .or(buildPublishedAtOrFilter())
1254
1257
  .in('id', productIds);
1255
1258
 
1256
1259
  if (error) {
@@ -3,6 +3,7 @@ import { cookies, draftMode, headers } from 'next/headers';
3
3
  import { notFound } from 'next/navigation';
4
4
  import type { Metadata } from 'next';
5
5
  import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
6
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
6
7
  import PageClientContent from './[slug]/PageClientContent';
7
8
  import { getPageDataBySlug } from './[slug]/page.utils';
8
9
  import BlockRenderer from '../components/BlockRenderer';
@@ -56,7 +57,7 @@ async function resolveHomepageData(preferredLocale: string) {
56
57
  .limit(1);
57
58
 
58
59
  if (!draft.isEnabled) {
59
- siblingQuery = siblingQuery.eq('status', 'published');
60
+ siblingQuery = siblingQuery.eq('status', 'published').or(buildPublishedAtOrFilter());
60
61
  }
61
62
 
62
63
  const { data: sibling } = await siblingQuery.maybeSingle();
@@ -131,7 +132,8 @@ export async function generateMetadata(): Promise<Metadata> {
131
132
  .from('pages')
132
133
  .select('language_id, slug')
133
134
  .eq('translation_group_id', pageData.translation_group_id)
134
- .eq('status', 'published'),
135
+ .eq('status', 'published')
136
+ .or(buildPublishedAtOrFilter()),
135
137
  ]);
136
138
 
137
139
  const { data: languages } = languagesResult;
@@ -188,7 +190,8 @@ export default async function RootPage() {
188
190
  .from('pages')
189
191
  .select('slug, languages!inner(code)')
190
192
  .eq('translation_group_id', pageData.translation_group_id)
191
- .eq('status', 'published');
193
+ .eq('status', 'published')
194
+ .or(buildPublishedAtOrFilter());
192
195
 
193
196
  if (translations) {
194
197
  translations.forEach((translation: PageTranslation) => {
@@ -8,6 +8,7 @@ import {
8
8
  resolveTranslatedText,
9
9
  } from '@nextblock-cms/ecommerce';
10
10
  import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
11
+ import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
11
12
  import { notFound } from 'next/navigation';
12
13
  import { Metadata } from 'next';
13
14
  import { draftMode, cookies, headers } from 'next/headers';
@@ -124,7 +125,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
124
125
  const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
125
126
  const productRecord = product as any;
126
127
 
127
- if (!productRecord || productRecord.status !== 'active') return { title: 'Product Not Found' };
128
+ if (
129
+ !productRecord ||
130
+ !isPubliclyVisible({
131
+ status: productRecord.status,
132
+ publishedAt: productRecord.published_at,
133
+ liveStatus: LIVE_STATUS.product,
134
+ })
135
+ ) {
136
+ return { title: 'Product Not Found' };
137
+ }
128
138
 
129
139
  // Resolve image URL for OG Image
130
140
  let imageUrl = undefined;
@@ -147,6 +157,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
147
157
  .select('language_id, slug')
148
158
  .eq('translation_group_id', productRecord.translation_group_id)
149
159
  .eq('status', 'active')
160
+ // Never advertise a scheduled translation via hreflang.
161
+ .or(buildPublishedAtOrFilter())
150
162
  ]);
151
163
 
152
164
  const { data: languages } = languagesResult;
@@ -225,11 +237,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
225
237
  const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
226
238
  let productRecord = product as any;
227
239
 
228
- if (!productRecord || productRecord.status !== 'active') {
240
+ const draft = await draftMode();
241
+
242
+ // Draft mode is how the CMS previews a product before it is public, so it must
243
+ // reach draft and scheduled rows — everyone else only sees active products whose
244
+ // go-live moment has passed.
245
+ if (
246
+ !productRecord ||
247
+ (!draft.isEnabled &&
248
+ !isPubliclyVisible({
249
+ status: productRecord.status,
250
+ publishedAt: productRecord.published_at,
251
+ liveStatus: LIVE_STATUS.product,
252
+ }))
253
+ ) {
229
254
  notFound();
230
255
  }
231
256
 
232
- const draft = await draftMode();
233
257
  const visualEditingEnabled =
234
258
  draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
235
259
 
@@ -15,6 +15,7 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
15
15
  import { AuthProvider } from '../context/AuthContext';
16
16
  import { LanguageProvider, useLanguage } from '../context/LanguageContext';
17
17
  import { CurrentContentProvider } from '../context/CurrentContentContext';
18
+ import { ThemeCatalogProvider } from '../context/ThemeCatalogContext';
18
19
  import { DeferredCartTranslator } from '../components/DeferredCartTranslator';
19
20
  import { CurrencyProvider } from '@nextblock-cms/ecommerce/CurrencyProvider';
20
21
  import { TranslationsProvider } from '@nextblock-cms/utils';
@@ -46,9 +47,20 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
46
47
  initialDefaultLanguage,
47
48
  rememberVisitorChoice,
48
49
  translations,
49
- nonce
50
+ nonce,
51
+ themeSlugs,
52
+ initialTheme,
53
+ themeCatalog,
50
54
  } = props;
51
55
 
56
+ // Themes come from the site_themes table. Fall back to the three that ship in
57
+ // libs/ui/src/styles/theme.css if the table is empty or unreachable, so the
58
+ // switcher never renders an empty list.
59
+ const resolvedThemes: string[] =
60
+ Array.isArray(themeSlugs) && themeSlugs.length > 0 ? themeSlugs : ['light', 'dark', 'vibrant'];
61
+ const resolvedDefault: string =
62
+ typeof initialTheme === 'string' && resolvedThemes.includes(initialTheme) ? initialTheme : 'light';
63
+
52
64
  return (
53
65
  <AuthProvider serverUser={serverUser} serverProfile={serverProfile}>
54
66
  <LanguageProvider
@@ -67,13 +79,13 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
67
79
  <TranslationBridge translations={translations}>
68
80
  <ThemeProvider
69
81
  attribute="class"
70
- defaultTheme="light"
82
+ defaultTheme={resolvedDefault}
71
83
  enableSystem
72
84
  disableTransitionOnChange
73
85
  nonce={nonce}
74
- themes={['light', 'dark', 'vibrant']}
86
+ themes={resolvedThemes}
75
87
  >
76
- {children}
88
+ <ThemeCatalogProvider themes={themeCatalog}>{children}</ThemeCatalogProvider>
77
89
  </ThemeProvider>
78
90
  </TranslationBridge>
79
91
  </CurrentContentProvider>