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(git commit -m 'Add: Shipping selector component and checkout integration *)",
|
||||||
"Bash(node clear-pending-orders.js)",
|
"Bash(node clear-pending-orders.js)",
|
||||||
"mcp__visualize__read_me",
|
"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"
|
carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier"
|
||||||
trackingNumber String? // tracking number from carrier
|
trackingNumber String? // tracking number from carrier
|
||||||
estimatedDeliveryDate DateTime? // estimated delivery date
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -240,6 +243,7 @@ model Order {
|
|||||||
|
|
||||||
guestName String? // full name for guest orders
|
guestName String? // full name for guest orders
|
||||||
guestPhone String? // contact phone for guest orders
|
guestPhone String? // contact phone for guest orders
|
||||||
|
phoneNumber String? // phone number for WhatsApp notifications (collection orders)
|
||||||
|
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ function formatPrice(cents: number) {
|
|||||||
return `£${(cents / 100).toFixed(2)}`;
|
return `£${(cents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||||
if (status === 'PAID') return 'Confirmed';
|
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Delivered';
|
||||||
|
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||||
if (status === 'FAILED') return 'Failed';
|
if (status === 'FAILED') return 'Failed';
|
||||||
return 'Processing';
|
return 'Processing';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClasses(status: string) {
|
function statusClasses(status: string, trackingNumber?: string | null) {
|
||||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
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';
|
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||||
return 'bg-splash-orange/10 text-splash-orange';
|
return 'bg-splash-orange/10 text-splash-orange';
|
||||||
}
|
}
|
||||||
@@ -38,9 +40,32 @@ export default async function AccountOrderDetailPage({ params }: { params: { id:
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="mt-4 flex items-baseline justify-between gap-3">
|
<div className="mt-4 flex items-baseline justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl">Order</h1>
|
<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>
|
</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 className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="tag-label mb-1">Placed</p>
|
<p className="tag-label mb-1">Placed</p>
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ function formatPrice(cents: number) {
|
|||||||
return `£${(cents / 100).toFixed(2)}`;
|
return `£${(cents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||||
if (status === 'PAID') return 'Confirmed';
|
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Delivered';
|
||||||
|
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||||
if (status === 'FAILED') return 'Failed';
|
if (status === 'FAILED') return 'Failed';
|
||||||
if (status === 'PENDING') return 'Waiting payment';
|
if (status === 'PENDING') return 'Waiting payment';
|
||||||
return 'Processing';
|
return 'Processing';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClasses(status: string) {
|
function statusClasses(status: string, trackingNumber?: string | null) {
|
||||||
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
|
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';
|
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
|
||||||
return 'bg-splash-orange/10 text-splash-orange';
|
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' })} ·{' '}
|
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
|
||||||
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
|
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status, order.trackingNumber)}`}>
|
||||||
{statusLabel(order.status)}
|
{statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||||
</Link>
|
</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 */}
|
{/* Tracking Information */}
|
||||||
{order.carrier && order.trackingNumber && (
|
{order.carrier && order.trackingNumber && (
|
||||||
<div className="mt-3 border-t border-line pt-3 text-sm">
|
<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) {
|
export async function createGalleryCategory(formData: FormData) {
|
||||||
const name = String(formData.get('name') ?? '').trim();
|
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.');
|
if (!name) throw new Error('Category name is required.');
|
||||||
|
|
||||||
const slug = await uniqueSlug(slugify(name));
|
const slug = await uniqueSlug(slugify(name));
|
||||||
|
|
||||||
await prisma.galleryCategory.create({ data: { name, slug } });
|
await prisma.galleryCategory.create({ data: { name, slug } });
|
||||||
|
|
||||||
redirect('/admin/gallery/categories');
|
redirect(redirectTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGalleryCategory(formData: FormData) {
|
export async function deleteGalleryCategory(formData: FormData) {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import AdminNav from '@/components/AdminNav';
|
import AdminNav from '@/components/AdminNav';
|
||||||
import { createGalleryCategory } from '../../actions';
|
import { createGalleryCategory } from '../../actions';
|
||||||
|
|
||||||
export default function NewGalleryCategoryPage() {
|
export default function NewGalleryCategoryPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { redirectTo?: string };
|
||||||
|
}) {
|
||||||
|
const redirectTo = searchParams.redirectTo || '/admin/gallery/categories';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md px-6 py-12">
|
<div className="mx-auto max-w-md px-6 py-12">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
@@ -11,6 +17,7 @@ export default function NewGalleryCategoryPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form action={createGalleryCategory} className="mt-8 space-y-6">
|
<form action={createGalleryCategory} className="mt-8 space-y-6">
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="tag-label mb-2 block">
|
<label htmlFor="name" className="tag-label mb-2 block">
|
||||||
Category name
|
Category name
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default async function NewGalleryPhotoPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
+ Add a new category
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -144,6 +157,71 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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 && (
|
{order.trackingNumber && (
|
||||||
<div className="mt-4 border-t border-line pt-4">
|
<div className="mt-4 border-t border-line pt-4">
|
||||||
<p className="tag-label mb-3">Royal Mail Integration</p>
|
<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"
|
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<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 && (
|
{hasFrontDesign && (
|
||||||
<a
|
<a
|
||||||
href={item.designPreviewUrl}
|
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"
|
className="w-full max-w-md border border-line bg-paper object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<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 && (
|
{hasBackDesign && item.designPreviewUrlBack && (
|
||||||
<a
|
<a
|
||||||
href={item.designPreviewUrlBack}
|
href={item.designPreviewUrlBack}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { sendWhatsAppMessage } from '@/lib/whatsapp';
|
||||||
|
|
||||||
const AUTO_ARCHIVE_DAYS = 30;
|
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 carrier = formData.get('carrier') ? String(formData.get('carrier')) : null;
|
||||||
const trackingNumber = formData.get('trackingNumber') ? String(formData.get('trackingNumber')) : null;
|
const trackingNumber = formData.get('trackingNumber') ? String(formData.get('trackingNumber')) : null;
|
||||||
const estimatedDeliveryDate = formData.get('estimatedDeliveryDate') ? new Date(String(formData.get('estimatedDeliveryDate'))) : 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;
|
if (!id) return;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { customer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
// Update the order
|
||||||
await prisma.order.update({
|
await prisma.order.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -53,11 +67,31 @@ export async function updateOrderTracking(formData: FormData) {
|
|||||||
carrier,
|
carrier,
|
||||||
trackingNumber,
|
trackingNumber,
|
||||||
estimatedDeliveryDate,
|
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
|
// Revalidate the page to show updated data
|
||||||
revalidatePath(`/admin/orders/${id}`);
|
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)}`;
|
return `£${(cents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) {
|
||||||
if (status === 'PAID') return 'Confirmed';
|
if (status === 'DELIVERED') return collectedByCustomer ? 'Collected' : 'Complete';
|
||||||
|
if (status === 'PAID') return trackingNumber ? 'Dispatched' : 'Awaiting dispatch';
|
||||||
if (status === 'FAILED') return 'Failed';
|
if (status === 'FAILED') return 'Failed';
|
||||||
if (status === 'PENDING') return 'Waiting payment';
|
if (status === 'PENDING') return 'Waiting payment';
|
||||||
return 'Processing';
|
return 'Processing';
|
||||||
@@ -18,6 +19,7 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
PAID: 'bg-splash-blue/10 text-splash-blue',
|
PAID: 'bg-splash-blue/10 text-splash-blue',
|
||||||
PENDING: 'bg-splash-orange/10 text-splash-orange',
|
PENDING: 'bg-splash-orange/10 text-splash-orange',
|
||||||
FAILED: 'bg-splash-pink/10 text-splash-pink',
|
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 } }) {
|
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="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="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||||
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
<div className="flex items-center gap-2">
|
||||||
{statusLabel(order.status)}
|
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
|
||||||
</span>
|
{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}>
|
<form action={showArchived ? unarchiveOrder : archiveOrder}>
|
||||||
<input type="hidden" name="id" value={order.id} />
|
<input type="hidden" name="id" value={order.id} />
|
||||||
<button type="submit" className="tag-label text-muted hover:text-clay">
|
<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));
|
cleanupPendingOrders().catch((err) => console.error('Cleanup error:', err));
|
||||||
|
|
||||||
const body = await req.json();
|
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 items: CheckoutCartItem[] = body.items ?? [];
|
||||||
const guest: GuestInfo | undefined = body.guest;
|
const guest: GuestInfo | undefined = body.guest;
|
||||||
const shippingCountry: string = body.shippingCountry ?? 'GB';
|
const shippingCountry: string = body.shippingCountry ?? 'GB';
|
||||||
const shippingService: string | undefined = body.shippingService;
|
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) {
|
if (items.length === 0) {
|
||||||
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
|
||||||
@@ -95,11 +118,15 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const subtotal = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
|
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 shippingCost = 0;
|
||||||
let selectedShippingService = shippingService;
|
let selectedShippingService = shippingService;
|
||||||
|
|
||||||
try {
|
if (isCollection) {
|
||||||
|
console.log('Collection order - no shipping cost');
|
||||||
|
selectedShippingService = null;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
// Calculate total weight from cart items
|
// Calculate total weight from cart items
|
||||||
let totalWeightGrams = 0;
|
let totalWeightGrams = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -138,9 +165,9 @@ export async function POST(req: Request) {
|
|||||||
// Default shipping cost if calculation fails
|
// Default shipping cost if calculation fails
|
||||||
shippingCost = 500; // £5.00
|
shippingCost = 500; // £5.00
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const total = subtotal + shippingCost;
|
const total = subtotal + shippingCost;
|
||||||
const customer = await getCurrentCustomer();
|
|
||||||
|
|
||||||
// Save the order up front, before redirecting to Stripe — this is what makes
|
// Save the order up front, before redirecting to Stripe — this is what makes
|
||||||
// the design files (and the order itself) retrievable afterward, whether or
|
// 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,
|
email: guest?.email ?? customer?.email,
|
||||||
guestName: guest?.name,
|
guestName: guest?.name,
|
||||||
guestPhone: guest?.phone,
|
guestPhone: guest?.phone,
|
||||||
|
isCollectionPickup,
|
||||||
items: {
|
items: {
|
||||||
create: pricedItems.map((i) => ({
|
create: pricedItems.map((i) => ({
|
||||||
productId: i.productId,
|
productId: i.productId,
|
||||||
|
|||||||
+57
-10
@@ -18,6 +18,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
|||||||
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | 'edit-address' | null>(isLoggedIn ? 'logged-in' : null);
|
const [mode, setMode] = useState<'logged-in' | 'login' | 'guest' | 'edit-address' | null>(isLoggedIn ? 'logged-in' : null);
|
||||||
const [checkingOut, setCheckingOut] = useState(false);
|
const [checkingOut, setCheckingOut] = useState(false);
|
||||||
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
const [checkoutError, setCheckoutError] = useState<string | null>(null);
|
||||||
|
const [deliveryMode, setDeliveryMode] = useState<'shipping' | 'collection'>('shipping');
|
||||||
const [shippingCountry, setShippingCountry] = useState(customerAddress?.country || 'GB');
|
const [shippingCountry, setShippingCountry] = useState(customerAddress?.country || 'GB');
|
||||||
const [shippingService, setShippingService] = useState<string | null>(null);
|
const [shippingService, setShippingService] = useState<string | null>(null);
|
||||||
const [shippingCost, setShippingCost] = useState(0);
|
const [shippingCost, setShippingCost] = useState(0);
|
||||||
@@ -73,7 +74,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
|||||||
setCheckoutError('Please fill in all fields.');
|
setCheckoutError('Please fill in all fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shippingService) {
|
if (deliveryMode === 'shipping' && !shippingService) {
|
||||||
setCheckoutError('Please select a shipping method.');
|
setCheckoutError('Please select a shipping method.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,8 +98,9 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
|||||||
phone: guestData.phone,
|
phone: guestData.phone,
|
||||||
address: guestData.address,
|
address: guestData.address,
|
||||||
},
|
},
|
||||||
shippingCountry,
|
shippingCountry: deliveryMode === 'collection' ? null : shippingCountry,
|
||||||
shippingService,
|
shippingService: deliveryMode === 'collection' ? null : shippingService,
|
||||||
|
isCollection: deliveryMode === 'collection',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -211,14 +213,58 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShippingSelector
|
<div>
|
||||||
items={items}
|
<p className="font-medium text-sm mb-3">Delivery</p>
|
||||||
onShippingSelect={handleShippingSelect}
|
<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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!shippingService) {
|
if (deliveryMode === 'shipping' && !shippingService) {
|
||||||
setCheckoutError('Please select a shipping method.');
|
setCheckoutError('Please select a shipping method.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,8 +287,9 @@ export default function CheckoutPageClient({ isLoggedIn, customer, customerAddre
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
items,
|
items,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
shippingCountry,
|
shippingCountry: deliveryMode === 'collection' ? null : shippingCountry,
|
||||||
shippingService,
|
shippingService: deliveryMode === 'collection' ? null : shippingService,
|
||||||
|
isCollection: deliveryMode === 'collection',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ export default async function CheckoutSuccessPage({
|
|||||||
include: { items: true },
|
include: { items: true },
|
||||||
});
|
});
|
||||||
console.log('✓ Order status updated to PAID:', order.id);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error handling order for success page:', err);
|
console.error('Error handling order for success page:', err);
|
||||||
|
|||||||
+16
-16
@@ -105,23 +105,23 @@ export default async function HomePage() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delayMs={150} className="flex gap-4">
|
<Reveal delayMs={150} className="flex gap-4">
|
||||||
{/* Customer Work - Shirt 1 */}
|
{galleryPhotos.slice(0, 2).map((photo, idx) => (
|
||||||
<div className="flex-1">
|
<div key={photo.id} className={`flex-1 ${idx === 1 ? 'translate-y-6' : ''}`}>
|
||||||
<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">
|
<div className="border-2 border-line rounded-lg overflow-hidden aspect-square">
|
||||||
<p className="text-sm font-medium text-muted mb-2">Dad Bod Design</p>
|
<img
|
||||||
<p className="text-xs text-muted">Customer creation</p>
|
src={photo.imageDataUrl}
|
||||||
|
alt={photo.caption || 'Customer design'}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{photo.caption && (
|
||||||
|
<p className="mt-3 text-xs text-muted text-center font-medium">{photo.caption}</p>
|
||||||
|
)}
|
||||||
|
{!photo.caption && (
|
||||||
|
<p className="mt-3 text-xs text-muted text-center font-medium">Customer work</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs text-muted text-center font-medium">Real customer work</p>
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Work - Shirt 2 */}
|
|
||||||
<div className="flex-1 translate-y-6">
|
|
||||||
<div className="border-2 border-line bg-gradient-to-br from-splash-blue/10 to-splash-blue/5 rounded-lg p-6 aspect-square flex flex-col items-center justify-center text-center">
|
|
||||||
<p className="text-sm font-medium text-muted mb-2">Cheers & Beers</p>
|
|
||||||
<p className="text-xs text-muted">Customer creation</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-xs text-muted text-center font-medium">Real customer work</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export default function AdminNav() {
|
|||||||
<Link href="/admin/designs" className="tag-label hover:text-ink">
|
<Link href="/admin/designs" className="tag-label hover:text-ink">
|
||||||
Design Approvals
|
Design Approvals
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ export default async function AdminNotifications() {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.order.count({
|
prisma.order.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'PENDING',
|
OR: [
|
||||||
createdAt: { gte: recentThreshold },
|
{
|
||||||
|
status: 'PENDING',
|
||||||
|
archived: false,
|
||||||
|
createdAt: { gte: recentThreshold },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'PAID',
|
||||||
|
archived: false,
|
||||||
|
trackingNumber: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.designApproval.count({
|
prisma.designApproval.count({
|
||||||
where: { status: 'IN_REVIEW' },
|
where: { status: { in: ['PENDING', 'IN_REVIEW'] } },
|
||||||
}),
|
}),
|
||||||
prisma.customRequest.count({
|
prisma.customRequest.count({
|
||||||
where: { status: 'PENDING' },
|
where: { status: 'PENDING' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
interface AdminNotificationsBadgeProps {
|
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">
|
<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>
|
<p className="font-bold text-ink">Notifications</p>
|
||||||
{pendingOrders > 0 && (
|
{pendingOrders > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
<Link href="/admin/orders">
|
||||||
<span className="text-muted">Waiting payment</span>
|
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||||
<span className="font-bold text-splash-orange">{pendingOrders}</span>
|
<span className="text-muted">Action needed</span>
|
||||||
</div>
|
<span className="font-bold text-splash-orange">{pendingOrders}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{designApprovals > 0 && (
|
{designApprovals > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
<Link href="/admin/designs">
|
||||||
<span className="text-muted">Design approvals</span>
|
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||||
<span className="font-bold text-splash-blue">{designApprovals}</span>
|
<span className="text-muted">Design approvals</span>
|
||||||
</div>
|
<span className="font-bold text-splash-blue">{designApprovals}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{photoRequests > 0 && (
|
{photoRequests > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
<Link href="/admin/custom-requests">
|
||||||
<span className="text-muted">Photo requests</span>
|
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||||
<span className="font-bold text-clay">{photoRequests}</span>
|
<span className="text-muted">Photo requests</span>
|
||||||
</div>
|
<span className="font-bold text-clay">{photoRequests}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{unreadMessages > 0 && (
|
{unreadMessages > 0 && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded">
|
<Link href="/admin/inbox">
|
||||||
<span className="text-muted">Unread messages</span>
|
<div className="flex items-center justify-between py-2 px-3 bg-surface rounded hover:bg-surface-dark cursor-pointer transition-colors">
|
||||||
<span className="font-bold text-splash-pink">{unreadMessages}</span>
|
<span className="text-muted">Unread messages</span>
|
||||||
</div>
|
<span className="font-bold text-splash-pink">{unreadMessages}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { getCurrentCustomer } from '@/lib/auth';
|
||||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||||
import AdminMenu from './AdminMenu';
|
import AdminMenu from './AdminMenu';
|
||||||
import AdminNotifications from './AdminNotifications';
|
import AdminNotificationsWrapper from './AdminNotificationsWrapper';
|
||||||
|
|
||||||
export default async function Header() {
|
export default async function Header() {
|
||||||
const [categories, collections, customer, isAdmin] = await Promise.all([
|
const [categories, collections, customer, isAdmin] = await Promise.all([
|
||||||
@@ -75,7 +75,7 @@ export default async function Header() {
|
|||||||
<CurrencySelector />
|
<CurrencySelector />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AdminNotifications />
|
<AdminNotificationsWrapper />
|
||||||
<AdminMenu />
|
<AdminMenu />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import Link from 'next/link';
|
|||||||
interface OlderOrdersProps {
|
interface OlderOrdersProps {
|
||||||
orders: any[];
|
orders: any[];
|
||||||
formatPrice: (cents: number) => string;
|
formatPrice: (cents: number) => string;
|
||||||
statusLabel: (status: string) => string;
|
statusLabel: (status: string, trackingNumber?: string | null, collectedByCustomer?: boolean) => string;
|
||||||
statusClasses: (status: string) => string;
|
statusClasses: (status: string, trackingNumber?: string | null) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OlderOrders({ orders, formatPrice, statusLabel, statusClasses }: OlderOrdersProps) {
|
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' })} ·{' '}
|
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
|
||||||
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
{order.items.length} item{order.items.length === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
|
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status, order.trackingNumber)}`}>
|
||||||
{statusLabel(order.status)}
|
{statusLabel(order.status, order.trackingNumber, order.collectedByCustomer)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ export default function StandardProductView({ product }: { product: ProductDTO }
|
|||||||
unitPrice: product.effectivePrice,
|
unitPrice: product.effectivePrice,
|
||||||
designJson: { front: [], back: [] },
|
designJson: { front: [], back: [] },
|
||||||
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
||||||
designPreviewUrlBack: product.imageUrlBack ?? null,
|
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||||||
placementPreviewUrlBack: product.imageUrlBack ?? null,
|
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||||||
});
|
});
|
||||||
setShowAddToCartModal(true);
|
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