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:
2026-03-07 17:59:29 +03:00
parent 8e4309892c
commit 3d50cb895c
32 changed files with 3046 additions and 45 deletions

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() {
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 { useSearchParams } from "next/navigation";
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";
type PageState =
@@ -116,18 +117,18 @@ function CompleteView({ email }: { email: string | null }) {
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
<Link
href="/account/orders"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
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"
>
View your orders
</Button>
<Button as={Link} href="/shop" variant="ghost" className="w-full">
</Link>
<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
</Button>
</Link>
</div>
</>
);
@@ -165,18 +166,18 @@ function IncompleteView() {
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
<Link
href="/checkout"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
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"
>
Return to checkout
</Button>
<Button as={Link} href="/shop" variant="ghost" className="w-full">
</Link>
<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
</Button>
</Link>
</div>
</>
);
@@ -211,18 +212,18 @@ function ErrorView({ message }: { message: string }) {
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
<Link
href="/checkout"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
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"
>
Return to checkout
</Button>
<Button as={Link} href="/" variant="ghost" className="w-full">
</Link>
<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
</Button>
</Link>
</div>
</>
);

View File

@@ -1,6 +1,7 @@
"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.
@@ -17,17 +18,15 @@ export function CheckoutErrorState({
<div className="flex flex-col items-center gap-4 py-12 text-center">
<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">
<Button color="primary" onPress={onRetry} className="w-full md:w-auto">
<Button variant="primary" onPress={onRetry} className="w-full md:w-auto">
Try again
</Button>
<Button
as={Link}
<Link
href="/cart"
variant="flat"
className="w-full md:w-auto"
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"
>
Back to cart
</Button>
</Link>
</div>
</div>
);

View File

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

View File

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

View File

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