feat/admin #2
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() {
|
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,
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import { Button, Card, Link, Spinner } from "@heroui/react";
|
import Link from "next/link";
|
||||||
|
import { Card, Spinner } from "@heroui/react";
|
||||||
import { api } from "../../../../../../convex/_generated/api";
|
import { api } from "../../../../../../convex/_generated/api";
|
||||||
|
|
||||||
type PageState =
|
type PageState =
|
||||||
@@ -116,18 +117,18 @@ function CompleteView({ email }: { email: string | null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 pt-2">
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
<Button
|
<Link
|
||||||
as={Link}
|
|
||||||
href="/account/orders"
|
href="/account/orders"
|
||||||
color="primary"
|
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white"
|
|
||||||
size="lg"
|
|
||||||
>
|
>
|
||||||
View your orders
|
View your orders
|
||||||
</Button>
|
</Link>
|
||||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
<Link
|
||||||
|
href="/shop"
|
||||||
|
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||||
|
>
|
||||||
Continue shopping
|
Continue shopping
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -165,18 +166,18 @@ function IncompleteView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 pt-2">
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
<Button
|
<Link
|
||||||
as={Link}
|
|
||||||
href="/checkout"
|
href="/checkout"
|
||||||
color="primary"
|
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white"
|
|
||||||
size="lg"
|
|
||||||
>
|
>
|
||||||
Return to checkout
|
Return to checkout
|
||||||
</Button>
|
</Link>
|
||||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
<Link
|
||||||
|
href="/shop"
|
||||||
|
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||||
|
>
|
||||||
Continue shopping
|
Continue shopping
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -211,18 +212,18 @@ function ErrorView({ message }: { message: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 pt-2">
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
<Button
|
<Link
|
||||||
as={Link}
|
|
||||||
href="/checkout"
|
href="/checkout"
|
||||||
color="primary"
|
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white"
|
|
||||||
size="lg"
|
|
||||||
>
|
>
|
||||||
Return to checkout
|
Return to checkout
|
||||||
</Button>
|
</Link>
|
||||||
<Button as={Link} href="/" variant="ghost" className="w-full">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||||
|
>
|
||||||
Go to homepage
|
Go to homepage
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Link } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checkout error state: error message + retry button + back-to-cart link.
|
* Checkout error state: error message + retry button + back-to-cart link.
|
||||||
@@ -17,17 +18,15 @@ export function CheckoutErrorState({
|
|||||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
<p className="text-lg font-medium text-danger">{message}</p>
|
<p className="text-lg font-medium text-danger">{message}</p>
|
||||||
<div className="flex flex-col gap-2 w-full md:w-auto md:flex-row">
|
<div className="flex flex-col gap-2 w-full md:w-auto md:flex-row">
|
||||||
<Button color="primary" onPress={onRetry} className="w-full md:w-auto">
|
<Button variant="primary" onPress={onRetry} className="w-full md:w-auto">
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Link
|
||||||
as={Link}
|
|
||||||
href="/cart"
|
href="/cart"
|
||||||
variant="flat"
|
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-2 w-full md:w-auto bg-default-100 hover:bg-default-200 text-foreground font-medium transition-colors"
|
||||||
className="w-full md:w-auto"
|
|
||||||
>
|
>
|
||||||
Back to cart
|
Back to cart
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function OrderReviewStep({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
variant="primary"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white"
|
className="w-full bg-[#236f6b] font-medium text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
onPress={handleContinue}
|
onPress={handleContinue}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function PaymentStep({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col gap-2 md:flex-row">
|
<div className="flex flex-col gap-2 md:flex-row">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
variant="primary"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
||||||
onPress={initSession}
|
onPress={initSession}
|
||||||
>
|
>
|
||||||
@@ -147,7 +147,7 @@ function CheckoutForm({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col gap-2 md:flex-row">
|
<div className="flex flex-col gap-2 md:flex-row">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
variant="primary"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
||||||
onPress={onSessionExpired}
|
onPress={onSessionExpired}
|
||||||
>
|
>
|
||||||
@@ -237,12 +237,12 @@ function CheckoutForm({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
variant="primary"
|
||||||
className="w-full bg-[#236f6b] font-medium text-white"
|
className="w-full bg-[#236f6b] font-medium text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
isDisabled={isSubmitting || !checkout.canConfirm}
|
isDisabled={isSubmitting || !checkout.canConfirm}
|
||||||
isLoading={isSubmitting}
|
isPending={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Processing…" : `Pay ${total.amount}`}
|
{isSubmitting ? "Processing…" : `Pay ${total.amount}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ReviewList({ reviews, total, hasMore, isLoading, onLoadMore }: P
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onPress={onLoadMore}
|
onPress={onLoadMore}
|
||||||
isLoading={isLoading}
|
isPending={isLoading}
|
||||||
className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]"
|
className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]"
|
||||||
>
|
>
|
||||||
Show more reviews
|
Show more reviews
|
||||||
|
|||||||
283
convex/emails.ts
Normal file
283
convex/emails.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { components } from "./_generated/api";
|
||||||
|
import { Resend } from "@convex-dev/resend";
|
||||||
|
import { internalMutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
// ─── Component instance ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const resend = new Resend(components.resend, {
|
||||||
|
// Set testMode: false once you have a verified Resend domain and want to
|
||||||
|
// deliver to real addresses. While testMode is true, only Resend's own test
|
||||||
|
// addresses (e.g. delivered@resend.dev) will actually receive mail.
|
||||||
|
testMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update this once your sending domain is verified in Resend.
|
||||||
|
const FROM = "The Pet Loft <no-reply@thepetloft.co.uk>";
|
||||||
|
|
||||||
|
// ─── HTML helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function base(body: string): string {
|
||||||
|
return `
|
||||||
|
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;background:#f0f8f7;padding:24px">
|
||||||
|
<div style="background:#236f6b;padding:20px 24px;border-radius:8px 8px 0 0">
|
||||||
|
<h1 style="color:#fff;margin:0;font-size:22px;letter-spacing:-0.5px">The Pet Loft</h1>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;padding:32px 24px;border-radius:0 0 8px 8px;color:#1a2e2d">
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
<p style="color:#1a2e2d;font-size:12px;text-align:center;margin-top:16px;opacity:0.6">
|
||||||
|
© ${new Date().getFullYear()} The Pet Loft. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(amountInSmallestUnit: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat("en-GB", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amountInSmallestUnit / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function btn(href: string, label: string): string {
|
||||||
|
return `<a href="${href}" style="display:inline-block;background:#38a99f;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin-top:16px">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Order confirmation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendOrderConfirmation = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
total: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
items: v.array(
|
||||||
|
v.object({
|
||||||
|
productName: v.string(),
|
||||||
|
variantName: v.string(),
|
||||||
|
quantity: v.number(),
|
||||||
|
unitPrice: v.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
shippingAddress: v.object({
|
||||||
|
fullName: v.string(),
|
||||||
|
addressLine1: v.string(),
|
||||||
|
city: v.string(),
|
||||||
|
postalCode: v.string(),
|
||||||
|
country: v.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const rows = args.items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7">
|
||||||
|
<strong>${item.productName}</strong><br>
|
||||||
|
<span style="font-size:13px;opacity:0.7">${item.variantName}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:center">×${item.quantity}</td>
|
||||||
|
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:right;color:#236f6b;font-weight:bold">
|
||||||
|
${formatPrice(item.unitPrice * item.quantity, args.currency)}
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const addr = args.shippingAddress;
|
||||||
|
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Order confirmed!</h2>
|
||||||
|
<p>Hi ${args.firstName}, thank you for your order. We’re getting it ready now.</p>
|
||||||
|
<p style="margin-bottom:4px"><strong>Order:</strong> ${args.orderNumber}</p>
|
||||||
|
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:20px 0">
|
||||||
|
${rows}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="padding:12px 0;font-weight:bold">Total</td>
|
||||||
|
<td style="padding:12px 0;text-align:right;color:#236f6b;font-weight:bold;font-size:18px">
|
||||||
|
${formatPrice(args.total, args.currency)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-bottom:4px"><strong>Shipping to:</strong></p>
|
||||||
|
<p style="margin:0;line-height:1.6">
|
||||||
|
${addr.fullName}<br>
|
||||||
|
${addr.addressLine1}<br>
|
||||||
|
${addr.city}, ${addr.postalCode}<br>
|
||||||
|
${addr.country}
|
||||||
|
</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Order confirmed — ${args.orderNumber}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Shipping confirmation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendShippingConfirmation = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
trackingNumber: v.string(),
|
||||||
|
trackingUrl: v.string(),
|
||||||
|
carrier: v.string(),
|
||||||
|
estimatedDelivery: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const eta = args.estimatedDelivery
|
||||||
|
? `<p><strong>Estimated delivery:</strong> ${new Date(args.estimatedDelivery).toDateString()}</p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Your order is on its way!</h2>
|
||||||
|
<p>Hi ${args.firstName}, <strong>${args.orderNumber}</strong> has been shipped.</p>
|
||||||
|
<p><strong>Carrier:</strong> ${args.carrier}</p>
|
||||||
|
<p><strong>Tracking number:</strong> ${args.trackingNumber}</p>
|
||||||
|
${eta}
|
||||||
|
${btn(args.trackingUrl, "Track your order")}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Your order ${args.orderNumber} has shipped`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Delivery confirmation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendDeliveryConfirmation = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Your order has been delivered!</h2>
|
||||||
|
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been delivered.</p>
|
||||||
|
<p>We hope your pets love their new goodies! If anything is wrong with your order, please contact our support team.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Your order ${args.orderNumber} has been delivered`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cancellation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendCancellationNotice = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#f2705a;margin-top:0">Order cancelled</h2>
|
||||||
|
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been cancelled.</p>
|
||||||
|
<p>If you did not request this cancellation or need help, please get in touch with our support team.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Your order ${args.orderNumber} has been cancelled`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendRefundNotice = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
total: v.number(),
|
||||||
|
currency: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Refund processed</h2>
|
||||||
|
<p>Hi ${args.firstName}, your refund for order <strong>${args.orderNumber}</strong> has been processed.</p>
|
||||||
|
<p><strong>Refund amount:</strong> ${formatPrice(args.total, args.currency)}</p>
|
||||||
|
<p>Please allow 5–10 business days for the amount to appear on your statement.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Refund processed for order ${args.orderNumber}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Return label ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendReturnLabelEmail = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
returnLabelUrl: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Your return label is ready</h2>
|
||||||
|
<p>Hi ${args.firstName}, your return request for order <strong>${args.orderNumber}</strong> has been accepted.</p>
|
||||||
|
<p>Please use the link below to download your prepaid return label and attach it to your parcel.</p>
|
||||||
|
${btn(args.returnLabelUrl, "Download return label")}
|
||||||
|
<p style="margin-top:16px;font-size:13px;color:#888">Once we receive your return, we’ll process your refund promptly.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Return label for order ${args.orderNumber}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Return requested ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const sendReturnRequestedNotice = internalMutation({
|
||||||
|
args: {
|
||||||
|
to: v.string(),
|
||||||
|
firstName: v.string(),
|
||||||
|
orderNumber: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const html = base(`
|
||||||
|
<h2 style="color:#236f6b;margin-top:0">Return request received</h2>
|
||||||
|
<p>Hi ${args.firstName}, we’ve received your return request for order <strong>${args.orderNumber}</strong>.</p>
|
||||||
|
<p>Our team will review it and get back to you within 2 business days.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await resend.sendEmail(ctx, {
|
||||||
|
from: FROM,
|
||||||
|
to: args.to,
|
||||||
|
subject: `Return request received for order ${args.orderNumber}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
176
convex/fulfillmentActions.ts
Normal file
176
convex/fulfillmentActions.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { action } from "./_generated/server";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getShipmentRateObjectId } from "./model/shippo";
|
||||||
|
|
||||||
|
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
|
||||||
|
|
||||||
|
type LabelResult =
|
||||||
|
| { success: true; trackingNumber: string; trackingUrl: string }
|
||||||
|
| { success: false; code: string; message: string };
|
||||||
|
|
||||||
|
export const createShippingLabel = action({
|
||||||
|
args: { orderId: v.id("orders") },
|
||||||
|
handler: async (ctx, { orderId }): Promise<LabelResult> => {
|
||||||
|
// 1. Auth — must be admin
|
||||||
|
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
||||||
|
const user = await ctx.runQuery(internal.users.getById, { userId });
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
throw new Error("Unauthorized: admin access required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load order
|
||||||
|
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
|
||||||
|
id: orderId,
|
||||||
|
});
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
|
||||||
|
// 3. Validate — only confirmed orders without an existing label
|
||||||
|
if (order.status !== "confirmed") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "INVALID_STATUS",
|
||||||
|
message: "Only confirmed orders can receive a shipping label.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (order.trackingNumber) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "DUPLICATE_LABEL",
|
||||||
|
message: "A shipping label already exists for this order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!order.shippoShipmentId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "NO_SHIPMENT",
|
||||||
|
message: "Order has no Shippo shipment ID.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Resolve rate object ID from the stored shipment
|
||||||
|
let rateObjectId: string;
|
||||||
|
try {
|
||||||
|
rateObjectId = await getShipmentRateObjectId(
|
||||||
|
order.shippoShipmentId,
|
||||||
|
order.shippingServiceCode,
|
||||||
|
order.carrier,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to resolve shipping rate.";
|
||||||
|
return { success: false, code: "RATE_ERROR", message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Purchase label via Shippo POST /transactions (synchronous)
|
||||||
|
const apiKey = process.env.SHIPPO_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "CONFIG_ERROR",
|
||||||
|
message: "Shippo API key not configured.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let txResponse: Response;
|
||||||
|
try {
|
||||||
|
txResponse = await fetch(SHIPPO_TRANSACTIONS_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `ShippoToken ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rate: rateObjectId, async: false }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SHIPPO_UNREACHABLE",
|
||||||
|
message: "Could not reach Shippo to create the label.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txResponse.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
detail = JSON.stringify(await txResponse.json());
|
||||||
|
} catch {}
|
||||||
|
console.error(
|
||||||
|
"Shippo /transactions/ error:",
|
||||||
|
txResponse.status,
|
||||||
|
detail,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SHIPPO_ERROR",
|
||||||
|
message: `Shippo returned status ${txResponse.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx: {
|
||||||
|
object_id: string;
|
||||||
|
status: string;
|
||||||
|
tracking_number?: string;
|
||||||
|
tracking_url_provider?: string;
|
||||||
|
label_url?: string;
|
||||||
|
eta?: string;
|
||||||
|
messages?: Array<{ source: string; text: string; code: string }>;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
tx = await txResponse.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "PARSE_ERROR",
|
||||||
|
message: "Shippo response could not be parsed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tx.status !== "SUCCESS") {
|
||||||
|
const errMsg =
|
||||||
|
tx.messages?.map((m) => m.text).join("; ") ?? "Unknown Shippo error";
|
||||||
|
console.error("Shippo transaction failed:", tx.status, errMsg);
|
||||||
|
const isExpired = tx.messages?.some(
|
||||||
|
(m) =>
|
||||||
|
m.code === "carrier_account_invalid_credentials" ||
|
||||||
|
m.text.toLowerCase().includes("expired"),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: isExpired ? "RATE_EXPIRED" : "SHIPPO_ERROR",
|
||||||
|
message: isExpired
|
||||||
|
? "The shipping rate has expired. Please try again."
|
||||||
|
: errMsg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tx.tracking_number) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "NO_TRACKING",
|
||||||
|
message: "Shippo returned success but no tracking number.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Persist label data and update order status
|
||||||
|
const etaMs =
|
||||||
|
tx.eta && !isNaN(new Date(tx.eta).getTime())
|
||||||
|
? new Date(tx.eta).getTime()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.orders.applyLabel, {
|
||||||
|
orderId,
|
||||||
|
adminUserId: userId,
|
||||||
|
trackingNumber: tx.tracking_number,
|
||||||
|
trackingUrl: tx.tracking_url_provider ?? "",
|
||||||
|
labelUrl: tx.label_url,
|
||||||
|
estimatedDelivery: etaMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
trackingNumber: tx.tracking_number,
|
||||||
|
trackingUrl: tx.tracking_url_provider ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
322
convex/orders.ts
322
convex/orders.ts
@@ -1,9 +1,10 @@
|
|||||||
import { query, mutation, internalMutation } from "./_generated/server";
|
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
||||||
import { paginationOptsValidator } from "convex/server";
|
import { paginationOptsValidator } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import * as Users from "./model/users";
|
import * as Users from "./model/users";
|
||||||
import { getOrderWithItems, validateCartItems, canCustomerCancel } from "./model/orders";
|
import { getOrderWithItems, validateCartItems, canCustomerCancel, canCustomerRequestReturn, recordOrderTimelineEvent } from "./model/orders";
|
||||||
import * as CartsModel from "./model/carts";
|
import * as CartsModel from "./model/carts";
|
||||||
|
|
||||||
export const listMine = query({
|
export const listMine = query({
|
||||||
@@ -50,6 +51,21 @@ export const cancel = mutation({
|
|||||||
|
|
||||||
await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() });
|
await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() });
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: id,
|
||||||
|
eventType: "customer_cancel",
|
||||||
|
source: "customer_cancel",
|
||||||
|
fromStatus: "confirmed",
|
||||||
|
toStatus: "cancelled",
|
||||||
|
userId: user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendCancellationNotice, {
|
||||||
|
to: user.email,
|
||||||
|
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
});
|
||||||
|
|
||||||
// Restore stock for each line item
|
// Restore stock for each line item
|
||||||
const items = await ctx.db
|
const items = await ctx.db
|
||||||
.query("orderItems")
|
.query("orderItems")
|
||||||
@@ -366,6 +382,26 @@ export const createFromCart = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getTimeline = query({
|
||||||
|
args: { orderId: v.id("orders") },
|
||||||
|
handler: async (ctx, { orderId }) => {
|
||||||
|
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||||
|
const order = await ctx.db.get(orderId);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin";
|
||||||
|
if (!isAdmin && order.userId !== user._id) {
|
||||||
|
throw new Error("Unauthorized: order does not belong to you");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db
|
||||||
|
.query("orderTimelineEvents")
|
||||||
|
.withIndex("by_order_and_created_at", (q) => q.eq("orderId", orderId))
|
||||||
|
.order("asc")
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const updateStatus = mutation({
|
export const updateStatus = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("orders"),
|
id: v.id("orders"),
|
||||||
@@ -377,13 +413,24 @@ export const updateStatus = mutation({
|
|||||||
v.literal("delivered"),
|
v.literal("delivered"),
|
||||||
v.literal("cancelled"),
|
v.literal("cancelled"),
|
||||||
v.literal("refunded"),
|
v.literal("refunded"),
|
||||||
|
v.literal("return"),
|
||||||
|
v.literal("completed"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
handler: async (ctx, { id, status }) => {
|
handler: async (ctx, { id, status }) => {
|
||||||
await Users.requireAdmin(ctx);
|
const admin = await Users.requireAdmin(ctx);
|
||||||
const order = await ctx.db.get(id);
|
const order = await ctx.db.get(id);
|
||||||
if (!order) throw new Error("Order not found");
|
if (!order) throw new Error("Order not found");
|
||||||
await ctx.db.patch(id, { status });
|
const previousStatus = order.status;
|
||||||
|
await ctx.db.patch(id, { status, updatedAt: Date.now() });
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: id,
|
||||||
|
eventType: "status_change",
|
||||||
|
source: "admin",
|
||||||
|
fromStatus: previousStatus,
|
||||||
|
toStatus: status,
|
||||||
|
userId: admin._id,
|
||||||
|
});
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -542,6 +589,273 @@ export const fulfillFromCheckout = internalMutation({
|
|||||||
|
|
||||||
await ctx.db.patch(cart._id, { items: [], updatedAt: now });
|
await ctx.db.patch(cart._id, { items: [], updatedAt: now });
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId,
|
||||||
|
eventType: "status_change",
|
||||||
|
source: "stripe_webhook",
|
||||||
|
toStatus: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendOrderConfirmation, {
|
||||||
|
to: user.email,
|
||||||
|
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber,
|
||||||
|
total,
|
||||||
|
currency: args.currency ?? "gbp",
|
||||||
|
items: orderItems.map((i) => ({
|
||||||
|
productName: i.productName,
|
||||||
|
variantName: i.variantName,
|
||||||
|
quantity: i.quantity,
|
||||||
|
unitPrice: i.unitPrice,
|
||||||
|
})),
|
||||||
|
shippingAddress: {
|
||||||
|
fullName: address.fullName,
|
||||||
|
addressLine1: address.addressLine1,
|
||||||
|
city: address.city,
|
||||||
|
postalCode: address.postalCode,
|
||||||
|
country: address.country,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return orderId;
|
return orderId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Return flow ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const requestReturn = mutation({
|
||||||
|
args: { id: v.id("orders") },
|
||||||
|
handler: async (ctx, { id }) => {
|
||||||
|
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||||
|
|
||||||
|
const order = await ctx.db.get(id);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (order.userId !== user._id)
|
||||||
|
throw new Error("Unauthorized: order does not belong to you");
|
||||||
|
|
||||||
|
const { allowed, reason } = canCustomerRequestReturn(order);
|
||||||
|
if (!allowed) throw new Error(reason);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(id, { returnRequestedAt: now, updatedAt: now });
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: id,
|
||||||
|
eventType: "return_requested",
|
||||||
|
source: "customer_return",
|
||||||
|
userId: user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendReturnRequestedNotice, {
|
||||||
|
to: user.email,
|
||||||
|
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markReturnReceived = mutation({
|
||||||
|
args: { id: v.id("orders") },
|
||||||
|
handler: async (ctx, { id }) => {
|
||||||
|
const admin = await Users.requireAdmin(ctx);
|
||||||
|
|
||||||
|
const order = await ctx.db.get(id);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (!order.returnRequestedAt)
|
||||||
|
throw new Error("No return has been requested for this order");
|
||||||
|
if (order.returnReceivedAt)
|
||||||
|
throw new Error("Return has already been marked as received");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(id, { returnReceivedAt: now, status: "completed", updatedAt: now });
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: id,
|
||||||
|
eventType: "return_received",
|
||||||
|
source: "admin",
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: "completed",
|
||||||
|
userId: admin._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrderForRefund = internalQuery({
|
||||||
|
args: { id: v.id("orders") },
|
||||||
|
handler: async (ctx, { id }) => {
|
||||||
|
return await ctx.db.get(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrderByPaymentIntent = internalQuery({
|
||||||
|
args: { stripePaymentIntentId: v.string() },
|
||||||
|
handler: async (ctx, { stripePaymentIntentId }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("orders")
|
||||||
|
.withIndex("by_stripe_payment_intent_id", (q) =>
|
||||||
|
q.eq("stripePaymentIntentId", stripePaymentIntentId),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyReturnAccepted = internalMutation({
|
||||||
|
args: {
|
||||||
|
orderId: v.id("orders"),
|
||||||
|
adminUserId: v.id("users"),
|
||||||
|
returnLabelUrl: v.string(),
|
||||||
|
returnTrackingNumber: v.string(),
|
||||||
|
returnCarrier: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const order = await ctx.db.get(args.orderId);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (order.status !== "delivered")
|
||||||
|
throw new Error("Order must be in delivered status to accept return.");
|
||||||
|
if (!order.returnRequestedAt)
|
||||||
|
throw new Error("No return has been requested for this order.");
|
||||||
|
if (order.returnTrackingNumber)
|
||||||
|
throw new Error("Return label has already been created for this order.");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(args.orderId, {
|
||||||
|
status: "processing",
|
||||||
|
returnLabelUrl: args.returnLabelUrl,
|
||||||
|
returnTrackingNumber: args.returnTrackingNumber,
|
||||||
|
returnCarrier: args.returnCarrier,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: args.orderId,
|
||||||
|
eventType: "return_accepted",
|
||||||
|
source: "admin",
|
||||||
|
fromStatus: "delivered",
|
||||||
|
toStatus: "processing",
|
||||||
|
userId: args.adminUserId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyLabel = internalMutation({
|
||||||
|
args: {
|
||||||
|
orderId: v.id("orders"),
|
||||||
|
adminUserId: v.id("users"),
|
||||||
|
trackingNumber: v.string(),
|
||||||
|
trackingUrl: v.string(),
|
||||||
|
labelUrl: v.optional(v.string()),
|
||||||
|
estimatedDelivery: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const order = await ctx.db.get(args.orderId);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (order.status !== "confirmed") {
|
||||||
|
throw new Error("Only confirmed orders can receive a shipping label.");
|
||||||
|
}
|
||||||
|
if (order.trackingNumber) {
|
||||||
|
throw new Error(
|
||||||
|
"A shipping label has already been created for this order.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(args.orderId, {
|
||||||
|
trackingNumber: args.trackingNumber,
|
||||||
|
trackingUrl: args.trackingUrl,
|
||||||
|
labelUrl: args.labelUrl,
|
||||||
|
estimatedDelivery: args.estimatedDelivery,
|
||||||
|
shippedAt: now,
|
||||||
|
status: "processing",
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: args.orderId,
|
||||||
|
eventType: "label_created",
|
||||||
|
source: "admin",
|
||||||
|
fromStatus: "confirmed",
|
||||||
|
toStatus: "processing",
|
||||||
|
userId: args.adminUserId,
|
||||||
|
payload: JSON.stringify({
|
||||||
|
trackingNumber: args.trackingNumber,
|
||||||
|
carrier: order.carrier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await ctx.db.get(order.userId);
|
||||||
|
if (customer) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendShippingConfirmation, {
|
||||||
|
to: customer.email,
|
||||||
|
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
trackingNumber: args.trackingNumber,
|
||||||
|
trackingUrl: args.trackingUrl,
|
||||||
|
carrier: order.carrier,
|
||||||
|
estimatedDelivery: args.estimatedDelivery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyRefund = internalMutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("orders"),
|
||||||
|
adminUserId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { id, adminUserId }) => {
|
||||||
|
const order = await ctx.db.get(id);
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
|
||||||
|
// Idempotency guard: skip if already refunded
|
||||||
|
if (order.paymentStatus === "refunded") {
|
||||||
|
console.log(`[applyRefund] Order ${id} already refunded — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStatus = order.status;
|
||||||
|
await ctx.db.patch(id, {
|
||||||
|
status: "refunded",
|
||||||
|
paymentStatus: "refunded",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore stock
|
||||||
|
const items = await ctx.db
|
||||||
|
.query("orderItems")
|
||||||
|
.withIndex("by_order", (q) => q.eq("orderId", id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const variant = await ctx.db.get(item.variantId);
|
||||||
|
if (variant) {
|
||||||
|
await ctx.db.patch(item.variantId, {
|
||||||
|
stockQuantity: variant.stockQuantity + item.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: id,
|
||||||
|
eventType: "refund",
|
||||||
|
source: "admin",
|
||||||
|
fromStatus,
|
||||||
|
toStatus: "refunded",
|
||||||
|
userId: adminUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await ctx.db.get(order.userId);
|
||||||
|
if (customer) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendRefundNotice, {
|
||||||
|
to: customer.email,
|
||||||
|
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
total: order.total,
|
||||||
|
currency: order.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
219
convex/returnActions.ts
Normal file
219
convex/returnActions.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"use node";
|
||||||
|
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { action } from "./_generated/server";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { getShipmentRateObjectId } from "./model/shippo";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||||
|
|
||||||
|
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
|
||||||
|
|
||||||
|
type AcceptReturnResult =
|
||||||
|
| { success: true; returnTrackingNumber: string }
|
||||||
|
| { success: false; code: string; message: string };
|
||||||
|
|
||||||
|
export const acceptReturn = action({
|
||||||
|
args: { orderId: v.id("orders") },
|
||||||
|
handler: async (ctx, { orderId }): Promise<AcceptReturnResult> => {
|
||||||
|
// 1. Auth — must be admin
|
||||||
|
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
||||||
|
const user = await ctx.runQuery(internal.users.getById, { userId });
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
throw new Error("Unauthorized: admin access required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load order and validate
|
||||||
|
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
|
||||||
|
id: orderId,
|
||||||
|
});
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (order.status !== "delivered") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "INVALID_STATUS",
|
||||||
|
message: "Only delivered orders can have a return accepted.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!order.returnRequestedAt) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "NO_RETURN_REQUEST",
|
||||||
|
message: "No return has been requested for this order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (order.returnTrackingNumber) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "DUPLICATE_RETURN_LABEL",
|
||||||
|
message: "A return label has already been created for this order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!order.shippoShipmentId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "NO_SHIPMENT",
|
||||||
|
message: "Order has no Shippo shipment ID.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve rate object ID from the stored shipment
|
||||||
|
let rateObjectId: string;
|
||||||
|
try {
|
||||||
|
rateObjectId = await getShipmentRateObjectId(
|
||||||
|
order.shippoShipmentId,
|
||||||
|
order.shippingServiceCode,
|
||||||
|
order.carrier,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to resolve shipping rate.";
|
||||||
|
return { success: false, code: "RATE_ERROR", message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Purchase return label via Shippo POST /transactions (is_return: true)
|
||||||
|
const apiKey = process.env.SHIPPO_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "CONFIG_ERROR",
|
||||||
|
message: "Shippo API key not configured.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let txResponse: Response;
|
||||||
|
try {
|
||||||
|
txResponse = await fetch(SHIPPO_TRANSACTIONS_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `ShippoToken ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rate: rateObjectId, async: false, is_return: true }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SHIPPO_UNREACHABLE",
|
||||||
|
message: "Could not reach Shippo to create the return label.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txResponse.ok) {
|
||||||
|
let detail = "";
|
||||||
|
try {
|
||||||
|
detail = JSON.stringify(await txResponse.json());
|
||||||
|
} catch {}
|
||||||
|
console.error("Shippo /transactions/ return error:", txResponse.status, detail);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SHIPPO_ERROR",
|
||||||
|
message: `Shippo returned status ${txResponse.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx: {
|
||||||
|
object_id: string;
|
||||||
|
status: string;
|
||||||
|
tracking_number?: string;
|
||||||
|
tracking_url_provider?: string;
|
||||||
|
label_url?: string;
|
||||||
|
messages?: Array<{ source: string; text: string; code: string }>;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
tx = await txResponse.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "PARSE_ERROR",
|
||||||
|
message: "Shippo response could not be parsed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tx.status !== "SUCCESS") {
|
||||||
|
const errMsg =
|
||||||
|
tx.messages?.map((m) => m.text).join("; ") ?? "Unknown Shippo error";
|
||||||
|
console.error("Shippo return transaction failed:", tx.status, errMsg);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "SHIPPO_ERROR",
|
||||||
|
message: errMsg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tx.tracking_number || !tx.label_url) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "NO_TRACKING",
|
||||||
|
message: "Shippo returned success but no tracking number or label URL.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Persist return label data
|
||||||
|
await ctx.runMutation(internal.orders.applyReturnAccepted, {
|
||||||
|
orderId,
|
||||||
|
adminUserId: userId,
|
||||||
|
returnLabelUrl: tx.label_url,
|
||||||
|
returnTrackingNumber: tx.tracking_number,
|
||||||
|
returnCarrier: order.carrier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Send return label email to customer
|
||||||
|
const customer = await ctx.runQuery(internal.users.getById, {
|
||||||
|
userId: order.userId,
|
||||||
|
});
|
||||||
|
await ctx.runMutation(internal.emails.sendReturnLabelEmail, {
|
||||||
|
to: customer.email,
|
||||||
|
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
returnLabelUrl: tx.label_url,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, returnTrackingNumber: tx.tracking_number };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const issueRefund = action({
|
||||||
|
args: { orderId: v.id("orders") },
|
||||||
|
handler: async (ctx, { orderId }): Promise<{ success: boolean }> => {
|
||||||
|
// 1. Auth — must be an admin
|
||||||
|
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
||||||
|
const user = await ctx.runQuery(internal.users.getById, { userId });
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
throw new Error("Unauthorized: admin access required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load order
|
||||||
|
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
|
||||||
|
id: orderId,
|
||||||
|
});
|
||||||
|
if (!order) throw new Error("Order not found");
|
||||||
|
if (!order.stripePaymentIntentId)
|
||||||
|
throw new Error("Order has no Stripe payment intent to refund");
|
||||||
|
if (order.paymentStatus === "refunded")
|
||||||
|
throw new Error("Order has already been refunded");
|
||||||
|
|
||||||
|
// 3. Create Stripe refund — idempotent: skip if one already exists
|
||||||
|
const existingRefunds = await stripe.refunds.list({
|
||||||
|
payment_intent: order.stripePaymentIntentId,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
const hasRefund = existingRefunds.data.some(
|
||||||
|
(r) => r.status === "succeeded" || r.status === "pending",
|
||||||
|
);
|
||||||
|
if (!hasRefund) {
|
||||||
|
await stripe.refunds.create({
|
||||||
|
payment_intent: order.stripePaymentIntentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mark order refunded and restore stock — applyRefund is also idempotent
|
||||||
|
await ctx.runMutation(internal.orders.applyRefund, {
|
||||||
|
id: orderId,
|
||||||
|
adminUserId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -167,6 +167,8 @@ export default defineSchema({
|
|||||||
v.literal("delivered"),
|
v.literal("delivered"),
|
||||||
v.literal("cancelled"),
|
v.literal("cancelled"),
|
||||||
v.literal("refunded"),
|
v.literal("refunded"),
|
||||||
|
v.literal("return"),
|
||||||
|
v.literal("completed"),
|
||||||
),
|
),
|
||||||
paymentStatus: v.union(
|
paymentStatus: v.union(
|
||||||
v.literal("pending"),
|
v.literal("pending"),
|
||||||
@@ -209,6 +211,8 @@ export default defineSchema({
|
|||||||
carrier: v.string(),
|
carrier: v.string(),
|
||||||
trackingNumber: v.optional(v.string()),
|
trackingNumber: v.optional(v.string()),
|
||||||
trackingUrl: v.optional(v.string()),
|
trackingUrl: v.optional(v.string()),
|
||||||
|
labelUrl: v.optional(v.string()),
|
||||||
|
trackingStatus: v.optional(v.string()),
|
||||||
estimatedDelivery: v.optional(v.number()),
|
estimatedDelivery: v.optional(v.number()),
|
||||||
actualDelivery: v.optional(v.number()),
|
actualDelivery: v.optional(v.number()),
|
||||||
notes: v.optional(v.string()),
|
notes: v.optional(v.string()),
|
||||||
@@ -216,6 +220,11 @@ export default defineSchema({
|
|||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
paidAt: v.optional(v.number()),
|
paidAt: v.optional(v.number()),
|
||||||
shippedAt: v.optional(v.number()),
|
shippedAt: v.optional(v.number()),
|
||||||
|
returnRequestedAt: v.optional(v.number()),
|
||||||
|
returnReceivedAt: v.optional(v.number()),
|
||||||
|
returnLabelUrl: v.optional(v.string()),
|
||||||
|
returnTrackingNumber: v.optional(v.string()),
|
||||||
|
returnCarrier: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
.index("by_user", ["userId"])
|
.index("by_user", ["userId"])
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
@@ -223,7 +232,10 @@ export default defineSchema({
|
|||||||
.index("by_order_number", ["orderNumber"])
|
.index("by_order_number", ["orderNumber"])
|
||||||
.index("by_email", ["email"])
|
.index("by_email", ["email"])
|
||||||
.index("by_created_at", ["createdAt"])
|
.index("by_created_at", ["createdAt"])
|
||||||
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]),
|
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"])
|
||||||
|
.index("by_tracking_number_and_carrier", ["trackingNumber", "carrier"])
|
||||||
|
.index("by_return_tracking_number_and_carrier", ["returnTrackingNumber", "returnCarrier"])
|
||||||
|
.index("by_stripe_payment_intent_id", ["stripePaymentIntentId"]),
|
||||||
|
|
||||||
orderItems: defineTable({
|
orderItems: defineTable({
|
||||||
orderId: v.id("orders"),
|
orderId: v.id("orders"),
|
||||||
@@ -237,6 +249,19 @@ export default defineSchema({
|
|||||||
imageUrl: v.optional(v.string()),
|
imageUrl: v.optional(v.string()),
|
||||||
}).index("by_order", ["orderId"]),
|
}).index("by_order", ["orderId"]),
|
||||||
|
|
||||||
|
orderTimelineEvents: defineTable({
|
||||||
|
orderId: v.id("orders"),
|
||||||
|
eventType: v.string(), // "status_change" | "customer_cancel" | "return_requested" | "return_received" | "refund" | "tracking_update" | "label_created"
|
||||||
|
source: v.string(), // "stripe_webhook" | "fulfillment" | "admin" | "shippo_webhook" | "customer_cancel" | "customer_return"
|
||||||
|
fromStatus: v.optional(v.string()),
|
||||||
|
toStatus: v.optional(v.string()),
|
||||||
|
payload: v.optional(v.string()), // JSON string for Shippo/Stripe payloads
|
||||||
|
createdAt: v.number(),
|
||||||
|
userId: v.optional(v.id("users")),
|
||||||
|
})
|
||||||
|
.index("by_order", ["orderId"])
|
||||||
|
.index("by_order_and_created_at", ["orderId", "createdAt"]),
|
||||||
|
|
||||||
// ─── Reviews ───────────────────────────────────────────────────────────
|
// ─── Reviews ───────────────────────────────────────────────────────────
|
||||||
reviews: defineTable({
|
reviews: defineTable({
|
||||||
productId: v.id("products"),
|
productId: v.id("products"),
|
||||||
|
|||||||
187
convex/shippoWebhook.ts
Normal file
187
convex/shippoWebhook.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
internalAction,
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
} from "./_generated/server";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { recordOrderTimelineEvent } from "./model/orders";
|
||||||
|
|
||||||
|
// ─── Internal queries ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getOrderByTracking = internalQuery({
|
||||||
|
args: { trackingNumber: v.string(), carrier: v.string() },
|
||||||
|
handler: async (ctx, { trackingNumber, carrier }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("orders")
|
||||||
|
.withIndex("by_tracking_number_and_carrier", (q) =>
|
||||||
|
q.eq("trackingNumber", trackingNumber).eq("carrier", carrier),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrderByReturnTracking = internalQuery({
|
||||||
|
args: { returnTrackingNumber: v.string(), returnCarrier: v.string() },
|
||||||
|
handler: async (ctx, { returnTrackingNumber, returnCarrier }) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("orders")
|
||||||
|
.withIndex("by_return_tracking_number_and_carrier", (q) =>
|
||||||
|
q.eq("returnTrackingNumber", returnTrackingNumber).eq("returnCarrier", returnCarrier),
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Internal mutation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const applyTrackingUpdate = internalMutation({
|
||||||
|
args: {
|
||||||
|
orderId: v.id("orders"),
|
||||||
|
trackingStatus: v.string(),
|
||||||
|
estimatedDelivery: v.optional(v.number()),
|
||||||
|
isDelivered: v.boolean(),
|
||||||
|
isReturnTracking: v.boolean(),
|
||||||
|
payload: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const order = await ctx.db.get(args.orderId);
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
// Idempotency: skip if this exact status was already applied
|
||||||
|
if (order.trackingStatus === args.trackingStatus) {
|
||||||
|
console.log(
|
||||||
|
`[shippoWebhook] Skipping duplicate tracking status "${args.trackingStatus}" for order ${args.orderId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return tracking updates never set status to "delivered" — only outbound does
|
||||||
|
if (args.isDelivered && !args.isReturnTracking) {
|
||||||
|
await ctx.db.patch(args.orderId, {
|
||||||
|
trackingStatus: args.trackingStatus,
|
||||||
|
...(args.estimatedDelivery !== undefined
|
||||||
|
? { estimatedDelivery: args.estimatedDelivery }
|
||||||
|
: {}),
|
||||||
|
status: "delivered",
|
||||||
|
actualDelivery: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.patch(args.orderId, {
|
||||||
|
trackingStatus: args.trackingStatus,
|
||||||
|
...(args.estimatedDelivery !== undefined
|
||||||
|
? { estimatedDelivery: args.estimatedDelivery }
|
||||||
|
: {}),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordOrderTimelineEvent(ctx, {
|
||||||
|
orderId: args.orderId,
|
||||||
|
eventType: args.isReturnTracking ? "return_tracking_update" : "tracking_update",
|
||||||
|
source: "shippo_webhook",
|
||||||
|
...(args.isDelivered && !args.isReturnTracking
|
||||||
|
? { fromStatus: order.status, toStatus: "delivered" }
|
||||||
|
: {}),
|
||||||
|
payload: args.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.isDelivered && !args.isReturnTracking) {
|
||||||
|
const customer = await ctx.db.get(order.userId);
|
||||||
|
if (customer) {
|
||||||
|
await ctx.scheduler.runAfter(0, internal.emails.sendDeliveryConfirmation, {
|
||||||
|
to: customer.email,
|
||||||
|
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Internal action ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ShippoTrackUpdatedPayload = {
|
||||||
|
event?: string;
|
||||||
|
data?: {
|
||||||
|
carrier?: string;
|
||||||
|
tracking_number?: string;
|
||||||
|
eta?: string | null;
|
||||||
|
tracking_status?: {
|
||||||
|
status?: string;
|
||||||
|
status_details?: string;
|
||||||
|
status_date?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleTrackUpdated = internalAction({
|
||||||
|
args: { body: v.string() },
|
||||||
|
handler: async (ctx, { body }) => {
|
||||||
|
let payload: ShippoTrackUpdatedPayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body) as ShippoTrackUpdatedPayload;
|
||||||
|
} catch {
|
||||||
|
console.error("[shippoWebhook] Failed to parse JSON body");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.event !== "track_updated") {
|
||||||
|
console.log("[shippoWebhook] Ignoring event:", payload.event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = payload;
|
||||||
|
const trackingNumber = data?.tracking_number;
|
||||||
|
const carrier = data?.carrier;
|
||||||
|
|
||||||
|
if (!trackingNumber || !carrier) {
|
||||||
|
console.error(
|
||||||
|
"[shippoWebhook] Missing tracking_number or carrier in payload",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let order = await ctx.runQuery(
|
||||||
|
internal.shippoWebhook.getOrderByTracking,
|
||||||
|
{ trackingNumber, carrier },
|
||||||
|
);
|
||||||
|
let isReturnTracking = false;
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
order = await ctx.runQuery(
|
||||||
|
internal.shippoWebhook.getOrderByReturnTracking,
|
||||||
|
{ returnTrackingNumber: trackingNumber, returnCarrier: carrier },
|
||||||
|
);
|
||||||
|
isReturnTracking = !!order;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.log(
|
||||||
|
`[shippoWebhook] No order found for tracking ${trackingNumber} / ${carrier}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingStatus = data?.tracking_status?.status ?? "UNKNOWN";
|
||||||
|
const isDelivered = trackingStatus === "DELIVERED";
|
||||||
|
|
||||||
|
const eta = data?.eta;
|
||||||
|
const estimatedDelivery =
|
||||||
|
eta && !isNaN(new Date(eta).getTime())
|
||||||
|
? new Date(eta).getTime()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await ctx.runMutation(internal.shippoWebhook.applyTrackingUpdate, {
|
||||||
|
orderId: order._id,
|
||||||
|
trackingStatus,
|
||||||
|
estimatedDelivery,
|
||||||
|
isDelivered,
|
||||||
|
isReturnTracking,
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -229,6 +229,26 @@ export const handleWebhook = internalAction({
|
|||||||
(event.data.object as Stripe.Checkout.Session).id,
|
(event.data.object as Stripe.Checkout.Session).id,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "refund.updated": {
|
||||||
|
const refund = event.data.object as Stripe.Refund;
|
||||||
|
if (refund.status === "succeeded" && refund.payment_intent) {
|
||||||
|
const paymentIntentId =
|
||||||
|
typeof refund.payment_intent === "string"
|
||||||
|
? refund.payment_intent
|
||||||
|
: refund.payment_intent.id;
|
||||||
|
const order = await ctx.runQuery(
|
||||||
|
internal.orders.getOrderByPaymentIntent,
|
||||||
|
{ stripePaymentIntentId: paymentIntentId },
|
||||||
|
);
|
||||||
|
if (order && order.paymentStatus !== "refunded") {
|
||||||
|
await ctx.runMutation(internal.orders.applyRefund, {
|
||||||
|
id: order._id,
|
||||||
|
adminUserId: order.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
console.log("Unhandled Stripe event type:", event.type);
|
console.log("Unhandled Stripe event type:", event.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
|||||||
delivered: "Delivered",
|
delivered: "Delivered",
|
||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
refunded: "Refunded",
|
refunded: "Refunded",
|
||||||
|
return: "Return Requested",
|
||||||
|
completed: "Completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||||
@@ -139,6 +141,8 @@ export const ORDER_STATUS_COLORS: Record<OrderStatus, string> = {
|
|||||||
delivered: "green",
|
delivered: "green",
|
||||||
cancelled: "red",
|
cancelled: "red",
|
||||||
refunded: "gray",
|
refunded: "gray",
|
||||||
|
return: "orange",
|
||||||
|
completed: "teal",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user