create-nextblock 0.13.8 → 0.13.10
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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
- package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +3 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +10 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +5 -1
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +55 -24
- package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +263 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +375 -18
- package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -0
- package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +24 -4
- package/templates/nextblock-template/lib/blocks/blockTypes.ts +19 -0
- package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +61 -2
- package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -0
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
const EcommerceActiveContext = createContext<boolean>(false);
|
|
6
|
+
|
|
7
|
+
export function EcommerceActiveProvider({
|
|
8
|
+
children,
|
|
9
|
+
isActive,
|
|
10
|
+
}: {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<EcommerceActiveContext.Provider value={isActive}>
|
|
16
|
+
{children}
|
|
17
|
+
</EcommerceActiveContext.Provider>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the premium `ecommerce` package is activated for this install.
|
|
23
|
+
* Defaults to `false` outside the provider so any UI gated on it fails closed.
|
|
24
|
+
*/
|
|
25
|
+
export function useEcommerceActive() {
|
|
26
|
+
return useContext(EcommerceActiveContext);
|
|
27
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
|
5
|
+
import { Button } from '@nextblock-cms/ui';
|
|
6
|
+
import { ProductGrid } from '@nextblock-cms/ecommerce/components/ProductGrid';
|
|
7
|
+
import type { Product } from '@nextblock-cms/ecommerce/types';
|
|
8
|
+
import { cn } from '@nextblock-cms/utils';
|
|
9
|
+
|
|
10
|
+
import { fetchProductGridPage } from '../../app/actions/productGridActions';
|
|
11
|
+
import type { ProductGridQuery } from '../../lib/blocks/product-grid-data';
|
|
12
|
+
|
|
13
|
+
interface ProductGridClientProps {
|
|
14
|
+
initialProducts: Product[];
|
|
15
|
+
totalCount: number;
|
|
16
|
+
/** The block's resolved query, replayed by the server action for later pages. */
|
|
17
|
+
query: ProductGridQuery;
|
|
18
|
+
showPagination: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function ProductGridClient({
|
|
22
|
+
initialProducts,
|
|
23
|
+
totalCount,
|
|
24
|
+
query,
|
|
25
|
+
showPagination,
|
|
26
|
+
}: ProductGridClientProps) {
|
|
27
|
+
const [products, setProducts] = React.useState(initialProducts);
|
|
28
|
+
const [currentPage, setCurrentPage] = React.useState(1);
|
|
29
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
30
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
31
|
+
const gridRef = React.useRef<HTMLDivElement>(null);
|
|
32
|
+
|
|
33
|
+
// Re-sync when the server sends a new first page (e.g. a live draft edit).
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
setProducts(initialProducts);
|
|
36
|
+
setCurrentPage(1);
|
|
37
|
+
}, [initialProducts]);
|
|
38
|
+
|
|
39
|
+
const perPage = query.limit > 0 ? query.limit : products.length || 1;
|
|
40
|
+
const totalPages = showPagination ? Math.max(1, Math.ceil(totalCount / perPage)) : 1;
|
|
41
|
+
|
|
42
|
+
const goToPage = async (nextPage: number) => {
|
|
43
|
+
if (isLoading || nextPage < 1 || nextPage > totalPages || nextPage === currentPage) return;
|
|
44
|
+
|
|
45
|
+
setIsLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
const result = await fetchProductGridPage({ ...query, page: nextPage });
|
|
49
|
+
if (result.error) {
|
|
50
|
+
setError(result.error);
|
|
51
|
+
} else {
|
|
52
|
+
setProducts(result.products);
|
|
53
|
+
setCurrentPage(nextPage);
|
|
54
|
+
// Keep the top of the grid in view rather than leaving the reader
|
|
55
|
+
// stranded at the bottom of the previous page.
|
|
56
|
+
gridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
setError('Failed to load products.');
|
|
60
|
+
} finally {
|
|
61
|
+
setIsLoading(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div ref={gridRef} className="scroll-mt-24">
|
|
67
|
+
<div
|
|
68
|
+
aria-busy={isLoading}
|
|
69
|
+
className={cn('transition-opacity duration-200', isLoading && 'pointer-events-none opacity-50')}
|
|
70
|
+
>
|
|
71
|
+
<ProductGrid products={products} />
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{error && (
|
|
75
|
+
<p role="alert" className="mt-6 text-center text-sm text-destructive">
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{showPagination && totalPages > 1 && (
|
|
81
|
+
<nav
|
|
82
|
+
aria-label="Product grid pagination"
|
|
83
|
+
className="mt-10 flex items-center justify-center gap-3"
|
|
84
|
+
>
|
|
85
|
+
<Button
|
|
86
|
+
variant="outline"
|
|
87
|
+
size="sm"
|
|
88
|
+
onClick={() => goToPage(currentPage - 1)}
|
|
89
|
+
disabled={currentPage === 1 || isLoading}
|
|
90
|
+
>
|
|
91
|
+
<ChevronLeft className="h-4 w-4" />
|
|
92
|
+
Previous
|
|
93
|
+
</Button>
|
|
94
|
+
<span
|
|
95
|
+
aria-live="polite"
|
|
96
|
+
className="flex min-w-[7rem] items-center justify-center gap-1.5 text-sm text-muted-foreground"
|
|
97
|
+
>
|
|
98
|
+
{isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
99
|
+
Page {currentPage} of {totalPages}
|
|
100
|
+
</span>
|
|
101
|
+
<Button
|
|
102
|
+
variant="outline"
|
|
103
|
+
size="sm"
|
|
104
|
+
onClick={() => goToPage(currentPage + 1)}
|
|
105
|
+
disabled={currentPage === totalPages || isLoading}
|
|
106
|
+
>
|
|
107
|
+
Next
|
|
108
|
+
<ChevronRight className="h-4 w-4" />
|
|
109
|
+
</Button>
|
|
110
|
+
</nav>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -1,139 +1,78 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
id: p.id,
|
|
80
|
-
title: p.title,
|
|
81
|
-
slug: p.slug,
|
|
82
|
-
sku: p.sku,
|
|
83
|
-
average_rating: p.average_rating,
|
|
84
|
-
total_reviews: p.total_reviews,
|
|
85
|
-
upc: p.upc || undefined,
|
|
86
|
-
price: p.price,
|
|
87
|
-
prices: normalizePriceMap(p.prices),
|
|
88
|
-
sale_price: typeof p.sale_price === 'number' ? p.sale_price : undefined,
|
|
89
|
-
sale_prices: normalizeSalePriceMap(p.sale_prices),
|
|
90
|
-
sale_start_at: p.sale_start_at ?? null,
|
|
91
|
-
sale_end_at: p.sale_end_at ?? null,
|
|
92
|
-
scheduled_price: typeof p.scheduled_price === 'number' ? p.scheduled_price : undefined,
|
|
93
|
-
scheduled_prices: normalizePriceMap(p.scheduled_prices),
|
|
94
|
-
scheduled_price_at: p.scheduled_price_at ?? null,
|
|
95
|
-
is_taxable: p.is_taxable ?? true,
|
|
96
|
-
product_type: productRecord.product_type ?? undefined,
|
|
97
|
-
payment_provider: productRecord.payment_provider ?? undefined,
|
|
98
|
-
price_range_min: variantPriceRange?.min ?? null,
|
|
99
|
-
price_range_max: variantPriceRange?.max ?? null,
|
|
100
|
-
image_url: imageUrl,
|
|
101
|
-
short_description: p.short_description || undefined,
|
|
102
|
-
categories: (p.product_categories || []).map((pc: any) => pc.category).filter(Boolean),
|
|
103
|
-
language_id: p.language_id as number,
|
|
104
|
-
translation_group_id: p.translation_group_id || "",
|
|
105
|
-
freemius_product_id: productRecord.freemius_product_id || undefined,
|
|
106
|
-
freemius_plan_id: productRecord.freemius_plan_id || undefined,
|
|
107
|
-
trial_period_days: productRecord.trial_period_days ?? 0,
|
|
108
|
-
trial_requires_payment_method:
|
|
109
|
-
productRecord.trial_requires_payment_method ?? false,
|
|
110
|
-
freemius_plans: productRecord.freemius_plans,
|
|
111
|
-
has_variants: (p.product_variants?.length || 0) > 0,
|
|
112
|
-
product_variants: (p.product_variants || []).map((variant: any) => ({
|
|
113
|
-
id: variant.id,
|
|
114
|
-
price: variant.price,
|
|
115
|
-
prices: normalizePriceMap(variant.prices),
|
|
116
|
-
sale_price: variant.sale_price,
|
|
117
|
-
sale_prices: normalizeSalePriceMap(variant.sale_prices),
|
|
118
|
-
sale_start_at: variant.sale_start_at ?? null,
|
|
119
|
-
sale_end_at: variant.sale_end_at ?? null,
|
|
120
|
-
scheduled_price: variant.scheduled_price ?? null,
|
|
121
|
-
scheduled_prices: normalizePriceMap(variant.scheduled_prices),
|
|
122
|
-
scheduled_price_at: variant.scheduled_price_at ?? null,
|
|
123
|
-
})),
|
|
124
|
-
};
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<section className="py-12">
|
|
129
|
-
{content.title && (
|
|
130
|
-
<div className="container mb-8">
|
|
131
|
-
<h2 className="text-3xl font-bold tracking-tight">{content.title}</h2>
|
|
132
|
-
</div>
|
|
133
|
-
)}
|
|
134
|
-
<div className="container">
|
|
135
|
-
<ProductGrid products={uiProducts} />
|
|
136
|
-
</div>
|
|
137
|
-
</section>
|
|
138
|
-
);
|
|
139
|
-
};
|
|
1
|
+
import ProductGridClient from '../../components/blocks/ProductGridClient';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PRODUCT_GRID_UNLIMITED,
|
|
5
|
+
resolveProductGridCategoryIds,
|
|
6
|
+
resolveProductGridLimit,
|
|
7
|
+
resolveProductGridProductIds,
|
|
8
|
+
resolveProductGridShowPagination,
|
|
9
|
+
type ProductGridBlockContent,
|
|
10
|
+
} from './ecommerce-block-schemas';
|
|
11
|
+
import { loadProductGridPage, type ProductGridQuery } from './product-grid-data';
|
|
12
|
+
|
|
13
|
+
interface ProductGridBlockProps {
|
|
14
|
+
content: ProductGridBlockContent;
|
|
15
|
+
languageId?: number;
|
|
16
|
+
excludeProductId?: string;
|
|
17
|
+
excludeTranslationGroupId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Component (Server Component)
|
|
21
|
+
export const ProductGridBlock = async ({
|
|
22
|
+
content,
|
|
23
|
+
languageId,
|
|
24
|
+
excludeProductId,
|
|
25
|
+
excludeTranslationGroupId,
|
|
26
|
+
}: ProductGridBlockProps) => {
|
|
27
|
+
const isManual = content.type === 'manual';
|
|
28
|
+
const handPickedIds = isManual ? resolveProductGridProductIds(content) : [];
|
|
29
|
+
|
|
30
|
+
// A hand-picked grid with nothing picked has nothing to say — render nothing
|
|
31
|
+
// rather than silently falling back to unrelated products.
|
|
32
|
+
if (isManual && handPickedIds.length === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const paginate = resolveProductGridShowPagination(content);
|
|
37
|
+
// A hand-picked list is already exactly what the author asked for, so `limit`
|
|
38
|
+
// acts purely as a page size there — never as a cap.
|
|
39
|
+
const limit = isManual && !paginate ? PRODUCT_GRID_UNLIMITED : resolveProductGridLimit(content);
|
|
40
|
+
|
|
41
|
+
const query: ProductGridQuery = {
|
|
42
|
+
type: content.type ?? 'latest',
|
|
43
|
+
// With no category selected this resolves to an empty filter, i.e. latest products.
|
|
44
|
+
categoryIds: content.type === 'category' ? resolveProductGridCategoryIds(content) : undefined,
|
|
45
|
+
productIds: isManual ? handPickedIds : undefined,
|
|
46
|
+
limit,
|
|
47
|
+
page: 1,
|
|
48
|
+
languageId,
|
|
49
|
+
excludeProductId,
|
|
50
|
+
excludeTranslationGroupId,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const { products, totalCount } = await loadProductGridPage(query);
|
|
54
|
+
|
|
55
|
+
if (products.length === 0) {
|
|
56
|
+
return null; // Silent fail if no products
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showPagination = paginate && totalCount > limit;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<section className="py-12">
|
|
63
|
+
{content.title && (
|
|
64
|
+
<div className="container mb-8">
|
|
65
|
+
<h2 className="text-3xl font-bold tracking-tight">{content.title}</h2>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
<div className="container">
|
|
69
|
+
<ProductGridClient
|
|
70
|
+
initialProducts={products}
|
|
71
|
+
totalCount={totalCount}
|
|
72
|
+
query={query}
|
|
73
|
+
showPagination={showPagination}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
@@ -491,13 +491,13 @@ export const blockRegistry: Record<BlockType, BlockDefinition> = {
|
|
|
491
491
|
type: "product_grid",
|
|
492
492
|
label: "Product Grid",
|
|
493
493
|
icon: "ShoppingBag", // Lucide icon
|
|
494
|
-
initialContent: { type: 'latest', limit: 6 } as ProductGridBlockContent,
|
|
494
|
+
initialContent: { type: 'latest', limit: 6, showPagination: false } as ProductGridBlockContent,
|
|
495
495
|
editorComponentFilename: "ProductGridBlockEditor.tsx", // Assuming standard naming
|
|
496
496
|
rendererComponentFilename: "ProductGridBlockRenderer.tsx", // Assuming mapping to ProductGridBlock.tsx handled by renderer map not shown here, or we need to ensure the renderer map uses the file we created. The prompt implies creating the file in `lib/blocks` serves as the component. I will assume the system maps it.
|
|
497
497
|
schema: ProductGridBlockSchema,
|
|
498
498
|
documentation: {
|
|
499
|
-
description: 'Displays a grid of products.',
|
|
500
|
-
useCases: ['Homepage featured products', 'Category pages']
|
|
499
|
+
description: 'Displays a grid of products: the latest ones, everything in one or more categories, or a hand-picked list.',
|
|
500
|
+
useCases: ['Homepage featured products', 'Category pages', 'Curated collections']
|
|
501
501
|
}
|
|
502
502
|
},
|
|
503
503
|
|
|
@@ -572,9 +572,29 @@ export function getBlockDefinition(blockType: string): BlockDefinition | undefin
|
|
|
572
572
|
return undefined;
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Whether a block type has anything for an author to configure.
|
|
577
|
+
*
|
|
578
|
+
* `cart`, `checkout` and `product_details` are context-driven: they declare an
|
|
579
|
+
* empty schema and ship no editor file at all, so opening an editor for them
|
|
580
|
+
* would show an empty dialog (and fail on the missing module). Used to decide
|
|
581
|
+
* whether adding a block should jump straight into its editor.
|
|
582
|
+
*
|
|
583
|
+
* @param blockType - The block type or custom-block slug
|
|
584
|
+
* @returns true when the block exposes editable fields
|
|
585
|
+
*/
|
|
586
|
+
export function blockHasEditableContent(blockType: string): boolean {
|
|
587
|
+
const definition = getBlockDefinition(blockType);
|
|
588
|
+
// Custom blocks are not in the registry; they always render a field editor.
|
|
589
|
+
if (!definition) return true;
|
|
590
|
+
const shape = (definition.schema as unknown as { shape?: Record<string, unknown> })?.shape;
|
|
591
|
+
if (!shape) return true;
|
|
592
|
+
return Object.keys(shape).length > 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
575
595
|
/**
|
|
576
596
|
* Get the initial content for a specific block type
|
|
577
|
-
*
|
|
597
|
+
*
|
|
578
598
|
* @param blockType - The type of block to get initial content for
|
|
579
599
|
* @returns The initial content object or undefined if block type not found
|
|
580
600
|
*/
|
|
@@ -16,3 +16,22 @@ export const availableBlockTypes = [
|
|
|
16
16
|
] as const;
|
|
17
17
|
|
|
18
18
|
export type BlockType = (typeof availableBlockTypes)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Block types provided by the premium `ecommerce` package. They are hidden from
|
|
22
|
+
* the block picker when the package is not activated — see
|
|
23
|
+
* `app/cms/blocks/components/BlockTypeSelector.tsx`.
|
|
24
|
+
*/
|
|
25
|
+
export const ecommerceBlockTypes = [
|
|
26
|
+
"product_grid",
|
|
27
|
+
"featured_product",
|
|
28
|
+
"cart",
|
|
29
|
+
"checkout",
|
|
30
|
+
"product_details",
|
|
31
|
+
] as const satisfies readonly BlockType[];
|
|
32
|
+
|
|
33
|
+
export type EcommerceBlockType = (typeof ecommerceBlockTypes)[number];
|
|
34
|
+
|
|
35
|
+
export function isEcommerceBlockType(type: string): type is EcommerceBlockType {
|
|
36
|
+
return (ecommerceBlockTypes as readonly string[]).includes(type);
|
|
37
|
+
}
|
|
@@ -1,14 +1,73 @@
|
|
|
1
1
|
import { z } from '../zod-config';
|
|
2
2
|
|
|
3
|
+
/** Upper bound for how many categories / products a single grid can reference. */
|
|
4
|
+
export const PRODUCT_GRID_MAX_CATEGORIES = 20;
|
|
5
|
+
export const PRODUCT_GRID_MAX_PRODUCTS = 24;
|
|
6
|
+
export const PRODUCT_GRID_MAX_LIMIT = 48;
|
|
7
|
+
/** `limit: 0` means "no cap" — show every matching product on one page. */
|
|
8
|
+
export const PRODUCT_GRID_UNLIMITED = 0;
|
|
9
|
+
/** Page size applied when pagination is switched on from an unlimited grid. */
|
|
10
|
+
export const PRODUCT_GRID_DEFAULT_PAGE_SIZE = 12;
|
|
11
|
+
|
|
3
12
|
// Product Grid Block Schema
|
|
4
13
|
export const ProductGridBlockSchema = z.object({
|
|
5
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Where the grid gets its products:
|
|
16
|
+
* - `latest` newest active products
|
|
17
|
+
* - `category` every product in any of `categoryIds`
|
|
18
|
+
* - `manual` exactly the products in `productIds`, in that order
|
|
19
|
+
*/
|
|
20
|
+
type: z.enum(['latest', 'category', 'manual']).default('latest'),
|
|
21
|
+
/** Legacy single-category field, superseded by `categoryIds`. Still read so blocks saved before multi-select keep working. */
|
|
6
22
|
categoryId: z.string().optional(),
|
|
7
|
-
|
|
23
|
+
categoryIds: z.array(z.string()).max(PRODUCT_GRID_MAX_CATEGORIES).optional(),
|
|
24
|
+
productIds: z.array(z.string()).max(PRODUCT_GRID_MAX_PRODUCTS).optional(),
|
|
25
|
+
/** Products per page when paginating, otherwise the display cap. `0` means unlimited. */
|
|
26
|
+
limit: z.number().min(PRODUCT_GRID_UNLIMITED).max(PRODUCT_GRID_MAX_LIMIT).default(6),
|
|
27
|
+
/** Page through everything that matches instead of stopping at `limit`. */
|
|
28
|
+
showPagination: z.boolean().default(false),
|
|
8
29
|
title: z.string().optional(),
|
|
9
30
|
});
|
|
10
31
|
export type ProductGridBlockContent = z.infer<typeof ProductGridBlockSchema>;
|
|
11
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Selected categories, reading the legacy single `categoryId` when a block was
|
|
35
|
+
* saved before multi-select existed.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveProductGridCategoryIds(
|
|
38
|
+
content: Partial<ProductGridBlockContent>
|
|
39
|
+
): string[] {
|
|
40
|
+
const ids = (content.categoryIds ?? []).filter(Boolean);
|
|
41
|
+
if (ids.length > 0) return Array.from(new Set(ids));
|
|
42
|
+
return content.categoryId ? [content.categoryId] : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Hand-picked products, in the order the author chose them. */
|
|
46
|
+
export function resolveProductGridProductIds(
|
|
47
|
+
content: Partial<ProductGridBlockContent>
|
|
48
|
+
): string[] {
|
|
49
|
+
return Array.from(new Set((content.productIds ?? []).filter(Boolean)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Page size / display cap, clamped to the supported range. Returns
|
|
54
|
+
* `PRODUCT_GRID_UNLIMITED` (0) when the author asked for every match.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveProductGridLimit(
|
|
57
|
+
content: Partial<ProductGridBlockContent>
|
|
58
|
+
): number {
|
|
59
|
+
const raw = typeof content.limit === 'number' ? content.limit : 6;
|
|
60
|
+
if (!Number.isFinite(raw) || raw < 0) return 6;
|
|
61
|
+
return Math.min(Math.floor(raw), PRODUCT_GRID_MAX_LIMIT);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pagination needs a page size, so an unlimited grid is always a single page. */
|
|
65
|
+
export function resolveProductGridShowPagination(
|
|
66
|
+
content: Partial<ProductGridBlockContent>
|
|
67
|
+
): boolean {
|
|
68
|
+
return Boolean(content.showPagination) && resolveProductGridLimit(content) > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
12
71
|
// Featured Product Block Schema
|
|
13
72
|
export const FeaturedProductBlockSchema = z.object({
|
|
14
73
|
productId: z.string().min(1, 'Product is required'),
|