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';
}
/**