commerceforai 0.0.0-stage → 0.1.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 +39 -2
- package/dist/cart-core.d.ts +90 -0
- package/dist/cart-core.js +122 -0
- package/dist/checkout-handler.d.ts +26 -0
- package/dist/checkout-handler.js +63 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +216 -0
- package/dist/index.d.ts +761 -0
- package/dist/index.js +526 -0
- package/dist/react.d.ts +85 -0
- package/dist/react.js +162 -0
- package/dist/types.d.ts +530 -0
- package/dist/types.js +1 -0
- package/package.json +54 -3
- package/templates/nextjs/app/shop/[id]/page.tsx +44 -0
- package/templates/nextjs/app/shop/actions.ts +67 -0
- package/templates/nextjs/app/shop/cart/page.tsx +51 -0
- package/templates/nextjs/app/shop/cart-cookie.ts +22 -0
- package/templates/nextjs/app/shop/layout.tsx +25 -0
- package/templates/nextjs/app/shop/orders/lookup-form.tsx +32 -0
- package/templates/nextjs/app/shop/orders/page.tsx +13 -0
- package/templates/nextjs/app/shop/page.tsx +31 -0
- package/templates/nextjs/app/shop/shop.css +31 -0
- package/templates/nextjs/app/shop/thanks/clear-cart.tsx +10 -0
- package/templates/nextjs/app/shop/thanks/page.tsx +47 -0
- package/templates/nextjs/lib/commerce.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commerceforai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Commerce for apps built by AI. Products, inventory, checkout and orders on top of your Stripe account.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"types": "./dist/react.d.ts",
|
|
15
|
+
"default": "./dist/react.js"
|
|
16
|
+
},
|
|
17
|
+
"./cart": {
|
|
18
|
+
"types": "./dist/cart-core.d.ts",
|
|
19
|
+
"default": "./dist/cart-core.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"templates"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"bin": {
|
|
28
|
+
"commerceforai": "./dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"react": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "^19.3.0",
|
|
40
|
+
"react": "19.3.0",
|
|
41
|
+
"vitest": "^5.0.5"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://commerceforai.com",
|
|
44
|
+
"keywords": [
|
|
45
|
+
"commerce",
|
|
46
|
+
"ecommerce",
|
|
47
|
+
"stripe",
|
|
48
|
+
"checkout",
|
|
49
|
+
"ai",
|
|
50
|
+
"agents"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -p tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
6
57
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { commerce, money } from "../../../lib/commerce";
|
|
4
|
+
import { addToCart } from "../actions";
|
|
5
|
+
|
|
6
|
+
export default function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
|
7
|
+
return <Suspense fallback={<p className="cfa-muted">Loading…</p>}><Product params={params} /></Suspense>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function Product({ params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const { id } = await params;
|
|
12
|
+
const { data: p } = await commerce.products.get(id);
|
|
13
|
+
if (!p || !p.active) notFound();
|
|
14
|
+
const variants = p.variants.filter((v) => v.active);
|
|
15
|
+
const buyable = variants.filter((v) => v.inventory.available === null || v.inventory.available > 0);
|
|
16
|
+
return (
|
|
17
|
+
<div className="cfa-detail">
|
|
18
|
+
{p.images[0] ? <img src={p.images[0]} alt={p.name} className="cfa-image" /> : <div className="cfa-placeholder" />}
|
|
19
|
+
<div>
|
|
20
|
+
<h1 className="cfa-h1">{p.name}</h1>
|
|
21
|
+
<p className="cfa-price">{money(variants[0]?.price ?? 0, variants[0]?.currency)}</p>
|
|
22
|
+
{p.description ? <p className="cfa-muted">{p.description}</p> : null}
|
|
23
|
+
<form action={addToCart}>
|
|
24
|
+
{variants.length > 1 ? (
|
|
25
|
+
<label className="cfa-field">
|
|
26
|
+
Option
|
|
27
|
+
<select name="variant_id" defaultValue={buyable[0]?.id}>
|
|
28
|
+
{variants.map((v) => {
|
|
29
|
+
const soldOut = v.inventory.available !== null && v.inventory.available <= 0;
|
|
30
|
+
return <option key={v.id} value={v.id} disabled={soldOut}>{v.name} · {money(v.price, v.currency)}{soldOut ? " (sold out)" : ""}</option>;
|
|
31
|
+
})}
|
|
32
|
+
</select>
|
|
33
|
+
</label>
|
|
34
|
+
) : <input type="hidden" name="variant_id" value={variants[0]?.id ?? ""} />}
|
|
35
|
+
<label className="cfa-field">
|
|
36
|
+
Quantity
|
|
37
|
+
<input type="number" name="quantity" min={1} max={99} defaultValue={1} />
|
|
38
|
+
</label>
|
|
39
|
+
<button className="cfa-button" disabled={!buyable.length}>{buyable.length ? "Add to cart" : "Sold out"}</button>
|
|
40
|
+
</form>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { cookies, headers } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { revalidatePath } from "next/cache";
|
|
6
|
+
import { commerce } from "../../lib/commerce";
|
|
7
|
+
import { CHECKOUT_COOKIE, readCart, writeCart } from "./cart-cookie";
|
|
8
|
+
|
|
9
|
+
export async function addToCart(form: FormData) {
|
|
10
|
+
const variant_id = String(form.get("variant_id") ?? "");
|
|
11
|
+
const quantity = Math.max(1, Math.min(99, Number(form.get("quantity") ?? 1) || 1));
|
|
12
|
+
if (!variant_id) return;
|
|
13
|
+
const cart = await readCart();
|
|
14
|
+
const line = cart.find((l) => l.variant_id === variant_id);
|
|
15
|
+
if (line) line.quantity = Math.min(99, line.quantity + quantity);
|
|
16
|
+
else cart.push({ variant_id, quantity });
|
|
17
|
+
await writeCart(cart);
|
|
18
|
+
redirect("/shop/cart");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function setQuantity(form: FormData) {
|
|
22
|
+
const variant_id = String(form.get("variant_id") ?? "");
|
|
23
|
+
const quantity = Math.max(0, Math.min(99, Number(form.get("quantity") ?? 0) || 0));
|
|
24
|
+
await writeCart((await readCart()).map((l) => (l.variant_id === variant_id ? { ...l, quantity } : l)));
|
|
25
|
+
revalidatePath("/shop", "layout");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function clearCart() {
|
|
29
|
+
await writeCart([]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reserve stock and send the customer to Stripe Checkout. */
|
|
33
|
+
export async function checkout() {
|
|
34
|
+
const cart = await readCart();
|
|
35
|
+
if (!cart.length) redirect("/shop/cart");
|
|
36
|
+
const h = await headers();
|
|
37
|
+
const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("x-forwarded-host") ?? h.get("host")}`;
|
|
38
|
+
const { data, error } = await commerce.checkouts.create({
|
|
39
|
+
items: cart,
|
|
40
|
+
success_url: `${origin}/shop/thanks`,
|
|
41
|
+
cancel_url: `${origin}/shop/cart`,
|
|
42
|
+
});
|
|
43
|
+
if (error || !data?.url) redirect(`/shop/cart?error=${encodeURIComponent(error?.message ?? "Checkout isn't available right now")}`);
|
|
44
|
+
(await cookies()).set(CHECKOUT_COOKIE, data.id, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 });
|
|
45
|
+
redirect(data.url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LookupState = { error?: string; order?: { number: number; status: string; total: number; currency: string; items: { name: string; quantity: number }[]; tracking: { carrier: string | null; number: string | null; url: string | null }[] } } | null;
|
|
49
|
+
|
|
50
|
+
/** Order lookup needs both the order number and the email it was placed with. */
|
|
51
|
+
export async function lookupOrder(_prev: LookupState, form: FormData): Promise<LookupState> {
|
|
52
|
+
const number = String(form.get("number") ?? "").replace(/^#/, "").trim();
|
|
53
|
+
const email = String(form.get("email") ?? "").trim().toLowerCase();
|
|
54
|
+
if (!/^\d+$/.test(number) || !email) return { error: "Enter your order number and email." };
|
|
55
|
+
const { data } = await commerce.orders.get(Number(number));
|
|
56
|
+
if (!data || (data.email ?? "").toLowerCase() !== email) return { error: "We couldn't find that order. Check the number and email." };
|
|
57
|
+
const shipments = data.fulfillment?.shipments?.length
|
|
58
|
+
? data.fulfillment.shipments.map((s) => ({ carrier: s.carrier, number: s.tracking_number, url: s.tracking_url }))
|
|
59
|
+
: data.fulfillment ? [{ carrier: data.fulfillment.carrier, number: data.fulfillment.tracking_number, url: data.fulfillment.tracking_url }] : [];
|
|
60
|
+
return {
|
|
61
|
+
order: {
|
|
62
|
+
number: data.number, status: data.status.replace("_", " "), total: data.total, currency: data.currency,
|
|
63
|
+
items: data.items.map((i) => ({ name: i.variant_name === "Default" ? i.product_name : `${i.product_name} (${i.variant_name})`, quantity: i.quantity })),
|
|
64
|
+
tracking: shipments.filter((s) => s.number),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { commerce, money } from "../../../lib/commerce";
|
|
4
|
+
import { checkout, setQuantity } from "../actions";
|
|
5
|
+
import { readCart } from "../cart-cookie";
|
|
6
|
+
|
|
7
|
+
export const metadata = { title: "Cart" };
|
|
8
|
+
|
|
9
|
+
export default function CartPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
|
10
|
+
return <Suspense fallback={<p className="cfa-muted">Loading…</p>}><Cart searchParams={searchParams} /></Suspense>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function Cart({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
|
14
|
+
const [{ error }, cart] = await Promise.all([searchParams, readCart()]);
|
|
15
|
+
if (!cart.length) return <div className="cfa-empty"><p>Your cart is empty.</p><Link href="/shop" className="cfa-button">Keep shopping</Link></div>;
|
|
16
|
+
|
|
17
|
+
// Look up current names and prices (the cart cookie only holds variant ids and quantities).
|
|
18
|
+
const { data } = await commerce.products.list({ limit: 100 });
|
|
19
|
+
const variants = new Map((data?.data ?? []).flatMap((p) => p.variants.map((v) => [v.id, { ...v, product: p.name }] as const)));
|
|
20
|
+
const lines = cart.map((l) => ({ ...l, v: variants.get(l.variant_id) })).filter((l) => l.v);
|
|
21
|
+
const currency = lines[0]?.v?.currency ?? "usd";
|
|
22
|
+
const subtotal = lines.reduce((sum, l) => sum + l.v!.price * l.quantity, 0);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<h1 className="cfa-h1">Cart</h1>
|
|
27
|
+
{error ? <p className="cfa-alert">{error}</p> : null}
|
|
28
|
+
<table className="cfa-table">
|
|
29
|
+
<tbody>
|
|
30
|
+
{lines.map((l) => (
|
|
31
|
+
<tr key={l.variant_id}>
|
|
32
|
+
<td>{l.v!.product}{l.v!.name !== "Default" ? <span className="cfa-price"> · {l.v!.name}</span> : null}</td>
|
|
33
|
+
<td>
|
|
34
|
+
<form action={setQuantity} style={{ display: "flex", gap: 8 }}>
|
|
35
|
+
<input type="hidden" name="variant_id" value={l.variant_id} />
|
|
36
|
+
<input type="number" name="quantity" min={0} max={99} defaultValue={l.quantity} className="cfa-qty" aria-label="Quantity" />
|
|
37
|
+
<button className="cfa-button cfa-secondary" style={{ height: 36 }}>Update</button>
|
|
38
|
+
</form>
|
|
39
|
+
</td>
|
|
40
|
+
<td style={{ textAlign: "right" }}>{money(l.v!.price * l.quantity, currency)}</td>
|
|
41
|
+
</tr>
|
|
42
|
+
))}
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
<div className="cfa-row">
|
|
46
|
+
<p>Subtotal <strong>{money(subtotal, currency)}</strong> <span className="cfa-price">· shipping and tax at checkout</span></p>
|
|
47
|
+
<form action={checkout}><button className="cfa-button">Checkout</button></form>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The cart lives in a cookie: no database, works with server components and server actions.
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
|
|
4
|
+
export type CartLine = { variant_id: string; quantity: number };
|
|
5
|
+
export const CART_COOKIE = "shop_cart";
|
|
6
|
+
export const CHECKOUT_COOKIE = "shop_checkout";
|
|
7
|
+
|
|
8
|
+
export async function readCart(): Promise<CartLine[]> {
|
|
9
|
+
try {
|
|
10
|
+
const raw = (await cookies()).get(CART_COOKIE)?.value;
|
|
11
|
+
const lines = raw ? (JSON.parse(raw) as CartLine[]) : [];
|
|
12
|
+
return lines.filter((l) => typeof l.variant_id === "string" && Number.isInteger(l.quantity) && l.quantity > 0).slice(0, 50);
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeCart(lines: CartLine[]) {
|
|
19
|
+
(await cookies()).set(CART_COOKIE, JSON.stringify(lines.filter((l) => l.quantity > 0)), {
|
|
20
|
+
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 30,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { readCart } from "./cart-cookie";
|
|
4
|
+
import "./shop.css";
|
|
5
|
+
|
|
6
|
+
// Pages read cookies and live data, so each wraps its content in <Suspense> (works with or without cacheComponents).
|
|
7
|
+
async function CartCount() {
|
|
8
|
+
const count = (await readCart()).reduce((n, l) => n + l.quantity, 0);
|
|
9
|
+
return <>{count ? ` (${count})` : ""}</>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="cfa-page"><div className="cfa-shell">
|
|
15
|
+
<nav className="cfa-nav">
|
|
16
|
+
<Link href="/shop" className="cfa-brand">Shop</Link>
|
|
17
|
+
<div className="cfa-links">
|
|
18
|
+
<Link href="/shop/orders">Find an order</Link>
|
|
19
|
+
<Link href="/shop/cart">Cart<Suspense fallback={null}><CartCount /></Suspense></Link>
|
|
20
|
+
</div>
|
|
21
|
+
</nav>
|
|
22
|
+
{children}
|
|
23
|
+
</div></div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
import { lookupOrder, type LookupState } from "../actions";
|
|
5
|
+
|
|
6
|
+
const money = (cents: number, currency: string) =>
|
|
7
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
|
|
8
|
+
|
|
9
|
+
export function LookupForm() {
|
|
10
|
+
const [state, action, pending] = useActionState<LookupState, FormData>(lookupOrder, null);
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<form action={action}>
|
|
14
|
+
<label className="cfa-field">Order number<input name="number" inputMode="numeric" placeholder="1001" required /></label>
|
|
15
|
+
<label className="cfa-field">Email<input name="email" type="email" required /></label>
|
|
16
|
+
<button className="cfa-button" disabled={pending}>{pending ? "Looking…" : "Find order"}</button>
|
|
17
|
+
</form>
|
|
18
|
+
{state?.error ? <p className="cfa-alert" style={{ marginTop: 16 }}>{state.error}</p> : null}
|
|
19
|
+
{state?.order ? (
|
|
20
|
+
<div style={{ marginTop: 24 }}>
|
|
21
|
+
<p><strong>Order #{state.order.number}</strong> · {state.order.status} · {money(state.order.total, state.order.currency)}</p>
|
|
22
|
+
<ul className="cfa-muted">{state.order.items.map((i) => <li key={i.name}>{i.name} × {i.quantity}</li>)}</ul>
|
|
23
|
+
{state.order.tracking.map((t) => (
|
|
24
|
+
<p key={t.number} className="cfa-muted">
|
|
25
|
+
Shipped{t.carrier ? ` with ${t.carrier}` : ""}: {t.url ? <a href={t.url}>{t.number}</a> : t.number}
|
|
26
|
+
</p>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
) : null}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LookupForm } from "./lookup-form";
|
|
2
|
+
|
|
3
|
+
export const metadata = { title: "Find an order" };
|
|
4
|
+
|
|
5
|
+
export default function OrdersPage() {
|
|
6
|
+
return (
|
|
7
|
+
<div>
|
|
8
|
+
<h1 className="cfa-h1">Find an order</h1>
|
|
9
|
+
<p className="cfa-muted">Enter your order number and the email you used at checkout.</p>
|
|
10
|
+
<LookupForm />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { commerce, money } from "../../lib/commerce";
|
|
4
|
+
|
|
5
|
+
export const metadata = { title: "Shop" };
|
|
6
|
+
|
|
7
|
+
export default function ShopPage() {
|
|
8
|
+
return <Suspense fallback={<p className="cfa-muted">Loading…</p>}><Products /></Suspense>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function Products() {
|
|
12
|
+
const { data, error } = await commerce.products.list({ active: true, limit: 100 });
|
|
13
|
+
if (error) return <p className="cfa-alert">Couldn't load products: {error.message}</p>;
|
|
14
|
+
const products = data.data.filter((p) => p.variants.some((v) => v.active));
|
|
15
|
+
if (!products.length) return <p className="cfa-empty">No products yet. Add some with the SDK, the MCP server, or by connecting Printify/Printful.</p>;
|
|
16
|
+
return (
|
|
17
|
+
<div className="cfa-grid">
|
|
18
|
+
{products.map((p) => {
|
|
19
|
+
const prices = p.variants.filter((v) => v.active).map((v) => v.price);
|
|
20
|
+
const from = Math.min(...prices);
|
|
21
|
+
return (
|
|
22
|
+
<Link key={p.id} href={`/shop/${p.id}`} className="cfa-card">
|
|
23
|
+
{p.images[0] ? <img src={p.images[0]} alt={p.name} className="cfa-image" /> : <div className="cfa-placeholder" />}
|
|
24
|
+
<p className="cfa-title">{p.name}</p>
|
|
25
|
+
<p className="cfa-price">{prices.length > 1 && Math.max(...prices) !== from ? "From " : ""}{money(from, p.variants[0]?.currency)}</p>
|
|
26
|
+
</Link>
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Plain, prefixed CSS (cfa-*) so it works with or without Tailwind or CSS modules. Restyle freely. */
|
|
2
|
+
/* The shop paints its own light page so it's readable whatever the app's global (e.g. dark-mode) styles are. */
|
|
3
|
+
.cfa-page { min-height: 100vh; background: #fff; color: #111; color-scheme: light; }
|
|
4
|
+
.cfa-shell { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; font-family: system-ui, -apple-system, sans-serif; color: #111; }
|
|
5
|
+
.cfa-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 20px; margin-bottom: 28px; border-bottom: 1px solid #e5e5e5; }
|
|
6
|
+
.cfa-brand { font-weight: 600; font-size: 18px; color: inherit; text-decoration: none; }
|
|
7
|
+
.cfa-links { display: flex; gap: 18px; font-size: 14px; }
|
|
8
|
+
.cfa-links a { color: #444; text-decoration: none; }
|
|
9
|
+
.cfa-links a:hover { color: #111; }
|
|
10
|
+
.cfa-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 24px; }
|
|
11
|
+
.cfa-card { display: block; color: inherit; text-decoration: none; }
|
|
12
|
+
.cfa-image { aspect-ratio: 1; width: 100%; object-fit: cover; border-radius: 10px; background: #f3f3f3; }
|
|
13
|
+
.cfa-placeholder { aspect-ratio: 1; width: 100%; border-radius: 10px; background: #f3f3f3; }
|
|
14
|
+
.cfa-title { margin: 10px 0 2px; font-size: 15px; font-weight: 500; }
|
|
15
|
+
.cfa-price { color: #555; font-size: 14px; }
|
|
16
|
+
.cfa-detail { display: grid; grid-template-columns: 1fr; gap: 32px; }
|
|
17
|
+
@media (min-width: 760px) { .cfa-detail { grid-template-columns: 1fr 1fr; } }
|
|
18
|
+
.cfa-h1 { font-size: 28px; font-weight: 600; margin: 0 0 8px; }
|
|
19
|
+
.cfa-muted { color: #666; font-size: 14px; line-height: 1.5; white-space: pre-line; }
|
|
20
|
+
.cfa-field { display: block; margin: 16px 0; font-size: 14px; }
|
|
21
|
+
.cfa-field select, .cfa-field input { display: block; margin-top: 6px; width: 100%; max-width: 320px; height: 40px; padding: 0 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 15px; background: #fff; }
|
|
22
|
+
.cfa-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #111; color: #fff; font-size: 15px; font-weight: 500; cursor: pointer; text-decoration: none; }
|
|
23
|
+
.cfa-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
24
|
+
.cfa-secondary { background: #fff; color: #111; border: 1px solid #ccc; }
|
|
25
|
+
.cfa-table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
|
26
|
+
.cfa-table td { padding: 14px 0; border-bottom: 1px solid #eee; vertical-align: middle; }
|
|
27
|
+
.cfa-qty { width: 64px; height: 36px; padding: 0 8px; border: 1px solid #ccc; border-radius: 8px; }
|
|
28
|
+
.cfa-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 24px; flex-wrap: wrap; }
|
|
29
|
+
.cfa-alert { padding: 12px 14px; border-radius: 8px; background: #fff4f4; color: #9b1c1c; font-size: 14px; margin-bottom: 16px; }
|
|
30
|
+
.cfa-notice { padding: 12px 14px; border-radius: 8px; background: #f3f7ff; color: #1e3a8a; font-size: 14px; margin-bottom: 16px; }
|
|
31
|
+
.cfa-empty { padding: 48px 0; text-align: center; color: #666; }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { clearCart } from "../actions";
|
|
5
|
+
|
|
6
|
+
/** Empty the cart once the customer is back from Stripe (cookies can't be changed while rendering). */
|
|
7
|
+
export function ClearCart() {
|
|
8
|
+
useEffect(() => { void clearCart(); }, []);
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import { commerce, money } from "../../../lib/commerce";
|
|
5
|
+
import { CHECKOUT_COOKIE } from "../cart-cookie";
|
|
6
|
+
import { ClearCart } from "./clear-cart";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Thank you" };
|
|
9
|
+
|
|
10
|
+
export default function ThanksPage() {
|
|
11
|
+
return <Suspense fallback={<p className="cfa-muted">Loading…</p>}><Thanks /></Suspense>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function Thanks() {
|
|
15
|
+
const checkoutId = (await cookies()).get(CHECKOUT_COOKIE)?.value;
|
|
16
|
+
const { data: co } = checkoutId ? await commerce.checkouts.get(checkoutId) : { data: null };
|
|
17
|
+
// Stripe tells us about the payment by webhook, usually within a second or two.
|
|
18
|
+
const { data: order } = co?.order_id ? await commerce.orders.get(co.order_id) : { data: null };
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<ClearCart />
|
|
22
|
+
<h1 className="cfa-h1">Thank you!</h1>
|
|
23
|
+
{order ? (
|
|
24
|
+
<>
|
|
25
|
+
<p className="cfa-muted">Order #{order.number} is confirmed{order.email ? `. A receipt is on its way to ${order.email}` : ""}.</p>
|
|
26
|
+
<table className="cfa-table">
|
|
27
|
+
<tbody>
|
|
28
|
+
{order.items.map((i) => (
|
|
29
|
+
<tr key={i.variant_id + i.product_name}>
|
|
30
|
+
<td>{i.product_name}{i.variant_name !== "Default" ? ` · ${i.variant_name}` : ""} × {i.quantity}</td>
|
|
31
|
+
<td style={{ textAlign: "right" }}>{money(i.unit_price * i.quantity, order.currency)}</td>
|
|
32
|
+
</tr>
|
|
33
|
+
))}
|
|
34
|
+
<tr><td><strong>Total</strong></td><td style={{ textAlign: "right" }}><strong>{money(order.total, order.currency)}</strong></td></tr>
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
</>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
<p className="cfa-notice">We're confirming your payment. This page refreshes in a moment.</p>
|
|
41
|
+
<meta httpEquiv="refresh" content="3" />
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
<div className="cfa-row"><Link href="/shop" className="cfa-button cfa-secondary">Keep shopping</Link></div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// CommerceForAI client for server code (server components, server actions, route handlers).
|
|
2
|
+
// The API key is a secret: never import this file from a "use client" component.
|
|
3
|
+
import { Commerce } from "commerceforai";
|
|
4
|
+
|
|
5
|
+
if (!process.env.COMMERCEFORAI_API_KEY) {
|
|
6
|
+
console.warn("COMMERCEFORAI_API_KEY is not set. Run `npx commerceforai init` or add it to .env.local.");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const commerce = new Commerce(process.env.COMMERCEFORAI_API_KEY ?? "");
|
|
10
|
+
|
|
11
|
+
export const money = (cents: number, currency = "usd") =>
|
|
12
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
|