From 36cc6dddffeb45fda302942c8eb691bd1a72f968 Mon Sep 17 00:00:00 2001 From: Andymick Date: Sun, 19 Jul 2026 09:29:09 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 4 +- .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 2 + prisma/schema.prisma | 4 + src/app/account/orders/[id]/page.tsx | 35 ++++++-- src/app/account/page.tsx | 22 +++-- src/app/admin/gallery/actions.ts | 3 +- src/app/admin/gallery/categories/new/page.tsx | 9 +- src/app/admin/gallery/new/page.tsx | 2 +- src/app/admin/orders/[id]/page.tsx | 82 ++++++++++++++++++- src/app/admin/orders/actions.ts | 36 +++++++- src/app/admin/orders/page.tsx | 17 ++-- src/app/api/admin/notifications/route.ts | 62 ++++++++++++++ src/app/api/checkout/route.ts | 36 +++++++- src/app/checkout/client.tsx | 67 ++++++++++++--- src/app/checkout/success/page.tsx | 21 +++++ src/app/page.tsx | 32 ++++---- src/components/AdminNav.tsx | 6 ++ src/components/AdminNotifications.tsx | 16 +++- src/components/AdminNotificationsBadge.tsx | 41 ++++++---- src/components/AdminNotificationsWrapper.tsx | 53 ++++++++++++ src/components/Header.tsx | 4 +- src/components/OlderOrders.tsx | 8 +- src/components/StandardProductView.tsx | 4 +- src/lib/whatsapp.ts | 62 ++++++++++++++ 26 files changed, 553 insertions(+), 80 deletions(-) create mode 100644 prisma/migrations/20260719074839_add_collected_by_customer/migration.sql create mode 100644 prisma/migrations/20260719081419_add_collection_pickup_fields/migration.sql create mode 100644 prisma/migrations/20260719081905_add_phone_number_to_order/migration.sql create mode 100644 src/app/api/admin/notifications/route.ts create mode 100644 src/components/AdminNotificationsWrapper.tsx create mode 100644 src/lib/whatsapp.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dbb3e5b..c038a7c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/prisma/migrations/20260719074839_add_collected_by_customer/migration.sql b/prisma/migrations/20260719074839_add_collected_by_customer/migration.sql new file mode 100644 index 0000000..40862ac --- /dev/null +++ b/prisma/migrations/20260719074839_add_collected_by_customer/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "collectedByCustomer" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260719081419_add_collection_pickup_fields/migration.sql b/prisma/migrations/20260719081419_add_collection_pickup_fields/migration.sql new file mode 100644 index 0000000..0f89d10 --- /dev/null +++ b/prisma/migrations/20260719081419_add_collection_pickup_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "isCollectionPickup" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "readyForCollection" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260719081905_add_phone_number_to_order/migration.sql b/prisma/migrations/20260719081905_add_phone_number_to_order/migration.sql new file mode 100644 index 0000000..c728305 --- /dev/null +++ b/prisma/migrations/20260719081905_add_phone_number_to_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "phoneNumber" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e378fae..de1a354 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] } diff --git a/src/app/account/orders/[id]/page.tsx b/src/app/account/orders/[id]/page.tsx index 718576f..87e25d3 100644 --- a/src/app/account/orders/[id]/page.tsx +++ b/src/app/account/orders/[id]/page.tsx @@ -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:

Order

- {statusLabel(order.status)} + {statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
+ {order.isCollectionPickup && order.readyForCollection && ( +
+

Ready for Collection

+

✓ Your order is ready for pickup

+

Please collect your order at your earliest convenience

+
+ )} + + {order.trackingNumber && ( +
+

Tracking information

+

+ {order.carrier || 'Royal Mail'} +

+

{order.trackingNumber}

+ {order.estimatedDeliveryDate && ( +

+ Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })} +

+ )} +
+ )} +

Placed

diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index b22727b..136206f 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -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'}

