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:
Andymick
2026-07-19 09:29:09 +01:00
co-authored by Claude Haiku 4.5
parent a105fdf660
commit 36cc6dddff
26 changed files with 553 additions and 80 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+80 -2
View File
@@ -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}
+35 -1
View File
@@ -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');
}
}
+12 -5
View File
@@ -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">