@vitrine-kit/vitrine 0.4.3 → 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 -45
- 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 +46 -8
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +38 -3
- 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 +9 -7
- package/kit/registry/catalog/feature.json +1 -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 +14 -3
- 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-stripe/feature.json +1 -1
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- 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 +3 -3
- 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 +7 -3
- 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 +57 -6
- package/kit/templates/backend-payload/files/next.config.mjs +8 -2
- package/kit/templates/backend-payload/files/payload.config.ts +27 -3
- 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/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +51 -4
- 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 +49 -4
- 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 +8 -2
- package/package.json +2 -2
|
@@ -51,23 +51,53 @@ function parseRemoveItemBody(body: unknown): { lineId: string } | null {
|
|
|
51
51
|
return { lineId };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function publicError(error: unknown): string {
|
|
55
|
+
if (!(error instanceof Error)) return 'cart update failed';
|
|
56
|
+
return error.message.replace(/^\[vitrine\]\s*/, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
async function ensureCartId(commerce: CommerceBackend): Promise<string> {
|
|
55
60
|
const jar = await cookies();
|
|
56
61
|
const existing = jar.get(COOKIE)?.value;
|
|
57
62
|
if (existing) return existing;
|
|
58
63
|
const cart = await commerce.createCart();
|
|
59
|
-
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
|
+
});
|
|
60
70
|
return cart.id;
|
|
61
71
|
}
|
|
62
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
|
+
|
|
63
87
|
export async function POST(req: Request) {
|
|
64
88
|
const limited = rateLimited(req);
|
|
65
89
|
if (limited) return limited;
|
|
66
90
|
const parsed = parseAddItemBody(await req.json());
|
|
67
91
|
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
export async function PATCH(req: Request) {
|
|
@@ -77,8 +107,12 @@ export async function PATCH(req: Request) {
|
|
|
77
107
|
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
78
108
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
79
109
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
82
116
|
}
|
|
83
117
|
|
|
84
118
|
export async function DELETE(req: Request) {
|
|
@@ -88,6 +122,10 @@ export async function DELETE(req: Request) {
|
|
|
88
122
|
if (!parsed) return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
89
123
|
const id = (await cookies()).get(COOKIE)?.value;
|
|
90
124
|
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|
|
93
131
|
}
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
'use client';
|
|
5
5
|
import { useMemo, useState } from 'react';
|
|
6
6
|
import type { Product, Variant } from '@vitrine-kit/contracts';
|
|
7
|
+
import { formatMoney } from '../../lib/cart/data.js';
|
|
8
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
7
9
|
|
|
8
10
|
export interface AddToCartProps {
|
|
9
11
|
product: Product;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
const SIZE_ORDER = ['XXS', 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
|
15
|
+
|
|
12
16
|
function optionKeys(variants: Variant[]): string[] {
|
|
13
17
|
const keys = new Set<string>();
|
|
14
18
|
for (const v of variants) {
|
|
@@ -23,7 +27,18 @@ function valuesFor(variants: Variant[], key: string): string[] {
|
|
|
23
27
|
const value = v.options?.[key];
|
|
24
28
|
if (value) values.add(value);
|
|
25
29
|
}
|
|
26
|
-
|
|
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));
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
function matchVariant(
|
|
@@ -52,6 +67,8 @@ export function AddToCart({ product }: AddToCartProps) {
|
|
|
52
67
|
const variants = product.variants;
|
|
53
68
|
const keys = useMemo(() => optionKeys(variants), [variants]);
|
|
54
69
|
const [pending, setPending] = useState(false);
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
const addLabel = useChromeLabel('addToCart');
|
|
55
72
|
const [selected, setSelected] = useState<Record<string, string>>(() => ({
|
|
56
73
|
...(firstAvailable(variants)?.options ?? {}),
|
|
57
74
|
}));
|
|
@@ -62,17 +79,26 @@ export function AddToCart({ product }: AddToCartProps) {
|
|
|
62
79
|
const active = matched ?? firstAvailable(variants);
|
|
63
80
|
const activeId = active?.id;
|
|
64
81
|
const outOfStock = active != null && active.stock != null && active.stock <= 0;
|
|
82
|
+
const currency = active?.currency ?? product.priceRange?.currency ?? 'USD';
|
|
65
83
|
|
|
66
84
|
async function add(): Promise<void> {
|
|
67
85
|
if (!activeId || outOfStock) return;
|
|
68
86
|
setPending(true);
|
|
87
|
+
setError(null);
|
|
69
88
|
try {
|
|
70
|
-
await fetch('/api/cart', {
|
|
89
|
+
const res = await fetch('/api/cart', {
|
|
71
90
|
method: 'POST',
|
|
72
91
|
headers: { 'content-type': 'application/json' },
|
|
73
92
|
body: JSON.stringify({ variantId: activeId, quantity: 1 }),
|
|
74
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
|
+
}
|
|
75
99
|
location.assign('/cart');
|
|
100
|
+
} catch {
|
|
101
|
+
setError('Could not add to cart.');
|
|
76
102
|
} finally {
|
|
77
103
|
setPending(false);
|
|
78
104
|
}
|
|
@@ -119,14 +145,23 @@ export function AddToCart({ product }: AddToCartProps) {
|
|
|
119
145
|
</label>
|
|
120
146
|
) : null}
|
|
121
147
|
|
|
148
|
+
{active?.price != null ? (
|
|
149
|
+
<p className="text-price text-lg">{formatMoney(active.price, currency)}</p>
|
|
150
|
+
) : null}
|
|
151
|
+
|
|
122
152
|
<button
|
|
123
153
|
type="button"
|
|
124
154
|
onClick={add}
|
|
125
155
|
disabled={pending || !activeId || outOfStock}
|
|
126
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"
|
|
127
157
|
>
|
|
128
|
-
{pending ? 'Adding…' : outOfStock ? 'Out of stock' :
|
|
158
|
+
{pending ? 'Adding…' : outOfStock ? 'Out of stock' : addLabel}
|
|
129
159
|
</button>
|
|
160
|
+
{error ? (
|
|
161
|
+
<p role="alert" className="vt-add-to-cart-error text-danger">
|
|
162
|
+
{error}
|
|
163
|
+
</p>
|
|
164
|
+
) : null}
|
|
130
165
|
</div>
|
|
131
166
|
);
|
|
132
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,23 +12,22 @@ 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
19
|
return (
|
|
14
20
|
<div className="vt-cart-empty flex flex-col gap-gutter">
|
|
15
21
|
<p className="text-muted-fg">Your cart is empty.</p>
|
|
16
22
|
<p className="text-sm text-muted-fg">
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
Browse the catalog, open a product, then use <span className="text-fg">{addToCart}</span>{' '}
|
|
24
|
+
to start checkout.
|
|
23
25
|
</p>
|
|
24
26
|
<a
|
|
25
27
|
href="/"
|
|
26
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"
|
|
27
29
|
>
|
|
28
|
-
|
|
30
|
+
{continueShopping}
|
|
29
31
|
</a>
|
|
30
32
|
</div>
|
|
31
33
|
);
|
|
@@ -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.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Post-payment landing page. Stripe/Paddle/YooKassa redirect here after a successful
|
|
2
|
+
// hosted checkout (successPath default: /order/success). Order fulfillment happens
|
|
3
|
+
// asynchronously via the provider webhook — this page is confirmation UX only.
|
|
4
|
+
import type { Metadata } from 'next';
|
|
5
|
+
import { cookies } from 'next/headers';
|
|
6
|
+
import { Slot } from '@vitrine-kit/core/react';
|
|
7
|
+
import { siteName } from '@/lib/site';
|
|
8
|
+
import { t } from '@/lib/i18n/dictionary';
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: 'Order confirmed',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface PageProps {
|
|
15
|
+
searchParams: Promise<{ session_id?: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function OrderSuccessPage({ searchParams }: PageProps) {
|
|
19
|
+
const params = await searchParams;
|
|
20
|
+
const sessionId = params.session_id;
|
|
21
|
+
const locale = (await cookies()).get('vitrine_locale')?.value ?? 'en';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="vt-order-success flex flex-col gap-section">
|
|
25
|
+
<Slot name="order.top" />
|
|
26
|
+
<section aria-labelledby="order-success-heading" className="flex flex-col gap-gutter">
|
|
27
|
+
<h1 id="order-success-heading" className="font-heading text-3xl text-fg">
|
|
28
|
+
{t('thankYou', locale)}
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="max-w-prose text-muted-fg">
|
|
31
|
+
Your payment was received. {siteName} will email a confirmation when the order is
|
|
32
|
+
recorded{sessionId ? ' (this can take a few seconds while the payment webhook runs)' : ''}.
|
|
33
|
+
</p>
|
|
34
|
+
{sessionId ? (
|
|
35
|
+
<p className="text-sm text-muted-fg">
|
|
36
|
+
Reference: <span className="font-mono text-fg">{sessionId}</span>
|
|
37
|
+
</p>
|
|
38
|
+
) : null}
|
|
39
|
+
<a
|
|
40
|
+
href="/"
|
|
41
|
+
className="w-fit 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"
|
|
42
|
+
>
|
|
43
|
+
{t('continueShopping', locale)}
|
|
44
|
+
</a>
|
|
45
|
+
</section>
|
|
46
|
+
<Slot name="order.below" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -8,6 +8,11 @@ import { getCommerceBackend } from '@/lib/adapter';
|
|
|
8
8
|
|
|
9
9
|
const RATE_LIMIT = { limit: 10, windowMs: 60_000 };
|
|
10
10
|
|
|
11
|
+
function publicError(error: unknown): string {
|
|
12
|
+
if (!(error instanceof Error)) return 'Checkout is unavailable — try again later.';
|
|
13
|
+
return error.message.replace(/^\[vitrine\]\s*/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export async function POST(req: Request) {
|
|
12
17
|
const { allowed, retryAfterMs } = checkRateLimit(`checkout:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
13
18
|
if (!allowed) {
|
|
@@ -18,7 +23,13 @@ export async function POST(req: Request) {
|
|
|
18
23
|
}
|
|
19
24
|
const cartId = (await cookies()).get('vitrine_cart')?.value;
|
|
20
25
|
if (!cartId) return NextResponse.json({ error: 'cart is empty' }, { status: 400 });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
try {
|
|
27
|
+
const commerce = await getCommerceBackend();
|
|
28
|
+
const { redirectUrl } = await commerce.startCheckout(cartId);
|
|
29
|
+
return NextResponse.json({ url: redirectUrl });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = publicError(error);
|
|
32
|
+
const status = /is not set|empty/i.test(message) ? 503 : 500;
|
|
33
|
+
return NextResponse.json({ error: message }, { status });
|
|
34
|
+
}
|
|
24
35
|
}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// redirect URL — the component doesn't know which provider is configured (Stripe/Paddle/YooKassa).
|
|
4
4
|
'use client';
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
6
7
|
|
|
7
8
|
export function CheckoutButton() {
|
|
8
9
|
const [pending, setPending] = useState(false);
|
|
9
10
|
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
const checkout = useChromeLabel('checkout');
|
|
10
12
|
|
|
11
|
-
async function
|
|
13
|
+
async function startCheckout(): Promise<void> {
|
|
12
14
|
setPending(true);
|
|
13
15
|
setError(null);
|
|
14
16
|
try {
|
|
@@ -30,11 +32,11 @@ export function CheckoutButton() {
|
|
|
30
32
|
<>
|
|
31
33
|
<button
|
|
32
34
|
type="button"
|
|
33
|
-
onClick={
|
|
35
|
+
onClick={startCheckout}
|
|
34
36
|
disabled={pending}
|
|
35
37
|
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
38
|
>
|
|
37
|
-
{pending ? 'Redirecting to payment…' :
|
|
39
|
+
{pending ? 'Redirecting to payment…' : checkout}
|
|
38
40
|
</button>
|
|
39
41
|
{error ? (
|
|
40
42
|
<p role="alert" className="vt-checkout-error text-danger">
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type NormalizedPaymentEvent,
|
|
13
13
|
type PaymentProviderName,
|
|
14
14
|
} from '@vitrine-kit/core';
|
|
15
|
+
import { notifyOrderConfirmation } from './notify.js';
|
|
15
16
|
|
|
16
17
|
/** Creates an order from the cart on a payment event and marks the cart converted. */
|
|
17
18
|
export async function fulfillOrderFromEvent(
|
|
@@ -50,6 +51,22 @@ export async function fulfillOrderFromEvent(
|
|
|
50
51
|
};
|
|
51
52
|
const order = buildOrderFromCart(cart, { id: cartId, email: event.email });
|
|
52
53
|
|
|
54
|
+
// When the accounts feature is installed, attach the matching customer by checkout email.
|
|
55
|
+
let customerId: string | undefined;
|
|
56
|
+
const email = event.email?.trim().toLowerCase();
|
|
57
|
+
if (email) {
|
|
58
|
+
const customers = await payload
|
|
59
|
+
.find({
|
|
60
|
+
collection: 'customers',
|
|
61
|
+
where: { email: { equals: email } },
|
|
62
|
+
limit: 1,
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
})
|
|
65
|
+
.catch(() => null);
|
|
66
|
+
const doc = customers?.docs[0];
|
|
67
|
+
if (doc) customerId = String(doc.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
await payload.create({
|
|
54
71
|
collection: 'orders',
|
|
55
72
|
data: {
|
|
@@ -62,6 +79,7 @@ export async function fulfillOrderFromEvent(
|
|
|
62
79
|
createdAt: order.createdAt,
|
|
63
80
|
paymentProvider: providerName,
|
|
64
81
|
paymentRef: event.providerRef,
|
|
82
|
+
...(customerId ? { customer: customerId } : {}),
|
|
65
83
|
},
|
|
66
84
|
});
|
|
67
85
|
await payload.update({
|
|
@@ -69,4 +87,8 @@ export async function fulfillOrderFromEvent(
|
|
|
69
87
|
id: cartId,
|
|
70
88
|
data: { status: 'converted', paymentRef: event.providerRef },
|
|
71
89
|
});
|
|
90
|
+
|
|
91
|
+
await notifyOrderConfirmation(order).catch((err) => {
|
|
92
|
+
console.warn('[vitrine] order confirmation notify failed', err);
|
|
93
|
+
});
|
|
72
94
|
}
|