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
@@ -91,7 +91,9 @@
|
||||
"Bash(git commit -m 'Add: Shipping selector component and checkout integration *)",
|
||||
"Bash(node clear-pending-orders.js)",
|
||||
"mcp__visualize__read_me",
|
||||
"mcp__visualize__show_widget"
|
||||
"mcp__visualize__show_widget",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git remote *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "collectedByCustomer" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "isCollectionPickup" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "readyForCollection" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "phoneNumber" TEXT;
|
||||
@@ -231,6 +231,9 @@ model Order {
|
||||
carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier"
|
||||
trackingNumber String? // tracking number from carrier
|
||||
estimatedDeliveryDate DateTime? // estimated delivery date
|
||||
collectedByCustomer Boolean @default(false) // true if order was collected by customer (no shipping)
|
||||
isCollectionPickup Boolean @default(false) // true if customer selected collection at checkout
|
||||
readyForCollection Boolean @default(false) // true when admin marks order as ready for pickup
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -240,6 +243,7 @@ model Order {
|
||||
|
||||
guestName String? // full name for guest orders
|
||||
guestPhone String? // contact phone for guest orders
|
||||
phoneNumber String? // phone number for WhatsApp notifications (collection orders)
|
||||
|
||||
items OrderItem[]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
||||
{statusLabel(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,10 +118,14 @@ 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;
|
||||
|
||||
if (isCollection) {
|
||||
console.log('Collection order - no shipping cost');
|
||||
selectedShippingService = null;
|
||||
} else {
|
||||
try {
|
||||
// Calculate total weight from cart items
|
||||
let totalWeightGrams = 0;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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);
|
||||
|
||||
+15
-15
@@ -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>
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -30,6 +30,12 @@ export default function AdminNav() {
|
||||
<Link href="/admin/designs" className="tag-label hover:text-ink">
|
||||
Design Approvals
|
||||
</Link>
|
||||
<Link href="/admin/gallery/new" className="tag-label hover:text-ink">
|
||||
Add to gallery
|
||||
</Link>
|
||||
<Link href="/admin/gallery" className="tag-label hover:text-ink">
|
||||
Gallery
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,22 @@ export default async function AdminNotifications() {
|
||||
] = 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_REVIEW' },
|
||||
where: { status: { in: ['PENDING', 'IN_REVIEW'] } },
|
||||
}),
|
||||
prisma.customRequest.count({
|
||||
where: { status: 'PENDING' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface AdminNotificationsBadgeProps {
|
||||
@@ -46,28 +47,36 @@ export default function AdminNotificationsBadge({
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-paper border border-line rounded shadow-lg p-4 z-50 text-sm space-y-2">
|
||||
<p className="font-bold text-ink">Notifications</p>
|
||||
{pendingOrders > 0 && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
||||
<span className="text-muted">Waiting payment</span>
|
||||
<Link href="/admin/orders">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||
<span className="text-muted">Action needed</span>
|
||||
<span className="font-bold text-splash-orange">{pendingOrders}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{designApprovals > 0 && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
||||
<Link href="/admin/designs">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||
<span className="text-muted">Design approvals</span>
|
||||
<span className="font-bold text-splash-blue">{designApprovals}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{photoRequests > 0 && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
||||
<Link href="/admin/custom-requests">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||
<span className="text-muted">Photo requests</span>
|
||||
<span className="font-bold text-clay">{photoRequests}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{unreadMessages > 0 && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
||||
<Link href="/admin/inbox">
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||
<span className="text-muted">Unread messages</span>
|
||||
<span className="font-bold text-splash-pink">{unreadMessages}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import AdminNotificationsBadge from './AdminNotificationsBadge';
|
||||
|
||||
interface NotificationData {
|
||||
total: number;
|
||||
pendingOrders: number;
|
||||
designApprovals: number;
|
||||
photoRequests: number;
|
||||
unreadMessages: number;
|
||||
}
|
||||
|
||||
export default function AdminNotificationsWrapper() {
|
||||
const [notifications, setNotifications] = useState<NotificationData>({
|
||||
total: 0,
|
||||
pendingOrders: 0,
|
||||
designApprovals: 0,
|
||||
photoRequests: 0,
|
||||
unreadMessages: 0,
|
||||
});
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/notifications');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching notifications:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch immediately
|
||||
fetchNotifications();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchNotifications, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminNotificationsBadge
|
||||
total={notifications.total}
|
||||
pendingOrders={notifications.pendingOrders}
|
||||
designApprovals={notifications.designApprovals}
|
||||
photoRequests={notifications.photoRequests}
|
||||
unreadMessages={notifications.unreadMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { prisma } from '@/lib/prisma';
|
||||
import { getCurrentCustomer } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||
import AdminMenu from './AdminMenu';
|
||||
import AdminNotifications from './AdminNotifications';
|
||||
import AdminNotificationsWrapper from './AdminNotificationsWrapper';
|
||||
|
||||
export default async function Header() {
|
||||
const [categories, collections, customer, isAdmin] = await Promise.all([
|
||||
@@ -75,7 +75,7 @@ export default async function Header() {
|
||||
<CurrencySelector />
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminNotifications />
|
||||
<AdminNotificationsWrapper />
|
||||
<AdminMenu />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,8 @@ import Link from 'next/link';
|
||||
interface OlderOrdersProps {
|
||||
orders: any[];
|
||||
formatPrice: (cents: number) => string;
|
||||
statusLabel: (status: string) => string;
|
||||
statusClasses: (status: string) => string;
|
||||
statusLabel: (status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) => string;
|
||||
statusClasses: (status: string, trackingNumber?: string | null) => string;
|
||||
}
|
||||
|
||||
export default function OlderOrders({ orders, formatPrice, statusLabel, statusClasses }: OlderOrdersProps) {
|
||||
@@ -37,8 +37,8 @@ export default function OlderOrders({ orders, formatPrice, statusLabel, statusCl
|
||||
{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>
|
||||
|
||||
@@ -62,9 +62,9 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
||||
unitPrice: product.effectivePrice,
|
||||
designJson: { front: [], back: [] },
|
||||
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
||||
designPreviewUrlBack: product.imageUrlBack ?? null,
|
||||
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||||
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
||||
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||
});
|
||||
setShowAddToCartModal(true);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const META_PHONE_NUMBER_ID = process.env.WHATSAPP_PHONE_NUMBER_ID;
|
||||
const META_ACCESS_TOKEN = process.env.WHATSAPP_ACCESS_TOKEN;
|
||||
const META_BUSINESS_ACCOUNT_ID = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID;
|
||||
|
||||
export async function sendWhatsAppMessage(
|
||||
phoneNumber: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!META_PHONE_NUMBER_ID || !META_ACCESS_TOKEN) {
|
||||
console.warn('WhatsApp credentials not configured');
|
||||
return { success: false, error: 'WhatsApp not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Format phone number: remove any non-digits and ensure it starts with country code
|
||||
const cleanPhone = phoneNumber.replace(/\D/g, '');
|
||||
const formattedPhone = cleanPhone.startsWith('44') ? cleanPhone : `44${cleanPhone.replace(/^0/, '')}`;
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.instagram.com/v18.0/${META_PHONE_NUMBER_ID}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${META_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to: formattedPhone,
|
||||
type: 'text',
|
||||
text: {
|
||||
preview_url: false,
|
||||
body: message,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = (await response.json()) as { messages?: Array<{ id: string }>; error?: { message: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = data.error?.message || 'Failed to send WhatsApp message';
|
||||
console.error('WhatsApp error:', errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
console.log('✓ WhatsApp message sent:', formattedPhone);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('WhatsApp send error:', errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPhoneForDisplay(phone: string): string {
|
||||
const clean = phone.replace(/\D/g, '');
|
||||
if (clean.length === 10) return `+44 ${clean.slice(0, 4)} ${clean.slice(4, 8)} ${clean.slice(8)}`;
|
||||
if (clean.length === 12 && clean.startsWith('44')) return `+${clean.slice(0, 2)} ${clean.slice(2, 5)} ${clean.slice(5, 8)} ${clean.slice(8)}`;
|
||||
return phone;
|
||||
}
|
||||
Reference in New Issue
Block a user