From cf1d40b1c13bf84cacbe5be2641270252f8cc86c Mon Sep 17 00:00:00 2001 From: Andymick Date: Sat, 18 Jul 2026 08:44:59 +0100 Subject: [PATCH] Fix color name detection with fuzzy matching, fix checkout syntax errors, add error handling for products page --- src/app/admin/products/page.tsx | 11 +- src/app/api/checkout/route.ts | 180 ++++++++++++++++---------------- src/lib/colorNames.ts | 22 +++- 3 files changed, 119 insertions(+), 94 deletions(-) diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx index 79117f8..a92958e 100644 --- a/src/app/admin/products/page.tsx +++ b/src/app/admin/products/page.tsx @@ -12,8 +12,15 @@ export default async function AdminProductsPage({ searchParams: { error?: string }; }) { const activePromotions = await fetchActivePromotions(); - const rows = await prisma.product.findMany({ orderBy: { createdAt: 'desc' } }); - const products = rows.map((p) => toProductDTO(p, activePromotions)); + let products = []; + 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 (
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index b890a79..c758652 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -50,109 +50,109 @@ export async function POST(req: Request) { const body = await req.json(); console.log('Checkout request received with items:', body.items?.length, 'customerId:', body.customerId); - const items: CheckoutCartItem[] = body.items ?? []; - const guest: GuestInfo | undefined = body.guest; + const items: CheckoutCartItem[] = body.items ?? []; + const guest: GuestInfo | undefined = body.guest; - 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 }, - ); + if (items.length === 0) { + return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 }); } - } - const activePromotions = await fetchActivePromotions(); - const pricedItems = items.map((i) => ({ - ...i, - unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price, - })); + // 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])); - const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0); - const customer = await getCurrentCustomer(); + 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 }, + ); + } + } - // 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, - 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 activePromotions = await fetchActivePromotions(); + const pricedItems = items.map((i) => ({ + ...i, + unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price, + })); - 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 { - console.log('Creating Stripe session for order:', order.id, 'total:', total); - 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}` : ''}`, - }, + // 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, + 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, + })), }, - 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({ - where: { id: order.id }, - data: { stripeSessionId: session.id }, - }); + try { + console.log('Creating Stripe session for order:', order.id, 'total:', total); + 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 }); - } 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 }); - } + console.log('Stripe session created:', session.id, 'URL:', session.url); + + 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. + 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) { const message = err instanceof Error ? err.message : 'Something went wrong.'; return NextResponse.json({ error: message }, { status: 500 }); diff --git a/src/lib/colorNames.ts b/src/lib/colorNames.ts index d3d3982..ae004cb 100644 --- a/src/lib/colorNames.ts +++ b/src/lib/colorNames.ts @@ -95,13 +95,31 @@ const COLOR_NAMES: Record = { '#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 - * 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 { 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'; } /**