Merge pull request 'feat/admin' (#2) from feat/admin into main
Reviewed-on: http://72.61.144.167:3000/admin/the-pet-loft/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||
turbopack: {
|
||||
root: path.join(__dirname, "..", ".."),
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "res.cloudinary.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
// PPR: enable when using Next.js canary. Uncomment and add experimental_ppr to PDP page:
|
||||
// experimental: { ppr: "incremental" },
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAction } from "convex/react";
|
||||
import { Button, Card, Link, Spinner } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import { Card, Spinner } from "@heroui/react";
|
||||
import { api } from "../../../../../../convex/_generated/api";
|
||||
|
||||
type PageState =
|
||||
@@ -116,18 +117,18 @@ function CompleteView({ email }: { email: string | null }) {
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
<Link
|
||||
href="/account/orders"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||
>
|
||||
View your orders
|
||||
</Button>
|
||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||
</Link>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||
>
|
||||
Continue shopping
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -165,18 +166,18 @@ function IncompleteView() {
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
<Link
|
||||
href="/checkout"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||
>
|
||||
Return to checkout
|
||||
</Button>
|
||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||
</Link>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||
>
|
||||
Continue shopping
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -211,18 +212,18 @@ function ErrorView({ message }: { message: string }) {
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
<Link
|
||||
href="/checkout"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
className="inline-flex items-center justify-center rounded-lg gap-2 px-4 py-3 w-full bg-[#236f6b] font-medium text-white hover:bg-[#1e5f5b] transition-colors text-base"
|
||||
>
|
||||
Return to checkout
|
||||
</Button>
|
||||
<Button as={Link} href="/" variant="ghost" className="w-full">
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-3 w-full bg-transparent hover:bg-default-100 text-foreground font-medium transition-colors"
|
||||
>
|
||||
Go to homepage
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
12
apps/storefront/src/app/shop/layout.tsx
Normal file
12
apps/storefront/src/app/shop/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
|
||||
export default function ShopLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<ShopProductGridSkeleton />}>{children}</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Link } from "@heroui/react";
|
||||
import { Button } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Checkout error state: error message + retry button + back-to-cart link.
|
||||
@@ -17,17 +18,15 @@ export function CheckoutErrorState({
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<p className="text-lg font-medium text-danger">{message}</p>
|
||||
<div className="flex flex-col gap-2 w-full md:w-auto md:flex-row">
|
||||
<Button color="primary" onPress={onRetry} className="w-full md:w-auto">
|
||||
<Button variant="primary" onPress={onRetry} className="w-full md:w-auto">
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
as={Link}
|
||||
<Link
|
||||
href="/cart"
|
||||
variant="flat"
|
||||
className="w-full md:w-auto"
|
||||
className="inline-flex items-center justify-center rounded-medium gap-2 px-4 py-2 w-full md:w-auto bg-default-100 hover:bg-default-200 text-foreground font-medium transition-colors"
|
||||
>
|
||||
Back to cart
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export function OrderReviewStep({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
onPress={handleContinue}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function PaymentStep({
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
||||
onPress={initSession}
|
||||
>
|
||||
@@ -147,7 +147,7 @@ function CheckoutForm({
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
|
||||
onPress={onSessionExpired}
|
||||
>
|
||||
@@ -237,12 +237,12 @@ function CheckoutForm({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isSubmitting || !checkout.canConfirm}
|
||||
isLoading={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Processing…" : `Pay ${total.amount}`}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useProductSearch,
|
||||
useClickOutside,
|
||||
SEARCH_CATEGORIES,
|
||||
MIN_SEARCH_LENGTH,
|
||||
} from "@/lib/search";
|
||||
import type { SearchCategory } from "@/lib/search";
|
||||
import { SearchResultsPanel } from "@/components/search/SearchResultsPanel";
|
||||
@@ -39,15 +38,6 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
|
||||
function handleSearchButtonClick() {
|
||||
if (search.query.length >= MIN_SEARCH_LENGTH) {
|
||||
router.push(`/shop?search=${encodeURIComponent(search.query)}`);
|
||||
search.close();
|
||||
} else {
|
||||
search.open();
|
||||
}
|
||||
}
|
||||
|
||||
const isDesktop = variant === "desktop";
|
||||
|
||||
return (
|
||||
@@ -153,8 +143,8 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
||||
}
|
||||
className={
|
||||
isDesktop
|
||||
? "flex-1 bg-transparent py-3 pl-4 pr-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
|
||||
: "flex-1 border-none bg-transparent pl-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
||||
? "flex-1 bg-transparent py-3 pl-4 pr-5 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
|
||||
: "flex-1 border-none bg-transparent pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
||||
}
|
||||
role="combobox"
|
||||
aria-expanded={search.isOpen && search.showResults}
|
||||
@@ -168,27 +158,6 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Search Button */}
|
||||
<button
|
||||
onClick={handleSearchButtonClick}
|
||||
aria-label="Search"
|
||||
className={`${isDesktop ? "mr-1.5 " : ""}flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#236f6b] text-white transition-colors hover:bg-[#38a99f]`}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Results Panel */}
|
||||
{(search.showResults || search.showMinCharsHint) && (
|
||||
<SearchResultsPanel
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { Breadcrumbs, toast } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel";
|
||||
import { ORDERS_PATH, useOrderDetail, useOrderActions } from "@/lib/orders";
|
||||
import { useCartSession } from "@/lib/session";
|
||||
import { OrderHeader } from "./detail/OrderHeader";
|
||||
@@ -11,8 +14,10 @@ import { OrderPriceSummary } from "./detail/OrderPriceSummary";
|
||||
import { OrderAddresses } from "./detail/OrderAddresses";
|
||||
import { OrderTrackingInfo } from "./detail/OrderTrackingInfo";
|
||||
import { OrderActions } from "./detail/OrderActions";
|
||||
import { OrderTimeline } from "./detail/OrderTimeline";
|
||||
import { CancelOrderDialog } from "./actions/CancelOrderDialog";
|
||||
import { ReorderConfirmDialog } from "./actions/ReorderConfirmDialog";
|
||||
import { RequestReturnDialog } from "./actions/RequestReturnDialog";
|
||||
import { OrderDetailSkeleton } from "./state/OrderDetailSkeleton";
|
||||
|
||||
interface Props {
|
||||
@@ -21,11 +26,16 @@ interface Props {
|
||||
|
||||
export function OrderDetailPageView({ orderId }: Props) {
|
||||
const { order, isLoading } = useOrderDetail(orderId);
|
||||
const { cancelOrder, isCancelling, reorderItems, isReordering } =
|
||||
const timelineEvents = useQuery(
|
||||
api.orders.getTimeline,
|
||||
order ? { orderId: orderId as Id<"orders"> } : "skip",
|
||||
) ?? [];
|
||||
const { cancelOrder, isCancelling, requestReturn, isRequestingReturn, reorderItems, isReordering } =
|
||||
useOrderActions();
|
||||
const { sessionId } = useCartSession();
|
||||
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
|
||||
const [reorderDialogOpen, setReorderDialogOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <OrderDetailSkeleton />;
|
||||
@@ -54,6 +64,16 @@ export function OrderDetailPageView({ orderId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReturn = async () => {
|
||||
const result = await requestReturn(orderId);
|
||||
setReturnDialogOpen(false);
|
||||
if (result.success) {
|
||||
toast.success("Return requested. We'll be in touch with next steps.");
|
||||
} else {
|
||||
toast.danger("Failed to submit return request. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReorder = async () => {
|
||||
const { added, skipped } = await reorderItems(order.items, sessionId);
|
||||
setReorderDialogOpen(false);
|
||||
@@ -128,11 +148,23 @@ export function OrderDetailPageView({ orderId }: Props) {
|
||||
order={order}
|
||||
onCancel={() => setCancelDialogOpen(true)}
|
||||
isCancelling={isCancelling}
|
||||
onRequestReturn={() => setReturnDialogOpen(true)}
|
||||
isRequestingReturn={isRequestingReturn}
|
||||
onReorder={() => setReorderDialogOpen(true)}
|
||||
isReordering={isReordering}
|
||||
/>
|
||||
|
||||
{/* Timeline */}
|
||||
<OrderTimeline events={timelineEvents} />
|
||||
|
||||
{/* Dialogs */}
|
||||
<RequestReturnDialog
|
||||
isOpen={returnDialogOpen}
|
||||
onClose={() => setReturnDialogOpen(false)}
|
||||
onConfirm={handleConfirmReturn}
|
||||
isRequesting={isRequestingReturn}
|
||||
orderNumber={order.orderNumber}
|
||||
/>
|
||||
<CancelOrderDialog
|
||||
isOpen={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { AlertDialog, Button, Spinner } from "@heroui/react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isRequesting: boolean;
|
||||
orderNumber: string;
|
||||
}
|
||||
|
||||
export function RequestReturnDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isRequesting,
|
||||
orderNumber,
|
||||
}: Props) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialog.Backdrop isOpen={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<AlertDialog.Container>
|
||||
<AlertDialog.Dialog>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Icon status="warning" />
|
||||
<AlertDialog.Heading>Request a Return?</AlertDialog.Heading>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Body>
|
||||
<p className="text-sm text-gray-600">
|
||||
You are requesting a return for order{" "}
|
||||
<span className="font-medium text-[#1a2e2d]">{orderNumber}</span>.
|
||||
Our team will review your request and contact you with next steps.
|
||||
</p>
|
||||
</AlertDialog.Body>
|
||||
<AlertDialog.Footer>
|
||||
<Button variant="outline" slot="close" isDisabled={isRequesting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={onConfirm}
|
||||
isDisabled={isRequesting}
|
||||
>
|
||||
{isRequesting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
"Yes, Request Return"
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Dialog>
|
||||
</AlertDialog.Container>
|
||||
</AlertDialog.Backdrop>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { Button, Spinner } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import { CANCELLABLE_STATUSES, ORDERS_PATH } from "@/lib/orders";
|
||||
import { CANCELLABLE_STATUSES, RETURNABLE_STATUSES, ORDERS_PATH } from "@/lib/orders";
|
||||
import type { OrderDetail } from "@/lib/orders";
|
||||
|
||||
interface Props {
|
||||
order: OrderDetail;
|
||||
onCancel: () => void;
|
||||
isCancelling: boolean;
|
||||
onRequestReturn: () => void;
|
||||
isRequestingReturn: boolean;
|
||||
onReorder: () => void;
|
||||
isReordering: boolean;
|
||||
}
|
||||
@@ -19,12 +21,18 @@ export function OrderActions({
|
||||
order,
|
||||
onCancel,
|
||||
isCancelling,
|
||||
onRequestReturn,
|
||||
isRequestingReturn,
|
||||
onReorder,
|
||||
isReordering,
|
||||
}: Props) {
|
||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(
|
||||
order.status,
|
||||
);
|
||||
const canRequestReturn =
|
||||
(RETURNABLE_STATUSES as readonly string[]).includes(order.status) &&
|
||||
!order.returnRequestedAt &&
|
||||
order.paymentStatus !== "refunded";
|
||||
const canReorder = (REORDERABLE_STATUSES as readonly string[]).includes(
|
||||
order.status,
|
||||
);
|
||||
@@ -49,6 +57,30 @@ export function OrderActions({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canRequestReturn && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onPress={onRequestReturn}
|
||||
isDisabled={isRequestingReturn}
|
||||
>
|
||||
{isRequestingReturn ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
"Request Return"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{order.returnRequestedAt && order.status === "delivered" && (
|
||||
<span className="inline-flex w-full items-center justify-center rounded-md border border-gray-200 px-4 py-2 text-sm font-medium text-gray-500 md:w-auto">
|
||||
Return Requested
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canReorder && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -35,7 +35,7 @@ function AddressLines({
|
||||
export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<Card className="rounded-xl p-5">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Shipping Address
|
||||
@@ -46,7 +46,7 @@ export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="rounded-xl p-5">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Billing Address
|
||||
|
||||
@@ -63,7 +63,7 @@ export function OrderLineItems({ items, currency }: Props) {
|
||||
const scrollable = items.length > 4;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="rounded-xl p-5">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">
|
||||
Items ({items.length})
|
||||
|
||||
@@ -19,7 +19,7 @@ export function OrderPriceSummary({
|
||||
currency,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="rounded-xl p-5">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">Order Summary</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
134
apps/storefront/src/components/orders/detail/OrderTimeline.tsx
Normal file
134
apps/storefront/src/components/orders/detail/OrderTimeline.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
interface TimelineEvent {
|
||||
_id: string;
|
||||
eventType: string;
|
||||
source: string;
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: TimelineEvent[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
confirmed: "Confirmed",
|
||||
processing: "Processing",
|
||||
shipped: "Shipped",
|
||||
delivered: "Delivered",
|
||||
cancelled: "Cancelled",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
function getEventLabel(event: TimelineEvent): string {
|
||||
switch (event.eventType) {
|
||||
case "status_change":
|
||||
if (event.toStatus) {
|
||||
return `Order ${STATUS_LABELS[event.toStatus] ?? event.toStatus}`;
|
||||
}
|
||||
return "Status updated";
|
||||
case "customer_cancel":
|
||||
return "Order cancelled by you";
|
||||
case "return_requested":
|
||||
return "Return requested";
|
||||
case "return_received":
|
||||
return "Return received";
|
||||
case "refund":
|
||||
return "Refund issued";
|
||||
case "tracking_update":
|
||||
return "Tracking updated";
|
||||
case "label_created":
|
||||
return "Shipping label created";
|
||||
default:
|
||||
return "Order updated";
|
||||
}
|
||||
}
|
||||
|
||||
function getEventDescription(event: TimelineEvent): string | null {
|
||||
if (
|
||||
event.eventType === "status_change" &&
|
||||
event.fromStatus &&
|
||||
event.toStatus
|
||||
) {
|
||||
return `${STATUS_LABELS[event.fromStatus] ?? event.fromStatus} → ${STATUS_LABELS[event.toStatus] ?? event.toStatus}`;
|
||||
}
|
||||
if (event.eventType === "return_requested") {
|
||||
return "Our team will review and contact you with next steps.";
|
||||
}
|
||||
if (event.eventType === "refund") {
|
||||
return "Your refund has been processed. Allow 5–10 business days.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDotColor(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "customer_cancel":
|
||||
return "bg-[#f2705a]";
|
||||
case "return_requested":
|
||||
case "return_received":
|
||||
return "bg-[#f4a13a]";
|
||||
case "refund":
|
||||
return "bg-green-500";
|
||||
default:
|
||||
return "bg-[#38a99f]";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(ts));
|
||||
}
|
||||
|
||||
export function OrderTimeline({ events }: Props) {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-sm font-semibold text-[#1a2e2d]">
|
||||
Order Timeline
|
||||
</h2>
|
||||
<ol className="relative space-y-0">
|
||||
{events.map((event, index) => {
|
||||
const isLast = index === events.length - 1;
|
||||
return (
|
||||
<li key={event._id} className="relative flex gap-4">
|
||||
{/* Vertical line + dot */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`mt-1 size-2.5 shrink-0 rounded-full ${getDotColor(event.eventType)}`}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="mt-1 w-px flex-1 bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`pb-6 ${isLast ? "pb-0" : ""}`}>
|
||||
<p className="text-sm font-medium text-[#1a2e2d]">
|
||||
{getEventLabel(event)}
|
||||
</p>
|
||||
{getEventDescription(event) && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{getEventDescription(event)}
|
||||
</p>
|
||||
)}
|
||||
<time className="mt-1 block text-xs text-gray-400">
|
||||
{formatTimestamp(event.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function OrderTrackingInfo({
|
||||
const isPending = status === "pending" || status === "confirmed";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="rounded-xl p-5">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">Shipping & Tracking</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function OrderCard({ order, onViewDetails }: Props) {
|
||||
|
||||
return (
|
||||
<article>
|
||||
<Card className="w-full">
|
||||
<Card className="w-full rounded-xl p-5">
|
||||
<Card.Header className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
|
||||
{order.orderNumber}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ReviewList({ reviews, total, hasMore, isLoading, onLoadMore }: P
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={onLoadMore}
|
||||
isLoading={isLoading}
|
||||
isPending={isLoading}
|
||||
className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]"
|
||||
>
|
||||
Show more reviews
|
||||
|
||||
@@ -91,7 +91,7 @@ export function CategorySection() {
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex shrink-0 flex-col items-center gap-3 rounded-2xl transition-[transform] duration-[var(--transition-base)] hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--brand-mist)] lg:shrink"
|
||||
className="flex shrink-0 flex-col items-center gap-3 rounded-2xl transition-[transform] duration-[var(--transition-base)] hover:scale-[1.02] lg:shrink"
|
||||
style={{ scrollSnapAlign: "start" }}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OrderStatus, PaymentStatus } from "./types";
|
||||
export const ORDERS_PATH = "/account/orders";
|
||||
export const ORDERS_PAGE_SIZE = 10;
|
||||
export const CANCELLABLE_STATUSES = ["confirmed"] as const;
|
||||
export const RETURNABLE_STATUSES = ["delivered"] as const;
|
||||
|
||||
export const ORDER_STATUS_CONFIG: Record<
|
||||
OrderStatus,
|
||||
@@ -43,6 +44,16 @@ export const ORDER_STATUS_CONFIG: Record<
|
||||
colorClass: "bg-[#fce0da] text-[#f2705a]",
|
||||
chipVariant: "default",
|
||||
},
|
||||
return: {
|
||||
label: "Return Requested",
|
||||
colorClass: "bg-orange-50 text-orange-700",
|
||||
chipVariant: "default",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
colorClass: "bg-teal-50 text-teal-700",
|
||||
chipVariant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const PAYMENT_STATUS_CONFIG: Record<
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
ORDERS_PATH,
|
||||
ORDERS_PAGE_SIZE,
|
||||
CANCELLABLE_STATUSES,
|
||||
RETURNABLE_STATUSES,
|
||||
ORDER_STATUS_CONFIG,
|
||||
PAYMENT_STATUS_CONFIG,
|
||||
ORDER_TAB_FILTERS,
|
||||
|
||||
@@ -5,7 +5,9 @@ export type OrderStatus =
|
||||
| "shipped"
|
||||
| "delivered"
|
||||
| "cancelled"
|
||||
| "refunded";
|
||||
| "refunded"
|
||||
| "return"
|
||||
| "completed";
|
||||
|
||||
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
|
||||
|
||||
@@ -71,6 +73,8 @@ export interface OrderDetail extends OrderSummary {
|
||||
shippedAt?: number;
|
||||
actualDelivery?: number;
|
||||
notes?: string;
|
||||
returnRequestedAt?: number;
|
||||
returnReceivedAt?: number;
|
||||
}
|
||||
|
||||
export interface OrderCancellationResult {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { OrderLineItem } from "./types";
|
||||
export function useOrderActions(): {
|
||||
cancelOrder: (orderId: string) => Promise<{ success: boolean }>;
|
||||
isCancelling: boolean;
|
||||
requestReturn: (orderId: string) => Promise<{ success: boolean }>;
|
||||
isRequestingReturn: boolean;
|
||||
reorderItems: (
|
||||
items: OrderLineItem[],
|
||||
sessionId?: string,
|
||||
@@ -17,9 +19,11 @@ export function useOrderActions(): {
|
||||
isReordering: boolean;
|
||||
} {
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isRequestingReturn, setIsRequestingReturn] = useState(false);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
const cancelMutation = useMutation(api.orders.cancel);
|
||||
const requestReturnMutation = useMutation(api.orders.requestReturn);
|
||||
const addItemMutation = useMutation(api.carts.addItem);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -35,6 +39,18 @@ export function useOrderActions(): {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestReturn(orderId: string): Promise<{ success: boolean }> {
|
||||
setIsRequestingReturn(true);
|
||||
try {
|
||||
await requestReturnMutation({ id: orderId as Id<"orders"> });
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
} finally {
|
||||
setIsRequestingReturn(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderItems(
|
||||
items: OrderLineItem[],
|
||||
sessionId?: string,
|
||||
@@ -63,5 +79,5 @@ export function useOrderActions(): {
|
||||
return { added, skipped };
|
||||
}
|
||||
|
||||
return { cancelOrder, isCancelling, reorderItems, isReordering };
|
||||
return { cancelOrder, isCancelling, requestReturn, isRequestingReturn, reorderItems, isReordering };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user