create-nextblock 0.14.4 → 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 (44) 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 +362 -1
  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/components/DraftStatusActions.tsx +10 -0
  13. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  14. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  15. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  17. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  18. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  19. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  20. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  21. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  22. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  23. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  24. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  25. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  26. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  27. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  28. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  29. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  30. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  31. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  32. package/templates/nextblock-template/app/page.tsx +6 -3
  33. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  34. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  35. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  36. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  37. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  38. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  39. package/templates/nextblock-template/lib/search/server.ts +3 -0
  40. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  41. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  42. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -1,132 +1,304 @@
1
- // apps/nextblock/app/cms/revisions/utils.ts
2
- import { createClient } from "@nextblock-cms/db/server";
3
- import type { Database, Json } from "@nextblock-cms/db";
4
-
5
- type BlockRow = Database['public']['Tables']['blocks']['Row'];
6
-
7
- export interface PageMetaContent {
8
- title: string;
9
- slug: string;
10
- language_id: number;
11
- status: Database['public']['Enums']['page_status'];
12
- meta_title: string | null;
13
- meta_description: string | null;
14
- feature_image_id: string | null;
15
- }
16
-
17
- export interface PostMetaContent extends PageMetaContent {
18
- label: string | null;
19
- excerpt: string | null;
20
- subtitle: string | null;
21
- published_at: string | null;
22
- feature_image_id: string | null;
23
- }
24
-
25
- export interface SimpleBlockContent {
26
- language_id: number;
27
- block_type: BlockRow['block_type'];
28
- content: Json;
29
- order: number;
30
- }
31
-
32
- export interface FullPageContent {
33
- meta: PageMetaContent;
34
- blocks: SimpleBlockContent[];
35
- }
36
-
37
- export interface FullPostContent {
38
- meta: PostMetaContent;
39
- blocks: SimpleBlockContent[];
40
- }
41
-
42
- export async function getFullPageContent(
43
- pageId: number,
44
- opts?: { overrideBlockId?: number; overrideBlockContent?: unknown; excludeDeletedBlockId?: number }
45
- ): Promise<FullPageContent | null> {
46
- const supabase = createClient();
47
- const { data: page, error: pageError } = await supabase
48
- .from('pages')
49
- .select('id, title, slug, language_id, status, meta_title, meta_description, feature_image_id')
50
- .eq('id', pageId)
51
- .single();
52
- if (pageError || !page) return null;
53
-
54
- const { data: blocks, error: blocksError } = await supabase
55
- .from('blocks')
56
- .select('id, language_id, block_type, content, order, page_id, post_id')
57
- .eq('page_id', pageId)
58
- .order('order', { ascending: true });
59
- if (blocksError) return null;
60
-
61
- const processed = (blocks || [])
62
- .filter(b => opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true)
63
- .map(b => ({
64
- language_id: b.language_id,
65
- block_type: b.block_type,
66
- content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
67
- ? (opts.overrideBlockContent as Json)
68
- : (b.content as Json),
69
- order: b.order,
70
- } satisfies SimpleBlockContent));
71
-
72
- return {
73
- meta: {
74
- title: page.title,
75
- slug: page.slug,
76
- language_id: page.language_id,
77
- status: page.status,
78
- meta_title: page.meta_title,
79
- meta_description: page.meta_description,
80
- feature_image_id: page.feature_image_id ?? null,
81
- },
82
- blocks: processed,
83
- };
84
- }
85
-
86
- export async function getFullPostContent(
87
- postId: number,
88
- opts?: { overrideBlockId?: number; overrideBlockContent?: unknown; excludeDeletedBlockId?: number }
89
- ): Promise<FullPostContent | null> {
90
- const supabase = createClient();
91
- const { data: post, error: postError } = await supabase
92
- .from('posts')
93
- .select('id, title, slug, language_id, status, meta_title, meta_description, label, excerpt, subtitle, published_at, feature_image_id')
94
- .eq('id', postId)
95
- .single();
96
- if (postError || !post) return null;
97
-
98
- const { data: blocks, error: blocksError } = await supabase
99
- .from('blocks')
100
- .select('id, language_id, block_type, content, order, page_id, post_id')
101
- .eq('post_id', postId)
102
- .order('order', { ascending: true });
103
- if (blocksError) return null;
104
-
105
- const processed = (blocks || [])
106
- .filter(b => opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true)
107
- .map(b => ({
108
- language_id: b.language_id,
109
- block_type: b.block_type,
110
- content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
111
- ? (opts.overrideBlockContent as Json)
112
- : (b.content as Json),
113
- order: b.order,
114
- } satisfies SimpleBlockContent));
115
-
116
- return {
117
- meta: {
118
- title: post.title,
119
- slug: post.slug,
120
- language_id: post.language_id,
121
- status: post.status,
122
- meta_title: post.meta_title,
123
- meta_description: post.meta_description,
124
- label: post.label,
125
- excerpt: post.excerpt,
126
- subtitle: post.subtitle,
127
- published_at: post.published_at ? new Date(post.published_at).toISOString() : null,
128
- feature_image_id: post.feature_image_id ?? null,
129
- },
130
- blocks: processed,
131
- };
132
- }
1
+ // apps/nextblock/app/cms/revisions/utils.ts
2
+ import { createClient } from "@nextblock-cms/db/server";
3
+ import type { Database, Json } from "@nextblock-cms/db";
4
+
5
+ type BlockRow = Database['public']['Tables']['blocks']['Row'];
6
+
7
+ /**
8
+ * The columns a revision snapshot captures, in one place.
9
+ *
10
+ * These lists are the contract between four things that must agree exactly:
11
+ * - the SELECTs in this file (what a snapshot records),
12
+ * - the UPDATEs in service.ts (what a restore replays),
13
+ * - the jsonb_build_object() calls in migration 00000000000016 (the stored baseline),
14
+ * - RESTORE_EXCLUDED_META_COLUMNS below (what a restore must never replay).
15
+ *
16
+ * Anything editable that is missing here is silently un-restorable: a publish that only
17
+ * changed that field produces an empty JSON Patch, which createPageRevision treats as
18
+ * "nothing happened" and drops on the floor.
19
+ */
20
+ export const PAGE_META_COLUMNS = [
21
+ 'title',
22
+ 'slug',
23
+ 'language_id',
24
+ 'status',
25
+ 'meta_title',
26
+ 'meta_description',
27
+ 'custom_canonical',
28
+ 'published_at',
29
+ 'feature_image_id',
30
+ ] as const;
31
+
32
+ export const POST_META_COLUMNS = [
33
+ ...PAGE_META_COLUMNS,
34
+ 'label',
35
+ 'excerpt',
36
+ 'subtitle',
37
+ ] as const;
38
+
39
+ export const PRODUCT_META_COLUMNS = [
40
+ 'title',
41
+ 'slug',
42
+ 'language_id',
43
+ 'status',
44
+ 'meta_title',
45
+ 'meta_description',
46
+ 'custom_canonical',
47
+ 'published_at',
48
+ 'short_description',
49
+ 'description_json',
50
+ ] as const;
51
+
52
+ /**
53
+ * Visibility is owned by setContentVisibility (app/actions/visibilityActions.ts), which
54
+ * deliberately bypasses the draft/revision pipeline. Both fields are still *captured* —
55
+ * they are useful history — but replaying them on restore would let "restore the wording
56
+ * from last Tuesday" silently unpublish a live page or resurrect a cancelled schedule.
57
+ *
58
+ * They are excluded from change detection for the same reason: a status flip made from
59
+ * the top bar would otherwise be misattributed to whichever author saved next.
60
+ */
61
+ export const RESTORE_EXCLUDED_META_COLUMNS = ['status', 'published_at'] as const;
62
+
63
+ export interface PageMetaContent {
64
+ title: string;
65
+ slug: string;
66
+ language_id: number;
67
+ status: Database['public']['Enums']['page_status'];
68
+ meta_title: string | null;
69
+ meta_description: string | null;
70
+ custom_canonical: string | null;
71
+ published_at: string | null;
72
+ feature_image_id: string | null;
73
+ }
74
+
75
+ export interface PostMetaContent extends PageMetaContent {
76
+ label: string | null;
77
+ excerpt: string | null;
78
+ subtitle: string | null;
79
+ }
80
+
81
+ export interface ProductMetaContent {
82
+ title: string;
83
+ slug: string;
84
+ language_id: number;
85
+ status: string;
86
+ meta_title: string | null;
87
+ meta_description: string | null;
88
+ custom_canonical: string | null;
89
+ published_at: string | null;
90
+ short_description: string | null;
91
+ description_json: Json | null;
92
+ }
93
+
94
+ export interface SimpleBlockContent {
95
+ language_id: number;
96
+ block_type: BlockRow['block_type'];
97
+ content: Json;
98
+ order: number;
99
+ }
100
+
101
+ export interface FullPageContent {
102
+ meta: PageMetaContent;
103
+ blocks: SimpleBlockContent[];
104
+ }
105
+
106
+ export interface FullPostContent {
107
+ meta: PostMetaContent;
108
+ blocks: SimpleBlockContent[];
109
+ }
110
+
111
+ export interface FullProductContent {
112
+ meta: ProductMetaContent;
113
+ blocks: SimpleBlockContent[];
114
+ }
115
+
116
+ export type AnyFullContent = FullPageContent | FullPostContent | FullProductContent;
117
+
118
+ /**
119
+ * Any Supabase client shape. Callers pass the cookie-scoped client, the service-role client,
120
+ * or nothing at all — the generics on those two differ enough that a union of them makes
121
+ * `.from()` uncallable, so this stays deliberately loose.
122
+ */
123
+ type AnySupabaseClient = any;
124
+
125
+ /**
126
+ * Normalise a timestamp to the exact format JSON snapshots use everywhere else —
127
+ * `Date#toISOString()`, millisecond precision, `Z` suffix. Postgres renders timestamptz
128
+ * with microseconds and a `+00:00` offset, so without this a value that round-trips
129
+ * through the database comes back textually different and shows up as a phantom diff.
130
+ */
131
+ function normalizeTimestamp(value: string | null | undefined): string | null {
132
+ if (!value) return null;
133
+ const parsed = new Date(value);
134
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
135
+ }
136
+
137
+ interface BlockCaptureOptions {
138
+ overrideBlockId?: number;
139
+ overrideBlockContent?: unknown;
140
+ excludeDeletedBlockId?: number;
141
+ }
142
+
143
+ /** Reduce raw block rows to the id-free shape stored in revisions. */
144
+ function toSimpleBlocks(
145
+ blocks: Pick<BlockRow, 'id' | 'language_id' | 'block_type' | 'content' | 'order'>[] | null,
146
+ opts?: BlockCaptureOptions
147
+ ): SimpleBlockContent[] {
148
+ return (blocks || [])
149
+ .filter(b => (opts?.excludeDeletedBlockId ? b.id !== opts.excludeDeletedBlockId : true))
150
+ .map(b => ({
151
+ language_id: b.language_id,
152
+ block_type: b.block_type,
153
+ content: (opts?.overrideBlockId && b.id === opts.overrideBlockId)
154
+ ? (opts.overrideBlockContent as Json)
155
+ : (b.content as Json),
156
+ order: b.order,
157
+ } satisfies SimpleBlockContent));
158
+ }
159
+
160
+ /**
161
+ * Blocks are ordered by `order` then `id`. Revision blocks carry no id, so their array
162
+ * position *is* their identity — two blocks sharing an `order` value would otherwise swap
163
+ * places between two reads and register as a content change that never happened.
164
+ */
165
+ const BLOCK_SELECT = 'id, language_id, block_type, content, order';
166
+
167
+ export async function getFullPageContent(
168
+ pageId: number,
169
+ opts?: BlockCaptureOptions,
170
+ client?: AnySupabaseClient
171
+ ): Promise<FullPageContent | null> {
172
+ const supabase = (client ?? createClient()) as AnySupabaseClient;
173
+ const { data: page, error: pageError } = await supabase
174
+ .from('pages')
175
+ .select(`id, ${PAGE_META_COLUMNS.join(', ')}`)
176
+ .eq('id', pageId)
177
+ .single();
178
+ if (pageError || !page) return null;
179
+
180
+ const { data: blocks, error: blocksError } = await supabase
181
+ .from('blocks')
182
+ .select(BLOCK_SELECT)
183
+ .eq('page_id', pageId)
184
+ .order('order', { ascending: true })
185
+ .order('id', { ascending: true });
186
+ if (blocksError) return null;
187
+
188
+ const row = page as Record<string, any>;
189
+
190
+ return {
191
+ meta: {
192
+ title: row['title'],
193
+ slug: row['slug'],
194
+ language_id: row['language_id'],
195
+ status: row['status'],
196
+ meta_title: row['meta_title'] ?? null,
197
+ meta_description: row['meta_description'] ?? null,
198
+ custom_canonical: row['custom_canonical'] ?? null,
199
+ published_at: normalizeTimestamp(row['published_at']),
200
+ feature_image_id: row['feature_image_id'] ?? null,
201
+ },
202
+ blocks: toSimpleBlocks(blocks as any, opts),
203
+ };
204
+ }
205
+
206
+ export async function getFullPostContent(
207
+ postId: number,
208
+ opts?: BlockCaptureOptions,
209
+ client?: AnySupabaseClient
210
+ ): Promise<FullPostContent | null> {
211
+ const supabase = (client ?? createClient()) as AnySupabaseClient;
212
+ const { data: post, error: postError } = await supabase
213
+ .from('posts')
214
+ .select(`id, ${POST_META_COLUMNS.join(', ')}`)
215
+ .eq('id', postId)
216
+ .single();
217
+ if (postError || !post) return null;
218
+
219
+ const { data: blocks, error: blocksError } = await supabase
220
+ .from('blocks')
221
+ .select(BLOCK_SELECT)
222
+ .eq('post_id', postId)
223
+ .order('order', { ascending: true })
224
+ .order('id', { ascending: true });
225
+ if (blocksError) return null;
226
+
227
+ const row = post as Record<string, any>;
228
+
229
+ return {
230
+ meta: {
231
+ title: row['title'],
232
+ slug: row['slug'],
233
+ language_id: row['language_id'],
234
+ status: row['status'],
235
+ meta_title: row['meta_title'] ?? null,
236
+ meta_description: row['meta_description'] ?? null,
237
+ custom_canonical: row['custom_canonical'] ?? null,
238
+ published_at: normalizeTimestamp(row['published_at']),
239
+ feature_image_id: row['feature_image_id'] ?? null,
240
+ label: row['label'] ?? null,
241
+ excerpt: row['excerpt'] ?? null,
242
+ subtitle: row['subtitle'] ?? null,
243
+ },
244
+ blocks: toSimpleBlocks(blocks as any, opts),
245
+ };
246
+ }
247
+
248
+ export async function getFullProductContent(
249
+ productId: string,
250
+ opts?: BlockCaptureOptions,
251
+ client?: AnySupabaseClient
252
+ ): Promise<FullProductContent | null> {
253
+ const supabase = (client ?? createClient()) as AnySupabaseClient;
254
+ const { data: product, error: productError } = await supabase
255
+ .from('products')
256
+ .select(`id, ${PRODUCT_META_COLUMNS.join(', ')}`)
257
+ .eq('id', productId)
258
+ .single();
259
+ if (productError || !product) return null;
260
+
261
+ const { data: blocks, error: blocksError } = await supabase
262
+ .from('blocks')
263
+ .select(BLOCK_SELECT)
264
+ .eq('product_id', productId)
265
+ .order('order', { ascending: true })
266
+ .order('id', { ascending: true });
267
+ if (blocksError) return null;
268
+
269
+ const row = product as Record<string, any>;
270
+
271
+ return {
272
+ meta: {
273
+ title: row['title'],
274
+ slug: row['slug'],
275
+ language_id: row['language_id'],
276
+ status: row['status'],
277
+ meta_title: row['meta_title'] ?? null,
278
+ meta_description: row['meta_description'] ?? null,
279
+ custom_canonical: row['custom_canonical'] ?? null,
280
+ published_at: normalizeTimestamp(row['published_at']),
281
+ short_description: row['short_description'] ?? null,
282
+ description_json: (row['description_json'] ?? null) as Json | null,
283
+ },
284
+ blocks: toSimpleBlocks(blocks as any, opts),
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Build the meta payload a restore writes back, minus the fields a restore must never
290
+ * replay (see RESTORE_EXCLUDED_META_COLUMNS).
291
+ */
292
+ export function buildRestoreMetaUpdate(
293
+ meta: Record<string, unknown>,
294
+ columns: readonly string[]
295
+ ): Record<string, unknown> {
296
+ const excluded = new Set<string>(RESTORE_EXCLUDED_META_COLUMNS);
297
+ const update: Record<string, unknown> = {};
298
+ for (const column of columns) {
299
+ if (excluded.has(column)) continue;
300
+ if (!Object.prototype.hasOwnProperty.call(meta, column)) continue;
301
+ update[column] = meta[column];
302
+ }
303
+ return update;
304
+ }
@@ -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
 
@@ -1018,7 +1018,10 @@ function VisualEditingToolbar() {
1018
1018
  return;
1019
1019
  }
1020
1020
 
1021
- setMessage("Draft published.");
1021
+ // A warning means the content is live but the revision didn't record — still a
1022
+ // publish, so the editor closes, but don't report it as a clean one.
1023
+ const warning = result && "success" in result ? result.warning : undefined;
1024
+ setMessage(warning ?? "Draft published.");
1022
1025
  hasSavedSinceOpenRef.current = false;
1023
1026
  closeVisualEditor();
1024
1027
  router.refresh();
@@ -100,6 +100,7 @@ Defined primarily in `00000000000002_setup_content_tables.sql`:
100
100
  - `navigation_items`
101
101
  - `page_revisions`
102
102
  - `post_revisions`
103
+ - `product_revisions` (added in `00000000000016`, alongside `products.version`)
103
104
 
104
105
  ### Commerce tables
105
106
 
@@ -179,8 +180,12 @@ tree. The current sequence is:
179
180
 
180
181
  Every file is fully idempotent. Existing databases already have versions
181
182
  `000`–`003` recorded, so both appliers skip the baseline — it only runs on a
182
- fresh/empty database. **The next new migration is `00000000000004`**, appended
183
- forward-only.
183
+ fresh/empty database.
184
+
185
+ `00000000000004` was the first migration appended after that re-baseline, not the
186
+ one still to be written — the folder has grown well past it. **To find the next
187
+ number, list `libs/db/src/supabase/migrations` and take the one after the highest
188
+ file on disk.** Never copy a hardcoded "next is N" out of a doc.
184
189
 
185
190
  ### Production migration policy
186
191