feat(orders): implement return request functionality and order timeline
- Added RequestReturnDialog component for initiating return requests. - Enhanced OrderDetailPageView to handle return requests and display order timeline. - Updated OrderActions to include return request button based on order status. - Introduced OrderTimeline component to visualize order events. - Modified order-related types and constants to support return functionality. - Updated UI components for better styling and user experience. This commit improves the order management system by allowing users to request returns and view the timeline of their orders.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user