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:
@@ -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>
|
||||
);
|
||||
}
|
||||
53
apps/storefront/src/components/wishlist/WishlistActions.tsx
Normal file
53
apps/storefront/src/components/wishlist/WishlistActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
apps/storefront/src/components/wishlist/WishlistItemCard.tsx
Normal file
147
apps/storefront/src/components/wishlist/WishlistItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/storefront/src/components/wishlist/WishlistItemGrid.tsx
Normal file
35
apps/storefront/src/components/wishlist/WishlistItemGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
apps/storefront/src/components/wishlist/WishlistPageView.tsx
Normal file
99
apps/storefront/src/components/wishlist/WishlistPageView.tsx
Normal 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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user