feat/admin #2

Merged
admin merged 10 commits from feat/admin into main 2026-03-07 20:51:13 +00:00
32 changed files with 3046 additions and 45 deletions
Showing only changes of commit 3d50cb895c - Show all commits

View File

@@ -0,0 +1,143 @@
"use client"
import { useQuery } from "convex/react"
import { useParams } from "next/navigation"
import { api } from "../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../convex/_generated/dataModel"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { OrderPageHeader } from "@/components/orders/detail/OrderPageHeader"
import { OrderItemsCard } from "@/components/orders/detail/OrderItemsCard"
import { OrderFinancialsCard } from "@/components/orders/detail/OrderFinancialsCard"
import { CustomerCard } from "@/components/orders/detail/CustomerCard"
import { ShippingAddressCard } from "@/components/orders/detail/ShippingAddressCard"
import { FulfilmentCard } from "@/components/orders/detail/FulfilmentCard"
import { OrderTimelineCard } from "@/components/orders/detail/OrderTimelineCard"
import { OrderActionsBar } from "@/components/orders/detail/OrderActionsBar"
// ─── Per-card skeletons (no layout shift on load) ─────────────────────────────
function HeaderSkeleton() {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-20" />
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-32" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-36" />
</div>
</div>
)
}
function CardSkeleton({ rows = 3 }: { rows?: number }) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function OrderDetailPage() {
const params = useParams()
const orderId = params.id as Id<"orders">
const order = useQuery(api.orders.getById, { id: orderId })
// Timeline query is wired here for Phase 5 — passed to OrderTimelineCard
const events = useQuery(api.orders.getTimeline, { orderId })
// ── Loading state ───────────────────────────────────────────────────────────
if (order === undefined) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<HeaderSkeleton />
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
{/* Left column */}
<div className="flex flex-col gap-6">
<CardSkeleton rows={4} />
<CardSkeleton rows={4} />
<CardSkeleton rows={5} />
</div>
{/* Right column */}
<div className="flex flex-col gap-6">
<CardSkeleton rows={2} />
<CardSkeleton rows={3} />
<CardSkeleton rows={4} />
</div>
</div>
</main>
)
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
{/* Header — back link, order#, date, status badges */}
<OrderPageHeader
orderNumber={order.orderNumber}
createdAt={order.createdAt}
status={order.status}
paymentStatus={order.paymentStatus}
/>
<OrderActionsBar
orderId={order._id}
status={order.status}
paymentStatus={order.paymentStatus}
trackingNumber={order.trackingNumber}
returnRequestedAt={order.returnRequestedAt}
returnReceivedAt={order.returnReceivedAt}
total={order.total}
currency={order.currency}
/>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
{/* ── Left column ──────────────────────────────────────────────────── */}
<div className="flex flex-col gap-6">
<OrderItemsCard items={order.items} currency={order.currency} />
<OrderFinancialsCard
subtotal={order.subtotal}
shipping={order.shipping}
discount={order.discount}
tax={order.tax}
total={order.total}
currency={order.currency}
/>
<OrderTimelineCard events={events} />
</div>
{/* ── Right column ─────────────────────────────────────────────────── */}
<div className="flex flex-col gap-6">
<CustomerCard
name={order.shippingAddressSnapshot.fullName}
email={order.email}
/>
<ShippingAddressCard address={order.shippingAddressSnapshot} />
<FulfilmentCard
carrier={order.carrier}
shippingMethod={order.shippingMethod}
shippingServiceCode={order.shippingServiceCode}
trackingNumber={order.trackingNumber}
trackingUrl={order.trackingUrl}
labelUrl={order.labelUrl}
returnLabelUrl={order.returnLabelUrl}
trackingStatus={order.trackingStatus}
estimatedDelivery={order.estimatedDelivery}
actualDelivery={order.actualDelivery}
status={order.status}
/>
</div>
</div>
</main>
)
}

View File

@@ -1,5 +1,267 @@
import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; "use client"
import { useState, useMemo } from "react"
import { usePaginatedQuery } from "convex/react"
import { useRouter } from "next/navigation"
import { api } from "../../../../../../convex/_generated/api"
import { formatPrice } from "@repo/utils"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Search01Icon,
Cancel01Icon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons"
import { OrderStatusBadge } from "@/components/orders/shared/OrderStatusBadge"
import { OrderPaymentBadge } from "@/components/orders/shared/OrderPaymentBadge"
import {
ORDER_STATUS_CONFIG,
type OrderStatus,
} from "@/components/orders/shared/statusConfig"
// ─── Status filter options ────────────────────────────────────────────────────
const STATUS_OPTIONS = [
{ value: "all", label: "All statuses" },
...Object.entries(ORDER_STATUS_CONFIG).map(([value, config]) => ({
value,
label: config.label,
})),
]
// ─── Skeleton ─────────────────────────────────────────────────────────────────
function TableSkeleton() {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="size-4" />
</TableCell>
</TableRow>
))}
</>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function OrdersPage() { export default function OrdersPage() {
return <StillBuildingPlaceholder />; const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const {
results,
status,
loadMore,
} = usePaginatedQuery(
api.orders.listAll,
statusFilter !== "all" ? { status: statusFilter } : {},
{ initialNumItems: 25 },
)
const isLoading = status === "LoadingFirstPage"
// Client-side search over loaded results.
// NOTE: filtered to the currently loaded page only — no server-side search
// index exists yet. Add api.orders.searchByOrderNumberOrEmail when needed.
const orders = useMemo(() => {
const q = searchInput.trim().toLowerCase()
if (!q) return results
return results.filter(
(o) =>
o.orderNumber.toLowerCase().includes(q) ||
o.email.toLowerCase().includes(q),
)
}, [results, searchInput])
const isSearching = searchInput.trim().length > 0
function emptyMessage() {
if (isSearching) return `No orders match "${searchInput.trim()}".`
if (statusFilter !== "all") {
const label =
ORDER_STATUS_CONFIG[statusFilter as OrderStatus]?.label ?? statusFilter
return `No orders with status "${label}".`
}
return "No orders yet."
}
return (
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Orders</h1>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<HugeiconsIcon
icon={Search01Icon}
strokeWidth={2}
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by order # or email…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchInput("")}
aria-label="Clear search"
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="size-4"
/>
</button>
)}
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="default" className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Order #</TableHead>
<TableHead scope="col">Customer</TableHead>
<TableHead scope="col">Date</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col">Payment</TableHead>
<TableHead scope="col" className="text-right">
Total
</TableHead>
<TableHead scope="col" className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton />
) : orders.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="py-16 text-center text-sm text-muted-foreground"
>
{emptyMessage()}
</TableCell>
</TableRow>
) : (
orders.map((order) => (
<TableRow
key={order._id}
className="cursor-pointer"
onClick={() => router.push(`/orders/${order._id}`)}
>
<TableCell className="font-mono text-xs">
{order.orderNumber}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{order.email}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</TableCell>
<TableCell>
<OrderStatusBadge status={order.status} />
</TableCell>
<TableCell>
<OrderPaymentBadge status={order.paymentStatus} />
</TableCell>
<TableCell className="text-right font-medium">
{formatPrice(order.total, order.currency.toUpperCase())}
</TableCell>
<TableCell>
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination footer — list mode only (not shown during client-side search) */}
{!isSearching && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{status === "Exhausted"
? `${results.length} order${results.length !== 1 ? "s" : ""} total`
: `${results.length} loaded`}
</span>
{status === "CanLoadMore" && (
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
Load more
</Button>
)}
</div>
)}
</main>
)
} }

View File

