@vitrine-kit/vitrine 0.4.0 → 0.4.3
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/dist/index.js +247 -87
- package/kit/registry/_index.json +9 -9
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/cart/files/app/api/cart/route.ts +58 -6
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +106 -14
- package/kit/registry/cart/files/components/cart/CartView.tsx +19 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/app/api/checkout/route.ts +11 -1
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +25 -10
- package/kit/registry/checkout-paddle/docs/checkout-paddle.md +5 -2
- package/kit/registry/checkout-paddle/feature.json +1 -1
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +25 -4
- package/kit/registry/checkout-stripe/docs/checkout-stripe.md +3 -1
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +10 -3
- package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +5 -2
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +14 -4
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/product-page/files/components/product/ProductView.tsx +5 -1
- package/kit/registry/seo/feature.json +1 -1
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +9 -3
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
- package/kit/templates/backend-payload/files/lib/seed/run.ts +35 -10
- package/kit/templates/backend-payload/files/next.config.mjs +28 -0
- package/kit/templates/backend-payload/files/payload.config.ts +7 -1
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
- package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
- package/kit/templates/base/files/.gitattributes +12 -0
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +6 -1
- package/kit/templates/base/files/app/(frontend)/page.tsx +36 -3
- package/kit/templates/base/files/next.config.mjs +28 -0
- package/package.json +5 -2
|
@@ -11,10 +11,13 @@ A YooKassa (yookassa.ru) provider — Russian acquiring: cards, SBP, wallets. Fo
|
|
|
11
11
|
sets `integrations.payments: "yookassa"`.
|
|
12
12
|
- **API (Next glue):** `POST /api/webhooks/yookassa` → `handlePaymentWebhook` →
|
|
13
13
|
`fulfillOrderFromEvent`.
|
|
14
|
-
- **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required
|
|
14
|
+
- **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required — read at call time, a
|
|
15
|
+
missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400).
|
|
15
16
|
|
|
16
17
|
**Security:** YooKassa notifications are **not signed** — the provider re-verifies the
|
|
17
|
-
payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`.
|
|
18
|
+
payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`. A 404 on that
|
|
19
|
+
check (forged/foreign payment id) is acked as `kind: "unknown"`; other API failures throw,
|
|
20
|
+
so the non-200 response makes YooKassa redeliver the notification.
|
|
18
21
|
|
|
19
22
|
**Money:** YooKassa expects a decimal string (`"1990.00"`); `Money` is in minor
|
|
20
23
|
units, so we divide by 100 (RUB and most currencies have 2 decimal places).
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// re-checks the payment via the API (we trust only succeeded) → normalized
|
|
3
3
|
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
|
+
import { checkRateLimit, clientIpFromHeaders, handlePaymentWebhook } from '@vitrine-kit/core';
|
|
6
6
|
import { yookassaProvider } from '@/lib/checkout-yookassa/provider';
|
|
7
7
|
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
8
8
|
|
|
9
|
+
const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
|
|
10
|
+
|
|
9
11
|
export async function POST(req: Request) {
|
|
12
|
+
const { allowed } = checkRateLimit(`webhook:yookassa:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
13
|
+
if (!allowed) return NextResponse.json({ error: 'too many requests' }, { status: 429 });
|
|
14
|
+
|
|
10
15
|
const rawBody = await req.text();
|
|
11
16
|
|
|
12
17
|
try {
|
|
@@ -17,6 +22,8 @@ export async function POST(req: Request) {
|
|
|
17
22
|
});
|
|
18
23
|
return NextResponse.json(result);
|
|
19
24
|
} catch (err) {
|
|
20
|
-
|
|
25
|
+
// Don't echo provider-specific error text (e.g. re-confirmation failure detail) to the caller.
|
|
26
|
+
console.error('[vitrine] yookassa webhook error:', err);
|
|
27
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -18,9 +18,16 @@ function minorToDecimalString(amount: number, currency: string): string {
|
|
|
18
18
|
return ZERO_DECIMAL.has(currency.toUpperCase()) ? String(amount) : (amount / 100).toFixed(2);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Required env, read at call time — `next build` must not need secrets. */
|
|
22
|
+
function requiredEnv(key: string): string {
|
|
23
|
+
const value = process.env[key];
|
|
24
|
+
if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
function authHeader(): string {
|
|
22
|
-
const shopId =
|
|
23
|
-
const secret =
|
|
29
|
+
const shopId = requiredEnv('YOOKASSA_SHOP_ID');
|
|
30
|
+
const secret = requiredEnv('YOOKASSA_SECRET_KEY');
|
|
24
31
|
return `Basic ${Buffer.from(`${shopId}:${secret}`).toString('base64')}`;
|
|
25
32
|
}
|
|
26
33
|
|
|
@@ -57,9 +64,12 @@ export const yookassaProvider: PaymentProvider = {
|
|
|
57
64
|
const paymentId = body.object?.id;
|
|
58
65
|
if (!paymentId) return { kind: 'unknown', raw: body };
|
|
59
66
|
|
|
60
|
-
// The notification is unsigned — re-check the payment via the API.
|
|
67
|
+
// The notification is unsigned — re-check the payment via the API. 404 = no such
|
|
68
|
+
// payment for this shop (forged/foreign notification) → ack as 'unknown'; any other
|
|
69
|
+
// failure is transient → throw (a non-200 response makes YooKassa redeliver later).
|
|
61
70
|
const res = await fetch(`${API}/${paymentId}`, { headers: { Authorization: authHeader() } });
|
|
62
|
-
if (
|
|
71
|
+
if (res.status === 404) return { kind: 'unknown', raw: body };
|
|
72
|
+
if (!res.ok) throw new Error(`[vitrine] YooKassa verify: ${res.status} (transient — expecting a redelivery)`);
|
|
63
73
|
const payment = (await res.json()) as {
|
|
64
74
|
id?: string;
|
|
65
75
|
status?: string;
|
|
@@ -25,7 +25,11 @@ export function ProductView({ product }: ProductViewProps) {
|
|
|
25
25
|
<Slot name="product.below-title" />
|
|
26
26
|
<h1 className="font-heading text-fg">{product.title}</h1>
|
|
27
27
|
{price != null ? (
|
|
28
|
-
<p className="text-price text-xl">
|
|
28
|
+
<p className="text-price text-xl">
|
|
29
|
+
{product.priceRange && product.priceRange.min !== product.priceRange.max
|
|
30
|
+
? `${formatPrice(product.priceRange.min, currency)} – ${formatPrice(product.priceRange.max, currency)}`
|
|
31
|
+
: formatPrice(price, currency)}
|
|
32
|
+
</p>
|
|
29
33
|
) : null}
|
|
30
34
|
<Slot name="product.below-price" />
|
|
31
35
|
<div className="vt-product-purchase">
|
|
@@ -14,9 +14,15 @@ export async function ensureDevAdmin(payload: Payload): Promise<void> {
|
|
|
14
14
|
const { totalDocs } = await payload.count({ collection: 'users' });
|
|
15
15
|
if (!shouldRunDevTask({ isProd, existingCount: totalDocs })) return;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// `.env.example` ships `DEV_ADMIN_EMAIL=` / `DEV_ADMIN_PASSWORD=` — empty strings must
|
|
18
|
+
// not win over defaults (`??` only treats null/undefined).
|
|
19
|
+
const email = process.env.DEV_ADMIN_EMAIL?.trim() || 'admin@example.com';
|
|
20
|
+
const password = process.env.DEV_ADMIN_PASSWORD?.trim() || randomPassword();
|
|
21
|
+
await payload.create({
|
|
22
|
+
collection: 'users',
|
|
23
|
+
data: { email, password },
|
|
24
|
+
overrideAccess: true,
|
|
25
|
+
});
|
|
20
26
|
payload.logger.warn(
|
|
21
27
|
`[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
|
|
22
28
|
);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// Demo data for zero-config dev (§18.2):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Demo data for zero-config dev (§18.2): a small but realistic catalog so a
|
|
2
|
+
// freshly scaffolded shop has browseable examples (categories, variants,
|
|
3
|
+
// gallery images, SEO). Prices are integer minor units (cents): 2490 = $24.90.
|
|
4
|
+
// Pure data — covered by invariants tests in sandbox.
|
|
4
5
|
|
|
5
6
|
export interface DemoCategory {
|
|
6
7
|
slug: string;
|
|
7
8
|
title: string;
|
|
9
|
+
description: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export interface DemoVariant {
|
|
@@ -12,6 +14,8 @@ export interface DemoVariant {
|
|
|
12
14
|
/** Minor units (e.g. cents). */
|
|
13
15
|
price: number;
|
|
14
16
|
stock: number;
|
|
17
|
+
/** e.g. { size: 'M', color: 'Black' }. */
|
|
18
|
+
options?: Record<string, string>;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface DemoProduct {
|
|
@@ -19,15 +23,23 @@ export interface DemoProduct {
|
|
|
19
23
|
title: string;
|
|
20
24
|
category: string;
|
|
21
25
|
description: string;
|
|
22
|
-
/** File
|
|
23
|
-
|
|
26
|
+
/** File names in seed-assets/ (first = cover; extras feed the gallery). */
|
|
27
|
+
images: string[];
|
|
24
28
|
seo: { title: string; description: string };
|
|
25
29
|
variants: DemoVariant[];
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export const demoCategories: DemoCategory[] = [
|
|
29
|
-
{
|
|
30
|
-
|
|
33
|
+
{
|
|
34
|
+
slug: 'apparel',
|
|
35
|
+
title: 'Apparel',
|
|
36
|
+
description: 'Everyday layers — tees, hoodies, and socks with size options you can add to the cart.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
slug: 'accessories',
|
|
40
|
+
title: 'Accessories',
|
|
41
|
+
description: 'Caps and bags that round out a simple merch assortment.',
|
|
42
|
+
},
|
|
31
43
|
];
|
|
32
44
|
|
|
33
45
|
export const demoProducts: DemoProduct[] = [
|
|
@@ -35,45 +47,79 @@ export const demoProducts: DemoProduct[] = [
|
|
|
35
47
|
slug: 'classic-tee',
|
|
36
48
|
title: 'Classic T-Shirt',
|
|
37
49
|
category: 'apparel',
|
|
38
|
-
description:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
description:
|
|
51
|
+
'A straight-cut midweight cotton tee for daily wear. Soft hand-feel, reinforced shoulder seams, and a slightly longer hem that stays tucked. Pick a size below, add it to your cart, then open /cart to try checkout.',
|
|
52
|
+
images: ['placeholder-1.svg', 'placeholder-1b.svg'],
|
|
53
|
+
seo: {
|
|
54
|
+
title: 'Classic T-Shirt — midweight cotton',
|
|
55
|
+
description: 'Demo apparel product with multiple sizes. Midweight cotton, straight cut.',
|
|
56
|
+
},
|
|
57
|
+
variants: [
|
|
58
|
+
{ sku: 'TEE-S', price: 2490, stock: 18, options: { size: 'S' } },
|
|
59
|
+
{ sku: 'TEE-M', price: 2490, stock: 32, options: { size: 'M' } },
|
|
60
|
+
{ sku: 'TEE-L', price: 2490, stock: 21, options: { size: 'L' } },
|
|
61
|
+
{ sku: 'TEE-XL', price: 2690, stock: 9, options: { size: 'XL' } },
|
|
62
|
+
],
|
|
42
63
|
},
|
|
43
64
|
{
|
|
44
65
|
slug: 'zip-hoodie',
|
|
45
66
|
title: 'Zip Hoodie',
|
|
46
67
|
category: 'apparel',
|
|
47
|
-
description:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
description:
|
|
69
|
+
'A brushed-fleece zip hoodie with a roomy hood and metal zipper. Wear it open over the Classic Tee or zipped for cooler evenings. Sizes share the same cut; XL is a touch longer in the body.',
|
|
70
|
+
images: ['placeholder-2.svg', 'placeholder-2b.svg'],
|
|
71
|
+
seo: {
|
|
72
|
+
title: 'Zip Hoodie — brushed fleece',
|
|
73
|
+
description: 'Demo hoodie with size variants and a two-image gallery.',
|
|
74
|
+
},
|
|
75
|
+
variants: [
|
|
76
|
+
{ sku: 'HOD-S', price: 6900, stock: 8, options: { size: 'S' } },
|
|
77
|
+
{ sku: 'HOD-M', price: 6900, stock: 14, options: { size: 'M' } },
|
|
78
|
+
{ sku: 'HOD-L', price: 6900, stock: 11, options: { size: 'L' } },
|
|
79
|
+
{ sku: 'HOD-XL', price: 7200, stock: 4, options: { size: 'XL' } },
|
|
80
|
+
],
|
|
51
81
|
},
|
|
52
82
|
{
|
|
53
83
|
slug: 'logo-cap',
|
|
54
84
|
title: 'Logo Cap',
|
|
55
85
|
category: 'accessories',
|
|
56
|
-
description:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
description:
|
|
87
|
+
'A structured six-panel cap with an adjustable strap. One size fits most; choose color to see how option-based variants appear on accessories.',
|
|
88
|
+
images: ['placeholder-3.svg'],
|
|
89
|
+
seo: {
|
|
90
|
+
title: 'Logo Cap — adjustable',
|
|
91
|
+
description: 'Demo accessory with color options.',
|
|
92
|
+
},
|
|
93
|
+
variants: [
|
|
94
|
+
{ sku: 'CAP-BLK', price: 2900, stock: 24, options: { color: 'Black' } },
|
|
95
|
+
{ sku: 'CAP-NAV', price: 2900, stock: 16, options: { color: 'Navy' } },
|
|
96
|
+
{ sku: 'CAP-SND', price: 2900, stock: 12, options: { color: 'Sand' } },
|
|
97
|
+
],
|
|
60
98
|
},
|
|
61
99
|
{
|
|
62
100
|
slug: 'tote-bag',
|
|
63
101
|
title: 'Tote Bag',
|
|
64
102
|
category: 'accessories',
|
|
65
|
-
description:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
description:
|
|
104
|
+
'A heavyweight canvas tote with an interior pocket. Single SKU — useful for comparing a simple product against multi-variant apparel on the product page.',
|
|
105
|
+
images: ['placeholder-4.svg'],
|
|
106
|
+
seo: {
|
|
107
|
+
title: 'Tote Bag — heavyweight canvas',
|
|
108
|
+
description: 'Demo single-variant accessory for a simple add-to-cart path.',
|
|
109
|
+
},
|
|
110
|
+
variants: [{ sku: 'TOT-001', price: 1800, stock: 60 }],
|
|
69
111
|
},
|
|
70
112
|
{
|
|
71
113
|
slug: 'crew-socks',
|
|
72
|
-
title: 'Socks
|
|
114
|
+
title: 'Crew Socks (3-Pack)',
|
|
73
115
|
category: 'apparel',
|
|
74
|
-
description:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
116
|
+
description:
|
|
117
|
+
'A three-pair pack of ribbed crew socks. Soft cuff, reinforced heel and toe. Sold as one pack SKU so cart lines stay simple while you explore quantity updates on /cart.',
|
|
118
|
+
images: ['placeholder-5.svg'],
|
|
119
|
+
seo: {
|
|
120
|
+
title: 'Crew Socks — 3-pack',
|
|
121
|
+
description: 'Demo pack product with a single variant and high stock.',
|
|
122
|
+
},
|
|
123
|
+
variants: [{ sku: 'SOC-3PK', price: 1400, stock: 100 }],
|
|
78
124
|
},
|
|
79
125
|
];
|
|
@@ -17,16 +17,26 @@ export async function seedDemo(payload: Payload): Promise<void> {
|
|
|
17
17
|
|
|
18
18
|
const categoryId = new Map<string, string | number>();
|
|
19
19
|
for (const c of demoCategories) {
|
|
20
|
-
const doc = await payload.create({
|
|
20
|
+
const doc = await payload.create({
|
|
21
|
+
collection: 'categories',
|
|
22
|
+
data: { slug: c.slug, title: c.title, description: c.description },
|
|
23
|
+
overrideAccess: true,
|
|
24
|
+
});
|
|
21
25
|
categoryId.set(c.slug, doc.id);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
for (const p of demoProducts) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const mediaIds: Array<string | number> = [];
|
|
30
|
+
for (const [index, image] of p.images.entries()) {
|
|
31
|
+
const media = await payload.create({
|
|
32
|
+
collection: 'media',
|
|
33
|
+
data: { alt: index === 0 ? p.title : `${p.title} — detail` },
|
|
34
|
+
filePath: path.join(seedAssets, image),
|
|
35
|
+
overrideAccess: true,
|
|
36
|
+
});
|
|
37
|
+
mediaIds.push(media.id);
|
|
38
|
+
}
|
|
39
|
+
const coverId = mediaIds[0];
|
|
30
40
|
const catId = categoryId.get(p.category);
|
|
31
41
|
const product = await payload.create({
|
|
32
42
|
collection: 'products',
|
|
@@ -35,17 +45,32 @@ export async function seedDemo(payload: Payload): Promise<void> {
|
|
|
35
45
|
title: p.title,
|
|
36
46
|
description: plainToRichText(p.description),
|
|
37
47
|
categories: catId ? [catId] : [],
|
|
38
|
-
images:
|
|
39
|
-
seo: {
|
|
48
|
+
images: mediaIds,
|
|
49
|
+
seo: {
|
|
50
|
+
title: p.seo.title,
|
|
51
|
+
description: p.seo.description,
|
|
52
|
+
image: coverId,
|
|
53
|
+
},
|
|
40
54
|
},
|
|
55
|
+
overrideAccess: true,
|
|
41
56
|
});
|
|
42
57
|
for (const v of p.variants) {
|
|
43
58
|
await payload.create({
|
|
44
59
|
collection: 'variants',
|
|
45
|
-
data: {
|
|
60
|
+
data: {
|
|
61
|
+
sku: v.sku,
|
|
62
|
+
price: v.price,
|
|
63
|
+
stock: v.stock,
|
|
64
|
+
product: product.id,
|
|
65
|
+
...(v.options ? { options: v.options } : {}),
|
|
66
|
+
},
|
|
67
|
+
overrideAccess: true,
|
|
46
68
|
});
|
|
47
69
|
}
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
|
|
72
|
+
const variantCount = demoProducts.reduce((n, p) => n + p.variants.length, 0);
|
|
73
|
+
payload.logger.info(
|
|
74
|
+
`[vitrine] demo seed: ${demoProducts.length} products, ${demoCategories.length} categories, ${variantCount} variants — browse / then try Add to cart`,
|
|
75
|
+
);
|
|
51
76
|
}
|
|
@@ -2,9 +2,37 @@
|
|
|
2
2
|
// API into Next). output: 'standalone' — for the Docker image (VPS).
|
|
3
3
|
import { withPayload } from '@payloadcms/next/withPayload';
|
|
4
4
|
|
|
5
|
+
// Baseline security headers (OWASP A05: Security Misconfiguration) applied to every route.
|
|
6
|
+
// The CSP below is a conservative starting point — payment providers with client-side widgets
|
|
7
|
+
// (e.g. embedded Stripe Elements) may need their domains added to script-src/connect-src/frame-src.
|
|
8
|
+
const securityHeaders = [
|
|
9
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
10
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
11
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
12
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
13
|
+
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
|
14
|
+
{
|
|
15
|
+
key: 'Content-Security-Policy',
|
|
16
|
+
value:
|
|
17
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';",
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
5
21
|
/** @type {import('next').NextConfig} */
|
|
6
22
|
const nextConfig = {
|
|
7
23
|
output: 'standalone',
|
|
24
|
+
// Registry/template sources use TypeScript ESM-style relative imports (`.js` → `.ts`/`.tsx`).
|
|
25
|
+
// Webpack needs an explicit extensionAlias; without it, `pnpm dev` fails to resolve components.
|
|
26
|
+
webpack(config) {
|
|
27
|
+
config.resolve.extensionAlias = {
|
|
28
|
+
...(config.resolve.extensionAlias ?? {}),
|
|
29
|
+
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
|
30
|
+
};
|
|
31
|
+
return config;
|
|
32
|
+
},
|
|
33
|
+
async headers() {
|
|
34
|
+
return [{ source: '/:path*', headers: securityHeaders }];
|
|
35
|
+
},
|
|
8
36
|
};
|
|
9
37
|
|
|
10
38
|
export default withPayload(nextConfig);
|
|
@@ -14,6 +14,12 @@ import { ensureDevAdmin } from './lib/seed/admin.js';
|
|
|
14
14
|
|
|
15
15
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
|
|
17
|
+
const payloadSecret = process.env.PAYLOAD_SECRET;
|
|
18
|
+
if (!payloadSecret && process.env.NODE_ENV === 'production') {
|
|
19
|
+
// No silent fallback to the dev secret in production (mirrors the Vendure DATABASE_URL guard).
|
|
20
|
+
throw new Error('[vitrine] PAYLOAD_SECRET is required in production');
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export default buildConfig({
|
|
18
24
|
admin: {
|
|
19
25
|
user: 'users',
|
|
@@ -21,7 +27,7 @@ export default buildConfig({
|
|
|
21
27
|
},
|
|
22
28
|
collections: collections as unknown as CollectionConfig[],
|
|
23
29
|
editor: lexicalEditor(),
|
|
24
|
-
secret:
|
|
30
|
+
secret: payloadSecret ?? 'dev-secret-change-me',
|
|
25
31
|
db: await resolveDbAdapter(),
|
|
26
32
|
sharp,
|
|
27
33
|
typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Classic T-Shirt detail">
|
|
2
|
+
<rect width="600" height="600" fill="#f5f5f4"/>
|
|
3
|
+
<rect x="40" y="40" width="520" height="520" rx="16" fill="#e7e5e4"/>
|
|
4
|
+
<text x="300" y="290" font-family="system-ui, sans-serif" font-size="36" fill="#57534e" text-anchor="middle">T-Shirt</text>
|
|
5
|
+
<text x="300" y="340" font-family="system-ui, sans-serif" font-size="22" fill="#78716c" text-anchor="middle">detail view</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Zip Hoodie detail">
|
|
2
|
+
<rect width="600" height="600" fill="#fafaf9"/>
|
|
3
|
+
<rect x="40" y="40" width="520" height="520" rx="16" fill="#e7e5e4"/>
|
|
4
|
+
<text x="300" y="290" font-family="system-ui, sans-serif" font-size="36" fill="#44403c" text-anchor="middle">Hoodie</text>
|
|
5
|
+
<text x="300" y="340" font-family="system-ui, sans-serif" font-size="22" fill="#78716c" text-anchor="middle">detail view</text>
|
|
6
|
+
</svg>
|
|
@@ -5,10 +5,36 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { DefaultJobQueuePlugin, DefaultSearchPlugin, type VendureConfig } from '@vendure/core';
|
|
7
7
|
import { AssetServerPlugin } from '@vendure/asset-server-plugin';
|
|
8
|
+
import { checkRateLimit } from '@vitrine-kit/core';
|
|
8
9
|
|
|
9
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const IS_DEV = process.env.NODE_ENV !== 'production';
|
|
11
12
|
|
|
13
|
+
// Structural Express req/res shape — avoids a direct dependency on `express` (it's only a
|
|
14
|
+
// transitive dep of @vendure/core); any real Express Request/Response satisfies this.
|
|
15
|
+
interface MiddlewareRequest {
|
|
16
|
+
headers: Record<string, string | string[] | undefined>;
|
|
17
|
+
ip?: string;
|
|
18
|
+
}
|
|
19
|
+
interface MiddlewareResponse {
|
|
20
|
+
setHeader(name: string, value: string): void;
|
|
21
|
+
status(code: number): { json(body: unknown): void };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Coarse per-IP rate limit on the whole admin API — brute-force protection for the
|
|
25
|
+
// superadmin login mutation (Vendure has no built-in login lockout, unlike Payload).
|
|
26
|
+
function adminApiRateLimit(req: MiddlewareRequest, res: MiddlewareResponse, next: () => void): void {
|
|
27
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
28
|
+
const ip = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0].trim()) ?? req.ip ?? 'unknown';
|
|
29
|
+
const { allowed, retryAfterMs } = checkRateLimit(`admin-api:${ip}`, { limit: 60, windowMs: 60_000 });
|
|
30
|
+
if (!allowed) {
|
|
31
|
+
res.setHeader('Retry-After', String(Math.ceil((retryAfterMs ?? 0) / 1000)));
|
|
32
|
+
res.status(429).json({ error: 'too many requests' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
next();
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
function dbConnectionOptions(): VendureConfig['dbConnectionOptions'] {
|
|
13
39
|
const url = process.env.DATABASE_URL;
|
|
14
40
|
if (url) {
|
|
@@ -26,17 +52,28 @@ function dbConnectionOptions(): VendureConfig['dbConnectionOptions'] {
|
|
|
26
52
|
} as VendureConfig['dbConnectionOptions'];
|
|
27
53
|
}
|
|
28
54
|
|
|
55
|
+
if (!IS_DEV) {
|
|
56
|
+
// No silent fallback to the well-known dev defaults in production (mirrors the DATABASE_URL guard below).
|
|
57
|
+
if (!process.env.VENDURE_SUPERADMIN_PASSWORD) {
|
|
58
|
+
throw new Error('[vitrine] VENDURE_SUPERADMIN_PASSWORD is required in production');
|
|
59
|
+
}
|
|
60
|
+
if (!process.env.VENDURE_COOKIE_SECRET) {
|
|
61
|
+
throw new Error('[vitrine] VENDURE_COOKIE_SECRET is required in production');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export const config: VendureConfig = {
|
|
30
66
|
apiOptions: {
|
|
31
67
|
port: Number(process.env.VENDURE_PORT ?? 3001),
|
|
32
68
|
adminApiPath: 'admin-api',
|
|
33
69
|
shopApiPath: 'shop-api',
|
|
70
|
+
middleware: [{ handler: adminApiRateLimit, route: 'admin-api' }],
|
|
34
71
|
},
|
|
35
72
|
authOptions: {
|
|
36
73
|
tokenMethod: ['bearer', 'cookie'],
|
|
37
74
|
superadminCredentials: {
|
|
38
75
|
identifier: process.env.VENDURE_SUPERADMIN_USERNAME ?? 'superadmin',
|
|
39
|
-
// dev default;
|
|
76
|
+
// dev default; production is guarded above.
|
|
40
77
|
password: process.env.VENDURE_SUPERADMIN_PASSWORD ?? 'superadmin',
|
|
41
78
|
},
|
|
42
79
|
cookieOptions: { secret: process.env.VENDURE_COOKIE_SECRET ?? 'dev-cookie-secret' },
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Normalize line endings to LF — keeps `vitrine update` 3-way merges clean on Windows
|
|
2
|
+
# (.vitrine/originals and the registry are LF; a CRLF working tree would look all-changed).
|
|
3
|
+
* text=auto eol=lf
|
|
4
|
+
|
|
5
|
+
# Binary assets — no normalization.
|
|
6
|
+
*.png binary
|
|
7
|
+
*.jpg binary
|
|
8
|
+
*.jpeg binary
|
|
9
|
+
*.gif binary
|
|
10
|
+
*.ico binary
|
|
11
|
+
*.woff binary
|
|
12
|
+
*.woff2 binary
|
|
@@ -20,7 +20,12 @@ export default async function CategoryPage({ params }: PageProps) {
|
|
|
20
20
|
return (
|
|
21
21
|
<div className="flex flex-col gap-section">
|
|
22
22
|
<Slot name="category.header" category={category} />
|
|
23
|
-
<
|
|
23
|
+
<header className="flex flex-col gap-unit">
|
|
24
|
+
<h1 className="font-heading text-fg">{category.title}</h1>
|
|
25
|
+
{category.description ? (
|
|
26
|
+
<p className="max-w-prose text-muted-fg">{category.description}</p>
|
|
27
|
+
) : null}
|
|
28
|
+
</header>
|
|
24
29
|
<ProductGrid products={products} />
|
|
25
30
|
<Slot name="category.below-products" />
|
|
26
31
|
</div>
|
|
@@ -5,17 +5,50 @@ import { getCatalogSource } from '@/lib/adapter';
|
|
|
5
5
|
import { loadProducts } from '@/lib/catalog/data';
|
|
6
6
|
import { ProductGrid } from '@/components/catalog/ProductGrid';
|
|
7
7
|
|
|
8
|
+
function DemoHero() {
|
|
9
|
+
return (
|
|
10
|
+
<section
|
|
11
|
+
aria-labelledby="demo-hero-heading"
|
|
12
|
+
className="flex flex-col gap-gutter border-b border-border pb-section"
|
|
13
|
+
>
|
|
14
|
+
<p className="text-sm uppercase tracking-wide text-muted-fg">Demo catalog</p>
|
|
15
|
+
<h1 id="demo-hero-heading" className="font-heading text-3xl text-fg md:text-4xl">
|
|
16
|
+
Vitrine
|
|
17
|
+
</h1>
|
|
18
|
+
<p className="max-w-prose text-muted-fg">
|
|
19
|
+
Zero-config seed data is loaded for local development — five products across Apparel and
|
|
20
|
+
Accessories. Browse a category, open a product with size or color options, add it to the
|
|
21
|
+
cart, then continue to checkout when a payment provider is configured.
|
|
22
|
+
</p>
|
|
23
|
+
<div className="flex flex-wrap gap-gutter">
|
|
24
|
+
<a
|
|
25
|
+
href="/categories/apparel"
|
|
26
|
+
className="rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
27
|
+
>
|
|
28
|
+
Shop apparel
|
|
29
|
+
</a>
|
|
30
|
+
<a
|
|
31
|
+
href="/products/classic-tee"
|
|
32
|
+
className="rounded-md border border-border px-gutter py-unit text-fg transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
33
|
+
>
|
|
34
|
+
Try Classic T-Shirt
|
|
35
|
+
</a>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
export default async function HomePage() {
|
|
9
42
|
const source = await getCatalogSource();
|
|
10
43
|
const products = await loadProducts(source, { perPage: 12 });
|
|
11
44
|
|
|
12
45
|
return (
|
|
13
46
|
<div className="flex flex-col gap-section">
|
|
14
|
-
<Slot name="home.hero" />
|
|
47
|
+
<Slot name="home.hero" fallback={<DemoHero />} />
|
|
15
48
|
<section aria-labelledby="catalog-heading" className="flex flex-col gap-gutter">
|
|
16
|
-
<
|
|
49
|
+
<h2 id="catalog-heading" className="font-heading text-fg">
|
|
17
50
|
Catalog
|
|
18
|
-
</
|
|
51
|
+
</h2>
|
|
19
52
|
<Slot name="catalog.grid-top" />
|
|
20
53
|
<ProductGrid products={products} />
|
|
21
54
|
<Slot name="catalog.grid-bottom" />
|
|
@@ -1,6 +1,34 @@
|
|
|
1
|
+
// Baseline security headers (OWASP A05: Security Misconfiguration) applied to every route.
|
|
2
|
+
// The CSP below is a conservative starting point — payment providers with client-side widgets
|
|
3
|
+
// (e.g. embedded Stripe Elements) may need their domains added to script-src/connect-src/frame-src.
|
|
4
|
+
const securityHeaders = [
|
|
5
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
6
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
7
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
8
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
9
|
+
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
|
10
|
+
{
|
|
11
|
+
key: 'Content-Security-Policy',
|
|
12
|
+
value:
|
|
13
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';",
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
1
17
|
/** @type {import('next').NextConfig} */
|
|
2
18
|
const nextConfig = {
|
|
3
19
|
output: 'standalone',
|
|
20
|
+
// Registry/template sources use TypeScript ESM-style relative imports (`.js` → `.ts`/`.tsx`).
|
|
21
|
+
// Webpack needs an explicit extensionAlias; without it, `pnpm dev` fails to resolve components.
|
|
22
|
+
webpack(config) {
|
|
23
|
+
config.resolve.extensionAlias = {
|
|
24
|
+
...(config.resolve.extensionAlias ?? {}),
|
|
25
|
+
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
|
26
|
+
};
|
|
27
|
+
return config;
|
|
28
|
+
},
|
|
29
|
+
async headers() {
|
|
30
|
+
return [{ source: '/:path*', headers: securityHeaders }];
|
|
31
|
+
},
|
|
4
32
|
};
|
|
5
33
|
|
|
6
34
|
// backend-payload overwrites this file (wraps it in withPayload).
|