Fix color name detection with fuzzy matching, fix checkout syntax errors, add error handling for products page

This commit is contained in:
Andymick
2026-07-18 08:44:59 +01:00
parent c6850372cb
commit cf1d40b1c1
3 changed files with 119 additions and 94 deletions
+9 -2
View File
@@ -12,8 +12,15 @@ export default async function AdminProductsPage({
searchParams: { error?: string }; searchParams: { error?: string };
}) { }) {
const activePromotions = await fetchActivePromotions(); const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ orderBy: { createdAt: 'desc' } }); let products = [];
const products = rows.map((p) => toProductDTO(p, activePromotions)); try {
const rows = await prisma.product.findMany();
products = rows.map((p) => toProductDTO(p, activePromotions)).sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} catch (err) {
console.error('Error fetching products:', err);
}
return ( return (
<div className="mx-auto max-w-4xl px-6 py-12"> <div className="mx-auto max-w-4xl px-6 py-12">
+90 -90
View File
@@ -50,109 +50,109 @@ export async function POST(req: Request) {
const body = await req.json(); const body = await req.json();
console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId); console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId);
const items: CheckoutCartItem[] = body.items ?? []; const items: CheckoutCartItem[] = body.items ?? [];
const guest: GuestInfo | undefined = body.guest; const guest: GuestInfo | undefined = body.guest;
if (items.length === 0) { if (items.length === 0) {
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 }); 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(); // Never trust the client-submitted unitPrice for the actual charge — look up
const pricedItems = items.map((i) => ({ // each product and recompute its current price (including any active sale)
...i, // server-side. The client's unitPrice is only used for display before this point.
unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price, const products = await prisma.product.findMany({
})); where: { id: { in: items.map((i) => i.productId) } },
});
const productsById = new Map(products.map((p) => [p.id, p]));
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0); for (const i of items) {
const customer = await getCurrentCustomer(); if (!productsById.has(i.productId)) {
return NextResponse.json(
{ error: 'One of the items in your bag is no longer available.' },
{ status: 400 },
);
}
}
// Save the order up front, before redirecting to Stripe — this is what makes const activePromotions = await fetchActivePromotions();
// the design files (and the order itself) retrievable afterward, whether or const pricedItems = items.map((i) => ({
// not the customer ever makes it back to the success page. ...i,
const order = await prisma.order.create({ unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price,
data: { }));
status: 'PENDING',
total,
customerId: customer?.id,
email: guest?.email ?? customer?.email,
guestName: guest?.name,
guestPhone: guest?.phone,
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'; const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
const customer = await getCurrentCustomer();
try { // Save the order up front, before redirecting to Stripe — this is what makes
console.log('Creating Stripe session for order:', order.id, 'total:', total); // the design files (and the order itself) retrievable afterward, whether or
const session = await stripe.checkout.sessions.create({ // not the customer ever makes it back to the success page.
mode: 'payment', const order = await prisma.order.create({
line_items: pricedItems.map((i) => ({ data: {
price_data: { status: 'PENDING',
currency: 'gbp', total,
unit_amount: i.unitPrice, customerId: customer?.id,
product_data: { email: guest?.email ?? customer?.email,
name: `${i.name}${i.color}${i.size ? `, ${i.size}` : ''}`, guestName: guest?.name,
}, guestPhone: guest?.phone,
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,
})),
}, },
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 },
}); });
console.log('Stripe session created:', session.id, 'URL:', session.url); const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
await prisma.order.update({ try {
where: { id: order.id }, console.log('Creating Stripe session for order:', order.id, 'total:', total);
data: { stripeSessionId: session.id }, 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 },
});
return NextResponse.json({ url: session.url }); console.log('Stripe session created:', session.id, 'URL:', session.url);
} catch (err) {
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it. await prisma.order.update({
console.error('Stripe session creation failed:', err); where: { id: order.id },
await prisma.order.delete({ where: { id: order.id } }); data: { stripeSessionId: session.id },
const message = err instanceof Error ? err.message : 'Could not start checkout.'; });
console.error('Returning error to client:', message);
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ url: session.url });
} } catch (err) {
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it.
console.error('Stripe session creation failed:', err);
await prisma.order.delete({ where: { id: order.id } });
const message = err instanceof Error ? err.message : 'Could not start checkout.';
console.error('Returning error to client:', message);
return NextResponse.json({ error: message }, { status: 500 });
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Something went wrong.'; const message = err instanceof Error ? err.message : 'Something went wrong.';
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
+20 -2
View File
@@ -95,13 +95,31 @@ const COLOR_NAMES: Record<string, string> = {
'#f5f5f5': 'Smoke', '#f5f5f5': 'Smoke',
}; };
function hexDistance(a: string, b: string): number {
const [ar, ag, ab] = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const [br, bg, bb] = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
}
/** /**
* Get the friendly name for a hex color * Get the friendly name for a hex color
* Falls back to "Custom" if hex code is not in the standard palette * Matches exactly if available, otherwise finds the nearest color in the palette
*/ */
export function getColorName(hex: string): string { export function getColorName(hex: string): string {
const normalized = hex.toLowerCase(); const normalized = hex.toLowerCase();
return COLOR_NAMES[normalized] || 'Custom'; if (COLOR_NAMES[normalized]) return COLOR_NAMES[normalized];
// Find nearest color by distance (tolerance for lighting/JPEG compression variations)
let nearest: string | null = null;
let minDistance = Infinity;
for (const [colorHex, colorName] of Object.entries(COLOR_NAMES)) {
const dist = hexDistance(normalized, colorHex);
if (dist < minDistance && dist < 40) {
minDistance = dist;
nearest = colorName;
}
}
return nearest || 'Custom';
} }
/** /**