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,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
|
||||
|
||||
Reference in New Issue
Block a user