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:
2026-03-07 20:51:12 +00:00
149 changed files with 19776 additions and 159 deletions

View File

@@ -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>
</>
);

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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)}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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})

View File

@@ -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>

View 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 510 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>
);
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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<

View File

@@ -11,6 +11,7 @@ export {
ORDERS_PATH,
ORDERS_PAGE_SIZE,
CANCELLABLE_STATUSES,
RETURNABLE_STATUSES,
ORDER_STATUS_CONFIG,
PAYMENT_STATUS_CONFIG,
ORDER_TAB_FILTERS,

View File

@@ -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 {

View File

@@ -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 };
}