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
+3 -1
View File
@@ -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;
+4
View File
@@ -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[]
}
+30 -5
View File
@@ -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>
+16 -6
View File
@@ -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">
+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');
}
}
+10 -3
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>
<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">
+62
View File
@@ -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,
});
}
}
+31 -3
View File
@@ -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,
+53 -6
View File
@@ -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();
+21
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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>
);
}
+11 -1
View File
@@ -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' },
+14 -5
View File
@@ -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}
/>
);
}
+2 -2
View File
@@ -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>
)}
+4 -4
View File
@@ -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>
+2 -2
View File
@@ -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);
};
+62
View File
@@ -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;
}