Feature: Add WhatsApp notifications for collection-ready orders
- Implement WhatsApp Business API integration via Meta Cloud API - Add phoneNumber field to Order model for storing customer phone numbers - Update admin order detail page with phone number input for collection orders - Send WhatsApp notification when admin marks order as ready for collection - Add WhatsApp service module with phone number formatting and message sending - Create database migrations for collection-related fields - Add WhatsApp credentials to .env configuration Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
a105fdf660
commit
36cc6dddff
@@ -7,14 +7,16 @@ function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'PAID') return 'Confirmed';
|
||||
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Delivered';
|
||||
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||
if (status === 'FAILED') return 'Failed';
|
||||
return 'Processing';
|
||||
}
|
||||
|
||||
function statusClasses(status: string) {
|
||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
||||
function statusClasses(status: string, trackingNumber?: string | null) {
|
||||
if (status === 'DELIVERED') return 'bg-green-500/10 text-green-600';
|
||||
if (status === 'PAID') return trackingNumber ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange';
|
||||
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||
return 'bg-splash-orange/10 text-splash-orange';
|
||||
}
|
||||
@@ -38,9 +40,32 @@ export default async function AccountOrderDetailPage({ params }: { params: { id:
|
||||
</Link>
|
||||
<div className="mt-4 flex items-baseline justify-between gap-3">
|
||||
<h1 className="font-display text-3xl">Order</h1>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${statusClasses(order.status)}`}>{statusLabel(order.status)}</span>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${statusClasses(order.status, order.trackingNumber)}`}>{statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}</span>
|
||||
</div>
|
||||
|
||||
{order.isCollectionPickup && order.readyForCollection && (
|
||||
<div className="mt-6 border border-green-500/30 bg-green-500/5 p-6 rounded">
|
||||
<p className="tag-label mb-3 text-green-700">Ready for Collection</p>
|
||||
<p className="text-sm">✓ Your order is ready for pickup</p>
|
||||
<p className="text-sm text-muted mt-2">Please collect your order at your earliest convenience</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.trackingNumber && (
|
||||
<div className="mt-6 border border-line bg-surface p-6">
|
||||
<p className="tag-label mb-3">Tracking information</p>
|
||||
<p className="text-sm">
|
||||
<strong>{order.carrier || 'Royal Mail'}</strong>
|
||||
</p>
|
||||
<p className="font-mono text-sm">{order.trackingNumber}</p>
|
||||
{order.estimatedDeliveryDate && (
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="tag-label mb-1">Placed</p>
|
||||
|
||||
@@ -10,15 +10,17 @@ function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'PAID') return 'Confirmed';
|
||||
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Delivered';
|
||||
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||
if (status === 'FAILED') return 'Failed';
|
||||
if (status === 'PENDING') return 'Waiting payment';
|
||||
return 'Processing';
|
||||
}
|
||||
|
||||
function statusClasses(status: string) {
|
||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
||||
function statusClasses(status: string, trackingNumber?: string | null) {
|
||||
if (status === 'DELIVERED') return 'bg-green-500/10 text-green-600';
|
||||
if (status === 'PAID') return trackingNumber ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange';
|
||||
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||
return 'bg-splash-orange/10 text-splash-orange';
|
||||
}
|
||||
@@ -114,13 +116,21 @@ export default async function AccountPage() {
|
||||
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
|
||||
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
|
||||
{statusLabel(order.status)}
|
||||
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status, order.trackingNumber)}`}>
|
||||
{statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||
</Link>
|
||||
|
||||
{/* Collection Ready Notification */}
|
||||
{order.isCollectionPickup && order.readyForCollection && (
|
||||
<div className="mt-3 border-t border-line pt-3 bg-green-500/5 p-3 rounded border border-green-500/30 text-sm">
|
||||
<p className="text-green-700 font-medium">✓ Your order is ready for collection</p>
|
||||
<p className="text-muted text-xs mt-1">Please come collect your order at your earliest convenience</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking Information */}
|
||||
{order.carrier && order.trackingNumber && (
|
||||
<div className="mt-3 border-t border-line pt-3 text-sm">
|
||||
|
||||
@@ -59,13 +59,14 @@ export async function deleteGalleryPhoto(formData: FormData) {
|
||||
|
||||
export async function createGalleryCategory(formData: FormData) {
|
||||
const name = String(formData.get('name') ?? '').trim();
|
||||
const redirectTo = String(formData.get('redirectTo') ?? '/admin/gallery/categories').trim();
|
||||
if (!name) throw new Error('Category name is required.');
|
||||
|
||||
const slug = await uniqueSlug(slugify(name));
|
||||
|
||||
await prisma.galleryCategory.create({ data: { name, slug } });
|
||||
|
||||
redirect('/admin/gallery/categories');
|
||||
redirect(redirectTo);
|
||||
}
|
||||
|
||||
export async function deleteGalleryCategory(formData: FormData) {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import AdminNav from '@/components/AdminNav';
|
||||
import { createGalleryCategory } from '../../actions';
|
||||
|
||||
export default function NewGalleryCategoryPage() {
|
||||
export default function NewGalleryCategoryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { redirectTo?: string };
|
||||
}) {
|
||||
const redirectTo = searchParams.redirectTo || '/admin/gallery/categories';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-6 py-12">
|
||||
<AdminNav />
|
||||
@@ -11,6 +17,7 @@ export default function NewGalleryCategoryPage() {
|
||||
</p>
|
||||
|
||||
<form action={createGalleryCategory} className="mt-8 space-y-6">
|
||||
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||
<div>
|
||||
<label htmlFor="name" className="tag-label mb-2 block">
|
||||
Category name
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function NewGalleryPhotoPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<a href="/admin/gallery/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
<a href="/admin/gallery/categories/new?redirectTo=/admin/gallery/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
|
||||
+ Add a new category
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -134,6 +134,19 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collectedByCustomer"
|
||||
value="on"
|
||||
defaultChecked={order.collectedByCustomer}
|
||||
className="h-4 w-4 border border-line"
|
||||
/>
|
||||
<span className="text-sm">Collected by customer</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -144,6 +157,71 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Collection Ready */}
|
||||
{order.isCollectionPickup && (
|
||||
<div className="mt-4 border-t border-line pt-4 space-y-3 bg-green-500/5 p-4 rounded border border-green-500/20">
|
||||
<p className="tag-label mb-3">Customer Collection</p>
|
||||
<p className="text-sm text-muted mb-3">This order is for customer collection. Mark as ready when it's prepared for pickup.</p>
|
||||
{!order.readyForCollection ? (
|
||||
<form action={updateOrderTracking} className="space-y-3">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<input type="hidden" name="readyForCollection" value="true" />
|
||||
<div>
|
||||
<label className="tag-label mb-1 block">Customer phone (for WhatsApp notification)</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
defaultValue={order.phoneNumber ?? ''}
|
||||
placeholder="e.g. +44 1234 567890"
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">Required to send ready notification via WhatsApp</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
✓ Mark Ready for Collection
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="bg-green-500/10 px-4 py-2 rounded border border-green-500/30">
|
||||
<p className="text-sm text-green-700">✓ Marked ready for collection</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t border-line pt-4 space-y-3">
|
||||
<p className="tag-label mb-3">Order Complete</p>
|
||||
<p className="text-sm text-muted mb-3">Mark this order as complete if tracking won't be provided or it was collected by the customer.</p>
|
||||
<form action={updateOrderTracking} className="space-y-3">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<input type="hidden" name="status" value="DELIVERED" />
|
||||
|
||||
<div>
|
||||
<input type="hidden" name="collectedByCustomer" value="false" />
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="collectedByCustomer"
|
||||
value="true"
|
||||
defaultChecked={order.collectedByCustomer}
|
||||
className="h-4 w-4 border border-line"
|
||||
/>
|
||||
<span className="text-sm">Collected by customer</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-line px-4 py-2 text-sm font-medium hover:bg-surface"
|
||||
>
|
||||
✓ Mark Order Complete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{order.trackingNumber && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<p className="tag-label mb-3">Royal Mail Integration</p>
|
||||
@@ -186,7 +264,7 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="tag-label">{item.placementPreviewUrl ? 'On product — front' : 'Front'}</p>
|
||||
<p className="tag-label text-muted">{item.placementPreviewUrl ? 'On product — front' : 'Front'}</p>
|
||||
{hasFrontDesign && (
|
||||
<a
|
||||
href={item.designPreviewUrl}
|
||||
@@ -207,7 +285,7 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
||||
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="tag-label">On product — back</p>
|
||||
<p className="tag-label text-muted">On product — back</p>
|
||||
{hasBackDesign && item.designPreviewUrlBack && (
|
||||
<a
|
||||
href={item.designPreviewUrlBack}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { sendWhatsAppMessage } from '@/lib/whatsapp';
|
||||
|
||||
const AUTO_ARCHIVE_DAYS = 30;
|
||||
|
||||
@@ -43,9 +44,22 @@ export async function updateOrderTracking(formData: FormData) {
|
||||
const carrier = formData.get('carrier') ? String(formData.get('carrier')) : null;
|
||||
const trackingNumber = formData.get('trackingNumber') ? String(formData.get('trackingNumber')) : null;
|
||||
const estimatedDeliveryDate = formData.get('estimatedDeliveryDate') ? new Date(String(formData.get('estimatedDeliveryDate'))) : null;
|
||||
const collectedByCustomer = formData.has('collectedByCustomer');
|
||||
const readyForCollection = formData.has('readyForCollection') && formData.get('readyForCollection') === 'true';
|
||||
const phoneNumber = formData.get('phoneNumber') ? String(formData.get('phoneNumber')) : null;
|
||||
|
||||
console.log('✓ Order update:', { id, status, collectedByCustomer, readyForCollection });
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true },
|
||||
});
|
||||
|
||||
if (!order) return;
|
||||
|
||||
// Update the order
|
||||
await prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -53,11 +67,31 @@ export async function updateOrderTracking(formData: FormData) {
|
||||
carrier,
|
||||
trackingNumber,
|
||||
estimatedDeliveryDate,
|
||||
collectedByCustomer,
|
||||
readyForCollection: readyForCollection || undefined,
|
||||
phoneNumber: phoneNumber || order.phoneNumber || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Send WhatsApp notification if marking ready for collection
|
||||
if (readyForCollection && !order.readyForCollection) {
|
||||
const phone = phoneNumber || order.phoneNumber;
|
||||
if (phone) {
|
||||
await sendWhatsAppMessage(
|
||||
phone,
|
||||
`Hi! 👋 Your order from Craft2Prints is ready for collection. Please come pick it up at your earliest convenience. Thanks!`
|
||||
);
|
||||
} else {
|
||||
console.warn(`No phone number for order ${id} - WhatsApp message not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate the page to show updated data
|
||||
revalidatePath(`/admin/orders/${id}`);
|
||||
revalidatePath(`/admin/orders`);
|
||||
|
||||
// TODO: Send email notification to customer when status changes
|
||||
// Redirect back to orders list if marking as complete
|
||||
if (status === 'DELIVERED') {
|
||||
redirect('/admin/orders');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ function formatPrice(cents: number) {
|
||||
return `£${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'PAID') return 'Confirmed';
|
||||
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Complete';
|
||||
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||
if (status === 'FAILED') return 'Failed';
|
||||
if (status === 'PENDING') return 'Waiting payment';
|
||||
return 'Processing';
|
||||
@@ -18,6 +19,7 @@ const STATUS_STYLES: Record<string, string> = {
|
||||
PAID: 'bg-splash-blue/10 text-splash-blue',
|
||||
PENDING: 'bg-splash-orange/10 text-splash-orange',
|
||||
FAILED: 'bg-splash-pink/10 text-splash-pink',
|
||||
DELIVERED: 'bg-green-500/10 text-green-600',
|
||||
};
|
||||
|
||||
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string } }) {
|
||||
@@ -127,9 +129,14 @@ export default async function OrdersPage({ searchParams }: { searchParams: { arc
|
||||
<span className="tag-label rounded-full bg-splash-orange/10 px-3 py-1 text-splash-orange">Personalised</span>
|
||||
)}
|
||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
||||
{statusLabel(order.status)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
||||
{statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
|
||||
</span>
|
||||
{order.status === 'DELIVERED' && order.collectedByCustomer && (
|
||||
<span className="text-xs text-muted">✓ Collected</span>
|
||||
)}
|
||||
</div>
|
||||
<form action={showArchived ? unarchiveOrder : archiveOrder}>
|
||||
<input type="hidden" name="id" value={order.id} />
|
||||
<button type="submit" className="tag-label text-muted hover:text-clay">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { cleanupPendingOrders } from '@/lib/cleanupOrders';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await cleanupPendingOrders();
|
||||
|
||||
const recentThreshold = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const [
|
||||
pendingOrders,
|
||||
designApprovalsInReview,
|
||||
photoRequests,
|
||||
unreadMessages,
|
||||
] = await Promise.all([
|
||||
prisma.order.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
status: 'PENDING',
|
||||
archived: false,
|
||||
createdAt: { gte: recentThreshold },
|
||||
},
|
||||
{
|
||||
status: 'PAID',
|
||||
archived: false,
|
||||
trackingNumber: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
prisma.designApproval.count({
|
||||
where: { status: { in: ['PENDING', 'IN_REVIEW'] } },
|
||||
}),
|
||||
prisma.customRequest.count({
|
||||
where: { status: 'PENDING' },
|
||||
}),
|
||||
prisma.message.count({
|
||||
where: { isRead: false },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalNotifications = pendingOrders + designApprovalsInReview + photoRequests + unreadMessages;
|
||||
|
||||
return NextResponse.json({
|
||||
total: totalNotifications,
|
||||
pendingOrders,
|
||||
designApprovals: designApprovalsInReview,
|
||||
photoRequests,
|
||||
unreadMessages,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching admin notifications:', err);
|
||||
return NextResponse.json({
|
||||
total: 0,
|
||||
pendingOrders: 0,
|
||||
designApprovals: 0,
|
||||
photoRequests: 0,
|
||||
unreadMessages: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,34 @@ export async function POST(req: Request) {
|
||||
cleanupPendingOrders().catch((err) => console.error('Cleanup error:', err));
|
||||
|
||||
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, 'isCollection:', body.isCollection);
|
||||
const items: CheckoutCartItem[] = body.items ?? [];
|
||||
const guest: GuestInfo | undefined = body.guest;
|
||||
const shippingCountry: string = body.shippingCountry ?? 'GB';
|
||||
const shippingService: string | undefined = body.shippingService;
|
||||
const isCollection: boolean = body.isCollection ?? false;
|
||||
const customer = await getCurrentCustomer();
|
||||
const checkoutEmail = guest?.email ?? customer?.email;
|
||||
|
||||
// Delete any other recent PENDING orders for this email to avoid duplicates
|
||||
if (checkoutEmail) {
|
||||
try {
|
||||
const deletedCount = await prisma.order.deleteMany({
|
||||
where: {
|
||||
email: checkoutEmail,
|
||||
status: 'PENDING',
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 2 * 60 * 60 * 1000), // Within last 2 hours
|
||||
},
|
||||
},
|
||||
});
|
||||
if (deletedCount.count > 0) {
|
||||
console.log(`Cleaned up ${deletedCount.count} duplicate pending order(s) for ${checkoutEmail}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting duplicate pending orders:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||
@@ -95,11 +118,15 @@ export async function POST(req: Request) {
|
||||
|
||||
const subtotal = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
||||
|
||||
// Calculate shipping cost
|
||||
// Calculate shipping cost (skip for collection orders)
|
||||
let shippingCost = 0;
|
||||
let selectedShippingService = shippingService;
|
||||
|
||||
try {
|
||||
if (isCollection) {
|
||||
console.log('Collection order - no shipping cost');
|
||||
selectedShippingService = null;
|
||||
} else {
|
||||
try {
|
||||
// Calculate total weight from cart items
|
||||
let totalWeightGrams = 0;
|
||||
for (const item of items) {
|
||||
@@ -138,9 +165,9 @@ export async function POST(req: Request) {
|
||||
// Default shipping cost if calculation fails
|
||||
shippingCost = 500; // £5.00
|
||||
}
|
||||
}
|
||||
|
||||
const total = subtotal + shippingCost;
|
||||
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
|
||||
@@ -156,6 +183,7 @@ export async function POST(req: Request) {
|
||||
email: guest?.email ?? customer?.email,
|
||||
guestName: guest?.name,
|
||||
guestPhone: guest?.phone,
|
||||
isCollectionPickup,
|
||||
items: {
|
||||
create: pricedItems.map((i) => ({
|
||||
productId: i.productId,
|
||||
|
||||
+57
-10
@@ -18,6 +18,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
||||
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | 'edit-address' | null>(isLoggedIn ? 'logged-in' : null);
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||
const [deliveryMode, setDeliveryMode] = useState<'shipping' | 'collection'>('shipping');
|
||||
const [shippingCountry, setShippingCountry] = useState(customerAddress?.country || 'GB');
|
||||
const [shippingService, setShippingService] = useState<string | null>(null);
|
||||
const [shippingCost, setShippingCost] = useState(0);
|
||||
@@ -73,7 +74,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
||||
setCheckoutError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
if (!shippingService) {
|
||||
if (deliveryMode === 'shipping' && !shippingService) {
|
||||
setCheckoutError('Please select a shipping method.');
|
||||
return;
|
||||
}
|
||||
@@ -97,8 +98,9 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
||||
phone: guestData.phone,
|
||||
address: guestData.address,
|
||||
},
|
||||
shippingCountry,
|
||||
shippingService,
|
||||
shippingCountry: deliveryMode === 'collection' ? null : shippingCountry,
|
||||
shippingService: deliveryMode === 'collection' ? null : shippingService,
|
||||
isCollection: deliveryMode === 'collection',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -211,14 +213,58 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ShippingSelector
|
||||
items={items}
|
||||
onShippingSelect={handleShippingSelect}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-3">Delivery</p>
|
||||
<div className="inline-flex border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeliveryMode('shipping');
|
||||
setShippingCost(0);
|
||||
setShippingService(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm transition-colors ${
|
||||
deliveryMode === 'shipping'
|
||||
? 'bg-ink text-paper'
|
||||
: 'hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
Shipping
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeliveryMode('collection');
|
||||
setShippingCost(0);
|
||||
setShippingService(null);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm transition-colors border-l border-line ${
|
||||
deliveryMode === 'collection'
|
||||
? 'bg-ink text-paper'
|
||||
: 'hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliveryMode === 'shipping' && (
|
||||
<ShippingSelector
|
||||
items={items}
|
||||
onShippingSelect={handleShippingSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deliveryMode === 'collection' && (
|
||||
<div className="bg-surface p-4 rounded border border-line">
|
||||
<p className="text-sm text-muted">✓ Free collection pickup - no postage charge</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!shippingService) {
|
||||
if (deliveryMode === 'shipping' && !shippingService) {
|
||||
setCheckoutError('Please select a shipping method.');
|
||||
return;
|
||||
}
|
||||
@@ -241,8 +287,9 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
customerId: customer.id,
|
||||
shippingCountry,
|
||||
shippingService,
|
||||
shippingCountry: deliveryMode === 'collection' ? null : shippingCountry,
|
||||
shippingService: deliveryMode === 'collection' ? null : shippingService,
|
||||
isCollection: deliveryMode === 'collection',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -25,6 +25,27 @@ export default async function CheckoutSuccessPage({
|
||||
include: { items: true },
|
||||
});
|
||||
console.log('✓ Order status updated to PAID:', order.id);
|
||||
|
||||
// Clean up any other PENDING orders for this email (duplicates from retried checkouts)
|
||||
if (order.email) {
|
||||
try {
|
||||
const deletedCount = await prisma.order.deleteMany({
|
||||
where: {
|
||||
email: order.email,
|
||||
status: 'PENDING',
|
||||
id: { not: order.id }, // Don't delete the current order
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Within last 24 hours
|
||||
},
|
||||
},
|
||||
});
|
||||
if (deletedCount.count > 0) {
|
||||
console.log(`✓ Cleaned up ${deletedCount.count} duplicate pending order(s) for ${order.email}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting duplicate pending orders:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error handling order for success page:', err);
|
||||
|
||||
+16
-16
@@ -105,23 +105,23 @@ export default async function HomePage() {
|
||||
</Reveal>
|
||||
|
||||
<Reveal delayMs={150} className="flex gap-4">
|
||||
{/* Customer Work - Shirt 1 */}
|
||||
<div className="flex-1">
|
||||
<div className="border-2 border-line bg-gradient-to-br from-splash-coral/10 to-splash-coral/5 rounded-lg p-6 aspect-square flex flex-col items-center justify-center text-center">
|
||||
<p className="text-sm font-medium text-muted mb-2">Dad Bod Design</p>
|
||||
<p className="text-xs text-muted">Customer creation</p>
|
||||
{galleryPhotos.slice(0, 2).map((photo, idx) => (
|
||||
<div key={photo.id} className={`flex-1 ${idx === 1 ? 'translate-y-6' : ''}`}>
|
||||
<div className="border-2 border-line rounded-lg overflow-hidden aspect-square">
|
||||
<img
|
||||
src={photo.imageDataUrl}
|
||||
alt={photo.caption || 'Customer design'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<p className="mt-3 text-xs text-muted text-center font-medium">{photo.caption}</p>
|
||||
)}
|
||||
{!photo.caption && (
|
||||
<p className="mt-3 text-xs text-muted text-center font-medium">Customer work</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted text-center font-medium">Real customer work</p>
|
||||
</div>
|
||||
|
||||
{/* Customer Work - Shirt 2 */}
|
||||
<div className="flex-1 translate-y-6">
|
||||
<div className="border-2 border-line bg-gradient-to-br from-splash-blue/10 to-splash-blue/5 rounded-lg p-6 aspect-square flex flex-col items-center justify-center text-center">
|
||||
<p className="text-sm font-medium text-muted mb-2">Cheers & Beers</p>
|
||||
<p className="text-xs text-muted">Customer creation</p>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted text-center font-medium">Real customer work</p>
|
||||
</div>
|
||||
))}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user