feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
143
apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
139
apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx
Normal file
139
apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
apps/admin/src/components/orders/detail/CustomerCard.tsx
Normal file
25
apps/admin/src/components/orders/detail/CustomerCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
apps/admin/src/components/orders/detail/FulfilmentCard.tsx
Normal file
157
apps/admin/src/components/orders/detail/FulfilmentCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
apps/admin/src/components/orders/detail/OrderActionsBar.tsx
Normal file
75
apps/admin/src/components/orders/detail/OrderActionsBar.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
85
apps/admin/src/components/orders/detail/OrderItemsCard.tsx
Normal file
85
apps/admin/src/components/orders/detail/OrderItemsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/admin/src/components/orders/detail/OrderPageHeader.tsx
Normal file
50
apps/admin/src/components/orders/detail/OrderPageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
apps/admin/src/components/orders/detail/OrderTimelineCard.tsx
Normal file
177
apps/admin/src/components/orders/detail/OrderTimelineCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
17
apps/admin/src/components/orders/shared/OrderStatusBadge.tsx
Normal file
17
apps/admin/src/components/orders/shared/OrderStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
apps/admin/src/components/orders/shared/statusConfig.ts
Normal file
110
apps/admin/src/components/orders/shared/statusConfig.ts
Normal 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",
|
||||
}
|
||||
103
apps/admin/src/components/ui/card.tsx
Normal file
103
apps/admin/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user