feat: initial commit — storefront, convex backend, and shared packages

Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
"use client";
import { Modal, Button, Spinner } from "@heroui/react";
type RemoveFromWishlistDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isRemoving: boolean;
productName: string;
};
export function RemoveFromWishlistDialog({
isOpen,
onClose,
onConfirm,
isRemoving,
productName,
}: RemoveFromWishlistDialogProps) {
return (
<Modal.Backdrop isOpen={isOpen} onOpenChange={(open) => !open && onClose()}>
<Modal.Container size="sm">
<Modal.Dialog className="sm:max-w-[400px]">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>Remove from Wishlist?</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-[var(--muted)]">
Are you sure you want to remove{" "}
<strong className="text-[var(--foreground)]">{productName}</strong>{" "}
from your wishlist?
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" slot="close">
Keep
</Button>
<Button
variant="danger"
isDisabled={isRemoving}
onPress={onConfirm}
>
{isRemoving ? (
<>
<Spinner size="sm" color="current" />
Removing
</>
) : (
"Remove"
)}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { Button, Spinner } from "@heroui/react";
type WishlistActionsProps = {
onRemove: () => void;
onAddToCart: () => void;
isRemoving: boolean;
isAddingToCart: boolean;
isAvailable: boolean;
};
export function WishlistActions({
onRemove,
onAddToCart,
isRemoving,
isAddingToCart,
isAvailable,
}: WishlistActionsProps) {
return (
<div className="flex flex-col gap-2 md:flex-row">
<Button
className="w-full bg-[#f4a13a] font-medium text-white hover:bg-[#e0922e] md:w-auto md:flex-1"
isDisabled={!isAvailable || isAddingToCart}
onPress={onAddToCart}
>
{isAddingToCart ? (
<>
<Spinner size="sm" color="current" />
Adding
</>
) : (
"Add to Cart"
)}
</Button>
<Button
variant="ghost"
className="w-full text-[var(--coral)] hover:bg-[#fce0da] md:w-auto"
isDisabled={isRemoving}
onPress={onRemove}
>
{isRemoving ? (
<>
<Spinner size="sm" color="current" />
Removing
</>
) : (
"Remove"
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { Card, Chip, CloseButton } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { formatPrice } from "@repo/utils";
import { getStockStatus, STOCK_STATUS_CONFIG } from "@/lib/wishlist/constants";
import type { WishlistItem } from "@/lib/wishlist/types";
import { WishlistActions } from "./WishlistActions";
type WishlistItemCardProps = {
item: WishlistItem;
onRemove: (id: string) => void;
onAddToCart: (variantId: string) => void;
isRemoving: boolean;
isAddingToCart: boolean;
};
export function WishlistItemCard({
item,
onRemove,
onAddToCart,
isRemoving,
isAddingToCart,
}: WishlistItemCardProps) {
const { product, variant } = item;
if (!product) {
return (
<Card className="relative opacity-60">
<Card.Header className="relative">
<CloseButton
aria-label="Remove unavailable item from wishlist"
className="absolute right-2 top-2 z-10"
onPress={() => onRemove(item._id)}
/>
<Card.Title className="text-sm text-gray-500">
Product no longer available
</Card.Title>
</Card.Header>
<Card.Footer>
<button
className="w-full rounded-lg border border-gray-200 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50"
onClick={() => onRemove(item._id)}
>
Remove
</button>
</Card.Footer>
</Card>
);
}
const productUrl = `/shop/${product.parentCategorySlug}/${product.childCategorySlug}/${product.slug}`;
const imageSrc = product.images[0]?.url ?? "/placeholder-product.png";
const imageAlt = product.images[0]?.alt ?? product.name;
const activeVariant = variant ?? product.variants[0];
const currentPrice = activeVariant?.price ?? 0;
const compareAtPrice = activeVariant?.compareAtPrice;
const stockStatus = getStockStatus(product.status, activeVariant?.stockQuantity);
const stockConfig = STOCK_STATUS_CONFIG[stockStatus];
const isAvailable = stockStatus === "in_stock" || stockStatus === "low_stock";
const priceDiff = currentPrice - item.priceWhenAdded;
return (
<Card className="relative flex flex-col">
<Card.Header className="relative p-0">
<CloseButton
aria-label={`Remove ${product.name} from wishlist`}
className="absolute right-2 top-2 z-10"
onPress={() => onRemove(item._id)}
/>
<Link href={productUrl} className="block aspect-square w-full overflow-hidden rounded-t-2xl">
<div className="relative h-full w-full">
<Image
src={imageSrc}
alt={imageAlt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-contain mix-blend-multiply p-4"
loading="lazy"
/>
</div>
</Link>
</Card.Header>
<Card.Content className="flex flex-1 flex-col gap-2 p-4">
<Link href={productUrl} className="group">
{product.brand && (
<span className="block font-[family-name:var(--font-fraunces)] text-xs font-bold text-[var(--foreground)] group-hover:text-[#38a99f]">
{product.brand}
</span>
)}
<span className="line-clamp-2 text-sm font-medium leading-snug text-[var(--foreground)]">
{product.name}
</span>
</Link>
{variant && (
<Chip size="sm" className="w-fit text-xs">
{variant.name}
</Chip>
)}
<Chip size="sm" className={`w-fit text-xs ${stockConfig.colorClass}`}>
{stockConfig.label}
</Chip>
<div className="mt-auto flex items-baseline gap-2 pt-2">
<span className="text-lg font-semibold text-[var(--brand-dark)]">
{formatPrice(currentPrice)}
</span>
{compareAtPrice && compareAtPrice > currentPrice && (
<s className="text-sm text-[var(--muted)]" aria-label="Original price">
{formatPrice(compareAtPrice)}
</s>
)}
</div>
{item.priceWhenAdded > 0 && priceDiff < 0 && (
<span className="text-xs font-medium text-[var(--coral)]">
Price dropped!
</span>
)}
{item.priceWhenAdded > 0 && priceDiff > 0 && (
<span className="text-xs text-[var(--muted)]">
Price increased
</span>
)}
</Card.Content>
<Card.Footer className="p-4 pt-0">
<WishlistActions
onRemove={() => onRemove(item._id)}
onAddToCart={() => {
if (activeVariant) onAddToCart(activeVariant._id);
}}
isRemoving={isRemoving}
isAddingToCart={isAddingToCart}
isAvailable={isAvailable}
/>
</Card.Footer>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import type { WishlistItem } from "@/lib/wishlist/types";
import { WishlistItemCard } from "./WishlistItemCard";
type WishlistItemGridProps = {
items: WishlistItem[];
onRemove: (id: string) => void;
onAddToCart: (variantId: string) => void;
removingId: string | null;
addingToCartId: string | null;
};
export function WishlistItemGrid({
items,
onRemove,
onAddToCart,
removingId,
addingToCartId,
}: WishlistItemGridProps) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
{items.map((item) => (
<WishlistItemCard
key={item._id}
item={item}
onRemove={onRemove}
onAddToCart={onAddToCart}
isRemoving={removingId === item._id}
isAddingToCart={addingToCartId === item._id}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useConvexAuth } from "convex/react";
import { useState, useCallback } from "react";
import { toast } from "@heroui/react";
import { useWishlist } from "@/lib/wishlist/useWishlist";
import { useWishlistMutations } from "@/lib/wishlist/useWishlistMutations";
import type { WishlistItem } from "@/lib/wishlist/types";
import { WishlistItemGrid } from "./WishlistItemGrid";
import { RemoveFromWishlistDialog } from "./RemoveFromWishlistDialog";
import { WishlistSkeleton } from "./state/WishlistSkeleton";
import { WishlistEmptyState } from "./state/WishlistEmptyState";
import { WishlistSignInPrompt } from "./state/WishlistSignInPrompt";
export function WishlistPageView() {
const { isAuthenticated, isLoading: authLoading } = useConvexAuth();
const { items, isLoading, isEmpty } = useWishlist();
const { removeItem, isRemoving, addToCart, isAddingToCart } =
useWishlistMutations();
const [removeTarget, setRemoveTarget] = useState<WishlistItem | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [addingToCartId, setAddingToCartId] = useState<string | null>(null);
const handleRemove = useCallback(
(id: string) => {
const target = items.find((i) => i._id === id);
setRemoveTarget(target ?? null);
},
[items],
);
const handleConfirmRemove = useCallback(async () => {
if (!removeTarget) return;
const name = removeTarget.product?.name ?? "Item";
setRemovingId(removeTarget._id);
try {
await removeItem(removeTarget._id);
toast.success(`${name} removed from wishlist`);
} catch {
toast.danger("Failed to remove item");
} finally {
setRemovingId(null);
setRemoveTarget(null);
}
}, [removeTarget, removeItem]);
const handleAddToCart = useCallback(
async (variantId: string) => {
const item = items.find(
(i) =>
i.variant?._id === variantId ||
i.product?.variants[0]?._id === variantId,
);
setAddingToCartId(item?._id ?? null);
try {
await addToCart(variantId, 1);
toast.success("Added to cart");
} catch {
toast.danger("This item is currently out of stock");
} finally {
setAddingToCartId(null);
}
},
[items, addToCart],
);
if (!authLoading && !isAuthenticated) {
return <WishlistSignInPrompt />;
}
if (authLoading || isLoading) {
return <WishlistSkeleton />;
}
if (isEmpty) {
return <WishlistEmptyState />;
}
return (
<>
<WishlistItemGrid
items={items}
onRemove={handleRemove}
onAddToCart={handleAddToCart}
removingId={removingId}
addingToCartId={addingToCartId}
/>
<RemoveFromWishlistDialog
isOpen={!!removeTarget}
onClose={() => setRemoveTarget(null)}
onConfirm={handleConfirmRemove}
isRemoving={isRemoving}
productName={removeTarget?.product?.name ?? "this item"}
/>
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { WISHLIST_PATH } from "@/lib/wishlist/constants";
function HeartIcon() {
return (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
}
export function WishlistEmptyState() {
return (
<section
aria-labelledby="wishlist-empty-heading"
className="flex flex-col items-center justify-center gap-5 py-20 text-center"
>
<div className="flex size-20 items-center justify-center rounded-full bg-[#e8f7f6] text-[#38a99f]">
<HeartIcon />
</div>
<div className="flex flex-col gap-1">
<h2
id="wishlist-empty-heading"
className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#1a2e2d]"
>
Your wishlist is empty
</h2>
<p className="max-w-xs text-sm text-gray-500">
Save items you love and they&apos;ll appear here.
</p>
</div>
<Link
href="/shop"
className="mt-2 inline-flex items-center rounded-md bg-[#f4a13a] px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-[#e0922e]"
>
Browse Products
</Link>
</section>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
function LockIcon() {
return (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function WishlistSignInPrompt() {
return (
<section
aria-labelledby="wishlist-signin-heading"
className="flex flex-col items-center justify-center gap-5 py-20 text-center"
>
<div className="flex size-20 items-center justify-center rounded-full bg-[#e8f7f6] text-[#38a99f]">
<LockIcon />
</div>
<div className="flex flex-col gap-1">
<h2
id="wishlist-signin-heading"
className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#1a2e2d]"
>
Sign in to view your wishlist
</h2>
<p className="max-w-xs text-sm text-gray-500">
Save your favorite items by signing in.
</p>
</div>
<Link
href="/sign-in"
className="mt-2 inline-flex items-center rounded-md bg-[#236f6b] px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-[#1b5955]"
>
Sign In
</Link>
</section>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Skeleton } from "@heroui/react";
function CardSkeleton() {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4">
<Skeleton className="aspect-square w-full rounded-xl" />
<Skeleton className="h-4 w-3/4 rounded" />
<Skeleton className="h-4 w-1/2 rounded" />
<Skeleton className="h-3 w-1/3 rounded" />
<div className="mt-auto flex items-center justify-between pt-2">
<Skeleton className="h-5 w-16 rounded" />
<Skeleton className="h-3 w-12 rounded" />
</div>
<Skeleton className="h-10 w-full rounded-lg" />
</div>
);
}
export function WishlistSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-48 rounded" />
<Skeleton className="h-6 w-10 rounded-full" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</div>
);
}