@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.
Files changed (39) hide show
  1. package/dist/index.js +247 -87
  2. package/kit/registry/_index.json +9 -9
  3. package/kit/registry/cart/feature.json +1 -1
  4. package/kit/registry/cart/files/app/api/cart/route.ts +58 -6
  5. package/kit/registry/cart/files/components/cart/AddToCart.tsx +106 -14
  6. package/kit/registry/cart/files/components/cart/CartView.tsx +19 -1
  7. package/kit/registry/catalog/feature.json +1 -1
  8. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
  9. package/kit/registry/checkout/feature.json +1 -1
  10. package/kit/registry/checkout/files/app/api/checkout/route.ts +11 -1
  11. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +25 -10
  12. package/kit/registry/checkout-paddle/docs/checkout-paddle.md +5 -2
  13. package/kit/registry/checkout-paddle/feature.json +1 -1
  14. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
  15. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +25 -4
  16. package/kit/registry/checkout-stripe/docs/checkout-stripe.md +3 -1
  17. package/kit/registry/checkout-stripe/feature.json +1 -1
  18. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
  19. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +10 -3
  20. package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +5 -2
  21. package/kit/registry/checkout-yookassa/feature.json +1 -1
  22. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
  23. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +14 -4
  24. package/kit/registry/product-page/feature.json +1 -1
  25. package/kit/registry/product-page/files/components/product/ProductView.tsx +5 -1
  26. package/kit/registry/seo/feature.json +1 -1
  27. package/kit/templates/backend-payload/files/lib/seed/admin.ts +9 -3
  28. package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
  29. package/kit/templates/backend-payload/files/lib/seed/run.ts +35 -10
  30. package/kit/templates/backend-payload/files/next.config.mjs +28 -0
  31. package/kit/templates/backend-payload/files/payload.config.ts +7 -1
  32. package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
  33. package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
  34. package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
  35. package/kit/templates/base/files/.gitattributes +12 -0
  36. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +6 -1
  37. package/kit/templates/base/files/app/(frontend)/page.tsx +36 -3
  38. package/kit/templates/base/files/next.config.mjs +28 -0
  39. package/package.json +5 -2
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "$schema": "../schemas/registry-index.schema.json",
3
- "kitVersion": "0.4.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
67
+ "kitVersion": "0.4.3",
68
68
  "tier": [
69
69
  "simple-store",
70
70
  "full-store"
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "cart",
4
4
  "title": "Cart",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.3",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-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 { variantId, quantity = 1 } = (await req.json()) as { variantId: string; quantity?: number };
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 { lineId, quantity } = (await req.json()) as { lineId: string; quantity: number };
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 { lineId } = (await req.json()) as { lineId: string };
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). Mutation goes through POST /api/cart (the feature's route).
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 variantId = product.variants[0]?.id;
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 (!variantId) return;
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
- <button
32
- type="button"
33
- onClick={add}
34
- disabled={pending || !variantId}
35
- 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"
36
- >
37
- {pending ? 'Adding…' : 'Add to cart'}
38
- </button>
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 <p className="text-muted-fg">Your cart is empty.</p>;
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]">
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "catalog",
4
4
  "title": "Product catalog",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.3",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "catalog",
@@ -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">{formatPrice(price, currency)}</p>
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>
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout",
4
4
  "title": "Checkout",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.3",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -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
