@vitrine-kit/vitrine 0.3.1 → 0.4.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/dist/index.js +102 -83
- package/kit/registry/README.md +9 -0
- package/kit/registry/_index.json +74 -0
- package/kit/registry/cart/docs/cart.md +13 -0
- package/kit/registry/cart/feature.json +51 -0
- package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
- package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
- package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
- package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
- package/kit/registry/cart/files/lib/cart/data.ts +12 -0
- package/kit/registry/cart/files/lib/cart/register.ts +10 -0
- package/kit/registry/catalog/docs/catalog.md +18 -0
- package/kit/registry/catalog/feature.json +43 -0
- package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
- package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
- package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
- package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
- package/kit/registry/checkout/docs/checkout.md +17 -0
- package/kit/registry/checkout/feature.json +47 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +31 -0
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
- package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
- package/kit/registry/checkout-paddle/docs/checkout-paddle.md +22 -0
- package/kit/registry/checkout-paddle/feature.json +62 -0
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +59 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
- package/kit/registry/checkout-stripe/docs/checkout-stripe.md +17 -0
- package/kit/registry/checkout-stripe/feature.json +54 -0
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +73 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
- package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +20 -0
- package/kit/registry/checkout-yookassa/feature.json +52 -0
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +82 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
- package/kit/registry/product-page/docs/product-page.md +16 -0
- package/kit/registry/product-page/feature.json +39 -0
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
- package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
- package/kit/registry/product-page/files/lib/product/data.ts +10 -0
- package/kit/registry/seo/docs/seo.md +14 -0
- package/kit/registry/seo/feature.json +35 -0
- package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
- package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
- package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
- package/kit/registry/stubs/route-glue.d.ts +45 -0
- package/kit/registry/tsconfig.json +21 -0
- package/kit/registry/tsconfig.routes.json +9 -0
- package/kit/templates/README.md +27 -0
- package/kit/templates/backend-payload/files/.dockerignore +8 -0
- package/kit/templates/backend-payload/files/Dockerfile +29 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
- package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
- package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
- package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
- package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
- package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
- package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
- package/kit/templates/backend-payload/files/next.config.mjs +10 -0
- package/kit/templates/backend-payload/files/payload.config.ts +32 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
- package/kit/templates/backend-vendure/files/.dockerignore +8 -0
- package/kit/templates/backend-vendure/files/Dockerfile +24 -0
- package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
- package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
- package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
- package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
- package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
- package/kit/templates/backend-vendure/files/src/index.ts +15 -0
- package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
- package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
- package/kit/templates/base/files/.claude/commands/design.md +14 -0
- package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
- package/kit/templates/base/files/.claude/commands/setup.md +15 -0
- package/kit/templates/base/files/.claude/commands/update.md +16 -0
- package/kit/templates/base/files/AGENTS.md +20 -0
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +28 -0
- package/kit/templates/base/files/app/(frontend)/layout.tsx +32 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +27 -0
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +43 -0
- package/kit/templates/base/files/app/globals.css +6 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +15 -0
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +23 -0
- package/kit/templates/base/files/design/README.md +9 -0
- package/kit/templates/base/files/lib/site.ts +10 -0
- package/kit/templates/base/files/next.config.mjs +7 -0
- package/kit/templates/base/files/postcss.config.mjs +7 -0
- package/kit/templates/base/files/public/.gitkeep +0 -0
- package/kit/templates/base/files/tailwind.config.ts +13 -0
- package/kit/templates/base/files/tsconfig.json +25 -0
- package/kit/templates/tsconfig.json +18 -0
- package/package.json +5 -4
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Cart API: POST add, PATCH change quantity, DELETE remove a line.
|
|
2
|
+
// Cart id in an httpOnly cookie. Mutations are delegated to CommerceBackend (lib/adapter),
|
|
3
|
+
// the arithmetic lives in @vitrine-kit/core. Next glue, not typechecked in the monorepo.
|
|
4
|
+
import { cookies } from 'next/headers';
|
|
5
|
+
import { NextResponse } from 'next/server';
|
|
6
|
+
import type { CommerceBackend } from '@vitrine-kit/contracts';
|
|
7
|
+
import { getCommerceBackend } from '@/lib/adapter';
|
|
8
|
+
|
|
9
|
+
const COOKIE = 'vitrine_cart';
|
|
10
|
+
|
|
11
|
+
async function ensureCartId(commerce: CommerceBackend): Promise<string> {
|
|
12
|
+
const jar = await cookies();
|
|
13
|
+
const existing = jar.get(COOKIE)?.value;
|
|
14
|
+
if (existing) return existing;
|
|
15
|
+
const cart = await commerce.createCart();
|
|
16
|
+
jar.set(COOKIE, cart.id, { httpOnly: true, sameSite: 'lax', path: '/' });
|
|
17
|
+
return cart.id;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function POST(req: Request) {
|
|
21
|
+
const { variantId, quantity = 1 } = (await req.json()) as { variantId: string; quantity?: number };
|
|
22
|
+
const commerce = await getCommerceBackend();
|
|
23
|
+
const id = await ensureCartId(commerce);
|
|
24
|
+
return NextResponse.json(await commerce.addItem(id, variantId, quantity));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function PATCH(req: Request) {
|
|
28
|
+
const { lineId, quantity } = (await req.json()) as { lineId: string; quantity: number };
|
|
29
|
+
const id = (await cookies()).get(COOKIE)?.value;
|
|
30
|
+
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
31
|
+
const commerce = await getCommerceBackend();
|
|
32
|
+
return NextResponse.json(await commerce.updateItem(id, lineId, quantity));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function DELETE(req: Request) {
|
|
36
|
+
const { lineId } = (await req.json()) as { lineId: string };
|
|
37
|
+
const id = (await cookies()).get(COOKIE)?.value;
|
|
38
|
+
if (!id) return NextResponse.json({ error: 'cart not found' }, { status: 400 });
|
|
39
|
+
const commerce = await getCommerceBackend();
|
|
40
|
+
return NextResponse.json(await commerce.removeItem(id, lineId));
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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).
|
|
3
|
+
'use client';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
6
|
+
|
|
7
|
+
export interface AddToCartProps {
|
|
8
|
+
product: Product;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function AddToCart({ product }: AddToCartProps) {
|
|
12
|
+
const [pending, setPending] = useState(false);
|
|
13
|
+
const variantId = product.variants[0]?.id;
|
|
14
|
+
|
|
15
|
+
async function add(): Promise<void> {
|
|
16
|
+
if (!variantId) return;
|
|
17
|
+
setPending(true);
|
|
18
|
+
try {
|
|
19
|
+
await fetch('/api/cart', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ variantId, quantity: 1 }),
|
|
23
|
+
});
|
|
24
|
+
location.assign('/cart');
|
|
25
|
+
} finally {
|
|
26
|
+
setPending(false);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
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>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Header cart link (global.header-actions slot). Presentational.
|
|
2
|
+
export function CartIndicator() {
|
|
3
|
+
return (
|
|
4
|
+
<a
|
|
5
|
+
href="/cart"
|
|
6
|
+
className="vt-cart-indicator text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
7
|
+
>
|
|
8
|
+
Cart
|
|
9
|
+
</a>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Cart line item — presentational.
|
|
2
|
+
import type { CartLine } from '@vitrine-kit/contracts';
|
|
3
|
+
import { formatMoney } from '../../lib/cart/data.js';
|
|
4
|
+
|
|
5
|
+
export interface CartLineItemProps {
|
|
6
|
+
line: CartLine;
|
|
7
|
+
currency: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CartLineItem({ line, currency }: CartLineItemProps) {
|
|
11
|
+
return (
|
|
12
|
+
<li className="vt-cart-line flex items-center gap-gutter border-b border-border py-unit">
|
|
13
|
+
{line.image ? (
|
|
14
|
+
<img src={line.image} alt="" className="h-16 w-16 rounded-md object-cover" />
|
|
15
|
+
) : (
|
|
16
|
+
<div className="h-16 w-16 rounded-md bg-muted" aria-hidden="true" />
|
|
17
|
+
)}
|
|
18
|
+
<div className="flex-1">
|
|
19
|
+
<p className="text-fg">{line.title}</p>
|
|
20
|
+
<p className="text-muted-fg">
|
|
21
|
+
{line.quantity} × {formatMoney(line.unitPrice, currency)}
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
<p className="text-price">{formatMoney(line.lineTotal, currency)}</p>
|
|
25
|
+
</li>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Cart totals. Hosts the cart.summary slot (where checkout-stripe mounts the checkout
|
|
2
|
+
// button) and passes it cartId.
|
|
3
|
+
import type { Cart } from '@vitrine-kit/contracts';
|
|
4
|
+
import { Slot } from '@vitrine-kit/core/react';
|
|
5
|
+
import { formatMoney } from '../../lib/cart/data.js';
|
|
6
|
+
|
|
7
|
+
export interface CartSummaryProps {
|
|
8
|
+
cart: Cart;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CartSummary({ cart }: CartSummaryProps) {
|
|
12
|
+
return (
|
|
13
|
+
<aside className="vt-cart-summary flex flex-col gap-unit rounded-md border border-border p-gutter">
|
|
14
|
+
<div className="flex justify-between text-muted-fg">
|
|
15
|
+
<span>Subtotal</span>
|
|
16
|
+
<span>{formatMoney(cart.subtotal, cart.currency)}</span>
|
|
17
|
+
</div>
|
|
18
|
+
{cart.discountTotal ? (
|
|
19
|
+
<div className="flex justify-between text-muted-fg">
|
|
20
|
+
<span>Discount</span>
|
|
21
|
+
<span>−{formatMoney(cart.discountTotal, cart.currency)}</span>
|
|
22
|
+
</div>
|
|
23
|
+
) : null}
|
|
24
|
+
<div className="flex justify-between text-fg">
|
|
25
|
+
<span>Total</span>
|
|
26
|
+
<span className="text-price">{formatMoney(cart.total, cart.currency)}</span>
|
|
27
|
+
</div>
|
|
28
|
+
<Slot name="cart.summary" cartId={cart.id} />
|
|
29
|
+
</aside>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Cart contents — presentational. Hosts the cart.items-bottom / cart.below slots.
|
|
2
|
+
import type { Cart } from '@vitrine-kit/contracts';
|
|
3
|
+
import { Slot } from '@vitrine-kit/core/react';
|
|
4
|
+
import { CartLineItem } from './CartLineItem.js';
|
|
5
|
+
import { CartSummary } from './CartSummary.js';
|
|
6
|
+
|
|
7
|
+
export interface CartViewProps {
|
|
8
|
+
cart: Cart;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CartView({ cart }: CartViewProps) {
|
|
12
|
+
if (cart.lines.length === 0) {
|
|
13
|
+
return <p className="text-muted-fg">Your cart is empty.</p>;
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<div className="vt-cart grid gap-section md:grid-cols-[2fr_1fr]">
|
|
17
|
+
<div>
|
|
18
|
+
<ul role="list">
|
|
19
|
+
{cart.lines.map((line) => (
|
|
20
|
+
<CartLineItem key={line.id} line={line} currency={cart.currency} />
|
|
21
|
+
))}
|
|
22
|
+
</ul>
|
|
23
|
+
<Slot name="cart.items-bottom" />
|
|
24
|
+
</div>
|
|
25
|
+
<div className="flex flex-col gap-gutter">
|
|
26
|
+
<CartSummary cart={cart} />
|
|
27
|
+
<Slot name="cart.below" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Cart access via the CommerceBackend contract + price formatting.
|
|
2
|
+
// The cart arithmetic lives in @vitrine-kit/core (critical logic); a ready Cart arrives here.
|
|
3
|
+
import type { Cart, CommerceBackend } from '@vitrine-kit/contracts';
|
|
4
|
+
|
|
5
|
+
export async function loadCart(commerce: CommerceBackend, cartId: string): Promise<Cart | null> {
|
|
6
|
+
return commerce.getCart(cartId);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Money — minor units (e.g. cents); divide by 100 for 2-decimal currencies. */
|
|
10
|
+
export function formatMoney(amount: number, currency: string, locale = 'en-US'): string {
|
|
11
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Registers the cart feature's slots. The client calls this from lib/slots.ts.
|
|
2
|
+
// AddToCart — on the product page (product.purchase), CartIndicator — in the header.
|
|
3
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
4
|
+
import { AddToCart } from '../../components/cart/AddToCart.js';
|
|
5
|
+
import { CartIndicator } from '../../components/cart/CartIndicator.js';
|
|
6
|
+
|
|
7
|
+
export function registerCartSlots(): void {
|
|
8
|
+
registerSlot({ slot: 'product.purchase', component: AddToCart, order: 10 });
|
|
9
|
+
registerSlot({ slot: 'global.header-actions', component: CartIndicator, order: 20 });
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Feature: catalog
|
|
2
|
+
|
|
3
|
+
Browsing the assortment: product grid, product card, category navigation.
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
- `components/catalog/ProductGrid` — grid (semantic list) of `ProductCard`s.
|
|
7
|
+
- `components/catalog/ProductCard` — card (image, title, price).
|
|
8
|
+
- `components/catalog/CategoryNav` — navigation; mounted into the `global.header-nav` slot.
|
|
9
|
+
|
|
10
|
+
## Data
|
|
11
|
+
- `lib/catalog/data` — `loadProducts`/`loadProduct`/`loadCategories` over the `CatalogSource` contract + `formatPrice` (price in minor units).
|
|
12
|
+
- `lib/catalog/register` — registers the `global.header-nav` slot.
|
|
13
|
+
|
|
14
|
+
## Styling
|
|
15
|
+
All classes are token-based (`bg-surface`, `text-fg`, `text-price`, `rounded-md`, `gap-gutter`). The design step changes only token values, not structure/a11y.
|
|
16
|
+
|
|
17
|
+
## Contracts
|
|
18
|
+
Depends only on `@vitrine-kit/contracts` (types, `CatalogSource`) and `@vitrine-kit/core` (`registerSlot`). Portable between Payload and Vendure.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "catalog",
|
|
4
|
+
"title": "Product catalog",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"catalog",
|
|
9
|
+
"simple-store",
|
|
10
|
+
"full-store"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [],
|
|
13
|
+
"corePackages": {
|
|
14
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
15
|
+
},
|
|
16
|
+
"npm": [],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"from": "files/components/catalog/",
|
|
20
|
+
"to": "components/catalog/"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"from": "files/lib/catalog/",
|
|
24
|
+
"to": "lib/catalog/"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"config": {
|
|
28
|
+
"set": {
|
|
29
|
+
"features.catalog": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"slots": [
|
|
33
|
+
{
|
|
34
|
+
"slot": "global.header-nav",
|
|
35
|
+
"component": "CategoryNav",
|
|
36
|
+
"order": 10
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"env": [],
|
|
40
|
+
"claudeDoc": "docs/catalog.md",
|
|
41
|
+
"conflicts": [],
|
|
42
|
+
"removable": false
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Category navigation — presentational. Data comes as a prop from the slot host
|
|
2
|
+
// (<Slot name="global.header-nav" categories={…} />).
|
|
3
|
+
import type { Category } from '@vitrine-kit/contracts';
|
|
4
|
+
|
|
5
|
+
export interface CategoryNavProps {
|
|
6
|
+
categories: Category[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function CategoryNav({ categories }: CategoryNavProps) {
|
|
10
|
+
return (
|
|
11
|
+
<nav aria-label="Categories" className="vt-category-nav">
|
|
12
|
+
<ul role="list" className="flex flex-wrap gap-gutter">
|
|
13
|
+
{categories.map((category) => (
|
|
14
|
+
<li key={category.id}>
|
|
15
|
+
<a
|
|
16
|
+
href={`/categories/${category.slug}`}
|
|
17
|
+
className="text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
18
|
+
>
|
|
19
|
+
{category.title}
|
|
20
|
+
</a>
|
|
21
|
+
</li>
|
|
22
|
+
))}
|
|
23
|
+
</ul>
|
|
24
|
+
</nav>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Product card — presentational, visually neutral (classes → tokens).
|
|
2
|
+
// The design step doesn't touch structure/a11y, only tokens.
|
|
3
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
4
|
+
import { formatPrice } from '../../lib/catalog/data.js';
|
|
5
|
+
|
|
6
|
+
export interface ProductCardProps {
|
|
7
|
+
product: Product;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ProductCard({ product }: ProductCardProps) {
|
|
11
|
+
const price = product.priceRange?.min ?? product.variants[0]?.price;
|
|
12
|
+
const currency = product.priceRange?.currency ?? product.variants[0]?.currency ?? 'USD';
|
|
13
|
+
const image = product.images[0];
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<article className="vt-product-card rounded-md border border-border bg-surface text-surface-fg">
|
|
17
|
+
<a
|
|
18
|
+
href={`/products/${product.slug}`}
|
|
19
|
+
className="block focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
20
|
+
>
|
|
21
|
+
{image ? (
|
|
22
|
+
<img
|
|
23
|
+
src={image.url}
|
|
24
|
+
alt={image.alt ?? product.title}
|
|
25
|
+
width={image.width}
|
|
26
|
+
height={image.height}
|
|
27
|
+
loading="lazy"
|
|
28
|
+
className="aspect-square w-full rounded-t-md object-cover"
|
|
29
|
+
/>
|
|
30
|
+
) : (
|
|
31
|
+
<div className="aspect-square w-full rounded-t-md bg-muted" aria-hidden="true" />
|
|
32
|
+
)}
|
|
33
|
+
<div className="p-gutter">
|
|
34
|
+
<h3 className="font-heading text-fg">{product.title}</h3>
|
|
35
|
+
{price != null ? (
|
|
36
|
+
<p className="text-price">{formatPrice(price, currency)}</p>
|
|
37
|
+
) : null}
|
|
38
|
+
</div>
|
|
39
|
+
</a>
|
|
40
|
+
</article>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Product grid — presentational, semantic list.
|
|
2
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
3
|
+
import { ProductCard } from './ProductCard.js';
|
|
4
|
+
|
|
5
|
+
export interface ProductGridProps {
|
|
6
|
+
products: Product[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ProductGrid({ products }: ProductGridProps) {
|
|
10
|
+
if (products.length === 0) {
|
|
11
|
+
return <p className="text-muted-fg">No products found.</p>;
|
|
12
|
+
}
|
|
13
|
+
return (
|
|
14
|
+
<ul
|
|
15
|
+
role="list"
|
|
16
|
+
className="vt-product-grid grid grid-cols-2 gap-gutter md:grid-cols-3 lg:grid-cols-4"
|
|
17
|
+
>
|
|
18
|
+
{products.map((product) => (
|
|
19
|
+
<li key={product.id}>
|
|
20
|
+
<ProductCard product={product} />
|
|
21
|
+
</li>
|
|
22
|
+
))}
|
|
23
|
+
</ul>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Catalog access via the CatalogSource contract. The source (Payload*/Vendure*)
|
|
2
|
+
// is resolved in the page data (template); a ready CatalogSource arrives here —
|
|
3
|
+
// so the feature depends only on the contract and is portable across backends.
|
|
4
|
+
import type { CatalogSource, Category, Product, ProductQuery } from '@vitrine-kit/contracts';
|
|
5
|
+
|
|
6
|
+
export async function loadProducts(
|
|
7
|
+
source: CatalogSource,
|
|
8
|
+
query: ProductQuery = {},
|
|
9
|
+
): Promise<Product[]> {
|
|
10
|
+
return source.listProducts(query);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function loadProduct(source: CatalogSource, slug: string): Promise<Product | null> {
|
|
14
|
+
return source.getProduct(slug);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadCategories(source: CatalogSource): Promise<Category[]> {
|
|
18
|
+
return source.listCategories();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Money — minor units (e.g. cents); divide by 100 for 2-decimal currencies. */
|
|
22
|
+
export function formatPrice(amount: number, currency: string, locale = 'en-US'): string {
|
|
23
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
|
|
24
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Registers the catalog feature's slots. The client calls this from lib/slots.ts.
|
|
2
|
+
// CategoryNav receives categories from the slot host via <Slot> props.
|
|
3
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
4
|
+
import { CategoryNav } from '../../components/catalog/CategoryNav.js';
|
|
5
|
+
|
|
6
|
+
export function registerCatalogSlots(): void {
|
|
7
|
+
registerSlot({ slot: 'global.header-nav', component: CategoryNav, order: 10 });
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Feature: checkout
|
|
2
|
+
|
|
3
|
+
A provider-agnostic checkout scaffold for `simple-store` / `full-store`. Depends on the
|
|
4
|
+
`cart` feature. It does NOT process payments on its own — add one of the provider features:
|
|
5
|
+
`checkout-stripe`, `checkout-paddle`, or `checkout-yookassa` (exactly one).
|
|
6
|
+
|
|
7
|
+
- **Component:** `CheckoutButton` (client component) — `cart.summary` slot. Calls
|
|
8
|
+
`POST /api/checkout` and redirects to the provider URL. It knows nothing about the provider.
|
|
9
|
+
- **API (Next glue):** `POST /api/checkout` — `CommerceBackend.startCheckout`
|
|
10
|
+
delegates to the active provider (`payments.resolve` by `integrations.payments`).
|
|
11
|
+
- **Shared webhook code:** `lib/checkout/fulfill.ts` → `fulfillOrderFromEvent` —
|
|
12
|
+
idempotent order creation from a normalized event (used by all provider webhook
|
|
13
|
+
routes). The critical logic (dedup by `paymentRef`, order snapshot) lives in
|
|
14
|
+
`@vitrine-kit/core` (`shouldCreateOrder`, `buildOrderFromCart`).
|
|
15
|
+
|
|
16
|
+
Flow: cart → `Checkout` → `POST /api/checkout` → redirect to the provider →
|
|
17
|
+
provider webhook → `fulfillOrderFromEvent` → order in the admin, cart `converted`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "checkout",
|
|
4
|
+
"title": "Checkout",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"simple-store",
|
|
9
|
+
"full-store"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"cart"
|
|
13
|
+
],
|
|
14
|
+
"corePackages": {
|
|
15
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"npm": [],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"from": "files/components/checkout/",
|
|
21
|
+
"to": "components/checkout/"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"from": "files/lib/checkout/",
|
|
25
|
+
"to": "lib/checkout/"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"from": "files/app/",
|
|
29
|
+
"to": "app/"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"config": {
|
|
33
|
+
"set": {
|
|
34
|
+
"features.checkout": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"slots": [
|
|
38
|
+
{
|
|
39
|
+
"slot": "cart.summary",
|
|
40
|
+
"component": "CheckoutButton",
|
|
41
|
+
"order": 10
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"claudeDoc": "docs/checkout.md",
|
|
45
|
+
"conflicts": [],
|
|
46
|
+
"removable": true
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// POST /api/checkout — creates a checkout session with the active payment provider for
|
|
2
|
+
// the current cart (cookie) via CommerceBackend.startCheckout and returns the
|
|
3
|
+
// redirect URL. Provider-agnostic: startCheckout delegates to payments.resolve. Next glue.
|
|
4
|
+
import { cookies } from 'next/headers';
|
|
5
|
+
import { NextResponse } from 'next/server';
|
|
6
|
+
import { getCommerceBackend } from '@/lib/adapter';
|
|
7
|
+
|
|
8
|
+
export async function POST() {
|
|
9
|
+
const cartId = (await cookies()).get('vitrine_cart')?.value;
|
|
10
|
+
if (!cartId) return NextResponse.json({ error: 'cart is empty' }, { status: 400 });
|
|
11
|
+
const commerce = await getCommerceBackend();
|
|
12
|
+
const { redirectUrl } = await commerce.startCheckout(cartId);
|
|
13
|
+
return NextResponse.json({ url: redirectUrl });
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Checkout button — client component. Mounted into the cart.summary slot.
|
|
2
|
+
// POST /api/checkout creates a payment session with the active provider and returns the
|
|
3
|
+
// redirect URL — the component doesn't know which provider is configured (Stripe/Paddle/YooKassa).
|
|
4
|
+
'use client';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
export function CheckoutButton() {
|
|
8
|
+
const [pending, setPending] = useState(false);
|
|
9
|
+
|
|
10
|
+
async function checkout(): Promise<void> {
|
|
11
|
+
setPending(true);
|
|
12
|
+
try {
|
|
13
|
+
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
|
+
} finally {
|
|
17
|
+
setPending(false);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Idempotent order creation from a normalized payment event — shared code
|
|
2
|
+
// for all webhook routes (checkout-stripe/paddle/yookassa). The provider has already
|
|
3
|
+
// verified and normalized the event; here it's only Payload persistence.
|
|
4
|
+
// The critical logic (dedup, order snapshot) lives in @vitrine-kit/core (shouldCreateOrder,
|
|
5
|
+
// buildOrderFromCart). Next/Payload glue, not typechecked in the monorepo.
|
|
6
|
+
import { getPayload } from 'payload';
|
|
7
|
+
import config from '@payload-config';
|
|
8
|
+
import type { Cart } from '@vitrine-kit/contracts';
|
|
9
|
+
import {
|
|
10
|
+
buildOrderFromCart,
|
|
11
|
+
shouldCreateOrder,
|
|
12
|
+
type NormalizedPaymentEvent,
|
|
13
|
+
type PaymentProviderName,
|
|
14
|
+
} from '@vitrine-kit/core';
|
|
15
|
+
|
|
16
|
+
/** Creates an order from the cart on a payment event and marks the cart converted. */
|
|
17
|
+
export async function fulfillOrderFromEvent(
|
|
18
|
+
event: NormalizedPaymentEvent,
|
|
19
|
+
providerName: PaymentProviderName,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
if (event.kind !== 'checkout_completed') return;
|
|
22
|
+
const cartId = event.cartId;
|
|
23
|
+
if (!cartId) return;
|
|
24
|
+
|
|
25
|
+
const payload = await getPayload({ config });
|
|
26
|
+
const cartDoc = await payload.findByID({ collection: 'carts', id: cartId }).catch(() => null);
|
|
27
|
+
if (!cartDoc) return;
|
|
28
|
+
|
|
29
|
+
// Idempotency: the provider retries the webhook — don't create the order twice
|
|
30
|
+
// (the cart is already converted, or an order with this payment reference already exists).
|
|
31
|
+
const prior = event.providerRef
|
|
32
|
+
? await payload
|
|
33
|
+
.find({ collection: 'orders', where: { paymentRef: { equals: event.providerRef } }, limit: 1 })
|
|
34
|
+
.catch(() => ({ docs: [] as Array<{ paymentRef?: string }> }))
|
|
35
|
+
: { docs: [] as Array<{ paymentRef?: string }> };
|
|
36
|
+
const consumable = shouldCreateOrder({
|
|
37
|
+
cartStatus: (cartDoc as { status?: string }).status,
|
|
38
|
+
providerRef: event.providerRef,
|
|
39
|
+
existingOrderRefs: (prior.docs as Array<{ paymentRef?: string }>).map((d) => d.paymentRef),
|
|
40
|
+
});
|
|
41
|
+
if (!consumable) return;
|
|
42
|
+
|
|
43
|
+
const cart: Cart = {
|
|
44
|
+
id: String(cartDoc.id),
|
|
45
|
+
lines: (cartDoc.lines as Cart['lines']) ?? [],
|
|
46
|
+
currency: (cartDoc.currency as string) ?? 'USD',
|
|
47
|
+
subtotal: (cartDoc.subtotal as number) ?? 0,
|
|
48
|
+
discountTotal: cartDoc.discountTotal as number | undefined,
|
|
49
|
+
total: (cartDoc.total as number) ?? 0,
|
|
50
|
+
};
|
|
51
|
+
const order = buildOrderFromCart(cart, { id: cartId, email: event.email });
|
|
52
|
+
|
|
53
|
+
await payload.create({
|
|
54
|
+
collection: 'orders',
|
|
55
|
+
data: {
|
|
56
|
+
status: order.status,
|
|
57
|
+
email: order.email,
|
|
58
|
+
currency: order.currency,
|
|
59
|
+
subtotal: order.subtotal,
|
|
60
|
+
total: order.total,
|
|
61
|
+
lines: order.lines,
|
|
62
|
+
createdAt: order.createdAt,
|
|
63
|
+
paymentProvider: providerName,
|
|
64
|
+
paymentRef: event.providerRef,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
await payload.update({
|
|
68
|
+
collection: 'carts',
|
|
69
|
+
id: cartId,
|
|
70
|
+
data: { status: 'converted', paymentRef: event.providerRef },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Registers the checkout feature's slots. The checkout button goes in the cart totals.
|
|
2
|
+
// The payment provider is registered separately — in the checkout-<provider> feature.
|
|
3
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
4
|
+
import { CheckoutButton } from '../../components/checkout/CheckoutButton.js';
|
|
5
|
+
|
|
6
|
+
export function registerCheckoutSlots(): void {
|
|
7
|
+
registerSlot({ slot: 'cart.summary', component: CheckoutButton, order: 10 });
|
|
8
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Feature: checkout-paddle (Paddle payment provider)
|
|
2
|
+
|
|
3
|
+
A Paddle Billing provider for the `checkout` scaffold (which it depends on). Installed instead of
|
|
4
|
+
`checkout-stripe` / `checkout-yookassa` (mutually exclusive). Paddle is a Merchant of
|
|
5
|
+
Record: it handles taxes/VAT. The critical logic (webhook dispatcher, order from cart) lives
|
|
6
|
+
in `@vitrine-kit/core`.
|
|
7
|
+
|
|
8
|
+
- **Provider:** `lib/checkout-paddle/provider.ts` → `paddleProvider`
|
|
9
|
+
(`PaymentProvider`): `createCheckout` creates a Paddle transaction with non-catalog
|
|
10
|
+
line items (prices from the cart, `customData.cartId`); `verifyWebhook` verifies the
|
|
11
|
+
`Paddle-Signature` via `@paddle/paddle-node-sdk`.
|
|
12
|
+
- **Registration:** `registerCheckoutPaddleProvider()` (from `lib/payments.ts`), sets
|
|
13
|
+
`integrations.payments: "paddle"`.
|
|
14
|
+
- **API (Next glue):** `POST /api/webhooks/paddle` → `handlePaymentWebhook` →
|
|
15
|
+
`fulfillOrderFromEvent` (on `transaction.completed`/`transaction.paid`).
|
|
16
|
+
- **env:** `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET` (required);
|
|
17
|
+
`PADDLE_ENVIRONMENT` (`sandbox`|`production`, default `sandbox`),
|
|
18
|
+
`PADDLE_CHECKOUT_URL` (override the hosted checkout, otherwise a default payment link
|
|
19
|
+
in the Paddle dashboard is required) — optional.
|
|
20
|
+
|
|
21
|
+
Paddle expects amounts as a string in the currency's minor units — this matches `Money`,
|
|
22
|
+
so we pass `String(unitPrice)` as-is.
|