feat/admin #2
2
apps/storefront/next-env.d.ts
vendored
2
apps/storefront/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference path="./.next/types/routes.d.ts" />
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
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:
|
// PPR: enable when using Next.js canary. Uncomment and add experimental_ppr to PDP page:
|
||||||
// experimental: { ppr: "incremental" },
|
// experimental: { ppr: "incremental" },
|
||||||
};
|
};
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useProductSearch,
|
useProductSearch,
|
||||||
useClickOutside,
|
useClickOutside,
|
||||||
SEARCH_CATEGORIES,
|
SEARCH_CATEGORIES,
|
||||||
MIN_SEARCH_LENGTH,
|
|
||||||
} from "@/lib/search";
|
} from "@/lib/search";
|
||||||
import type { SearchCategory } from "@/lib/search";
|
import type { SearchCategory } from "@/lib/search";
|
||||||
import { SearchResultsPanel } from "@/components/search/SearchResultsPanel";
|
import { SearchResultsPanel } from "@/components/search/SearchResultsPanel";
|
||||||
@@ -39,15 +38,6 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
|||||||
setDropdownOpen(false);
|
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";
|
const isDesktop = variant === "desktop";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,8 +143,8 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
|||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? "flex-1 bg-transparent py-3 pl-4 pr-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
|
? "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 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
: "flex-1 border-none bg-transparent pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
||||||
}
|
}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={search.isOpen && search.showResults}
|
aria-expanded={search.isOpen && search.showResults}
|
||||||
@@ -168,27 +158,6 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
|||||||
autoComplete="off"
|
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 */}
|
{/* Results Panel */}
|
||||||
{(search.showResults || search.showMinCharsHint) && (
|
{(search.showResults || search.showMinCharsHint) && (
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "convex/react";
|
||||||
import { Breadcrumbs, toast } from "@heroui/react";
|
import { Breadcrumbs, toast } from "@heroui/react";
|
||||||
import Link from "next/link";
|
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 { ORDERS_PATH, useOrderDetail, useOrderActions } from "@/lib/orders";
|
||||||
import { useCartSession } from "@/lib/session";
|
import { useCartSession } from "@/lib/session";
|
||||||
import { OrderHeader } from "./detail/OrderHeader";
|
import { OrderHeader } from "./detail/OrderHeader";
|
||||||
@@ -11,8 +14,10 @@ import { OrderPriceSummary } from "./detail/OrderPriceSummary";
|
|||||||
import { OrderAddresses } from "./detail/OrderAddresses";
|
import { OrderAddresses } from "./detail/OrderAddresses";
|
||||||
import { OrderTrackingInfo } from "./detail/OrderTrackingInfo";
|
import { OrderTrackingInfo } from "./detail/OrderTrackingInfo";
|
||||||
import { OrderActions } from "./detail/OrderActions";
|
import { OrderActions } from "./detail/OrderActions";
|
||||||
|
import { OrderTimeline } from "./detail/OrderTimeline";
|
||||||
import { CancelOrderDialog } from "./actions/CancelOrderDialog";
|
import { CancelOrderDialog } from "./actions/CancelOrderDialog";
|
||||||
import { ReorderConfirmDialog } from "./actions/ReorderConfirmDialog";
|
import { ReorderConfirmDialog } from "./actions/ReorderConfirmDialog";
|
||||||
|
import { RequestReturnDialog } from "./actions/RequestReturnDialog";
|
||||||
import { OrderDetailSkeleton } from "./state/OrderDetailSkeleton";
|
import { OrderDetailSkeleton } from "./state/OrderDetailSkeleton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,11 +26,16 @@ interface Props {
|
|||||||
|
|
||||||
export function OrderDetailPageView({ orderId }: Props) {
|
export function OrderDetailPageView({ orderId }: Props) {
|
||||||
const { order, isLoading } = useOrderDetail(orderId);
|
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();
|
useOrderActions();
|
||||||
const { sessionId } = useCartSession();
|
const { sessionId } = useCartSession();
|
||||||
|
|
||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [returnDialogOpen, setReturnDialogOpen] = useState(false);
|
||||||
const [reorderDialogOpen, setReorderDialogOpen] = useState(false);
|
const [reorderDialogOpen, setReorderDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (isLoading) return <OrderDetailSkeleton />;
|
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 handleConfirmReorder = async () => {
|
||||||
const { added, skipped } = await reorderItems(order.items, sessionId);
|
const { added, skipped } = await reorderItems(order.items, sessionId);
|
||||||
setReorderDialogOpen(false);
|
setReorderDialogOpen(false);
|
||||||
@@ -128,11 +148,23 @@ export function OrderDetailPageView({ orderId }: Props) {
|
|||||||
order={order}
|
order={order}
|
||||||
onCancel={() => setCancelDialogOpen(true)}
|
onCancel={() => setCancelDialogOpen(true)}
|
||||||
isCancelling={isCancelling}
|
isCancelling={isCancelling}
|
||||||
|
onRequestReturn={() => setReturnDialogOpen(true)}
|
||||||
|
isRequestingReturn={isRequestingReturn}
|
||||||
onReorder={() => setReorderDialogOpen(true)}
|
onReorder={() => setReorderDialogOpen(true)}
|
||||||
isReordering={isReordering}
|
isReordering={isReordering}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<OrderTimeline events={timelineEvents} />
|
||||||
|
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
|
<RequestReturnDialog
|
||||||
|
isOpen={returnDialogOpen}
|
||||||
|
onClose={() => setReturnDialogOpen(false)}
|
||||||
|
onConfirm={handleConfirmReturn}
|
||||||
|
isRequesting={isRequestingReturn}
|
||||||
|
orderNumber={order.orderNumber}
|
||||||
|
/>
|
||||||
<CancelOrderDialog
|
<CancelOrderDialog
|
||||||
isOpen={cancelDialogOpen}
|
isOpen={cancelDialogOpen}
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
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 { Button, Spinner } from "@heroui/react";
|
||||||
import Link from "next/link";
|
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";
|
import type { OrderDetail } from "@/lib/orders";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
order: OrderDetail;
|
order: OrderDetail;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
|
onRequestReturn: () => void;
|
||||||
|
isRequestingReturn: boolean;
|
||||||
onReorder: () => void;
|
onReorder: () => void;
|
||||||
isReordering: boolean;
|
isReordering: boolean;
|
||||||
}
|
}
|
||||||
@@ -19,12 +21,18 @@ export function OrderActions({
|
|||||||
order,
|
order,
|
||||||
onCancel,
|
onCancel,
|
||||||
isCancelling,
|
isCancelling,
|
||||||
|
onRequestReturn,
|
||||||
|
isRequestingReturn,
|
||||||
onReorder,
|
onReorder,
|
||||||
isReordering,
|
isReordering,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(
|
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(
|
||||||
order.status,
|
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(
|
const canReorder = (REORDERABLE_STATUSES as readonly string[]).includes(
|
||||||
order.status,
|
order.status,
|
||||||
);
|
);
|
||||||
@@ -49,6 +57,30 @@ export function OrderActions({
|
|||||||
</Button>
|
</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 && (
|
{canReorder && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function AddressLines({
|
|||||||
export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
|
export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Card>
|
<Card className="rounded-xl p-5">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
Shipping Address
|
Shipping Address
|
||||||
@@ -46,7 +46,7 @@ export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="rounded-xl p-5">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
Billing Address
|
Billing Address
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function OrderLineItems({ items, currency }: Props) {
|
|||||||
const scrollable = items.length > 4;
|
const scrollable = items.length > 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="rounded-xl p-5">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-base">
|
<Card.Title className="text-base">
|
||||||
Items ({items.length})
|
Items ({items.length})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function OrderPriceSummary({
|
|||||||
currency,
|
currency,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="rounded-xl p-5">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-base">Order Summary</Card.Title>
|
<Card.Title className="text-base">Order Summary</Card.Title>
|
||||||
</Card.Header>
|
</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";
|
const isPending = status === "pending" || status === "confirmed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="rounded-xl p-5">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-base">Shipping & Tracking</Card.Title>
|
<Card.Title className="text-base">Shipping & Tracking</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function OrderCard({ order, onViewDetails }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<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">
|
<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">
|
<h3 className="font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
|
||||||
{order.orderNumber}
|
{order.orderNumber}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function CategorySection() {
|
|||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={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" }}
|
style={{ scrollSnapAlign: "start" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OrderStatus, PaymentStatus } from "./types";
|
|||||||
export const ORDERS_PATH = "/account/orders";
|
export const ORDERS_PATH = "/account/orders";
|
||||||
export const ORDERS_PAGE_SIZE = 10;
|
export const ORDERS_PAGE_SIZE = 10;
|
||||||
export const CANCELLABLE_STATUSES = ["confirmed"] as const;
|
export const CANCELLABLE_STATUSES = ["confirmed"] as const;
|
||||||
|
export const RETURNABLE_STATUSES = ["delivered"] as const;
|
||||||
|
|
||||||
export const ORDER_STATUS_CONFIG: Record<
|
export const ORDER_STATUS_CONFIG: Record<
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
@@ -43,6 +44,16 @@ export const ORDER_STATUS_CONFIG: Record<
|
|||||||
colorClass: "bg-[#fce0da] text-[#f2705a]",
|
colorClass: "bg-[#fce0da] text-[#f2705a]",
|
||||||
chipVariant: "default",
|
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<
|
export const PAYMENT_STATUS_CONFIG: Record<
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
ORDERS_PATH,
|
ORDERS_PATH,
|
||||||
ORDERS_PAGE_SIZE,
|
ORDERS_PAGE_SIZE,
|
||||||
CANCELLABLE_STATUSES,
|
CANCELLABLE_STATUSES,
|
||||||
|
RETURNABLE_STATUSES,
|
||||||
ORDER_STATUS_CONFIG,
|
ORDER_STATUS_CONFIG,
|
||||||
PAYMENT_STATUS_CONFIG,
|
PAYMENT_STATUS_CONFIG,
|
||||||
ORDER_TAB_FILTERS,
|
ORDER_TAB_FILTERS,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export type OrderStatus =
|
|||||||
| "shipped"
|
| "shipped"
|
||||||
| "delivered"
|
| "delivered"
|
||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "refunded";
|
| "refunded"
|
||||||
|
| "return"
|
||||||
|
| "completed";
|
||||||
|
|
||||||
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
|
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ export interface OrderDetail extends OrderSummary {
|
|||||||
shippedAt?: number;
|
shippedAt?: number;
|
||||||
actualDelivery?: number;
|
actualDelivery?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
returnRequestedAt?: number;
|
||||||
|
returnReceivedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderCancellationResult {
|
export interface OrderCancellationResult {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type { OrderLineItem } from "./types";
|
|||||||
export function useOrderActions(): {
|
export function useOrderActions(): {
|
||||||
cancelOrder: (orderId: string) => Promise<{ success: boolean }>;
|
cancelOrder: (orderId: string) => Promise<{ success: boolean }>;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
|
requestReturn: (orderId: string) => Promise<{ success: boolean }>;
|
||||||
|
isRequestingReturn: boolean;
|
||||||
reorderItems: (
|
reorderItems: (
|
||||||
items: OrderLineItem[],
|
items: OrderLineItem[],
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
@@ -17,9 +19,11 @@ export function useOrderActions(): {
|
|||||||
isReordering: boolean;
|
isReordering: boolean;
|
||||||
} {
|
} {
|
||||||
const [isCancelling, setIsCancelling] = useState(false);
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
|
const [isRequestingReturn, setIsRequestingReturn] = useState(false);
|
||||||
const [isReordering, setIsReordering] = useState(false);
|
const [isReordering, setIsReordering] = useState(false);
|
||||||
|
|
||||||
const cancelMutation = useMutation(api.orders.cancel);
|
const cancelMutation = useMutation(api.orders.cancel);
|
||||||
|
const requestReturnMutation = useMutation(api.orders.requestReturn);
|
||||||
const addItemMutation = useMutation(api.carts.addItem);
|
const addItemMutation = useMutation(api.carts.addItem);
|
||||||
const router = useRouter();
|
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(
|
async function reorderItems(
|
||||||
items: OrderLineItem[],
|
items: OrderLineItem[],
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
@@ -63,5 +79,5 @@ export function useOrderActions(): {
|
|||||||
return { added, skipped };
|
return { added, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cancelOrder, isCancelling, reorderItems, isReordering };
|
return { cancelOrder, isCancelling, requestReturn, isRequestingReturn, reorderItems, isReordering };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
@@ -24,14 +24,17 @@
|
|||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|
|||||||
Reference in New Issue
Block a user