@vitrine-kit/vitrine 0.4.1 → 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 +5 -4
- 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-paddle/feature.json +1 -1
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
- 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-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
- 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/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 +2 -2
package/dist/index.js
CHANGED
|
@@ -1192,10 +1192,10 @@ import { readdirSync as readdirSync4 } from "fs";
|
|
|
1192
1192
|
import { join as join13 } from "path";
|
|
1193
1193
|
|
|
1194
1194
|
// src/kit-versions.generated.ts
|
|
1195
|
-
var KIT_VERSION = "0.4.
|
|
1195
|
+
var KIT_VERSION = "0.4.3";
|
|
1196
1196
|
var CONTRACTS_VERSION = "1.0.0";
|
|
1197
1197
|
var CONTRACTS_RANGE = "^1.0.0";
|
|
1198
|
-
var CORE_RANGE = "^0.
|
|
1198
|
+
var CORE_RANGE = "^0.3.0";
|
|
1199
1199
|
var BLUEPRINT_RANGE = "^0.2.0";
|
|
1200
1200
|
|
|
1201
1201
|
// src/kit.ts
|
|
@@ -1385,8 +1385,9 @@ function clientReadme(name, backend, tier) {
|
|
|
1385
1385
|
"- Admin: http://localhost:3000/admin",
|
|
1386
1386
|
"",
|
|
1387
1387
|
"Without Postgres, dev starts a built-in SQLite (`.vitrine/dev.sqlite`), seeds a",
|
|
1388
|
-
"demo catalog (5 products, 2 categories) and creates a
|
|
1389
|
-
"to the console once). Disable the fallback in
|
|
1388
|
+
"demo catalog (5 products, 2 categories, size/color variants, gallery images) and creates a",
|
|
1389
|
+
"dev admin (login/password printed to the console once). Disable the fallback in",
|
|
1390
|
+
"dev too with `VITRINE_DB_STRICT=1`."
|
|
1390
1391
|
].join("\n");
|
|
1391
1392
|
const deploySecret = backend === "vendure" ? "export VENDURE_COOKIE_SECRET=... # Vendure cookie secret" : "export PAYLOAD_SECRET=... # random Payload secret";
|
|
1392
1393
|
return `# ${name}
|
package/kit/registry/_index.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "../schemas/registry-index.schema.json",
|
|
3
|
-
"kitVersion": "0.4.
|
|
3
|
+
"kitVersion": "0.4.3",
|
|
4
4
|
"contracts": "1.0.0",
|
|
5
5
|
"features": {
|
|
6
6
|
"catalog": {
|
|
7
7
|
"title": "Product catalog",
|
|
8
|
-
"kitVersion": "0.4.
|
|
8
|
+
"kitVersion": "0.4.3",
|
|
9
9
|
"tier": [
|
|
10
10
|
"catalog",
|
|
11
11
|
"simple-store",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"product-page": {
|
|
16
16
|
"title": "Product page",
|
|
17
|
-
"kitVersion": "0.4.
|
|
17
|
+
"kitVersion": "0.4.3",
|
|
18
18
|
"tier": [
|
|
19
19
|
"catalog",
|
|
20
20
|
"simple-store",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"seo": {
|
|
25
25
|
"title": "SEO — metadata and JSON-LD",
|
|
26
|
-
"kitVersion": "0.4.
|
|
26
|
+
"kitVersion": "0.4.3",
|
|
27
27
|
"tier": [
|
|
28
28
|
"catalog",
|
|
29
29
|
"simple-store",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"cart": {
|
|
34
34
|
"title": "Cart",
|
|
35
|
-
"kitVersion": "0.4.
|
|
35
|
+
"kitVersion": "0.4.3",
|
|
36
36
|
"tier": [
|
|
37
37
|
"simple-store",
|
|
38
38
|
"full-store"
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"checkout": {
|
|
42
42
|
"title": "Checkout",
|
|
43
|
-
"kitVersion": "0.4.
|
|
43
|
+
"kitVersion": "0.4.3",
|
|
44
44
|
"tier": [
|
|
45
45
|
"simple-store",
|
|
46
46
|
"full-store"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"checkout-stripe": {
|
|
50
50
|
"title": "Payment — Stripe",
|
|
51
|
-
"kitVersion": "0.4.
|
|
51
|
+
"kitVersion": "0.4.3",
|
|
52
52
|
"tier": [
|
|
53
53
|
"simple-store",
|
|
54
54
|
"full-store"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"checkout-paddle": {
|
|
58
58
|
"title": "Payment — Paddle",
|
|
59
|
-
"kitVersion": "0.4.
|
|
59
|
+
"kitVersion": "0.4.3",
|
|
60
60
|
"tier": [
|
|
61
61
|
"simple-store",
|
|
62
62
|
"full-store"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"checkout-yookassa": {
|
|
66
66
|
"title": "Payment — YooKassa",
|
|
67
|
-
"kitVersion": "0.4.
|
|
67
|
+
"kitVersion": "0.4.3",
|
|
68
68
|
"tier": [
|
|
69
69
|
"simple-store",
|
|
70
70
|
"full-store"
|
|
@@ -3,10 +3,53 @@
|
|
|
3
3
|
// the arithmetic lives in @vitrine-kit/core. Next glue, not typechecked in the monorepo.
|
|
4
4
|
import { cookies } from 'next/headers';
|
|
5
5
|
import { NextResponse } from 'next/server';
|
|
6
|
+
import { checkRateLimit, clientIpFromHeaders } from '@vitrine-kit/core';
|
|
6
7
|
import type { CommerceBackend } from '@vitrine-kit/contracts';
|
|
7
8
|
import { getCommerceBackend } from '@/lib/adapter';
|
|
8
9
|
|
|
9
10
|
const COOKIE = 'vitrine_cart';
|
|
11
|
+
const MAX_QUANTITY = 999;
|
|
12
|
+
const RATE_LIMIT = { limit: 30, windowMs: 60_000 };
|
|
13
|
+
|
|
14
|
+
/** Per-IP rate limit on cart mutations — returns a 429 response, or null if under the limit. */
|
|
15
|
+
function rateLimited(req: Request): NextResponse | null {
|
|
16
|
+
const { allowed, retryAfterMs } = checkRateLimit(`cart:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
17
|
+
if (allowed) return null;
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: 'too many requests' },
|
|
20
|
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 0) / 1000)) } },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseQuantity(value: unknown): number | null {
|
|
25
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
26
|
+
return Number.isInteger(n) && n >= 0 && n <= MAX_QUANTITY ? n : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseAddItemBody(body: unknown): { variantId: string; quantity: number } | null {
|
|
30
|
+
if (typeof body !== 'object' || body === null) return null;
|
|
31
|
+
const { variantId, quantity = 1 } = body as { variantId?: unknown; quantity?: unknown };
|
|
32
|
+
if (typeof variantId !== 'string' || variantId.length === 0) return null;
|
|
33
|
+
const parsedQuantity = parseQuantity(quantity);
|
|
34
|
+
if (parsedQuantity === null || parsedQuantity < 1) return null;
|
|
35
|
+
return { variantId, quantity: parsedQuantity };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseUpdateItemBody(body: unknown): { lineId: string; quantity: number } | null {
|
|
39
|
+
if (typeof body !== 'object' || body === null) return null;
|
|
40
|
+
const { lineId, quantity } = body as { lineId?: unknown; quantity?: unknown };
|
|
41
|
+
if (typeof lineId !== 'string' || lineId.length === 0) return null;
|
|
42
|
+
const parsedQuantity = parseQuantity(quantity);
|
|
43
|
+
if (parsedQuantity === null) return null;
|
|
44
|
+
return { lineId, quantity: parsedQuantity };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseRemoveItemBody(body: unknown): { lineId: string } | null {
|
|
48
|
+
if (typeof body !== 'object' || body === null) return null;
|
|
49
|
+
const { lineId } = body as { lineId?: unknown };
|
|
50
|
+
if (typeof lineId !== 'string' || lineId.length === 0) return null;
|
|
51
|
+
return { lineId };
|
|
52
|
+
}
|
|
10
53
|
|
|
11
54
|
async function ensureCartId(commerce: CommerceBackend): Promise<string> {
|
|
12
55
|
const jar = await cookies();
|
|
@@ -18,24 +61,33 @@ async function ensureCartId(commerce: CommerceBackend): Promise<string> {
|
|
|
18
61
|
}
|
|
19
62
|
|
|
20
63
|
export async function POST(req: Request) {
|
|
21
|
-
const
|
|
64
|
+
const limited = rateLimited(req);
|
|
65
|
+
if (limited) return limited;
|
|
66
|
+
const parsed = parseAddItemBody(await req.json());
|
|
67
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
22
68
|
const commerce = await getCommerceBackend();
|
|
23
69
|
const id = await ensureCartId(commerce);
|
|
24
|
-
return NextResponse.json(await commerce.addItem(id, variantId, quantity));
|
|
70
|
+
return NextResponse.json(await commerce.addItem(id, parsed.variantId, parsed.quantity));
|
|
25
71
|
}
|
|
26
72
|
|
|
27
73
|
export async function PATCH(req: Request) {
|
|
28
|
-
const
|
|
74
|
+
const limited = rateLimited(req);
|
|
75
|
+
if (limited) return limited;
|
|
76
|
+
const parsed = parseUpdateItemBody(await req.json());
|
|
77
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
29
78
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
30
79
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
31
80
|
const commerce = await getCommerceBackend();
|
|
32
|
-
return NextResponse.json(await commerce.updateItem(id, lineId, quantity));
|
|
81
|
+
return NextResponse.json(await commerce.updateItem(id, parsed.lineId, parsed.quantity));
|
|
33
82
|
}
|
|
34
83
|
|
|
35
84
|
export async function DELETE(req: Request) {
|
|
36
|
-
const
|
|
85
|
+
const limited = rateLimited(req);
|
|
86
|
+
if (limited) return limited;
|
|
87
|
+
const parsed = parseRemoveItemBody(await req.json());
|
|
88
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
37
89
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
38
90
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
39
91
|
const commerce = await getCommerceBackend();
|
|
40
|
-
return NextResponse.json(await commerce.removeItem(id, lineId));
|
|
92
|
+
return NextResponse.json(await commerce.removeItem(id, parsed.lineId));
|
|
41
93
|
}
|
|
@@ -1,25 +1,76 @@
|
|
|
1
1
|
// "Add to cart" button — client component. Mounted into the product.purchase slot
|
|
2
|
-
// (ProductView passes product).
|
|
2
|
+
// (ProductView passes product). Supports option-based variants from the demo seed
|
|
3
|
+
// (size/color). Mutation goes through POST /api/cart (the feature's route).
|
|
3
4
|
'use client';
|
|
4
|
-
import { useState } from 'react';
|
|
5
|
-
import type { Product } from '@vitrine-kit/contracts';
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
import type { Product, Variant } from '@vitrine-kit/contracts';
|
|
6
7
|
|
|
7
8
|
export interface AddToCartProps {
|
|
8
9
|
product: Product;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function optionKeys(variants: Variant[]): string[] {
|
|
13
|
+
const keys = new Set<string>();
|
|
14
|
+
for (const v of variants) {
|
|
15
|
+
for (const key of Object.keys(v.options ?? {})) keys.add(key);
|
|
16
|
+
}
|
|
17
|
+
return [...keys];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function valuesFor(variants: Variant[], key: string): string[] {
|
|
21
|
+
const values = new Set<string>();
|
|
22
|
+
for (const v of variants) {
|
|
23
|
+
const value = v.options?.[key];
|
|
24
|
+
if (value) values.add(value);
|
|
25
|
+
}
|
|
26
|
+
return [...values];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matchVariant(
|
|
30
|
+
variants: Variant[],
|
|
31
|
+
selected: Record<string, string>,
|
|
32
|
+
keys: string[],
|
|
33
|
+
): Variant | undefined {
|
|
34
|
+
return variants.find((v) => keys.every((key) => (v.options?.[key] ?? '') === (selected[key] ?? '')));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function firstAvailable(variants: Variant[]): Variant | undefined {
|
|
38
|
+
return variants.find((v) => v.stock == null || v.stock > 0) ?? variants[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function variantLabel(variant: Variant): string {
|
|
42
|
+
const opts = variant.options;
|
|
43
|
+
if (opts && Object.keys(opts).length > 0) {
|
|
44
|
+
return Object.entries(opts)
|
|
45
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
46
|
+
.join(', ');
|
|
47
|
+
}
|
|
48
|
+
return variant.title ?? variant.sku;
|
|
49
|
+
}
|
|
50
|
+
|
|
11
51
|
export function AddToCart({ product }: AddToCartProps) {
|
|
52
|
+
const variants = product.variants;
|
|
53
|
+
const keys = useMemo(() => optionKeys(variants), [variants]);
|
|
12
54
|
const [pending, setPending] = useState(false);
|
|
13
|
-
const
|
|
55
|
+
const [selected, setSelected] = useState<Record<string, string>>(() => ({
|
|
56
|
+
...(firstAvailable(variants)?.options ?? {}),
|
|
57
|
+
}));
|
|
58
|
+
const [variantId, setVariantId] = useState<string | undefined>(() => firstAvailable(variants)?.id);
|
|
59
|
+
|
|
60
|
+
const matched =
|
|
61
|
+
keys.length > 0 ? matchVariant(variants, selected, keys) : variants.find((v) => v.id === variantId);
|
|
62
|
+
const active = matched ?? firstAvailable(variants);
|
|
63
|
+
const activeId = active?.id;
|
|
64
|
+
const outOfStock = active != null && active.stock != null && active.stock <= 0;
|
|
14
65
|
|
|
15
66
|
async function add(): Promise<void> {
|
|
16
|
-
if (!
|
|
67
|
+
if (!activeId || outOfStock) return;
|
|
17
68
|
setPending(true);
|
|
18
69
|
try {
|
|
19
70
|
await fetch('/api/cart', {
|
|
20
71
|
method: 'POST',
|
|
21
72
|
headers: { 'content-type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({ variantId, quantity: 1 }),
|
|
73
|
+
body: JSON.stringify({ variantId: activeId, quantity: 1 }),
|
|
23
74
|
});
|
|
24
75
|
location.assign('/cart');
|
|
25
76
|
} finally {
|
|
@@ -27,14 +78,55 @@ export function AddToCart({ product }: AddToCartProps) {
|
|
|
27
78
|
}
|
|
28
79
|
}
|
|
29
80
|
|
|
81
|
+
if (variants.length === 0) {
|
|
82
|
+
return <p className="text-muted-fg">This product has no purchasable variants yet.</p>;
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
return (
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
86
|
+
<div className="vt-add-to-cart-wrap flex flex-col gap-gutter">
|
|
87
|
+
{keys.length > 0 ? (
|
|
88
|
+
keys.map((key) => (
|
|
89
|
+
<label key={key} className="flex flex-col gap-unit text-sm">
|
|
90
|
+
<span className="font-medium capitalize text-fg">{key}</span>
|
|
91
|
+
<select
|
|
92
|
+
className="rounded-md border border-input bg-surface px-gutter py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
93
|
+
value={selected[key] ?? ''}
|
|
94
|
+
onChange={(e) => setSelected((prev) => ({ ...prev, [key]: e.target.value }))}
|
|
95
|
+
>
|
|
96
|
+
{valuesFor(variants, key).map((value) => (
|
|
97
|
+
<option key={value} value={value}>
|
|
98
|
+
{value}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</select>
|
|
102
|
+
</label>
|
|
103
|
+
))
|
|
104
|
+
) : variants.length > 1 ? (
|
|
105
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
106
|
+
<span className="font-medium text-fg">Option</span>
|
|
107
|
+
<select
|
|
108
|
+
className="rounded-md border border-input bg-surface px-gutter py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
109
|
+
value={activeId ?? ''}
|
|
110
|
+
onChange={(e) => setVariantId(e.target.value)}
|
|
111
|
+
>
|
|
112
|
+
{variants.map((v) => (
|
|
113
|
+
<option key={v.id} value={v.id} disabled={v.stock != null && v.stock <= 0}>
|
|
114
|
+
{variantLabel(v)}
|
|
115
|
+
{v.stock != null && v.stock <= 0 ? ' (out of stock)' : ''}
|
|
116
|
+
</option>
|
|
117
|
+
))}
|
|
118
|
+
</select>
|
|
119
|
+
</label>
|
|
120
|
+
) : null}
|
|
121
|
+
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={add}
|
|
125
|
+
disabled={pending || !activeId || outOfStock}
|
|
126
|
+
className="vt-add-to-cart rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
127
|
+
>
|
|
128
|
+
{pending ? 'Adding…' : outOfStock ? 'Out of stock' : 'Add to cart'}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
39
131
|
);
|
|
40
132
|
}
|
|
@@ -10,7 +10,25 @@ export interface CartViewProps {
|
|
|
10
10
|
|
|
11
11
|
export function CartView({ cart }: CartViewProps) {
|
|
12
12
|
if (cart.lines.length === 0) {
|
|
13
|
-
return
|
|
13
|
+
return (
|
|
14
|
+
<div className="vt-cart-empty flex flex-col gap-gutter">
|
|
15
|
+
<p className="text-muted-fg">Your cart is empty.</p>
|
|
16
|
+
<p className="text-sm text-muted-fg">
|
|
17
|
+
Open a product such as{' '}
|
|
18
|
+
<a href="/products/classic-tee" className="text-fg underline underline-offset-2">
|
|
19
|
+
Classic T-Shirt
|
|
20
|
+
</a>
|
|
21
|
+
, pick a size, then use <span className="text-fg">Add to cart</span> to try the checkout
|
|
22
|
+
flow.
|
|
23
|
+
</p>
|
|
24
|
+
<a
|
|
25
|
+
href="/"
|
|
26
|
+
className="w-fit 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"
|
|
27
|
+
>
|
|
28
|
+
Continue shopping
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
14
32
|
}
|
|
15
33
|
return (
|
|
16
34
|
<div className="vt-cart grid gap-section md:grid-cols-[2fr_1fr]">
|
|
@@ -33,7 +33,11 @@ export function ProductCard({ product }: ProductCardProps) {
|
|
|
33
33
|
<div className="p-gutter">
|
|
34
34
|
<h3 className="font-heading text-fg">{product.title}</h3>
|
|
35
35
|
{price != null ? (
|
|
36
|
-
<p className="text-price">
|
|
36
|
+
<p className="text-price">
|
|
37
|
+
{product.priceRange && product.priceRange.min !== product.priceRange.max
|
|
38
|
+
? `From ${formatPrice(price, currency)}`
|
|
39
|
+
: formatPrice(price, currency)}
|
|
40
|
+
</p>
|
|
37
41
|
) : null}
|
|
38
42
|
</div>
|
|
39
43
|
</a>
|
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
// redirect URL. Provider-agnostic: startCheckout delegates to payments.resolve. Next glue.
|
|
4
4
|
import { cookies } from 'next/headers';
|
|
5
5
|
import { NextResponse } from 'next/server';
|
|
6
|
+
import { checkRateLimit, clientIpFromHeaders } from '@vitrine-kit/core';
|
|
6
7
|
import { getCommerceBackend } from '@/lib/adapter';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
const RATE_LIMIT = { limit: 10, windowMs: 60_000 };
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request) {
|
|
12
|
+
const { allowed, retryAfterMs } = checkRateLimit(`checkout:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
13
|
+
if (!allowed) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: 'too many requests' },
|
|
16
|
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 0) / 1000)) } },
|
|
17
|
+
);
|
|
18
|
+
}
|
|
9
19
|
const cartId = (await cookies()).get('vitrine_cart')?.value;
|
|
10
20
|
if (!cartId) return NextResponse.json({ error: 'cart is empty' }, { status: 400 });
|
|
11
21
|
const commerce = await getCommerceBackend();
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// Paddle webhook: paddleProvider.verifyWebhook (Paddle-Signature) →
|
|
2
2
|
// normalized event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
3
3
|
import { NextResponse } from 'next/server';
|
|
4
|
-
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
4
|
+
import { checkRateLimit, clientIpFromHeaders, handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
5
|
import { paddleProvider } from '@/lib/checkout-paddle/provider';
|
|
6
6
|
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
7
7
|
|
|
8
|
+
const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
|
|
9
|
+
|
|
8
10
|
export async function POST(req: Request) {
|
|
11
|
+
const { allowed } = checkRateLimit(`webhook:paddle:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
12
|
+
if (!allowed) return NextResponse.json({ error: 'too many requests' }, { status: 429 });
|
|
13
|
+
|
|
9
14
|
const rawBody = await req.text();
|
|
10
15
|
const headers = { 'paddle-signature': req.headers.get('paddle-signature') };
|
|
11
16
|
|
|
@@ -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. signature failure detail) to the caller.
|
|
26
|
+
console.error('[vitrine] paddle webhook error:', err);
|
|
27
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent creates an order from
|
|
3
3
|
// the cart and marks it converted. No provider-specific code here — just glue.
|
|
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 { stripeProvider } from '@/lib/checkout-stripe/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:stripe:${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
|
const headers = { 'stripe-signature': req.headers.get('stripe-signature') };
|
|
12
17
|
|
|
@@ -18,6 +23,8 @@ export async function POST(req: Request) {
|
|
|
18
23
|
});
|
|
19
24
|
return NextResponse.json(result);
|
|
20
25
|
} catch (err) {
|
|
21
|
-
|
|
26
|
+
// Don't echo provider-specific error text (e.g. signature failure detail) to the caller.
|
|
27
|
+
console.error('[vitrine] stripe webhook error:', err);
|
|
28
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -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
|
}
|
|
@@ -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' },
|
|
@@ -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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitrine-kit/vitrine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Vitrine CLI — feature install primitive, init (wizard), add, update, doctor, design apply.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@clack/prompts": "^0.8.2",
|
|
27
27
|
"commander": "^12.1.0",
|
|
28
|
-
"@vitrine-kit/contracts": "1.2.
|
|
28
|
+
"@vitrine-kit/contracts": "1.2.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "node scripts/generate-kit-versions.mjs && tsup src/index.ts --format esm --clean && node scripts/copy-kit.mjs",
|