create-nextblock 0.13.7 → 0.13.9
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/(auth-pages)/two-factor/actions.ts +21 -1
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
- package/templates/nextblock-template/app/actions/email.ts +78 -7
- package/templates/nextblock-template/app/actions/feedback.ts +57 -14
- package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
- package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
- package/templates/nextblock-template/app/actions.ts +17 -4
- package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
- package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +251 -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/app/cms/settings/bot-protection/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
- package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
- package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
- package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
- package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
- package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -3
- 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/lib/cms/action-result.ts +12 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
- 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
|
@@ -16,20 +16,32 @@ const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
16
16
|
const TWO_FACTOR_SESSION_TTL_SECONDS = 12 * 60 * 60; // 12 hours
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* How many of a user's most recent codes stay usable at once.
|
|
20
|
+
*
|
|
21
|
+
* Relays (SMTP2GO, SES, …) do not preserve send order and can take minutes to deliver, so
|
|
22
|
+
* a user who clicks "resend" often receives the ORIGINAL code first. Hard-invalidating on
|
|
23
|
+
* every request made that first-arriving code fail — the classic "the code you emailed me
|
|
24
|
+
* doesn't work" loop. Keeping a short window of concurrently valid codes removes the race;
|
|
25
|
+
* the guess surface stays bounded because only this many are ever checked and all of them
|
|
26
|
+
* die on first successful use (and after EMAIL_CODE_TTL_MS regardless).
|
|
27
|
+
*/
|
|
28
|
+
const MAX_LIVE_EMAIL_CODES = 3;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimum gap between user-requested codes. Deliberately shorter than the UI countdown so a
|
|
32
|
+
* normal user never sees the server refusal — the client timer is a courtesy that a crafted
|
|
33
|
+
* request ignores, and this is what actually bounds how fast one session can drive the mailer.
|
|
34
|
+
*/
|
|
35
|
+
const RESEND_COOLDOWN_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create and persist a hashed 6-digit email code and return the RAW code so the caller can
|
|
39
|
+
* email it. Earlier codes are deliberately left alive — see MAX_LIVE_EMAIL_CODES. Kept to a
|
|
40
|
+
* single round trip: this sits directly in front of the SMTP handoff on a click path.
|
|
21
41
|
*/
|
|
22
42
|
export async function createEmailChallenge(userId: string): Promise<string> {
|
|
23
43
|
const code = generateNumericCode(6);
|
|
24
44
|
const svc = getServiceRoleSupabaseClient();
|
|
25
|
-
const nowIso = new Date().toISOString();
|
|
26
|
-
|
|
27
|
-
// Invalidate any earlier pending codes so only the newest one works.
|
|
28
|
-
await svc
|
|
29
|
-
.from('email_2fa_challenges')
|
|
30
|
-
.update({ consumed_at: nowIso })
|
|
31
|
-
.eq('user_id', userId)
|
|
32
|
-
.is('consumed_at', null);
|
|
33
45
|
|
|
34
46
|
await svc.from('email_2fa_challenges').insert({
|
|
35
47
|
user_id: userId,
|
|
@@ -40,6 +52,27 @@ export async function createEmailChallenge(userId: string): Promise<string> {
|
|
|
40
52
|
return code;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Seconds the caller must wait before another code may be requested, or 0 when a send is
|
|
57
|
+
* allowed. Considers consumed rows too — this measures time since the last SEND, not since
|
|
58
|
+
* the last still-usable code.
|
|
59
|
+
*/
|
|
60
|
+
export async function getEmailResendCooldownSeconds(userId: string): Promise<number> {
|
|
61
|
+
const svc = getServiceRoleSupabaseClient();
|
|
62
|
+
const { data } = await svc
|
|
63
|
+
.from('email_2fa_challenges')
|
|
64
|
+
.select('created_at')
|
|
65
|
+
.eq('user_id', userId)
|
|
66
|
+
.order('created_at', { ascending: false })
|
|
67
|
+
.limit(1);
|
|
68
|
+
|
|
69
|
+
const latest = data?.[0]?.created_at;
|
|
70
|
+
if (!latest) return 0;
|
|
71
|
+
const elapsed = Date.now() - new Date(latest).getTime();
|
|
72
|
+
if (!Number.isFinite(elapsed) || elapsed >= RESEND_COOLDOWN_MS) return 0;
|
|
73
|
+
return Math.ceil((RESEND_COOLDOWN_MS - elapsed) / 1000);
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
/** True when the user has an unconsumed, unexpired email code awaiting entry. */
|
|
44
77
|
export async function hasPendingEmailChallenge(userId: string): Promise<boolean> {
|
|
45
78
|
const svc = getServiceRoleSupabaseClient();
|
|
@@ -53,7 +86,11 @@ export async function hasPendingEmailChallenge(userId: string): Promise<boolean>
|
|
|
53
86
|
return Boolean(data && data.length > 0);
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Verify a submitted email code against the user's live challenges. Any of the newest
|
|
91
|
+
* MAX_LIVE_EMAIL_CODES is accepted, so a code that arrives out of order still works;
|
|
92
|
+
* anything older than that window is unreachable even though its row lingers until expiry.
|
|
93
|
+
*/
|
|
57
94
|
export async function verifyEmailChallenge(userId: string, code: string): Promise<boolean> {
|
|
58
95
|
const trimmed = (code || '').trim();
|
|
59
96
|
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
@@ -67,14 +104,20 @@ export async function verifyEmailChallenge(userId: string, code: string): Promis
|
|
|
67
104
|
.is('consumed_at', null)
|
|
68
105
|
.gt('expires_at', nowIso)
|
|
69
106
|
.order('created_at', { ascending: false })
|
|
70
|
-
.limit(
|
|
107
|
+
.limit(MAX_LIVE_EMAIL_CODES);
|
|
71
108
|
|
|
72
109
|
if (!data || data.length === 0) return false;
|
|
73
110
|
const candidateHash = sha256Hex(trimmed);
|
|
74
111
|
const match = data.find((row) => safeEqual(row.token_hash, candidateHash));
|
|
75
112
|
if (!match) return false;
|
|
76
113
|
|
|
77
|
-
|
|
114
|
+
// Burn every live code for this user, not just the matched row: the siblings from a
|
|
115
|
+
// resend have served their purpose and must not stay redeemable after a success.
|
|
116
|
+
await svc
|
|
117
|
+
.from('email_2fa_challenges')
|
|
118
|
+
.update({ consumed_at: nowIso })
|
|
119
|
+
.eq('user_id', userId)
|
|
120
|
+
.is('consumed_at', null);
|
|
78
121
|
return true;
|
|
79
122
|
}
|
|
80
123
|
|
|
@@ -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
|
|
|
@@ -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'),
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { getProducts } from '@nextblock-cms/ecommerce/server';
|
|
2
|
+
import { normalizePriceMap, normalizeSalePriceMap } from '@nextblock-cms/ecommerce/currency';
|
|
3
|
+
import { getVariantEffectivePriceRange } from '@nextblock-cms/ecommerce/variation-utils';
|
|
4
|
+
import type { Product } from '@nextblock-cms/ecommerce/types';
|
|
5
|
+
import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
|
|
6
|
+
|
|
7
|
+
import { PRODUCT_GRID_MAX_LIMIT } from './ecommerce-block-schemas';
|
|
8
|
+
|
|
9
|
+
type SupabaseClientLike = ReturnType<typeof getSsgSupabaseClient>;
|
|
10
|
+
|
|
11
|
+
export interface ProductGridQuery {
|
|
12
|
+
type?: 'latest' | 'category' | 'manual';
|
|
13
|
+
categoryIds?: string[];
|
|
14
|
+
productIds?: string[];
|
|
15
|
+
/** Products per page, or `0` for every match on a single page. */
|
|
16
|
+
limit: number;
|
|
17
|
+
/** 1-based page number. */
|
|
18
|
+
page?: number;
|
|
19
|
+
languageId?: number;
|
|
20
|
+
excludeProductId?: string;
|
|
21
|
+
excludeTranslationGroupId?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProductGridPage {
|
|
25
|
+
products: Product[];
|
|
26
|
+
/** Total matches across all pages, before exclusions. */
|
|
27
|
+
totalCount: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Turn a `getProducts` row into the shape `<ProductGrid />` renders. */
|
|
31
|
+
export function mapProductRowToUiProduct(p: any): Product {
|
|
32
|
+
const productRecord = p as any;
|
|
33
|
+
let imageUrl = undefined;
|
|
34
|
+
// Accessing the nested media object correctly (array of objects with media property)
|
|
35
|
+
// The type from getProducts select is: product_media: { media: { file_path: string | null } | null }[]
|
|
36
|
+
const mediaItem = p.product_media?.[0]?.media;
|
|
37
|
+
if (mediaItem?.file_path) {
|
|
38
|
+
if (mediaItem.file_path.startsWith('http')) {
|
|
39
|
+
imageUrl = mediaItem.file_path;
|
|
40
|
+
} else if (process.env.NEXT_PUBLIC_R2_BASE_URL) {
|
|
41
|
+
imageUrl = `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${mediaItem.file_path}`;
|
|
42
|
+
} else {
|
|
43
|
+
imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''}/storage/v1/object/public/media/${mediaItem.file_path}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const variantPriceRange = getVariantEffectivePriceRange(
|
|
48
|
+
(p.product_variants || []).map((variant: any) => ({
|
|
49
|
+
price: variant.price,
|
|
50
|
+
sale_price: variant.sale_price,
|
|
51
|
+
sale_start_at: variant.sale_start_at ?? null,
|
|
52
|
+
sale_end_at: variant.sale_end_at ?? null,
|
|
53
|
+
}))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: p.id,
|
|
58
|
+
title: p.title,
|
|
59
|
+
slug: p.slug,
|
|
60
|
+
sku: p.sku,
|
|
61
|
+
average_rating: p.average_rating,
|
|
62
|
+
total_reviews: p.total_reviews,
|
|
63
|
+
upc: p.upc || undefined,
|
|
64
|
+
price: p.price,
|
|
65
|
+
prices: normalizePriceMap(p.prices),
|
|
66
|
+
sale_price: typeof p.sale_price === 'number' ? p.sale_price : undefined,
|
|
67
|
+
sale_prices: normalizeSalePriceMap(p.sale_prices),
|
|
68
|
+
sale_start_at: p.sale_start_at ?? null,
|
|
69
|
+
sale_end_at: p.sale_end_at ?? null,
|
|
70
|
+
scheduled_price: typeof p.scheduled_price === 'number' ? p.scheduled_price : undefined,
|
|
71
|
+
scheduled_prices: normalizePriceMap(p.scheduled_prices),
|
|
72
|
+
scheduled_price_at: p.scheduled_price_at ?? null,
|
|
73
|
+
is_taxable: p.is_taxable ?? true,
|
|
74
|
+
product_type: productRecord.product_type ?? undefined,
|
|
75
|
+
payment_provider: productRecord.payment_provider ?? undefined,
|
|
76
|
+
price_range_min: variantPriceRange?.min ?? null,
|
|
77
|
+
price_range_max: variantPriceRange?.max ?? null,
|
|
78
|
+
image_url: imageUrl,
|
|
79
|
+
short_description: p.short_description || undefined,
|
|
80
|
+
categories: (p.product_categories || []).map((pc: any) => pc.category).filter(Boolean),
|
|
81
|
+
language_id: p.language_id as number,
|
|
82
|
+
translation_group_id: p.translation_group_id || "",
|
|
83
|
+
freemius_product_id: productRecord.freemius_product_id || undefined,
|
|
84
|
+
freemius_plan_id: productRecord.freemius_plan_id || undefined,
|
|
85
|
+
trial_period_days: productRecord.trial_period_days ?? 0,
|
|
86
|
+
trial_requires_payment_method:
|
|
87
|
+
productRecord.trial_requires_payment_method ?? false,
|
|
88
|
+
freemius_plans: productRecord.freemius_plans,
|
|
89
|
+
has_variants: (p.product_variants?.length || 0) > 0,
|
|
90
|
+
product_variants: (p.product_variants || []).map((variant: any) => ({
|
|
91
|
+
id: variant.id,
|
|
92
|
+
price: variant.price,
|
|
93
|
+
prices: normalizePriceMap(variant.prices),
|
|
94
|
+
sale_price: variant.sale_price,
|
|
95
|
+
sale_prices: normalizeSalePriceMap(variant.sale_prices),
|
|
96
|
+
sale_start_at: variant.sale_start_at ?? null,
|
|
97
|
+
sale_end_at: variant.sale_end_at ?? null,
|
|
98
|
+
scheduled_price: variant.scheduled_price ?? null,
|
|
99
|
+
scheduled_prices: normalizePriceMap(variant.scheduled_prices),
|
|
100
|
+
scheduled_price_at: variant.scheduled_price_at ?? null,
|
|
101
|
+
})),
|
|
102
|
+
} as Product;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load hand-picked products in the author's chosen order. Selections are made
|
|
107
|
+
* against a single language, so when the page renders in another language each
|
|
108
|
+
* pick is swapped for its translation (falling back to the picked row when the
|
|
109
|
+
* product has not been translated yet).
|
|
110
|
+
*/
|
|
111
|
+
async function fetchHandPickedProducts(
|
|
112
|
+
supabase: SupabaseClientLike,
|
|
113
|
+
productIds: string[],
|
|
114
|
+
languageId?: number
|
|
115
|
+
): Promise<any[]> {
|
|
116
|
+
const { data: pickedRows } = await getProducts(supabase, {
|
|
117
|
+
productIds,
|
|
118
|
+
limit: productIds.length,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const picked = (pickedRows || []) as any[];
|
|
122
|
+
if (picked.length === 0) return [];
|
|
123
|
+
|
|
124
|
+
const rowById = new Map(picked.map((row) => [row.id, row]));
|
|
125
|
+
let ordered = productIds
|
|
126
|
+
.map((id) => rowById.get(id))
|
|
127
|
+
.filter(Boolean) as any[];
|
|
128
|
+
|
|
129
|
+
if (languageId && ordered.some((row) => row.language_id !== languageId)) {
|
|
130
|
+
const groupIds = Array.from(
|
|
131
|
+
new Set(ordered.map((row) => row.translation_group_id).filter(Boolean))
|
|
132
|
+
) as string[];
|
|
133
|
+
|
|
134
|
+
if (groupIds.length > 0) {
|
|
135
|
+
const { data: translatedRows } = await getProducts(supabase, {
|
|
136
|
+
translationGroupIds: groupIds,
|
|
137
|
+
languageId,
|
|
138
|
+
limit: groupIds.length,
|
|
139
|
+
});
|
|
140
|
+
const rowByGroup = new Map(
|
|
141
|
+
((translatedRows || []) as any[]).map((row) => [row.translation_group_id, row])
|
|
142
|
+
);
|
|
143
|
+
ordered = ordered.map((row) => rowByGroup.get(row.translation_group_id) ?? row);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Picking two translations of the same product resolves to one product here.
|
|
148
|
+
const seen = new Set<string>();
|
|
149
|
+
return ordered.filter((row) => {
|
|
150
|
+
const key = row.translation_group_id || row.id;
|
|
151
|
+
if (seen.has(key)) return false;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Drop the product the grid is embedded under (related-products placements). */
|
|
158
|
+
function applyExclusions(rows: any[], query: ProductGridQuery): any[] {
|
|
159
|
+
const { excludeProductId, excludeTranslationGroupId } = query;
|
|
160
|
+
if (!excludeProductId && !excludeTranslationGroupId) return rows;
|
|
161
|
+
return rows.filter((row) => {
|
|
162
|
+
if (excludeProductId && row.id === excludeProductId) return false;
|
|
163
|
+
if (excludeTranslationGroupId && row.translation_group_id === excludeTranslationGroupId) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One page of a product grid. Shared by the block's server render (page 1) and
|
|
172
|
+
* the pagination server action (page N) so both agree on ordering and shape.
|
|
173
|
+
*/
|
|
174
|
+
export async function loadProductGridPage(query: ProductGridQuery): Promise<ProductGridPage> {
|
|
175
|
+
const supabase = getSsgSupabaseClient();
|
|
176
|
+
const page = Math.max(1, Math.floor(query.page ?? 1));
|
|
177
|
+
const limit = Math.min(Math.max(0, Math.floor(query.limit ?? 6)), PRODUCT_GRID_MAX_LIMIT);
|
|
178
|
+
const hasExclusions = Boolean(query.excludeProductId || query.excludeTranslationGroupId);
|
|
179
|
+
|
|
180
|
+
if (query.type === 'manual') {
|
|
181
|
+
const productIds = query.productIds ?? [];
|
|
182
|
+
if (productIds.length === 0) return { products: [], totalCount: 0 };
|
|
183
|
+
|
|
184
|
+
// The whole hand-picked list is at most PRODUCT_GRID_MAX_PRODUCTS rows, so
|
|
185
|
+
// it is cheaper to resolve it once and page in memory than to re-query.
|
|
186
|
+
const rows = applyExclusions(
|
|
187
|
+
await fetchHandPickedProducts(supabase, productIds, query.languageId),
|
|
188
|
+
query
|
|
189
|
+
);
|
|
190
|
+
const paged = limit > 0 ? rows.slice((page - 1) * limit, page * limit) : rows;
|
|
191
|
+
return { products: paged.map(mapProductRowToUiProduct), totalCount: rows.length };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Over-fetch just enough to refill a page after the excluded product is dropped.
|
|
195
|
+
const overFetch = hasExclusions ? 2 : 0;
|
|
196
|
+
const { data, count } = await getProducts(supabase, {
|
|
197
|
+
languageId: query.languageId,
|
|
198
|
+
categoryIds: query.type === 'category' ? query.categoryIds : undefined,
|
|
199
|
+
offset: limit > 0 ? (page - 1) * limit : undefined,
|
|
200
|
+
limit: limit > 0 ? limit + overFetch : 0,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const rows = applyExclusions((data || []) as any[], query);
|
|
204
|
+
const visible = limit > 0 ? rows.slice(0, limit) : rows;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
products: visible.map(mapProductRowToUiProduct),
|
|
208
|
+
totalCount: count ?? rows.length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard result shape for CMS settings server actions.
|
|
3
|
+
*
|
|
4
|
+
* Next replaces the message of an uncaught Server Action error with a generic string in
|
|
5
|
+
* production builds, so anything the operator has to be able to read — a permission
|
|
6
|
+
* refusal, a relay's rejection, a validation complaint — must come back as data rather
|
|
7
|
+
* than as a throw. Actions that only ever fail in ways nobody needs to read (pure data
|
|
8
|
+
* readers rendered by a Server Component) can still throw and hit the error boundary.
|
|
9
|
+
*/
|
|
10
|
+
export type SettingsActionResult =
|
|
11
|
+
| { ok: true; message: string }
|
|
12
|
+
| { ok: false; error: string };
|