Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripe } from '@/lib/stripe';
import { getCurrentCustomer } from '@/lib/auth';
import { getEffectivePrice } from '@/lib/pricing';
import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
type CheckoutCartItem = {
productId: string;
slug: string;
name: string;
color: string;
size: string | null;
quantity: number;
unitPrice: number;
designJson: { front: unknown[]; back: unknown[] };
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
};
export async function POST(req: Request) {
if (!process.env.STRIPE_SECRET_KEY) {
return NextResponse.json(
{ error: 'Stripe is not configured on this server yet. Add STRIPE_SECRET_KEY to .env.' },
{ status: 500 },
);
}
const ip = getClientIp(req.headers);
const { allowed } = await checkRateLimit(`checkout:${ip}`, 20, 10 * 60 * 1000);
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests — please wait a moment and try again.' },
{ status: 429 },
);
}
const body = await req.json();
const items: CheckoutCartItem[] = body.items ?? [];
if (items.length === 0) {
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
}
// Never trust the client-submitted unitPrice for the actual charge — look up
// each product and recompute its current price (including any active sale)
// server-side. The client's unitPrice is only used for display before this point.
const products = await prisma.product.findMany({
where: { id: { in: items.map((i) => i.productId) } },
});
const productsById = new Map(products.map((p) => [p.id, p]));
for (const i of items) {
if (!productsById.has(i.productId)) {
return NextResponse.json(
{ error: 'One of the items in your bag is no longer available.' },
{ status: 400 },
);
}
}
const activePromotions = await fetchActivePromotions();
const pricedItems = items.map((i) => ({
...i,
unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price,
}));
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
const customer = await getCurrentCustomer();
// Save the order up front, before redirecting to Stripe — this is what makes
// the design files (and the order itself) retrievable afterward, whether or
// not the customer ever makes it back to the success page.
const order = await prisma.order.create({
data: {
status: 'PENDING',
total,
customerId: customer?.id,
items: {
create: pricedItems.map((i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: i.designPreviewUrl,
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
})),
},
},
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
try {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: pricedItems.map((i) => ({
price_data: {
currency: 'gbp',
unit_amount: i.unitPrice,
product_data: {
name: `${i.name}${i.color}${i.size ? `, ${i.size}` : ''}`,
},
},
quantity: i.quantity,
})),
shipping_address_collection: {
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'],
},
customer_email: customer?.email,
success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}/checkout/cancel`,
metadata: { orderId: order.id },
});
await prisma.order.update({
where: { id: order.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url });
} catch (err) {
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it.
await prisma.order.delete({ where: { id: order.id } });
const message = err instanceof Error ? err.message : 'Could not start checkout.';
return NextResponse.json({ error: message }, { status: 500 });
}
}