- export async function POST() {
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();
@@ -6,26 +6,41 @@ import { useState } from 'react';
6
6
 
7
7
  export function CheckoutButton() {
8
8
  const [pending, setPending] = useState(false);
9
+ const [error, setError] = useState<string | null>(null);
9
10
 
10
11
  async function checkout(): Promise<void> {
11
12
  setPending(true);
13
+ setError(null);
12
14
  try {
13
15
  const res = await fetch('/api/checkout', { method: 'POST' });
14
- const data = (await res.json()) as { url?: string; error?: string };
15
- if (data.url) location.assign(data.url);
16
+ const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
17
+ if (!res.ok || !data.url) {
18
+ setError(data.error ?? 'Checkout is unavailable — try again later.');
19
+ return;
20
+ }
21
+ location.assign(data.url);
22
+ } catch {
23
+ setError('Checkout is unavailable — try again later.');
16
24
  } finally {
17
25
  setPending(false);
18
26
  }
19
27
  }
20
28
 
21
29
  return (
22
- <button
23
- type="button"
24
- onClick={checkout}
25
- disabled={pending}
26
- className="vt-checkout-button 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"
27
- >
28
- {pending ? 'Redirecting to payment…' : 'Checkout'}
29
- </button>
30
+ <>
31
+ <button
32
+ type="button"
33
+ onClick={checkout}
34
+ disabled={pending}
35
+ className="vt-checkout-button 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"
36
+ >
37
+ {pending ? 'Redirecting to payment…' : 'Checkout'}
38
+ </button>
39
+ {error ? (
40
+ <p role="alert" className="vt-checkout-error text-danger">
41
+ {error}
42
+ </p>
43
+ ) : null}
44
+ </>
30
45
  );
31
46
  }
@@ -8,12 +8,15 @@ in `@vitrine-kit/core`.
8
8
  - **Provider:** `lib/checkout-paddle/provider.ts` → `paddleProvider`
9
9
  (`PaymentProvider`): `createCheckout` creates a Paddle transaction with non-catalog
10
10
  line items (prices from the cart, `customData.cartId`); `verifyWebhook` verifies the
11
- `Paddle-Signature` via `@paddle/paddle-node-sdk`.
11
+ `Paddle-Signature` via `@paddle/paddle-node-sdk`. The notification carries no email, so
12
+ the provider does a best-effort `customers.get(customerId)` lookup for the order's
13
+ `email` — a lookup failure does not fail fulfillment.
12
14
  - **Registration:** `registerCheckoutPaddleProvider()` (from `lib/payments.ts`), sets
13
15
  `integrations.payments: "paddle"`.
14
16
  - **API (Next glue):** `POST /api/webhooks/paddle` → `handlePaymentWebhook` →
15
17
  `fulfillOrderFromEvent` (on `transaction.completed`/`transaction.paid`).
16
- - **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required);
18
+ - **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required — read at call time, a
19
+ missing key throws `[vitrine] <KEY> is not set …`; webhook route → 400);
17
20
  `PADDLE_ENVIRONMENT` (`sandbox`|`production`, default `sandbox`),
18
21
  `PADDLE_CHECKOUT_URL` (override the hosted checkout, otherwise a default payment link
19
22
  in the Paddle dashboard is required) — optional.
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-paddle",
4
4
  "title": "Payment — Paddle",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.3",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -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
- return NextResponse.json({ error: (err as Error).message }, { status: 400 });
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
  }
@@ -9,8 +9,15 @@
9
9
  import { Environment, Paddle, type EventEntity } from '@paddle/paddle-node-sdk';
10
10
  import type { CreateCheckoutArgs, NormalizedPaymentEvent, PaymentProvider, PaymentWebhookRequest } from '@vitrine-kit/core';
11
11
 