@@ -0,0 +1,62 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
interface Props {
orderId: Id<"orders">
}
export function AcceptReturnButton({ orderId }: Props) {
const [isLoading, setIsLoading] = useState(false)
const acceptReturn = useAction(api.returnActions.acceptReturn)
async function handleClick() {
setIsLoading(true)
try {
const result = await acceptReturn({ orderId })
if (result.success) {
toast.success(`Return accepted. Tracking: ${result.returnTrackingNumber}`)
} else {
toast.error((result as { success: false; code: string; message: string }).message)
}
} catch (e: any) {
toast.error(e?.message ?? "Failed to accept return.")
} finally {
setIsLoading(false)
}
}
return (
<Button onClick={handleClick} disabled={isLoading} variant="outline">
{isLoading && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? "Accepting…" : "Accept Return"}
</Button>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
interface Props {
orderId: Id<"orders">
}
export function CreateLabelButton({ orderId }: Props) {
const [isLoading, setIsLoading] = useState(false)
const createLabel = useAction(api.fulfillmentActions.createShippingLabel)
async function handleClick() {
setIsLoading(true)
try {
const result = await createLabel({ orderId })
if (result.success) {
toast.success(`Label created. Tracking: ${result.trackingNumber}`)
} else {
toast.error((result as { success: false; code: string; message: string }).message)
}
} catch (e: any) {
toast.error(e?.message ?? "Failed to create shipping label.")
} finally {
setIsLoading(false)
}
}
return (
<Button onClick={handleClick} disabled={isLoading}>
{isLoading && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? "Creating…" : "Create Label"}
</Button>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { formatPrice } from "@repo/utils"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface Props {
orderId: Id<"orders">
total: number
currency: string
}
export function IssueRefundButton({ orderId, total, currency }: Props) {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const issueRefund = useAction(api.returnActions.issueRefund)
const formattedTotal = formatPrice(total, currency.toUpperCase())
async function handleConfirm() {
setIsLoading(true)
try {
await issueRefund({ orderId })
toast.success("Refund issued.")
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to issue refund.")
} finally {
setIsLoading(false)
}
}
return (
<>
<Button variant="destructive" onClick={() => setOpen(true)}>
Issue Refund
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Issue full refund?</AlertDialogTitle>
<AlertDialogDescription>
A full refund of {formattedTotal} will be sent to the customer via
Stripe. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? "Refunding…" : "Issue Refund"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface Props {
orderId: Id<"orders">
}
export function MarkReturnReceivedButton({ orderId }: Props) {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const markReceived = useMutation(api.orders.markReturnReceived)
async function handleConfirm() {
setIsLoading(true)
try {
await markReceived({ id: orderId })
toast.success("Return marked as received.")
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to mark return as received.")
} finally {
setIsLoading(false)
}
}
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
Mark Return Received
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark return as received?</AlertDialogTitle>
<AlertDialogDescription>
Confirm that the returned items have arrived. You can then issue a
refund.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isLoading}>
{isLoading && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? "Saving…" : "Confirm"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,139 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ORDER_STATUS_CONFIG,
type OrderStatus,
} from "../shared/statusConfig"
interface Props {
orderId: Id<"orders">
currentStatus: string
}
const ALL_STATUSES = Object.keys(ORDER_STATUS_CONFIG) as OrderStatus[]
export function UpdateStatusDialog({ orderId, currentStatus }: Props) {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const availableStatuses = ALL_STATUSES.filter((s) => s !== currentStatus)
const [selectedStatus, setSelectedStatus] = useState<string>(
availableStatuses[0] ?? "",
)
const updateStatus = useMutation(api.orders.updateStatus)
// Reset selection to first available when dialog opens
function handleOpenChange(next: boolean) {
if (next) {
setSelectedStatus(availableStatuses[0] ?? "")
}
setOpen(next)
}
async function handleSubmit() {
if (!selectedStatus) return
setIsSubmitting(true)
try {
await updateStatus({
id: orderId,
status: selectedStatus as OrderStatus,
})
const label = ORDER_STATUS_CONFIG[selectedStatus as OrderStatus]?.label
toast.success(`Status updated to ${label ?? selectedStatus}`)
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to update status.")
} finally {
setIsSubmitting(false)
}
}
return (
<>
<Button variant="outline" onClick={() => handleOpenChange(true)}>
Update Status
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Update order status</DialogTitle>
</DialogHeader>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
{availableStatuses.map((s) => (
<SelectItem key={s} value={s}>
{ORDER_STATUS_CONFIG[s].label}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedStatus || isSubmitting}
>
{isSubmitting && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isSubmitting ? "Updating…" : "Update"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface Props {
name: string
email: string
}
export function CustomerCard({ name, email }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Customer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
<p className="text-sm font-medium">{name}</p>
<a
href={`mailto:${email}`}
className="text-sm text-muted-foreground hover:underline"
>
{email}
</a>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,157 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { HugeiconsIcon } from "@hugeicons/react"
import { ExternalLink } from "@hugeicons/core-free-icons"
interface Props {
carrier: string
shippingMethod: string
shippingServiceCode: string
trackingNumber?: string
trackingUrl?: string
labelUrl?: string
returnLabelUrl?: string
trackingStatus?: string
estimatedDelivery?: number
actualDelivery?: number
status: string
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-right">{children}</span>
</div>
)
}
function ExternalLinkIcon() {
return (
<HugeiconsIcon
icon={ExternalLink}
strokeWidth={2}
className="inline size-3 shrink-0"
aria-hidden="true"
/>
)
}
export function FulfilmentCard({
carrier,
shippingMethod,
shippingServiceCode,
trackingNumber,
trackingUrl,
labelUrl,
returnLabelUrl,
trackingStatus,
estimatedDelivery,
actualDelivery,
status,
}: Props) {
const isDelivered = status === "delivered" || !!actualDelivery
return (
<Card>
<CardHeader>
<CardTitle>Fulfilment</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{/* ── State A & B: carrier info always shown ─────────────────────── */}
{carrier && <InfoRow label="Carrier">{carrier}</InfoRow>}
{shippingMethod && (
<InfoRow label="Service">{shippingMethod}</InfoRow>
)}
{shippingServiceCode && !isDelivered && !trackingNumber && (
<InfoRow label="Method">{shippingServiceCode}</InfoRow>
)}
{/* ── State A: no label yet ──────────────────────────────────────── */}
{!trackingNumber && (
<p className="mt-1 text-sm text-muted-foreground">
No label created yet.
</p>
)}
{/* ── State B & C: tracking info ─────────────────────────────────── */}
{trackingNumber && (
<>
<InfoRow label="Tracking">
<span className="font-mono text-xs">{trackingNumber}</span>
{trackingUrl && (
<a
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-1.5 inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Track <ExternalLinkIcon />
</a>
)}
</InfoRow>
{trackingStatus && (
<InfoRow label="Status">
<span className="font-mono text-xs uppercase">
{trackingStatus}
</span>
</InfoRow>
)}
{/* State B: estimated delivery */}
{!isDelivered && estimatedDelivery && (
<InfoRow label="Est. delivery">
{new Date(estimatedDelivery).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</InfoRow>
)}
{/* State C: actual delivery */}
{isDelivered && actualDelivery && (
<InfoRow label="Delivered">
{new Date(actualDelivery).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</InfoRow>
)}
{/* Label download — State B only */}
{!isDelivered && labelUrl && (
<InfoRow label={returnLabelUrl ? "Outbound label" : "Label"}>
<a
href={labelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Download <ExternalLinkIcon />
</a>
</InfoRow>
)}
{/* Return label — shown when return has been accepted */}
{returnLabelUrl && (
<InfoRow label="Return label">
<a
href={returnLabelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Download <ExternalLinkIcon />
</a>
</InfoRow>
)}
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,75 @@
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { CreateLabelButton } from "../actions/CreateLabelButton"
import { AcceptReturnButton } from "../actions/AcceptReturnButton"
import { MarkReturnReceivedButton } from "../actions/MarkReturnReceivedButton"
import { IssueRefundButton } from "../actions/IssueRefundButton"
interface Props {
orderId: Id<"orders">
status: string
paymentStatus: string
trackingNumber?: string
returnRequestedAt?: number
returnReceivedAt?: number
total: number
currency: string
}
export function OrderActionsBar({
orderId,
status,
paymentStatus,
returnRequestedAt,
returnReceivedAt,
total,
currency,
}: Props) {
// confirmed + no return → create outbound label
if (status === "confirmed" && !returnRequestedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<CreateLabelButton orderId={orderId} />
</div>
)
}
// cancelled → refund if not already refunded
if (status === "cancelled" && paymentStatus !== "refunded") {
return (
<div className="flex flex-wrap items-center gap-2">
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
</div>
)
}
// delivered + return requested → accept return (create return label)
if (status === "delivered" && returnRequestedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<AcceptReturnButton orderId={orderId} />
</div>
)
}
// processing + return in transit (accepted but not received) → mark received
if (status === "processing" && returnRequestedAt && !returnReceivedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<MarkReturnReceivedButton orderId={orderId} />
</div>
)
}
// completed → refund if not already refunded
if (status === "completed" && paymentStatus !== "refunded") {
return (
<div className="flex flex-wrap items-center gap-2">
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
</div>
)
}
// All other states (pending, processing w/o return, shipped, delivered w/o return,
// refunded, return status) → no actions
return null
}

View File

@@ -0,0 +1,61 @@
import { formatPrice } from "@repo/utils"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
interface Props {
subtotal: number
shipping: number
discount: number
tax: number
total: number
currency: string
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
)
}
export function OrderFinancialsCard({
subtotal,
shipping,
discount,
tax,
total,
currency,
}: Props) {
const curr = currency.toUpperCase()
return (
<Card>
<CardHeader>
<CardTitle>Financials</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<InfoRow label="Subtotal" value={formatPrice(subtotal, curr)} />
<InfoRow label="Shipping" value={formatPrice(shipping, curr)} />
{discount > 0 && (
<InfoRow
label="Discount"
value={`-${formatPrice(discount, curr)}`}
/>
)}
{tax > 0 && <InfoRow label="Tax" value={formatPrice(tax, curr)} />}
<Separator />
<div className="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span>{formatPrice(total, curr)}</span>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,85 @@
import { formatPrice } from "@repo/utils"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface OrderItem {
_id: string
productName: string
variantName: string
sku: string
quantity: number
unitPrice: number
totalPrice: number
}
interface Props {
items: OrderItem[]
currency: string
}
export function OrderItemsCard({ items, currency }: Props) {
const curr = currency.toUpperCase()
return (
<Card>
<CardHeader>
<CardTitle>Order items</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Product</TableHead>
<TableHead scope="col">SKU</TableHead>
<TableHead scope="col" className="text-right">
Qty
</TableHead>
<TableHead scope="col" className="text-right">
Unit
</TableHead>
<TableHead scope="col" className="text-right">
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item._id}>
<TableCell>
<div className="font-medium">{item.productName}</div>
<div className="text-xs text-muted-foreground">
{item.variantName}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{item.sku}
</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right">
{formatPrice(item.unitPrice, curr)}
</TableCell>
<TableCell className="text-right font-medium">
{formatPrice(item.totalPrice, curr)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
import { OrderStatusBadge } from "../shared/OrderStatusBadge"
import { OrderPaymentBadge } from "../shared/OrderPaymentBadge"
interface Props {
orderNumber: string
createdAt: number
status: string
paymentStatus: string
}
export function OrderPageHeader({
orderNumber,
createdAt,
status,
paymentStatus,
}: Props) {
const formattedDate = new Date(createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
return (
<div className="flex flex-col gap-1">
<Link
href="/orders"
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"-ml-2 w-fit",
)}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Orders
</Link>
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-mono text-xl font-semibold">{orderNumber}</h1>
<OrderStatusBadge status={status} />
<OrderPaymentBadge status={paymentStatus} />
<span className="text-sm text-muted-foreground">{formattedDate}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { cn } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import {
EVENT_TYPE_LABELS,
TIMELINE_DOT_COLOR,
type OrderEventType,
type OrderStatus,
} from "../shared/statusConfig"
// ─── Types ────────────────────────────────────────────────────────────────────
interface TimelineEvent {
_id: string
eventType: string
source: string
fromStatus?: string
toStatus?: string
payload?: string
createdAt: number
}
interface Props {
events: TimelineEvent[] | undefined
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parsePayload(raw: string | undefined): Record<string, string> {
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
return {}
}
}
function getEventLabel(event: TimelineEvent): string {
switch (event.eventType as OrderEventType) {
case "status_change": {
const from = event.fromStatus ?? "—"
const to = event.toStatus ?? "—"
return `Status changed: ${from}${to}`
}
case "label_created": {
const p = parsePayload(event.payload)
const suffix = p.trackingNumber ? ` · ${p.trackingNumber}` : ""
return `${EVENT_TYPE_LABELS.label_created}${suffix}`
}
case "tracking_update": {
const p = parsePayload(event.payload)
const s = p.status ?? event.toStatus ?? ""
return s
? `${EVENT_TYPE_LABELS.tracking_update}: ${s}`
: EVENT_TYPE_LABELS.tracking_update
}
default: {
const label = EVENT_TYPE_LABELS[event.eventType as OrderEventType]
return label ?? event.eventType
}
}
}
function getDotColor(event: TimelineEvent): string {
if (event.eventType === "status_change" && event.toStatus) {
return (
TIMELINE_DOT_COLOR[event.toStatus as OrderStatus] ??
"text-muted-foreground"
)
}
return "text-muted-foreground"
}
const SOURCE_LABELS: Record<string, string> = {
admin: "by admin",
stripe_webhook: "via payment",
customer_cancel: "by customer",
customer_return: "by customer",
shippo_webhook: "via courier",
fulfillment: "by system",
}
function formatEventDate(ts: number): string {
const d = new Date(ts)
const date = d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
const time = d.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
})
return `${date} · ${time}`
}
// ─── Skeleton ─────────────────────────────────────────────────────────────────
function TimelineSkeleton() {
return (
<div className="flex flex-col gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="mt-0.5 size-3 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-20 shrink-0" />
</div>
))}
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
export function OrderTimelineCard({ events }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
</CardHeader>
<CardContent>
{events === undefined ? (
<TimelineSkeleton />
) : events.length === 0 ? (
<p className="text-sm text-muted-foreground">No activity yet.</p>
) : (
<ol className="flex flex-col">
{events.map((event, index) => {
const isLast = index === events.length - 1
const dotColor = getDotColor(event)
const sourceLabel =
SOURCE_LABELS[event.source] ?? event.source
return (
<li key={event._id} className="flex items-start gap-3">
{/* Dot + vertical connector */}
<div className="flex flex-col items-center">
<span
className={cn(
"mt-1 size-2.5 shrink-0 rounded-full border-2 border-current",
dotColor,
)}
aria-hidden="true"
/>
{!isLast && (
<span className="mt-1 w-px flex-1 bg-border" />
)}
</div>
{/* Event content */}
<div
className={cn(
"flex flex-1 items-start justify-between gap-4 pb-4",
isLast && "pb-0",
)}
>
<div className="flex flex-col gap-0.5">
<p className="text-sm">{getEventLabel(event)}</p>
<p className="text-xs text-muted-foreground">
{sourceLabel}
</p>
</div>
<time
dateTime={new Date(event.createdAt).toISOString()}
className="shrink-0 text-xs text-muted-foreground"
>
{formatEventDate(event.createdAt)}
</time>
</div>
</li>
)
})}
</ol>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,39 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface AddressSnapshot {
fullName: string
addressLine1: string
additionalInformation?: string
city: string
postalCode: string
country: string
phone?: string
}
interface Props {
address: AddressSnapshot
}
export function ShippingAddressCard({ address }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Shipping address</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{address.fullName}</p>
<p className="text-sm">{address.addressLine1}</p>
{address.additionalInformation && (
<p className="text-sm">{address.additionalInformation}</p>
)}
<p className="text-sm">
{address.city}, {address.postalCode}
</p>
<p className="text-xs text-muted-foreground">{address.country}</p>
{address.phone && (
<p className="mt-1 text-sm text-muted-foreground">{address.phone}</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,17 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { PAYMENT_STATUS_CONFIG, type PaymentStatus } from "./statusConfig"
export function OrderPaymentBadge({ status }: { status: string }) {
const config = PAYMENT_STATUS_CONFIG[status as PaymentStatus]
if (!config) {
return <span className="text-xs text-muted-foreground">{status}</span>
}
return (
<Badge variant={config.variant} className={cn(config.className)}>
{config.label}
</Badge>
)
}

View File

@@ -0,0 +1,17 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { ORDER_STATUS_CONFIG, type OrderStatus } from "./statusConfig"
export function OrderStatusBadge({ status }: { status: string }) {
const config = ORDER_STATUS_CONFIG[status as OrderStatus]
if (!config) {
return <span className="text-xs text-muted-foreground">{status}</span>
}
return (
<Badge variant={config.variant} className={cn(config.className)}>
{config.label}
</Badge>
)
}

View File

@@ -0,0 +1,110 @@
// ─── Types ────────────────────────────────────────────────────────────────────
export type OrderStatus =
| "pending"
| "confirmed"
| "processing"
| "shipped"
| "delivered"
| "cancelled"
| "refunded"
| "return"
| "completed"
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded"
export type OrderEventType =
| "status_change"
| "label_created"
| "tracking_update"
| "customer_cancel"
| "return_requested"
| "return_received"
| "return_accepted"
| "refund"
type BadgeVariant = "default" | "secondary" | "destructive" | "outline"
interface StatusConfig {
label: string
variant: BadgeVariant
className?: string
}
// ─── Order status ─────────────────────────────────────────────────────────────
export const ORDER_STATUS_CONFIG: Record<OrderStatus, StatusConfig> = {
pending: { label: "Pending", variant: "secondary" },
confirmed: {
label: "Confirmed",
variant: "outline",
className: "border-blue-500 text-blue-600",
},
processing: {
label: "Processing",
variant: "outline",
className: "border-violet-500 text-violet-600",
},
shipped: {
label: "Shipped",
variant: "outline",
className: "border-indigo-500 text-indigo-600",
},
delivered: {
label: "Delivered",
variant: "default",
className: "bg-green-600",
},
cancelled: { label: "Cancelled", variant: "destructive" },
refunded: {
label: "Refunded",
variant: "secondary",
className: "line-through",
},
return: {
label: "Return Requested",
variant: "outline",
className: "border-orange-500 text-orange-600",
},
completed: {
label: "Completed",
variant: "default",
className: "bg-teal-600",
},
}
// ─── Payment status ───────────────────────────────────────────────────────────
export const PAYMENT_STATUS_CONFIG: Record<PaymentStatus, StatusConfig> = {
pending: { label: "Pending", variant: "secondary" },
paid: { label: "Paid", variant: "default", className: "bg-green-600" },
failed: { label: "Failed", variant: "destructive" },
refunded: { label: "Refunded", variant: "secondary" },
}
// ─── Timeline event labels ────────────────────────────────────────────────────
export const EVENT_TYPE_LABELS: Record<OrderEventType, string> = {
status_change: "Status changed",
label_created: "Shipping label created",
tracking_update: "Tracking update",
customer_cancel: "Customer requested cancellation",
return_requested: "Customer requested return",
return_received: "Return marked as received",
return_accepted: "Return accepted — label created",
refund: "Refund issued",
}
// ─── Timeline dot color (status_change events only) ───────────────────────────
// Neutral dot for all other event types.
export const TIMELINE_DOT_COLOR: Partial<Record<OrderStatus, string>> = {
confirmed: "text-blue-500",
processing: "text-violet-500",
shipped: "text-indigo-500",
delivered: "text-green-600",
cancelled: "text-destructive",
refunded: "text-muted-foreground",
return: "text-orange-500",
completed: "text-teal-600",
}

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -3,7 +3,8 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { Button, Card, Link, Spinner } from "@heroui/react"; import Link from "next/link";
import { Card, Spinner } from "@heroui/react";
import { api } from "../../../../../../convex/_generated/api"; import { api } from "../../../../../../convex/_generated/api";
type PageState = type PageState =
@@ -116,18 +117,18 @@ function CompleteView({ email }: { email: string | null }) {
</div> </div>
<div className="flex w-full flex-col gap-2 pt-2"> <div className="flex w-full flex-col gap-2 pt-2">
<Button <Link
as={Link}
href="/account/orders" href="/account/orders"
color="primary" className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
> >
View your orders View your orders
</Button> </Link>
<Button as={Link} href="/shop" variant="ghost" className="w-full"> <Link
href="/shop"
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
>
Continue shopping Continue shopping
</Button> </Link>
</div> </div>
</> </>
); );
@@ -165,18 +166,18 @@ function IncompleteView() {
</div> </div>
<div className="flex w-full flex-col gap-2 pt-2"> <div className="flex w-full flex-col gap-2 pt-2">
<Button <Link
as={Link}
href="/checkout" href="/checkout"
color="primary" className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
> >
Return to checkout Return to checkout
</Button> </Link>
<Button as={Link} href="/shop" variant="ghost" className="w-full"> <Link
href="/shop"
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
>
Continue shopping Continue shopping
</Button> </Link>
</div> </div>
</> </>
); );
@@ -211,18 +212,18 @@ function ErrorView({ message }: { message: string }) {
</div> </div>
<div className="flex w-full flex-col gap-2 pt-2"> <div className="flex w-full flex-col gap-2 pt-2">
<Button <Link
as={Link}
href="/checkout" href="/checkout"
color="primary" className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
> >
Return to checkout Return to checkout
</Button> </Link>
<Button as={Link} href="/" variant="ghost" className="w-full"> <Link
href="/"
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
>
Go to homepage Go to homepage
</Button> </Link>
</div> </div>
</> </>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Button, Link } from "@heroui/react"; import { Button } from "@heroui/react";
import Link from "next/link";
/** /**
* Checkout error state: error message + retry button + back-to-cart link. * Checkout error state: error message + retry button + back-to-cart link.
@@ -17,17 +18,15 @@ export function CheckoutErrorState({
<div className="flex flex-col items-center gap-4 py-12 text-center"> <div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-lg font-medium text-danger">{message}</p> <p className="text-lg font-medium text-danger">{message}</p>
<div className="flex flex-col gap-2 w-full md:w-auto md:flex-row"> <div className="flex flex-col gap-2 w-full md:w-auto md:flex-row">
<Button color="primary" onPress={onRetry} className="w-full md:w-auto"> <Button variant="primary" onPress={onRetry} className="w-full md:w-auto">
Try again Try again
</Button> </Button>
<Button <Link
as={Link}
href="/cart" href="/cart"
variant="flat" className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-2 w-full md:w-auto bg-default-100 hover:bg-default-200 text-foreground font-medium transition-colors"
className="w-full md:w-auto"
> >
Back to cart Back to cart
</Button> </Link>
</div> </div>
</div> </div>
); );

View File

@@ -119,7 +119,7 @@ export function OrderReviewStep({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button <Button
color="primary" variant="primary"
className="w-full bg-[#236f6b] font-medium text-white" className="w-full bg-[#236f6b] font-medium text-white"
size="lg" size="lg"
onPress={handleContinue} onPress={handleContinue}

View File

@@ -80,7 +80,7 @@ export function PaymentStep({
</Alert> </Alert>
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<Button <Button
color="primary" variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto" className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={initSession} onPress={initSession}
> >
@@ -147,7 +147,7 @@ function CheckoutForm({
</Alert> </Alert>
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<Button <Button
color="primary" variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto" className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onSessionExpired} onPress={onSessionExpired}
> >
@@ -237,12 +237,12 @@ function CheckoutForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button <Button
color="primary" variant="primary"
className="w-full bg-[#236f6b] font-medium text-white" className="w-full bg-[#236f6b] font-medium text-white"
size="lg" size="lg"
onPress={handleSubmit} onPress={handleSubmit}
isDisabled={isSubmitting || !checkout.canConfirm} isDisabled={isSubmitting || !checkout.canConfirm}
isLoading={isSubmitting} isPending={isSubmitting}
> >
{isSubmitting ? "Processing…" : `Pay ${total.amount}`} {isSubmitting ? "Processing…" : `Pay ${total.amount}`}
</Button> </Button>

View File

@@ -40,7 +40,7 @@ export function ReviewList({ reviews, total, hasMore, isLoading, onLoadMore }: P
<Button <Button
variant="ghost" variant="ghost"
onPress={onLoadMore} onPress={onLoadMore}
isLoading={isLoading} isPending={isLoading}
className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]" className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]"
> >
Show more reviews Show more reviews

283
convex/emails.ts Normal file
View File

@@ -0,0 +1,283 @@
import { components } from "./_generated/api";
import { Resend } from "@convex-dev/resend";
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
// ─── Component instance ───────────────────────────────────────────────────────
export const resend = new Resend(components.resend, {
// Set testMode: false once you have a verified Resend domain and want to
// deliver to real addresses. While testMode is true, only Resend's own test
// addresses (e.g. delivered@resend.dev) will actually receive mail.
testMode: false,
});
// Update this once your sending domain is verified in Resend.
const FROM = "The Pet Loft <no-reply@thepetloft.co.uk>";
// ─── HTML helpers ─────────────────────────────────────────────────────────────
function base(body: string): string {
return `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;background:#f0f8f7;padding:24px">
<div style="background:#236f6b;padding:20px 24px;border-radius:8px 8px 0 0">
<h1 style="color:#fff;margin:0;font-size:22px;letter-spacing:-0.5px">The Pet Loft</h1>
</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 8px 8px;color:#1a2e2d">
${body}
</div>
<p style="color:#1a2e2d;font-size:12px;text-align:center;margin-top:16px;opacity:0.6">
&copy; ${new Date().getFullYear()} The Pet Loft. All rights reserved.
</p>
</div>
`;
}
function formatPrice(amountInSmallestUnit: number, currency: string): string {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amountInSmallestUnit / 100);
}
function btn(href: string, label: string): string {
return `<a href="${href}" style="display:inline-block;background:#38a99f;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin-top:16px">${label}</a>`;
}
// ─── Order confirmation ───────────────────────────────────────────────────────
export const sendOrderConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
items: v.array(
v.object({
productName: v.string(),
variantName: v.string(),
quantity: v.number(),
unitPrice: v.number(),
}),
),
shippingAddress: v.object({
fullName: v.string(),
addressLine1: v.string(),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
},
handler: async (ctx, args) => {
const rows = args.items
.map(
(item) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7">
<strong>${item.productName}</strong><br>
<span style="font-size:13px;opacity:0.7">${item.variantName}</span>
</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:center">×${item.quantity}</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:right;color:#236f6b;font-weight:bold">
${formatPrice(item.unitPrice * item.quantity, args.currency)}
</td>
</tr>`,
)
.join("");
const addr = args.shippingAddress;
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Order confirmed!</h2>
<p>Hi ${args.firstName}, thank you for your order. We&rsquo;re getting it ready now.</p>
<p style="margin-bottom:4px"><strong>Order:</strong> ${args.orderNumber}</p>
<table style="width:100%;border-collapse:collapse;margin:20px 0">
${rows}
<tr>
<td colspan="2" style="padding:12px 0;font-weight:bold">Total</td>
<td style="padding:12px 0;text-align:right;color:#236f6b;font-weight:bold;font-size:18px">
${formatPrice(args.total, args.currency)}
</td>
</tr>
</table>
<p style="margin-bottom:4px"><strong>Shipping to:</strong></p>
<p style="margin:0;line-height:1.6">
${addr.fullName}<br>
${addr.addressLine1}<br>
${addr.city}, ${addr.postalCode}<br>
${addr.country}
</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Order confirmed — ${args.orderNumber}`,
html,
});
},
});
// ─── Shipping confirmation ────────────────────────────────────────────────────
export const sendShippingConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
trackingNumber: v.string(),
trackingUrl: v.string(),
carrier: v.string(),
estimatedDelivery: v.optional(v.number()),
},
handler: async (ctx, args) => {
const eta = args.estimatedDelivery
? `<p><strong>Estimated delivery:</strong> ${new Date(args.estimatedDelivery).toDateString()}</p>`
: "";
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order is on its way!</h2>
<p>Hi ${args.firstName}, <strong>${args.orderNumber}</strong> has been shipped.</p>
<p><strong>Carrier:</strong> ${args.carrier}</p>
<p><strong>Tracking number:</strong> ${args.trackingNumber}</p>
${eta}
${btn(args.trackingUrl, "Track your order")}
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has shipped`,
html,
});
},
});
// ─── Delivery confirmation ────────────────────────────────────────────────────
export const sendDeliveryConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order has been delivered!</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been delivered.</p>
<p>We hope your pets love their new goodies! If anything is wrong with your order, please contact our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been delivered`,
html,
});
},
});
// ─── Cancellation ─────────────────────────────────────────────────────────────
export const sendCancellationNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#f2705a;margin-top:0">Order cancelled</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been cancelled.</p>
<p>If you did not request this cancellation or need help, please get in touch with our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been cancelled`,
html,
});
},
});
// ─── Refund ───────────────────────────────────────────────────────────────────
export const sendRefundNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Refund processed</h2>
<p>Hi ${args.firstName}, your refund for order <strong>${args.orderNumber}</strong> has been processed.</p>
<p><strong>Refund amount:</strong> ${formatPrice(args.total, args.currency)}</p>
<p>Please allow 510 business days for the amount to appear on your statement.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Refund processed for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return label ─────────────────────────────────────────────────────────────
export const sendReturnLabelEmail = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
returnLabelUrl: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your return label is ready</h2>
<p>Hi ${args.firstName}, your return request for order <strong>${args.orderNumber}</strong> has been accepted.</p>
<p>Please use the link below to download your prepaid return label and attach it to your parcel.</p>
${btn(args.returnLabelUrl, "Download return label")}
<p style="margin-top:16px;font-size:13px;color:#888">Once we receive your return, we&rsquo;ll process your refund promptly.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return label for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return requested ─────────────────────────────────────────────────────────
export const sendReturnRequestedNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Return request received</h2>
<p>Hi ${args.firstName}, we&rsquo;ve received your return request for order <strong>${args.orderNumber}</strong>.</p>
<p>Our team will review it and get back to you within 2 business days.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return request received for order ${args.orderNumber}`,
html,
});
},
});

View File

@@ -0,0 +1,176 @@
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { getShipmentRateObjectId } from "./model/shippo";
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
type LabelResult =
| { success: true; trackingNumber: string; trackingUrl: string }
| { success: false; code: string; message: string };
export const createShippingLabel = action({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }): Promise<LabelResult> => {
// 1. Auth — must be admin
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
const user = await ctx.runQuery(internal.users.getById, { userId });
if (user.role !== "admin" && user.role !== "super_admin") {
throw new Error("Unauthorized: admin access required");
}
// 2. Load order
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
id: orderId,
});
if (!order) throw new Error("Order not found");
// 3. Validate — only confirmed orders without an existing label
if (order.status !== "confirmed") {
return {
success: false,
code: "INVALID_STATUS",
message: "Only confirmed orders can receive a shipping label.",
};
}
if (order.trackingNumber) {
return {
success: false,
code: "DUPLICATE_LABEL",
message: "A shipping label already exists for this order.",
};
}
if (!order.shippoShipmentId) {
return {
success: false,
code: "NO_SHIPMENT",
message: "Order has no Shippo shipment ID.",
};
}
// 4. Resolve rate object ID from the stored shipment
let rateObjectId: string;
try {
rateObjectId = await getShipmentRateObjectId(
order.shippoShipmentId,
order.shippingServiceCode,
order.carrier,
);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to resolve shipping rate.";
return { success: false, code: "RATE_ERROR", message };
}
// 5. Purchase label via Shippo POST /transactions (synchronous)
const apiKey = process.env.SHIPPO_API_KEY;
if (!apiKey) {
return {
success: false,
code: "CONFIG_ERROR",
message: "Shippo API key not configured.",
};
}
let txResponse: Response;
try {
txResponse = await fetch(SHIPPO_TRANSACTIONS_URL, {
method: "POST",
headers: {
Authorization: `ShippoToken ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ rate: rateObjectId, async: false }),
});
} catch {
return {
success: false,
code: "SHIPPO_UNREACHABLE",
message: "Could not reach Shippo to create the label.",
};
}
if (!txResponse.ok) {
let detail = "";
try {
detail = JSON.stringify(await txResponse.json());
} catch {}
console.error(
"Shippo /transactions/ error:",
txResponse.status,
detail,
);
return {
success: false,
code: "SHIPPO_ERROR",
message: `Shippo returned status ${txResponse.status}.`,
};
}
let tx: {
object_id: string;
status: string;
tracking_number?: string;
tracking_url_provider?: string;
label_url?: string;
eta?: string;
messages?: Array<{ source: string; text: string; code: string }>;
};
try {
tx = await txResponse.json();
} catch {
return {
success: false,
code: "PARSE_ERROR",
message: "Shippo response could not be parsed.",
};
}
if (tx.status !== "SUCCESS") {
const errMsg =
tx.messages?.map((m) => m.text).join("; ") ?? "Unknown Shippo error";
console.error("Shippo transaction failed:", tx.status, errMsg);
const isExpired = tx.messages?.some(
(m) =>
m.code === "carrier_account_invalid_credentials" ||
m.text.toLowerCase().includes("expired"),
);
return {
success: false,
code: isExpired ? "RATE_EXPIRED" : "SHIPPO_ERROR",
message: isExpired
? "The shipping rate has expired. Please try again."
: errMsg,
};
}
if (!tx.tracking_number) {
return {
success: false,
code: "NO_TRACKING",
message: "Shippo returned success but no tracking number.",
};
}
// 6. Persist label data and update order status
const etaMs =
tx.eta && !isNaN(new Date(tx.eta).getTime())
? new Date(tx.eta).getTime()
: undefined;
await ctx.runMutation(internal.orders.applyLabel, {
orderId,
adminUserId: userId,
trackingNumber: tx.tracking_number,
trackingUrl: tx.tracking_url_provider ?? "",
labelUrl: tx.label_url,
estimatedDelivery: etaMs,
});
return {
success: true,
trackingNumber: tx.tracking_number,
trackingUrl: tx.tracking_url_provider ?? "",
};
},
});

View File

@@ -1,9 +1,10 @@
import { query, mutation, internalMutation } from "./_generated/server"; import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
import { paginationOptsValidator } from "convex/server"; import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values"; import { v } from "convex/values";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { internal } from "./_generated/api";
import * as Users from "./model/users"; import * as Users from "./model/users";
import { getOrderWithItems, validateCartItems, canCustomerCancel } from "./model/orders"; import { getOrderWithItems, validateCartItems, canCustomerCancel, canCustomerRequestReturn, recordOrderTimelineEvent } from "./model/orders";
import * as CartsModel from "./model/carts"; import * as CartsModel from "./model/carts";
export const listMine = query({ export const listMine = query({
@@ -50,6 +51,21 @@ export const cancel = mutation({
await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() }); await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() });
await recordOrderTimelineEvent(ctx, {
orderId: id,
eventType: "customer_cancel",
source: "customer_cancel",
fromStatus: "confirmed",
toStatus: "cancelled",
userId: user._id,
});
await ctx.scheduler.runAfter(0, internal.emails.sendCancellationNotice, {
to: user.email,
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
});
// Restore stock for each line item // Restore stock for each line item
const items = await ctx.db const items = await ctx.db
.query("orderItems") .query("orderItems")
@@ -366,6 +382,26 @@ export const createFromCart = mutation({
}, },
}); });
export const getTimeline = query({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const order = await ctx.db.get(orderId);
if (!order) throw new Error("Order not found");
const isAdmin = user.role === "admin" || user.role === "super_admin";
if (!isAdmin && order.userId !== user._id) {
throw new Error("Unauthorized: order does not belong to you");
}
return await ctx.db
.query("orderTimelineEvents")
.withIndex("by_order_and_created_at", (q) => q.eq("orderId", orderId))
.order("asc")
.collect();
},
});
export const updateStatus = mutation({ export const updateStatus = mutation({
args: { args: {
id: v.id("orders"), id: v.id("orders"),
@@ -377,13 +413,24 @@ export const updateStatus = mutation({
v.literal("delivered"), v.literal("delivered"),
v.literal("cancelled"), v.literal("cancelled"),
v.literal("refunded"), v.literal("refunded"),
v.literal("return"),
v.literal("completed"),
), ),
}, },
handler: async (ctx, { id, status }) => { handler: async (ctx, { id, status }) => {
await Users.requireAdmin(ctx); const admin = await Users.requireAdmin(ctx);
const order = await ctx.db.get(id); const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found"); if (!order) throw new Error("Order not found");
await ctx.db.patch(id, { status }); const previousStatus = order.status;
await ctx.db.patch(id, { status, updatedAt: Date.now() });
await recordOrderTimelineEvent(ctx, {
orderId: id,
eventType: "status_change",
source: "admin",
fromStatus: previousStatus,
toStatus: status,
userId: admin._id,
});
return id; return id;
}, },
}); });
@@ -542,6 +589,273 @@ export const fulfillFromCheckout = internalMutation({
await ctx.db.patch(cart._id, { items: [], updatedAt: now }); await ctx.db.patch(cart._id, { items: [], updatedAt: now });
await recordOrderTimelineEvent(ctx, {
orderId,
eventType: "status_change",
source: "stripe_webhook",
toStatus: "confirmed",
});
await ctx.scheduler.runAfter(0, internal.emails.sendOrderConfirmation, {
to: user.email,
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
orderNumber,
total,
currency: args.currency ?? "gbp",
items: orderItems.map((i) => ({
productName: i.productName,
variantName: i.variantName,
quantity: i.quantity,
unitPrice: i.unitPrice,
})),
shippingAddress: {
fullName: address.fullName,
addressLine1: address.addressLine1,
city: address.city,
postalCode: address.postalCode,
country: address.country,
},
});
return orderId; return orderId;
}, },
}); });
// ─── Return flow ─────────────────────────────────────────────────────────────
export const requestReturn = mutation({
args: { id: v.id("orders") },
handler: async (ctx, { id }) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found");
if (order.userId !== user._id)
throw new Error("Unauthorized: order does not belong to you");
const { allowed, reason } = canCustomerRequestReturn(order);
if (!allowed) throw new Error(reason);
const now = Date.now();
await ctx.db.patch(id, { returnRequestedAt: now, updatedAt: now });
await recordOrderTimelineEvent(ctx, {
orderId: id,
eventType: "return_requested",
source: "customer_return",
userId: user._id,
});
await ctx.scheduler.runAfter(0, internal.emails.sendReturnRequestedNotice, {
to: user.email,
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
});
return { success: true };
},
});
export const markReturnReceived = mutation({
args: { id: v.id("orders") },
handler: async (ctx, { id }) => {
const admin = await Users.requireAdmin(ctx);
const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found");
if (!order.returnRequestedAt)
throw new Error("No return has been requested for this order");
if (order.returnReceivedAt)
throw new Error("Return has already been marked as received");
const now = Date.now();
await ctx.db.patch(id, { returnReceivedAt: now, status: "completed", updatedAt: now });
await recordOrderTimelineEvent(ctx, {
orderId: id,
eventType: "return_received",
source: "admin",
fromStatus: order.status,
toStatus: "completed",
userId: admin._id,
});
return { success: true };
},
});
export const getOrderForRefund = internalQuery({
args: { id: v.id("orders") },
handler: async (ctx, { id }) => {
return await ctx.db.get(id);
},
});
export const getOrderByPaymentIntent = internalQuery({
args: { stripePaymentIntentId: v.string() },
handler: async (ctx, { stripePaymentIntentId }) => {
return await ctx.db
.query("orders")
.withIndex("by_stripe_payment_intent_id", (q) =>
q.eq("stripePaymentIntentId", stripePaymentIntentId),
)
.first();
},
});
export const applyReturnAccepted = internalMutation({
args: {
orderId: v.id("orders"),
adminUserId: v.id("users"),
returnLabelUrl: v.string(),
returnTrackingNumber: v.string(),
returnCarrier: v.string(),
},
handler: async (ctx, args) => {
const order = await ctx.db.get(args.orderId);
if (!order) throw new Error("Order not found");
if (order.status !== "delivered")
throw new Error("Order must be in delivered status to accept return.");
if (!order.returnRequestedAt)
throw new Error("No return has been requested for this order.");
if (order.returnTrackingNumber)
throw new Error("Return label has already been created for this order.");
const now = Date.now();
await ctx.db.patch(args.orderId, {
status: "processing",
returnLabelUrl: args.returnLabelUrl,
returnTrackingNumber: args.returnTrackingNumber,
returnCarrier: args.returnCarrier,
updatedAt: now,
});
await recordOrderTimelineEvent(ctx, {
orderId: args.orderId,
eventType: "return_accepted",
source: "admin",
fromStatus: "delivered",
toStatus: "processing",
userId: args.adminUserId,
});
},
});
export const applyLabel = internalMutation({
args: {
orderId: v.id("orders"),
adminUserId: v.id("users"),
trackingNumber: v.string(),
trackingUrl: v.string(),
labelUrl: v.optional(v.string()),
estimatedDelivery: v.optional(v.number()),
},
handler: async (ctx, args) => {
const order = await ctx.db.get(args.orderId);
if (!order) throw new Error("Order not found");
if (order.status !== "confirmed") {
throw new Error("Only confirmed orders can receive a shipping label.");
}
if (order.trackingNumber) {
throw new Error(
"A shipping label has already been created for this order.",
);
}
const now = Date.now();
await ctx.db.patch(args.orderId, {
trackingNumber: args.trackingNumber,
trackingUrl: args.trackingUrl,
labelUrl: args.labelUrl,
estimatedDelivery: args.estimatedDelivery,
shippedAt: now,
status: "processing",
updatedAt: now,
});
await recordOrderTimelineEvent(ctx, {
orderId: args.orderId,
eventType: "label_created",
source: "admin",
fromStatus: "confirmed",
toStatus: "processing",
userId: args.adminUserId,
payload: JSON.stringify({
trackingNumber: args.trackingNumber,
carrier: order.carrier,
}),
});
const customer = await ctx.db.get(order.userId);
if (customer) {
await ctx.scheduler.runAfter(0, internal.emails.sendShippingConfirmation, {
to: customer.email,
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
trackingNumber: args.trackingNumber,
trackingUrl: args.trackingUrl,
carrier: order.carrier,
estimatedDelivery: args.estimatedDelivery,
});
}
},
});
export const applyRefund = internalMutation({
args: {
id: v.id("orders"),
adminUserId: v.id("users"),
},
handler: async (ctx, { id, adminUserId }) => {
const order = await ctx.db.get(id);
if (!order) throw new Error("Order not found");
// Idempotency guard: skip if already refunded
if (order.paymentStatus === "refunded") {
console.log(`[applyRefund] Order ${id} already refunded — skipping`);
return;
}
const fromStatus = order.status;
await ctx.db.patch(id, {
status: "refunded",
paymentStatus: "refunded",
updatedAt: Date.now(),
});
// Restore stock
const items = await ctx.db
.query("orderItems")
.withIndex("by_order", (q) => q.eq("orderId", id))
.collect();
for (const item of items) {
const variant = await ctx.db.get(item.variantId);
if (variant) {
await ctx.db.patch(item.variantId, {
stockQuantity: variant.stockQuantity + item.quantity,
});
}
}
await recordOrderTimelineEvent(ctx, {
orderId: id,
eventType: "refund",
source: "admin",
fromStatus,
toStatus: "refunded",
userId: adminUserId,
});
const customer = await ctx.db.get(order.userId);
if (customer) {
await ctx.scheduler.runAfter(0, internal.emails.sendRefundNotice, {
to: customer.email,
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
total: order.total,
currency: order.currency,
});
}
},
});

219
convex/returnActions.ts Normal file
View File

@@ -0,0 +1,219 @@
"use node";
import Stripe from "stripe";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { getShipmentRateObjectId } from "./model/shippo";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
type AcceptReturnResult =
| { success: true; returnTrackingNumber: string }
| { success: false; code: string; message: string };
export const acceptReturn = action({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }): Promise<AcceptReturnResult> => {
// 1. Auth — must be admin
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
const user = await ctx.runQuery(internal.users.getById, { userId });
if (user.role !== "admin" && user.role !== "super_admin") {
throw new Error("Unauthorized: admin access required");
}
// 2. Load order and validate
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
id: orderId,
});
if (!order) throw new Error("Order not found");
if (order.status !== "delivered") {
return {
success: false,
code: "INVALID_STATUS",
message: "Only delivered orders can have a return accepted.",
};
}
if (!order.returnRequestedAt) {
return {
success: false,
code: "NO_RETURN_REQUEST",
message: "No return has been requested for this order.",
};
}
if (order.returnTrackingNumber) {
return {
success: false,
code: "DUPLICATE_RETURN_LABEL",
message: "A return label has already been created for this order.",
};
}
if (!order.shippoShipmentId) {
return {
success: false,
code: "NO_SHIPMENT",
message: "Order has no Shippo shipment ID.",
};
}
// 3. Resolve rate object ID from the stored shipment
let rateObjectId: string;
try {
rateObjectId = await getShipmentRateObjectId(
order.shippoShipmentId,
order.shippingServiceCode,
order.carrier,
);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to resolve shipping rate.";
return { success: false, code: "RATE_ERROR", message };
}
// 4. Purchase return label via Shippo POST /transactions (is_return: true)
const apiKey = process.env.SHIPPO_API_KEY;
if (!apiKey) {
return {
success: false,
code: "CONFIG_ERROR",
message: "Shippo API key not configured.",
};
}
let txResponse: Response;
try {
txResponse = await fetch(SHIPPO_TRANSACTIONS_URL, {
method: "POST",
headers: {
Authorization: `ShippoToken ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ rate: rateObjectId, async: false, is_return: true }),
});
} catch {
return {
success: false,
code: "SHIPPO_UNREACHABLE",
message: "Could not reach Shippo to create the return label.",
};
}
if (!txResponse.ok) {
let detail = "";
try {
detail = JSON.stringify(await txResponse.json());
} catch {}
console.error("Shippo /transactions/ return error:", txResponse.status, detail);
return {
success: false,
code: "SHIPPO_ERROR",
message: `Shippo returned status ${txResponse.status}.`,
};
}
let tx: {
object_id: string;
status: string;
tracking_number?: string;
tracking_url_provider?: string;
label_url?: string;
messages?: Array<{ source: string; text: string; code: string }>;
};
try {
tx = await txResponse.json();
} catch {
return {
success: false,
code: "PARSE_ERROR",
message: "Shippo response could not be parsed.",
};
}
if (tx.status !== "SUCCESS") {
const errMsg =
tx.messages?.map((m) => m.text).join("; ") ?? "Unknown Shippo error";
console.error("Shippo return transaction failed:", tx.status, errMsg);
return {
success: false,
code: "SHIPPO_ERROR",
message: errMsg,
};
}
if (!tx.tracking_number || !tx.label_url) {
return {
success: false,
code: "NO_TRACKING",
message: "Shippo returned success but no tracking number or label URL.",
};
}
// 5. Persist return label data
await ctx.runMutation(internal.orders.applyReturnAccepted, {
orderId,
adminUserId: userId,
returnLabelUrl: tx.label_url,
returnTrackingNumber: tx.tracking_number,
returnCarrier: order.carrier,
});
// 6. Send return label email to customer
const customer = await ctx.runQuery(internal.users.getById, {
userId: order.userId,
});
await ctx.runMutation(internal.emails.sendReturnLabelEmail, {
to: customer.email,
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
returnLabelUrl: tx.label_url,
});
return { success: true, returnTrackingNumber: tx.tracking_number };
},
});
export const issueRefund = action({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }): Promise<{ success: boolean }> => {
// 1. Auth — must be an admin
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
const user = await ctx.runQuery(internal.users.getById, { userId });
if (user.role !== "admin" && user.role !== "super_admin") {
throw new Error("Unauthorized: admin access required");
}
// 2. Load order
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
id: orderId,
});
if (!order) throw new Error("Order not found");
if (!order.stripePaymentIntentId)
throw new Error("Order has no Stripe payment intent to refund");
if (order.paymentStatus === "refunded")
throw new Error("Order has already been refunded");
// 3. Create Stripe refund — idempotent: skip if one already exists
const existingRefunds = await stripe.refunds.list({
payment_intent: order.stripePaymentIntentId,
limit: 10,
});
const hasRefund = existingRefunds.data.some(
(r) => r.status === "succeeded" || r.status === "pending",
);
if (!hasRefund) {
await stripe.refunds.create({
payment_intent: order.stripePaymentIntentId,
});
}
// 4. Mark order refunded and restore stock — applyRefund is also idempotent
await ctx.runMutation(internal.orders.applyRefund, {
id: orderId,
adminUserId: userId,
});
return { success: true };
},
});

View File

@@ -167,6 +167,8 @@ export default defineSchema({
v.literal("delivered"), v.literal("delivered"),
v.literal("cancelled"), v.literal("cancelled"),
v.literal("refunded"), v.literal("refunded"),
v.literal("return"),
v.literal("completed"),
), ),
paymentStatus: v.union( paymentStatus: v.union(
v.literal("pending"), v.literal("pending"),
@@ -209,6 +211,8 @@ export default defineSchema({
carrier: v.string(), carrier: v.string(),
trackingNumber: v.optional(v.string()), trackingNumber: v.optional(v.string()),
trackingUrl: v.optional(v.string()), trackingUrl: v.optional(v.string()),
labelUrl: v.optional(v.string()),
trackingStatus: v.optional(v.string()),
estimatedDelivery: v.optional(v.number()), estimatedDelivery: v.optional(v.number()),
actualDelivery: v.optional(v.number()), actualDelivery: v.optional(v.number()),
notes: v.optional(v.string()), notes: v.optional(v.string()),
@@ -216,6 +220,11 @@ export default defineSchema({
updatedAt: v.number(), updatedAt: v.number(),
paidAt: v.optional(v.number()), paidAt: v.optional(v.number()),
shippedAt: v.optional(v.number()), shippedAt: v.optional(v.number()),
returnRequestedAt: v.optional(v.number()),
returnReceivedAt: v.optional(v.number()),
returnLabelUrl: v.optional(v.string()),
returnTrackingNumber: v.optional(v.string()),
returnCarrier: v.optional(v.string()),
}) })
.index("by_user", ["userId"]) .index("by_user", ["userId"])
.index("by_status", ["status"]) .index("by_status", ["status"])
@@ -223,7 +232,10 @@ export default defineSchema({
.index("by_order_number", ["orderNumber"]) .index("by_order_number", ["orderNumber"])
.index("by_email", ["email"]) .index("by_email", ["email"])
.index("by_created_at", ["createdAt"]) .index("by_created_at", ["createdAt"])
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]), .index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"])
.index("by_tracking_number_and_carrier", ["trackingNumber", "carrier"])
.index("by_return_tracking_number_and_carrier", ["returnTrackingNumber", "returnCarrier"])
.index("by_stripe_payment_intent_id", ["stripePaymentIntentId"]),
orderItems: defineTable({ orderItems: defineTable({
orderId: v.id("orders"), orderId: v.id("orders"),
@@ -237,6 +249,19 @@ export default defineSchema({
imageUrl: v.optional(v.string()), imageUrl: v.optional(v.string()),
}).index("by_order", ["orderId"]), }).index("by_order", ["orderId"]),
orderTimelineEvents: defineTable({
orderId: v.id("orders"),
eventType: v.string(), // "status_change" | "customer_cancel" | "return_requested" | "return_received" | "refund" | "tracking_update" | "label_created"
source: v.string(), // "stripe_webhook" | "fulfillment" | "admin" | "shippo_webhook" | "customer_cancel" | "customer_return"
fromStatus: v.optional(v.string()),
toStatus: v.optional(v.string()),
payload: v.optional(v.string()), // JSON string for Shippo/Stripe payloads
createdAt: v.number(),
userId: v.optional(v.id("users")),
})
.index("by_order", ["orderId"])
.index("by_order_and_created_at", ["orderId", "createdAt"]),
// ─── Reviews ─────────────────────────────────────────────────────────── // ─── Reviews ───────────────────────────────────────────────────────────
reviews: defineTable({ reviews: defineTable({
productId: v.id("products"), productId: v.id("products"),

187
convex/shippoWebhook.ts Normal file
View File

@@ -0,0 +1,187 @@
import {
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { recordOrderTimelineEvent } from "./model/orders";
// ─── Internal queries ─────────────────────────────────────────────────────────
export const getOrderByTracking = internalQuery({
args: { trackingNumber: v.string(), carrier: v.string() },
handler: async (ctx, { trackingNumber, carrier }) => {
return await ctx.db
.query("orders")
.withIndex("by_tracking_number_and_carrier", (q) =>
q.eq("trackingNumber", trackingNumber).eq("carrier", carrier),
)
.first();
},
});
export const getOrderByReturnTracking = internalQuery({
args: { returnTrackingNumber: v.string(), returnCarrier: v.string() },
handler: async (ctx, { returnTrackingNumber, returnCarrier }) => {
return await ctx.db
.query("orders")
.withIndex("by_return_tracking_number_and_carrier", (q) =>
q.eq("returnTrackingNumber", returnTrackingNumber).eq("returnCarrier", returnCarrier),
)
.first();
},
});
// ─── Internal mutation ────────────────────────────────────────────────────────
export const applyTrackingUpdate = internalMutation({
args: {
orderId: v.id("orders"),
trackingStatus: v.string(),
estimatedDelivery: v.optional(v.number()),
isDelivered: v.boolean(),
isReturnTracking: v.boolean(),
payload: v.string(),
},
handler: async (ctx, args) => {
const order = await ctx.db.get(args.orderId);
if (!order) return;
// Idempotency: skip if this exact status was already applied
if (order.trackingStatus === args.trackingStatus) {
console.log(
`[shippoWebhook] Skipping duplicate tracking status "${args.trackingStatus}" for order ${args.orderId}`,
);
return;
}
const now = Date.now();
// Return tracking updates never set status to "delivered" — only outbound does
if (args.isDelivered && !args.isReturnTracking) {
await ctx.db.patch(args.orderId, {
trackingStatus: args.trackingStatus,
...(args.estimatedDelivery !== undefined
? { estimatedDelivery: args.estimatedDelivery }
: {}),
status: "delivered",
actualDelivery: now,
updatedAt: now,
});
} else {
await ctx.db.patch(args.orderId, {
trackingStatus: args.trackingStatus,
...(args.estimatedDelivery !== undefined
? { estimatedDelivery: args.estimatedDelivery }
: {}),
updatedAt: now,
});
}
await recordOrderTimelineEvent(ctx, {
orderId: args.orderId,
eventType: args.isReturnTracking ? "return_tracking_update" : "tracking_update",
source: "shippo_webhook",
...(args.isDelivered && !args.isReturnTracking
? { fromStatus: order.status, toStatus: "delivered" }
: {}),
payload: args.payload,
});
if (args.isDelivered && !args.isReturnTracking) {
const customer = await ctx.db.get(order.userId);
if (customer) {
await ctx.scheduler.runAfter(0, internal.emails.sendDeliveryConfirmation, {
to: customer.email,
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
orderNumber: order.orderNumber,
});
}
}
},
});
// ─── Internal action ──────────────────────────────────────────────────────────
type ShippoTrackUpdatedPayload = {
event?: string;
data?: {
carrier?: string;
tracking_number?: string;
eta?: string | null;
tracking_status?: {
status?: string;
status_details?: string;
status_date?: string;
};
};
};
export const handleTrackUpdated = internalAction({
args: { body: v.string() },
handler: async (ctx, { body }) => {
let payload: ShippoTrackUpdatedPayload;
try {
payload = JSON.parse(body) as ShippoTrackUpdatedPayload;
} catch {
console.error("[shippoWebhook] Failed to parse JSON body");
return;
}
if (payload.event !== "track_updated") {
console.log("[shippoWebhook] Ignoring event:", payload.event);
return;
}
const { data } = payload;
const trackingNumber = data?.tracking_number;
const carrier = data?.carrier;
if (!trackingNumber || !carrier) {
console.error(
"[shippoWebhook] Missing tracking_number or carrier in payload",
);
return;
}
let order = await ctx.runQuery(
internal.shippoWebhook.getOrderByTracking,
{ trackingNumber, carrier },
);
let isReturnTracking = false;
if (!order) {
order = await ctx.runQuery(
internal.shippoWebhook.getOrderByReturnTracking,
{ returnTrackingNumber: trackingNumber, returnCarrier: carrier },
);
isReturnTracking = !!order;
}
if (!order) {
console.log(
`[shippoWebhook] No order found for tracking ${trackingNumber} / ${carrier}`,
);
return;
}
const trackingStatus = data?.tracking_status?.status ?? "UNKNOWN";
const isDelivered = trackingStatus === "DELIVERED";
const eta = data?.eta;
const estimatedDelivery =
eta && !isNaN(new Date(eta).getTime())
? new Date(eta).getTime()
: undefined;
await ctx.runMutation(internal.shippoWebhook.applyTrackingUpdate, {
orderId: order._id,
trackingStatus,
estimatedDelivery,
isDelivered,
isReturnTracking,
payload: body,
});
},
});

View File

@@ -229,6 +229,26 @@ export const handleWebhook = internalAction({
(event.data.object as Stripe.Checkout.Session).id, (event.data.object as Stripe.Checkout.Session).id,
); );
break; break;
case "refund.updated": {
const refund = event.data.object as Stripe.Refund;
if (refund.status === "succeeded" && refund.payment_intent) {
const paymentIntentId =
typeof refund.payment_intent === "string"
? refund.payment_intent
: refund.payment_intent.id;
const order = await ctx.runQuery(
internal.orders.getOrderByPaymentIntent,
{ stripePaymentIntentId: paymentIntentId },
);
if (order && order.paymentStatus !== "refunded") {
await ctx.runMutation(internal.orders.applyRefund, {
id: order._id,
adminUserId: order.userId,
});
}
}
break;
}
default: default:
console.log("Unhandled Stripe event type:", event.type); console.log("Unhandled Stripe event type:", event.type);
} }

View File

@@ -122,6 +122,8 @@ export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
delivered: "Delivered", delivered: "Delivered",
cancelled: "Cancelled", cancelled: "Cancelled",
refunded: "Refunded", refunded: "Refunded",
return: "Return Requested",
completed: "Completed",
}; };
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = { export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
@@ -139,6 +141,8 @@ export const ORDER_STATUS_COLORS: Record<OrderStatus, string> = {
delivered: "green", delivered: "green",
cancelled: "red", cancelled: "red",
refunded: "gray", refunded: "gray",
return: "orange",
completed: "teal",
}; };
// ─── Validation ─────────────────────────────────────────────────────────────── // ─── Validation ───────────────────────────────────────────────────────────────