Feature: Add order tracking and shipping status updates
- Add carrier, trackingNumber, and estimatedDeliveryDate fields to Order model - Update order statuses: PENDING, PAID, PRINTING, PRINTED, SHIPPED, DELIVERED, FAILED - Add admin UI on order detail page to update tracking information - Display tracking info on customer account page in order history - Server action to update order tracking and status Customers can now see their shipping status and tracking details from their account page. Admins can update order status and tracking info from the order detail page.
This commit is contained in:
@@ -75,7 +75,8 @@
|
|||||||
"Bash(git commit -m 'Fix: Handle undefined designElements in design spec SVG rendering *)",
|
"Bash(git commit -m 'Fix: Handle undefined designElements in design spec SVG rendering *)",
|
||||||
"Bash(git commit -m 'Fix: Ensure cart is completely cleared after payment success *)",
|
"Bash(git commit -m 'Fix: Ensure cart is completely cleared after payment success *)",
|
||||||
"Bash(git commit -m 'Fix: Update order status to PAID on checkout success page *)",
|
"Bash(git commit -m 'Fix: Update order status to PAID on checkout success page *)",
|
||||||
"Bash(git commit -m 'Improve: Cart clearing on success page with better state management *)"
|
"Bash(git commit -m 'Improve: Cart clearing on success page with better state management *)",
|
||||||
|
"Bash(git commit -m 'Feature: Add order tracking and shipping status updates *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Order" ADD COLUMN "carrier" TEXT,
|
||||||
|
ADD COLUMN "estimatedDeliveryDate" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "trackingNumber" TEXT;
|
||||||
@@ -218,9 +218,15 @@ model Order {
|
|||||||
stripeSessionId String? @unique
|
stripeSessionId String? @unique
|
||||||
email String?
|
email String?
|
||||||
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
|
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
|
||||||
status String @default("PENDING") // PENDING | PAID | FAILED
|
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
total Int // cents
|
total Int // cents
|
||||||
|
|
||||||
|
// Shipping & tracking info
|
||||||
|
carrier String? // e.g. "Royal Mail", "DPD", "Courier"
|
||||||
|
trackingNumber String? // tracking number from carrier
|
||||||
|
estimatedDeliveryDate DateTime? // estimated delivery date
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ export default async function AccountPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-4 divide-y divide-line border-t border-line">
|
<div className="mt-4 divide-y divide-line border-t border-line">
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
|
<div key={order.id} className="border-b border-line py-4 last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
key={order.id}
|
|
||||||
href={`/account/orders/${order.id}`}
|
href={`/account/orders/${order.id}`}
|
||||||
className="flex flex-wrap items-center justify-between gap-2 py-4 hover:bg-surface"
|
className="flex flex-wrap items-center justify-between gap-2 hover:opacity-80"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
@@ -106,6 +106,21 @@ export default async function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Tracking Information */}
|
||||||
|
{order.carrier && order.trackingNumber && (
|
||||||
|
<div className="mt-3 border-t border-line pt-3 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>Tracking:</strong> {order.carrier} · {order.trackingNumber}
|
||||||
|
</p>
|
||||||
|
{order.estimatedDeliveryDate && (
|
||||||
|
<p className="text-muted">
|
||||||
|
Est. delivery: {new Date(order.estimatedDeliveryDate).toLocaleDateString(undefined, { dateStyle: 'medium' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import AdminNav from '@/components/AdminNav';
|
import AdminNav from '@/components/AdminNav';
|
||||||
import { archiveOrder, unarchiveOrder } from '../actions';
|
import { archiveOrder, unarchiveOrder, updateOrderTracking } from '../actions';
|
||||||
|
|
||||||
function formatPrice(cents: number) {
|
function formatPrice(cents: number) {
|
||||||
return `£${(cents / 100).toFixed(2)}`;
|
return `£${(cents / 100).toFixed(2)}`;
|
||||||
@@ -76,6 +76,72 @@ export default async function OrderDetailPage({ params }: { params: { id: string
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tracking Information */}
|
||||||
|
<div className="mt-6 border border-line bg-surface p-6">
|
||||||
|
<h2 className="mb-4 font-display text-lg">Shipping & Tracking</h2>
|
||||||
|
<form action={updateOrderTracking} className="space-y-4">
|
||||||
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="tag-label mb-1 block">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={order.status}
|
||||||
|
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="PENDING">PENDING</option>
|
||||||
|
<option value="PAID">PAID</option>
|
||||||
|
<option value="PRINTING">PRINTING</option>
|
||||||
|
<option value="PRINTED">PRINTED</option>
|
||||||
|
<option value="SHIPPED">SHIPPED</option>
|
||||||
|
<option value="DELIVERED">DELIVERED</option>
|
||||||
|
<option value="FAILED">FAILED</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="tag-label mb-1 block">Carrier</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="carrier"
|
||||||
|
defaultValue={order.carrier ?? ''}
|
||||||
|
placeholder="e.g. Royal Mail, DPD, Courier"
|
||||||
|
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="tag-label mb-1 block">Tracking Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="trackingNumber"
|
||||||
|
defaultValue={order.trackingNumber ?? ''}
|
||||||
|
placeholder="e.g. RM123456789GB"
|
||||||
|
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="tag-label mb-1 block">Estimated Delivery Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="estimatedDeliveryDate"
|
||||||
|
defaultValue={order.estimatedDeliveryDate ? new Date(order.estimatedDeliveryDate).toISOString().split('T')[0] : ''}
|
||||||
|
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark"
|
||||||
|
>
|
||||||
|
Update Tracking Info
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 divide-y divide-line border-t border-line">
|
<div className="mt-10 divide-y divide-line border-t border-line">
|
||||||
{order.items.map((item) => {
|
{order.items.map((item) => {
|
||||||
let hasFrontDesign = false;
|
let hasFrontDesign = false;
|
||||||
|
|||||||
@@ -35,3 +35,25 @@ export async function unarchiveOrder(formData: FormData) {
|
|||||||
await prisma.order.update({ where: { id }, data: { archived: false } });
|
await prisma.order.update({ where: { id }, data: { archived: false } });
|
||||||
redirect('/admin/orders?archived=1');
|
redirect('/admin/orders?archived=1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateOrderTracking(formData: FormData) {
|
||||||
|
const id = String(formData.get('orderId') ?? '');
|
||||||
|
const status = String(formData.get('status') ?? 'PENDING');
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
carrier,
|
||||||
|
trackingNumber,
|
||||||
|
estimatedDeliveryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Send email notification to customer when status changes
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user