12
+ /** Required env, read at call time — `next build` must not need secrets. */
13
+ function requiredEnv(key: string): string {
14
+ const value = process.env[key];
15
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
16
+ return value;
17
+ }
18
+
12
19
  function client(): Paddle {
13
- return new Paddle(process.env.PADDLE_API_KEY ?? '', {
20
+ return new Paddle(requiredEnv('PADDLE_API_KEY'), {
14
21
  environment:
15
22
  process.env.PADDLE_ENVIRONMENT === 'production' ? Environment.production : Environment.sandbox,
16
23
  });
@@ -40,16 +47,30 @@ export const paddleProvider: PaymentProvider = {
40
47
 
41
48
  async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
42
49
  const paddle = client();
43
- const secret = process.env.PADDLE_WEBHOOK_SECRET ?? '';
50
+ const secret = requiredEnv('PADDLE_WEBHOOK_SECRET');
44
51
  const signature = req.headers['paddle-signature'] ?? '';
45
52
  // Throws on an invalid signature — handlePaymentWebhook returns a 400.
46
53
  const event = (await paddle.webhooks.unmarshal(req.rawBody, secret, signature)) as EventEntity | null;
47
54
  if (!event) return { kind: 'unknown', raw: req.rawBody };
48
55
 
49
56
  if (event.eventType === 'transaction.completed' || event.eventType === 'transaction.paid') {
50
- const data = event.data as { id?: string; customData?: { cartId?: string } | null };
57
+ const data = event.data as {
58
+ id?: string;
59
+ customData?: { cartId?: string } | null;
60
+ customerId?: string | null;
61
+ };
51
62
  const cartId = (data.customData ?? undefined)?.cartId;
52
- return { kind: 'checkout_completed', cartId, providerRef: data.id, raw: event };
63
+ // The notification carries no email best-effort lookup on the Customer entity.
64
+ // A lookup failure must not fail fulfillment: email is optional on the event.
65
+ let email: string | undefined;
66
+ if (data.customerId) {
67
+ try {
68
+ email = (await paddle.customers.get(data.customerId)).email ?? undefined;
69
+ } catch {
70
+ // keep email undefined — the order is still created
71
+ }
72
+ }
73
+ return { kind: 'checkout_completed', cartId, providerRef: data.id, email, raw: event };
53
74
  }
54
75
  if (event.eventType === 'transaction.payment_failed') {
55
76
  return { kind: 'payment_failed', raw: event };
@@ -11,7 +11,9 @@ logic (webhook dispatcher, order from cart) lives in `@vitrine-kit/core`.
11
11
  sets `integrations.payments: "stripe"` in `site.config`.
12
12
  - **API (Next glue):** `POST /api/webhooks/stripe` → `handlePaymentWebhook` →
13
13
  `fulfillOrderFromEvent` (the `checkout` feature's shared code).
14
- - **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required).
14
+ - **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required). Read at call time
15
+ (`next build` needs no secrets); a missing key throws `[vitrine] <KEY> is not set …` —
16
+ in the webhook route this surfaces as a 400, in `POST /api/checkout` as a 500.
15
17
 
16
18
  Flow: cart → `Checkout` → redirect to Stripe → webhook
17
19
  `checkout.session.completed` → order in the admin, cart `converted`.
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-stripe",
4
4
  "title": "Payment — Stripe",
5
- "kitVersion": "0.4.0",
5
+ "kitVersion": "0.4.3",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -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
- return NextResponse.json({ error: (err as Error).message }, { status: 400 });
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
  }
@@ -10,6 +10,13 @@ import type {
10
10
  PaymentWebhookRequest,
11
11
  } from '@vitrine-kit/core';
12
12
 
13
+ /** Required env, read at call time — `next build` must not need secrets. */
14
+ function requiredEnv(key: string): string {
15
+ const value = process.env[key];
16
+ if (!value) throw new Error(`[vitrine] ${key} is not set — add it to .env (see .env.example)`);
17
+ return value;
18
+ }
19
+
13
20
  interface StripeLineItem {
14
21
  quantity: number;
15
22
  price_data: { currency: string; unit_amount: number; product_data: { name: string } };
@@ -32,7 +39,7 @@ export const stripeProvider: PaymentProvider = {
32
39
  async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
33
40
  const { cart, baseUrl, successPath = '/order/success', cancelPath = '/cart' } = args;
34
41
  const { default: Stripe } = await import('stripe');
35
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
42
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
36
43
  const session = await stripe.checkout.sessions.create({
37
44
  mode: 'payment',
38
45
  line_items: cartToStripeLineItems(cart),
@@ -45,8 +52,8 @@ export const stripeProvider: PaymentProvider = {
45
52
 
46
53
  async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
47
54
  const { default: Stripe } = await import('stripe');
48
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
49
- const secret = process.env.STRIPE_WEBHOOK_SECRET ?? '';
55
+ const stripe = new Stripe(requiredEnv('STRIPE_SECRET_KEY'));
56
+ const secret = requiredEnv('STRIPE_WEBHOOK_SECRET');
50
57
  const signature = req.headers['stripe-signature'] ?? '';
51
58
  // Throws on an invalid signature — handlePaymentWebhook surfaces it as a 400.
52
59
  const event = stripe.webhooks.constructEvent(req.rawBody, signature, secret);