- - {statusLabel(order.status)} + + {statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
{formatPrice(order.total)} + {/* Collection Ready Notification */} + {order.isCollectionPickup && order.readyForCollection && ( +
+

✓ Your order is ready for collection

+

Please come collect your order at your earliest convenience

+
+ )} + {/* Tracking Information */} {order.carrier && order.trackingNumber && (
diff --git a/src/app/admin/gallery/actions.ts b/src/app/admin/gallery/actions.ts index 40dfe8d..ad3378e 100644 --- a/src/app/admin/gallery/actions.ts +++ b/src/app/admin/gallery/actions.ts @@ -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) { diff --git a/src/app/admin/gallery/categories/new/page.tsx b/src/app/admin/gallery/categories/new/page.tsx index a6fcf12..d5e00dc 100644 --- a/src/app/admin/gallery/categories/new/page.tsx +++ b/src/app/admin/gallery/categories/new/page.tsx @@ -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 (
@@ -11,6 +17,7 @@ export default function NewGalleryCategoryPage() {

+
diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index d377cd3..2a49147 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -134,6 +134,19 @@ export default async function OrderDetailPage({ params }: { params: { id: string
+
+ +
+
+ + ) : ( +
+

✓ Marked ready for collection

+
+ )} +
+ )} + +
+

Order Complete

+

Mark this order as complete if tracking won't be provided or it was collected by the customer.

+
+ + + +
+ + +
+ + +
+
+ {order.trackingNumber && (

Royal Mail Integration

@@ -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" />
-

{item.placementPreviewUrl ? 'On product — front' : 'Front'}

+

{item.placementPreviewUrl ? 'On product — front' : 'Front'}

{hasFrontDesign && (
-

On product — back

+

On product — back

{hasBackDesign && item.designPreviewUrlBack && (
= { 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 Personalised )} {formatPrice(order.total)} - - {statusLabel(order.status)} - +
+ + {statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)} + + {order.status === 'DELIVERED' && order.collectedByCustomer && ( + ✓ Collected + )} +
- +
+

Delivery

+
+ + +
+
+ + {deliveryMode === 'shipping' && ( + + )} + + {deliveryMode === 'collection' && ( +
+

✓ Free collection pickup - no postage charge

+
+ )}
); } diff --git a/src/components/AdminNotifications.tsx b/src/components/AdminNotifications.tsx index ca732ac..4a66690 100644 --- a/src/components/AdminNotifications.tsx +++ b/src/components/AdminNotifications.tsx @@ -17,12 +17,22 @@ export default async function AdminNotifications() { ] = await Promise.all([ prisma.order.count({ where: { - status: 'PENDING', - createdAt: { gte: recentThreshold }, + 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' }, diff --git a/src/components/AdminNotificationsBadge.tsx b/src/components/AdminNotificationsBadge.tsx index 0d6238f..df0d535 100644 --- a/src/components/AdminNotificationsBadge.tsx +++ b/src/components/AdminNotificationsBadge.tsx @@ -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({

Notifications

{pendingOrders > 0 && ( -
- Waiting payment - {pendingOrders} -
+ +
+ Action needed + {pendingOrders} +
+ )} {designApprovals > 0 && ( -
- Design approvals - {designApprovals} -
+ +
+ Design approvals + {designApprovals} +
+ )} {photoRequests > 0 && ( -
- Photo requests - {photoRequests} -
+ +
+ Photo requests + {photoRequests} +
+ )} {unreadMessages > 0 && ( -
- Unread messages - {unreadMessages} -
+ +
+ Unread messages + {unreadMessages} +
+ )}
)} diff --git a/src/components/AdminNotificationsWrapper.tsx b/src/components/AdminNotificationsWrapper.tsx new file mode 100644 index 0000000..bb8e120 --- /dev/null +++ b/src/components/AdminNotificationsWrapper.tsx @@ -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({ + 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 ( + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 04bc98a..b9ea3a1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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() { {isAdmin && (
- +
)} diff --git a/src/components/OlderOrders.tsx b/src/components/OlderOrders.tsx index 5b2350c..ec977b4 100644 --- a/src/components/OlderOrders.tsx +++ b/src/components/OlderOrders.tsx @@ -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'}

- - {statusLabel(order.status)} + + {statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
{formatPrice(order.total)} diff --git a/src/components/StandardProductView.tsx b/src/components/StandardProductView.tsx index ffb5ad0..de1115f 100644 --- a/src/components/StandardProductView.tsx +++ b/src/components/StandardProductView.tsx @@ -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); }; diff --git a/src/lib/whatsapp.ts b/src/lib/whatsapp.ts new file mode 100644 index 0000000..53ed79d --- /dev/null +++ b/src/lib/whatsapp.ts @@ -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; +}