@vitrine-kit/vitrine 0.4.1 → 0.5.0
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/README.md +1 -1
- package/dist/index.js +114 -44
- package/kit/registry/_index.json +70 -9
- package/kit/registry/accounts/docs/accounts.md +20 -0
- package/kit/registry/accounts/feature.json +55 -0
- package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
- package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
- package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
- package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
- package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
- package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
- package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
- package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
- package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
- package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
- package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
- package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
- package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
- package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/cart/files/app/api/cart/route.ts +101 -11
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
- package/kit/registry/cart/files/components/cart/CartView.tsx +21 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
- package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
- package/kit/registry/checkout/docs/checkout.md +19 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +25 -4
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
- package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
- 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/email/docs/email.md +19 -0
- package/kit/registry/email/feature.json +60 -0
- package/kit/registry/email/files/lib/email/adapter.ts +89 -0
- package/kit/registry/filters/docs/filters.md +13 -0
- package/kit/registry/filters/feature.json +45 -0
- package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
- package/kit/registry/filters/files/lib/filters/register.ts +6 -0
- package/kit/registry/i18n/docs/i18n.md +24 -0
- package/kit/registry/i18n/feature.json +47 -0
- package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
- package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
- package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
- package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
- package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
- package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/registry/i18n/files/middleware.ts +51 -0
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
- package/kit/registry/product-page/files/components/product/ProductView.tsx +8 -4
- package/kit/registry/reviews/docs/reviews.md +11 -0
- package/kit/registry/reviews/feature.json +55 -0
- package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
- package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
- package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
- package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
- package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
- package/kit/registry/search/docs/search.md +12 -0
- package/kit/registry/search/feature.json +49 -0
- package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
- package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
- package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
- package/kit/registry/search/files/lib/search/data.ts +8 -0
- package/kit/registry/search/files/lib/search/register.ts +8 -0
- package/kit/registry/seo/docs/seo.md +4 -1
- package/kit/registry/seo/feature.json +5 -1
- package/kit/registry/seo/files/app/robots.ts +13 -0
- package/kit/registry/seo/files/app/sitemap.ts +27 -0
- package/kit/registry/stubs/route-glue.d.ts +71 -0
- package/kit/registry/tsconfig.json +7 -2
- package/kit/registry/wishlist/docs/wishlist.md +11 -0
- package/kit/registry/wishlist/feature.json +55 -0
- package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
- package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
- package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
- package/kit/templates/backend-payload/files/Dockerfile +3 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
- package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +16 -6
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
- package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +92 -16
- package/kit/templates/backend-payload/files/next.config.mjs +34 -0
- package/kit/templates/backend-payload/files/payload.config.ts +33 -3
- 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/Dockerfile +1 -1
- package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
- package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
- package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
- package/kit/templates/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
- package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
- package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +84 -6
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
- package/kit/templates/base/files/app/globals.css +13 -0
- package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
- package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
- package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
- package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/templates/base/files/lib/site.ts +3 -0
- package/kit/templates/base/files/next.config.mjs +34 -0
- package/package.json +2 -2
|
@@ -3,39 +3,129 @@
|
|
|
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
|
+
}
|
|
53
|
+
|
|
54
|
+
function publicError(error: unknown): string {
|
|
55
|
+
if (!(error instanceof Error)) return 'cart update failed';
|
|
56
|
+
return error.message.replace(/^\[vitrine\]\s*/, '');
|
|
57
|
+
}
|
|
10
58
|
|
|
11
59
|
async function ensureCartId(commerce: CommerceBackend): Promise<string> {
|
|
12
60
|
const jar = await cookies();
|
|
13
61
|
const existing = jar.get(COOKIE)?.value;
|
|
14
62
|
if (existing) return existing;
|
|
15
63
|
const cart = await commerce.createCart();
|
|
16
|
-
jar.set(COOKIE, cart.id, {
|
|
64
|
+
jar.set(COOKIE, cart.id, {
|
|
65
|
+
httpOnly: true,
|
|
66
|
+
sameSite: 'lax',
|
|
67
|
+
path: '/',
|
|
68
|
+
secure: process.env.NODE_ENV === 'production',
|
|
69
|
+
});
|
|
17
70
|
return cart.id;
|
|
18
71
|
}
|
|
19
72
|
|
|
73
|
+
export async function GET() {
|
|
74
|
+
const id = (await cookies()).get(COOKIE)?.value;
|
|
75
|
+
if (!id) {
|
|
76
|
+
return NextResponse.json({ id: null, lines: [], currency: 'USD', subtotal: 0, total: 0 });
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const commerce = await getCommerceBackend();
|
|
80
|
+
const cart = await commerce.getCart(id);
|
|
81
|
+
return NextResponse.json(cart ?? { id, lines: [], currency: 'USD', subtotal: 0, total: 0 });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return NextResponse.json({ error: publicError(error) }, { status: 500 });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
20
87
|
export async function POST(req: Request) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
return NextResponse.json(
|
|
88
|
+
const limited = rateLimited(req);
|
|
89
|
+
if (limited) return limited;
|
|
90
|
+
const parsed = parseAddItemBody(await req.json());
|
|
91
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
92
|
+
try {
|
|
93
|
+
const commerce = await getCommerceBackend();
|
|
94
|
+
const id = await ensureCartId(commerce);
|
|
95
|
+
return NextResponse.json(await commerce.addItem(id, parsed.variantId, parsed.quantity));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const message = publicError(error);
|
|
98
|
+
const status = /out of stock|not found/i.test(message) ? 409 : 500;
|
|
99
|
+
return NextResponse.json({ error: message }, { status });
|
|
100
|
+
}
|
|
25
101
|
}
|
|
26
102
|
|
|
27
103
|
export async function PATCH(req: Request) {
|
|
28
|
-
const
|
|
104
|
+
const limited = rateLimited(req);
|
|
105
|
+
if (limited) return limited;
|
|
106
|
+
const parsed = parseUpdateItemBody(await req.json());
|
|
107
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
29
108
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
30
109
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
31
|
-
|
|
32
|
-
|
|
110
|
+
try {
|
|
111
|
+
const commerce = await getCommerceBackend();
|
|
112
|
+
return NextResponse.json(await commerce.updateItem(id, parsed.lineId, parsed.quantity));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return NextResponse.json({ error: publicError(error) }, { status: 500 });
|
|
115
|
+
}
|
|
33
116
|
}
|
|
34
117
|
|
|
35
118
|
export async function DELETE(req: Request) {
|
|
36
|
-
const
|
|
119
|
+
const limited = rateLimited(req);
|
|
120
|
+
if (limited) return limited;
|
|
121
|
+
const parsed = parseRemoveItemBody(await req.json());
|
|
122
|
+
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
37
123
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
38
124
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
39
|
-
|
|
40
|
-
|
|
125
|
+
try {
|
|
126
|
+
const commerce = await getCommerceBackend();
|
|
127
|
+
return NextResponse.json(await commerce.removeItem(id, parsed.lineId));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return NextResponse.json({ error: publicError(error) }, { status: 500 });
|
|
130
|
+
}
|
|
41
131
|
}
|
|
@@ -1,40 +1,167 @@
|
|
|
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';
|
|
7
|
+
import { formatMoney } from '../../lib/cart/data.js';
|
|
8
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
6
9
|
|
|
7
10
|
export interface AddToCartProps {
|
|
8
11
|
product: Product;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
const SIZE_ORDER = ['XXS', 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
|
15
|
+
|
|
16
|
+
function optionKeys(variants: Variant[]): string[] {
|
|
17
|
+
const keys = new Set<string>();
|
|
18
|
+
for (const v of variants) {
|
|
19
|
+
for (const key of Object.keys(v.options ?? {})) keys.add(key);
|
|
20
|
+
}
|
|
21
|
+
return [...keys];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function valuesFor(variants: Variant[], key: string): string[] {
|
|
25
|
+
const values = new Set<string>();
|
|
26
|
+
for (const v of variants) {
|
|
27
|
+
const value = v.options?.[key];
|
|
28
|
+
if (value) values.add(value);
|
|
29
|
+
}
|
|
30
|
+
const list = [...values];
|
|
31
|
+
if (key.toLowerCase() === 'size') {
|
|
32
|
+
return list.sort((a, b) => {
|
|
33
|
+
const ia = SIZE_ORDER.indexOf(a.toUpperCase());
|
|
34
|
+
const ib = SIZE_ORDER.indexOf(b.toUpperCase());
|
|
35
|
+
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
36
|
+
if (ia >= 0) return -1;
|
|
37
|
+
if (ib >= 0) return 1;
|
|
38
|
+
return a.localeCompare(b);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return list.sort((a, b) => a.localeCompare(b));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchVariant(
|
|
45
|
+
variants: Variant[],
|
|
46
|
+
selected: Record<string, string>,
|
|
47
|
+
keys: string[],
|
|
48
|
+
): Variant | undefined {
|
|
49
|
+
return variants.find((v) => keys.every((key) => (v.options?.[key] ?? '') === (selected[key] ?? '')));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function firstAvailable(variants: Variant[]): Variant | undefined {
|
|
53
|
+
return variants.find((v) => v.stock == null || v.stock > 0) ?? variants[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function variantLabel(variant: Variant): string {
|
|
57
|
+
const opts = variant.options;
|
|
58
|
+
if (opts && Object.keys(opts).length > 0) {
|
|
59
|
+
return Object.entries(opts)
|
|
60
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
61
|
+
.join(', ');
|
|
62
|
+
}
|
|
63
|
+
return variant.title ?? variant.sku;
|
|
64
|
+
}
|
|
65
|
+
|
|
11
66
|
export function AddToCart({ product }: AddToCartProps) {
|
|
67
|
+
const variants = product.variants;
|
|
68
|
+
const keys = useMemo(() => optionKeys(variants), [variants]);
|
|
12
69
|
const [pending, setPending] = useState(false);
|
|
13
|
-
const
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
const addLabel = useChromeLabel('addToCart');
|
|
72
|
+
const [selected, setSelected] = useState<Record<string, string>>(() => ({
|
|
73
|
+
...(firstAvailable(variants)?.options ?? {}),
|
|
74
|
+
}));
|
|
75
|
+
const [variantId, setVariantId] = useState<string | undefined>(() => firstAvailable(variants)?.id);
|
|
76
|
+
|
|
77
|
+
const matched =
|
|
78
|
+
keys.length > 0 ? matchVariant(variants, selected, keys) : variants.find((v) => v.id === variantId);
|
|
79
|
+
const active = matched ?? firstAvailable(variants);
|
|
80
|
+
const activeId = active?.id;
|
|
81
|
+
const outOfStock = active != null && active.stock != null && active.stock <= 0;
|
|
82
|
+
const currency = active?.currency ?? product.priceRange?.currency ?? 'USD';
|
|
14
83
|
|
|
15
84
|
async function add(): Promise<void> {
|
|
16
|
-
if (!
|
|
85
|
+
if (!activeId || outOfStock) return;
|
|
17
86
|
setPending(true);
|
|
87
|
+
setError(null);
|
|
18
88
|
try {
|
|
19
|
-
await fetch('/api/cart', {
|
|
89
|
+
const res = await fetch('/api/cart', {
|
|
20
90
|
method: 'POST',
|
|
21
91
|
headers: { 'content-type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({ variantId, quantity: 1 }),
|
|
92
|
+
body: JSON.stringify({ variantId: activeId, quantity: 1 }),
|
|
23
93
|
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
96
|
+
setError(data.error ?? 'Could not add to cart.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
24
99
|
location.assign('/cart');
|
|
100
|
+
} catch {
|
|
101
|
+
setError('Could not add to cart.');
|
|
25
102
|
} finally {
|
|
26
103
|
setPending(false);
|
|
27
104
|
}
|
|
28
105
|
}
|
|
29
106
|
|
|
107
|
+
if (variants.length === 0) {
|
|
108
|
+
return <p className="text-muted-fg">This product has no purchasable variants yet.</p>;
|
|
109
|
+
}
|
|
110
|
+
|
|
30
111
|
return (
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
112
|
+
<div className="vt-add-to-cart-wrap flex flex-col gap-gutter">
|
|
113
|
+
{keys.length > 0 ? (
|
|
114
|
+
keys.map((key) => (
|
|
115
|
+
<label key={key} className="flex flex-col gap-unit text-sm">
|
|
116
|
+
<span className="font-medium capitalize text-fg">{key}</span>
|
|
117
|
+
<select
|
|
118
|
+
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"
|
|
119
|
+
value={selected[key] ?? ''}
|
|
120
|
+
onChange={(e) => setSelected((prev) => ({ ...prev, [key]: e.target.value }))}
|
|
121
|
+
>
|
|
122
|
+
{valuesFor(variants, key).map((value) => (
|
|
123
|
+
<option key={value} value={value}>
|
|
124
|
+
{value}
|
|
125
|
+
</option>
|
|
126
|
+
))}
|
|
127
|
+
</select>
|
|
128
|
+
</label>
|
|
129
|
+
))
|
|
130
|
+
) : variants.length > 1 ? (
|
|
131
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
132
|
+
<span className="font-medium text-fg">Option</span>
|
|
133
|
+
<select
|
|
134
|
+
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"
|
|
135
|
+
value={activeId ?? ''}
|
|
136
|
+
onChange={(e) => setVariantId(e.target.value)}
|
|
137
|
+
>
|
|
138
|
+
{variants.map((v) => (
|
|
139
|
+
<option key={v.id} value={v.id} disabled={v.stock != null && v.stock <= 0}>
|
|
140
|
+
{variantLabel(v)}
|
|
141
|
+
{v.stock != null && v.stock <= 0 ? ' (out of stock)' : ''}
|
|
142
|
+
</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</label>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
{active?.price != null ? (
|
|
149
|
+
<p className="text-price text-lg">{formatMoney(active.price, currency)}</p>
|
|
150
|
+
) : null}
|
|
151
|
+
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={add}
|
|
155
|
+
disabled={pending || !activeId || outOfStock}
|
|
156
|
+
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"
|
|
157
|
+
>
|
|
158
|
+
{pending ? 'Adding…' : outOfStock ? 'Out of stock' : addLabel}
|
|
159
|
+
</button>
|
|
160
|
+
{error ? (
|
|
161
|
+
<p role="alert" className="vt-add-to-cart-error text-danger">
|
|
162
|
+
{error}
|
|
163
|
+
</p>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
39
166
|
);
|
|
40
167
|
}
|
|
@@ -1,11 +1,36 @@
|
|
|
1
|
-
// Header cart link (global.header-actions slot).
|
|
1
|
+
// Header cart link (global.header-actions slot). Client component loads the
|
|
2
|
+
// item count from GET /api/cart so the kit typecheck stays free of Next imports.
|
|
3
|
+
'use client';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
6
|
+
|
|
2
7
|
export function CartIndicator() {
|
|
8
|
+
const [count, setCount] = useState(0);
|
|
9
|
+
const label = useChromeLabel('cart');
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let cancelled = false;
|
|
13
|
+
fetch('/api/cart')
|
|
14
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
15
|
+
.then((data: { lines?: Array<{ quantity?: number }> } | null) => {
|
|
16
|
+
if (cancelled || !data?.lines) return;
|
|
17
|
+
setCount(data.lines.reduce((sum, line) => sum + (line.quantity ?? 0), 0));
|
|
18
|
+
})
|
|
19
|
+
.catch(() => {
|
|
20
|
+
/* cart API missing on catalog-only installs */
|
|
21
|
+
});
|
|
22
|
+
return () => {
|
|
23
|
+
cancelled = true;
|
|
24
|
+
};
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
3
27
|
return (
|
|
4
28
|
<a
|
|
5
29
|
href="/cart"
|
|
6
30
|
className="vt-cart-indicator text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
7
31
|
>
|
|
8
|
-
|
|
32
|
+
{label}
|
|
33
|
+
{count > 0 ? ` (${count})` : ''}
|
|
9
34
|
</a>
|
|
10
35
|
);
|
|
11
36
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// Cart line item —
|
|
1
|
+
// Cart line item — client controls for quantity and remove (PATCH/DELETE /api/cart).
|
|
2
|
+
'use client';
|
|
3
|
+
import { useState } from 'react';
|
|
2
4
|
import type { CartLine } from '@vitrine-kit/contracts';
|
|
3
5
|
import { formatMoney } from '../../lib/cart/data.js';
|
|
4
6
|
|
|
@@ -8,20 +10,76 @@ export interface CartLineItemProps {
|
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function CartLineItem({ line, currency }: CartLineItemProps) {
|
|
13
|
+
const [pending, setPending] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
async function mutate(method: 'PATCH' | 'DELETE', body: Record<string, unknown>): Promise<void> {
|
|
17
|
+
setPending(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('/api/cart', {
|
|
21
|
+
method,
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
27
|
+
setError(data.error ?? 'Could not update the cart.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
location.reload();
|
|
31
|
+
} catch {
|
|
32
|
+
setError('Could not update the cart.');
|
|
33
|
+
} finally {
|
|
34
|
+
setPending(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
return (
|
|
12
|
-
<li className="vt-cart-line flex
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
</
|
|
39
|
+
<li className="vt-cart-line flex flex-col gap-unit border-b border-border py-unit">
|
|
40
|
+
<div className="flex items-center gap-gutter">
|
|
41
|
+
{line.image ? (
|
|
42
|
+
<img src={line.image} alt="" className="h-16 w-16 rounded-md object-cover" />
|
|
43
|
+
) : (
|
|
44
|
+
<div className="h-16 w-16 rounded-md bg-muted" aria-hidden="true" />
|
|
45
|
+
)}
|
|
46
|
+
<div className="flex-1">
|
|
47
|
+
<p className="text-fg">{line.title}</p>
|
|
48
|
+
<p className="text-muted-fg">{formatMoney(line.unitPrice, currency)} each</p>
|
|
49
|
+
</div>
|
|
50
|
+
<p className="text-price">{formatMoney(line.lineTotal, currency)}</p>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="flex flex-wrap items-center gap-gutter">
|
|
53
|
+
<label className="flex items-center gap-unit text-sm text-fg">
|
|
54
|
+
<span className="text-muted-fg">Qty</span>
|
|
55
|
+
<input
|
|
56
|
+
type="number"
|
|
57
|
+
min={1}
|
|
58
|
+
max={999}
|
|
59
|
+
value={line.quantity}
|
|
60
|
+
disabled={pending}
|
|
61
|
+
className="w-16 rounded-md border border-input bg-surface px-unit py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring disabled:opacity-50"
|
|
62
|
+
onChange={(e) => {
|
|
63
|
+
const quantity = Number(e.target.value);
|
|
64
|
+
if (!Number.isInteger(quantity) || quantity < 1) return;
|
|
65
|
+
void mutate('PATCH', { lineId: line.id, quantity });
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
</label>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
disabled={pending}
|
|
72
|
+
onClick={() => void mutate('DELETE', { lineId: line.id })}
|
|
73
|
+
className="text-sm text-muted-fg underline underline-offset-2 transition hover:text-fg disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
74
|
+
>
|
|
75
|
+
Remove
|
|
76
|
+
</button>
|
|
77
|
+
{error ? (
|
|
78
|
+
<p role="alert" className="text-sm text-danger">
|
|
79
|
+
{error}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
23
82
|
</div>
|
|
24
|
-
<p className="text-price">{formatMoney(line.lineTotal, currency)}</p>
|
|
25
83
|
</li>
|
|
26
84
|
);
|
|
27
85
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Cart contents — presentational. Hosts the cart.items-bottom / cart.below slots.
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
2
4
|
import type { Cart } from '@vitrine-kit/contracts';
|
|
3
5
|
import { Slot } from '@vitrine-kit/core/react';
|
|
6
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
4
7
|
import { CartLineItem } from './CartLineItem.js';
|
|
5
8
|
import { CartSummary } from './CartSummary.js';
|
|
6
9
|
|
|
@@ -9,8 +12,25 @@ export interface CartViewProps {
|
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
export function CartView({ cart }: CartViewProps) {
|
|
15
|
+
const continueShopping = useChromeLabel('continueShopping');
|
|
16
|
+
const addToCart = useChromeLabel('addToCart');
|
|
17
|
+
|
|
12
18
|
if (cart.lines.length === 0) {
|
|
13
|
-
return
|
|
19
|
+
return (
|
|
20
|
+
<div className="vt-cart-empty flex flex-col gap-gutter">
|
|
21
|
+
<p className="text-muted-fg">Your cart is empty.</p>
|
|
22
|
+
<p className="text-sm text-muted-fg">
|
|
23
|
+
Browse the catalog, open a product, then use <span className="text-fg">{addToCart}</span>{' '}
|
|
24
|
+
to start checkout.
|
|
25
|
+
</p>
|
|
26
|
+
<a
|
|
27
|
+
href="/"
|
|
28
|
+
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"
|
|
29
|
+
>
|
|
30
|
+
{continueShopping}
|
|
31
|
+
</a>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
14
34
|
}
|
|
15
35
|
return (
|
|
16
36
|
<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>
|
|
@@ -1,7 +1,71 @@
|
|
|
1
1
|
// Catalog access via the CatalogSource contract. The source (Payload*/Vendure*)
|
|
2
2
|
// is resolved in the page data (template); a ready CatalogSource arrives here —
|
|
3
3
|
// so the feature depends only on the contract and is portable across backends.
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
CatalogSource,
|
|
6
|
+
Category,
|
|
7
|
+
Product,
|
|
8
|
+
ProductQuery,
|
|
9
|
+
ProductSort,
|
|
10
|
+
} from '@vitrine-kit/contracts';
|
|
11
|
+
|
|
12
|
+
export function parseProductSort(value: string | undefined | null): ProductSort | undefined {
|
|
13
|
+
if (value === 'newest' || value === 'price-asc' || value === 'price-desc' || value === 'relevance') {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse `?size=S&size=L&color=Black` style params into ProductQuery.filters. */
|
|
20
|
+
export function parseProductFilters(
|
|
21
|
+
params: Record<string, string | string[] | undefined>,
|
|
22
|
+
facetKeys: string[] = ['size', 'color'],
|
|
23
|
+
): Record<string, string[]> | undefined {
|
|
24
|
+
const filters: Record<string, string[]> = {};
|
|
25
|
+
for (const key of facetKeys) {
|
|
26
|
+
const raw = params[key];
|
|
27
|
+
if (raw == null) continue;
|
|
28
|
+
const values = (Array.isArray(raw) ? raw : [raw])
|
|
29
|
+
.flatMap((v) => String(v).split(','))
|
|
30
|
+
.map((v) => v.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
if (values.length) filters[key] = [...new Set(values)];
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(filters).length ? filters : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parsePriceBound(value: string | string[] | undefined | null): number | undefined {
|
|
38
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
39
|
+
if (raw == null || raw === '') return undefined;
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
42
|
+
// URL values are major currency units ($); ProductQuery uses minor units.
|
|
43
|
+
return Math.round(n * 100);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Aggregate option values present on the given products (for toolbar facets). */
|
|
47
|
+
export function collectOptionFacets(products: Product[]): Record<string, string[]> {
|
|
48
|
+
const buckets = new Map<string, Set<string>>();
|
|
49
|
+
for (const p of products) {
|
|
50
|
+
for (const v of p.variants) {
|
|
51
|
+
if (!v.options) continue;
|
|
52
|
+
for (const [key, val] of Object.entries(v.options)) {
|
|
53
|
+
if (!val) continue;
|
|
54
|
+
let set = buckets.get(key);
|
|
55
|
+
if (!set) {
|
|
56
|
+
set = new Set();
|
|
57
|
+
buckets.set(key, set);
|
|
58
|
+
}
|
|
59
|
+
set.add(val);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const out: Record<string, string[]> = {};
|
|
64
|
+
for (const [key, set] of [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
65
|
+
out[key] = [...set].sort((a, b) => a.localeCompare(b));
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
5
69
|
|
|
6
70
|
export async function loadProducts(
|
|
7
71
|
source: CatalogSource,
|
|
@@ -10,12 +74,16 @@ export async function loadProducts(
|
|
|
10
74
|
return source.listProducts(query);
|
|
11
75
|
}
|
|
12
76
|
|
|
13
|
-
export async function loadProduct(
|
|
14
|
-
|
|
77
|
+
export async function loadProduct(
|
|
78
|
+
source: CatalogSource,
|
|
79
|
+
slug: string,
|
|
80
|
+
locale?: string,
|
|
81
|
+
): Promise<Product | null> {
|
|
82
|
+
return source.getProduct(slug, locale);
|
|
15
83
|
}
|
|
16
84
|
|
|
17
|
-
export async function loadCategories(source: CatalogSource): Promise<Category[]> {
|
|
18
|
-
return source.listCategories();
|
|
85
|
+
export async function loadCategories(source: CatalogSource, locale?: string): Promise<Category[]> {
|
|
86
|
+
return source.listCategories(locale);
|
|
19
87
|
}
|
|
20
88
|
|
|
21
89
|
/** Money — minor units (e.g. cents); divide by 100 for 2-decimal currencies. */
|
|
@@ -8,10 +8,28 @@ A provider-agnostic checkout scaffold for `simple-store` / `full-store`. Depends
|
|
|
8
8
|
`POST /api/checkout` and redirects to the provider URL. It knows nothing about the provider.
|
|
9
9
|
- **API (Next glue):** `POST /api/checkout` — `CommerceBackend.startCheckout`
|
|
10
10
|
delegates to the active provider (`payments.resolve` by `integrations.payments`).
|
|
11
|
+
- **Success page:** `app/(frontend)/order/success` — post-payment landing (`order.*` slots).
|
|
11
12
|
- **Shared webhook code:** `lib/checkout/fulfill.ts` → `fulfillOrderFromEvent` —
|
|
12
13
|
idempotent order creation from a normalized event (used by all provider webhook
|
|
13
14
|
routes). The critical logic (dedup by `paymentRef`, order snapshot) lives in
|
|
14
15
|
`@vitrine-kit/core` (`shouldCreateOrder`, `buildOrderFromCart`).
|
|
16
|
+
- **Email:** `lib/checkout/notify.ts` — after fulfill, calls `payload.sendEmail` when the
|
|
17
|
+
`email` feature + SMTP (or console adapter) is configured; otherwise logs to the server console.
|
|
15
18
|
|
|
16
19
|
Flow: cart → `Checkout` → `POST /api/checkout` → redirect to the provider →
|
|
17
|
-
provider webhook → `fulfillOrderFromEvent` → order in the admin, cart `converted
|
|
20
|
+
provider webhook → `fulfillOrderFromEvent` → order in the admin, cart `converted` →
|
|
21
|
+
confirmation email (or console log).
|
|
22
|
+
|
|
23
|
+
## Shipping and tax (v1 scope)
|
|
24
|
+
|
|
25
|
+
This kit uses **hosted checkout** (Stripe Checkout / Paddle / YooKassa). There is no
|
|
26
|
+
in-store multi-step address form.
|
|
27
|
+
|
|
28
|
+
| Concern | v1 behavior |
|
|
29
|
+
|---|---|
|
|
30
|
+
| **Shipping address** | Collected by the payment provider on their hosted page (when enabled in the PSP dashboard). Not stored on the Payload cart/order beyond what the webhook returns (email). |
|
|
31
|
+
| **Shipping rates** | Configure in the PSP (Stripe Shipping Rates, etc.). The storefront does not compute rates. |
|
|
32
|
+
| **Tax / VAT** | Prefer PSP tax (Stripe Tax, Paddle VAT). Cart/order only expose optional `discountTotal` — no tax engine in `@vitrine-kit/core` yet. |
|
|
33
|
+
| **Digital goods** | Leave shipping disabled in the PSP; customers still get `/order/success` after pay. |
|
|
34
|
+
|
|
35
|
+
Physical-goods merchants should enable shipping/tax in the chosen provider dashboard before going live.
|