Fix color name detection with fuzzy matching, fix checkout syntax errors, add error handling for products page
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user