Initial commit

This commit is contained in:
Rami Bitar
2026-08-08 14:21:24 -04:00
commit 58742d5d00
127 changed files with 18859 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
export interface AccountFormField {
name: string;
label: string;
type?: 'text' | 'email' | 'password';
autoComplete?: string;
required?: boolean;
}
interface AccountFormProps {
title: string;
description?: string;
fields: AccountFormField[];
submitLabel: string;
endpoint: string;
/** Merged into the request body alongside the field values. */
extraPayload?: Record<string, string>;
/** Where to go on success; omit to show `successMessage` instead. */
redirectTo?: string;
successMessage?: string;
footer?: React.ReactNode;
}
const AccountForm: React.FC<AccountFormProps> = ({
title,
description,
fields,
submitLabel,
endpoint,
extraPayload,
redirectTo,
successMessage,
footer,
}) => {
const router = useRouter();
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (submitting) return;
try {
setSubmitting(true);
setError(null);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, ...extraPayload }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? 'Something went wrong. Please try again.');
return;
}
if (redirectTo) {
// refresh() so server components re-read the new session cookie.
router.push(redirectTo);
router.refresh();
} else {
setDone(true);
}
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
{description && (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
)}
{done && successMessage ? (
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
{fields.map((field) => (
<label key={field.name} className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<input
type={field.type ?? 'text'}
name={field.name}
required={field.required ?? true}
autoComplete={field.autoComplete}
value={values[field.name] ?? ''}
onChange={(event) =>
setValues((prev) => ({
...prev,
[field.name]: event.target.value,
}))
}
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
/>
</label>
))}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={submitting} className="h-11">
{submitting && <Loader size={16} />}
{submitLabel}
</Button>
</form>
)}
{footer && (
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
{footer}
</div>
)}
</div>
);
};
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
{children}
</Link>
);
export default AccountForm;
+112
View File
@@ -0,0 +1,112 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { RiUserLine } from '@remixicon/react';
interface SessionCustomer {
displayName: string;
email: string;
firstName?: string | null;
}
const AccountMenu: React.FC = () => {
const router = useRouter();
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
const [open, setOpen] = useState(false);
// The token lives in an httpOnly cookie, so the signed-in state has to come
// from the server rather than being read directly.
useEffect(() => {
let cancelled = false;
fetch('/api/account/me')
.then((response) => response.json())
.then((data) => {
if (!cancelled) setCustomer(data.customer ?? null);
})
.catch(() => {
if (!cancelled) setCustomer(null);
});
return () => {
cancelled = true;
};
}, []);
const handleSignOut = async () => {
setOpen(false);
await fetch('/api/account/logout', { method: 'POST' });
setCustomer(null);
router.push('/');
router.refresh();
};
// Signed out: straight to the sign-in page, no menu.
if (!customer) {
return (
<Button
asChild
variant="ghost"
size="icon"
aria-label="Sign in"
className="rounded-full"
>
<Link href="/account/login">
<RiUserLine className="size-5" />
</Link>
</Button>
);
}
return (
<div className="relative">
<Button
onClick={() => setOpen((prev) => !prev)}
variant="ghost"
size="icon"
aria-label="Account menu"
aria-expanded={open}
className="rounded-full"
>
<RiUserLine className="size-5" />
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
<div className="px-4 py-2">
<p className="truncate text-sm font-medium text-foreground">
{customer.firstName || customer.displayName}
</p>
<p className="truncate text-xs text-muted-foreground">
{customer.email}
</p>
</div>
<div className="my-1 h-px bg-border" />
<Link
href="/account"
onClick={() => setOpen(false)}
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Order history
</Link>
<button
onClick={handleSignOut}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
Sign out
</button>
</div>
</>
)}
</div>
);
};
export default AccountMenu;
+348
View File
@@ -0,0 +1,348 @@
'use client';
import React, { useState } from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import {
RiCloseLine,
RiImageLine,
RiSubtractLine,
RiAddLine,
} from '@remixicon/react';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart);
const loading = useCartStore((s) => s.loading);
const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem);
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
const [discountCode, setDiscountCode] = useState('');
const [discountError, setDiscountError] = useState<string | null>(null);
const [applyingDiscount, setApplyingDiscount] = useState(false);
// The store's `loading` flag is global, so track the specific line being
// changed to keep the other rows interactive.
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
if (pendingLineId) return;
try {
setPendingLineId(lineId);
await action();
} catch (err) {
console.error('Cart line update failed:', err);
} finally {
setPendingLineId(null);
}
};
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
const checkoutUrl = cart?.checkoutUrl ?? null;
const appliedDiscounts =
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
const handleCheckout = () => {
if (checkoutUrl) {
redirectToCheckout(checkoutUrl);
}
};
const handleApplyDiscount = async (event: React.FormEvent) => {
event.preventDefault();
const code = discountCode.trim();
if (!code || applyingDiscount) return;
try {
setApplyingDiscount(true);
setDiscountError(null);
const updatedCart = await applyDiscountCode(code);
// Shopify accepts unknown codes silently, flagging them as inapplicable.
const accepted = updatedCart.discountCodes?.some(
(discount) =>
discount.applicable &&
discount.code.toLowerCase() === code.toLowerCase()
);
if (accepted) {
setDiscountCode('');
} else {
setDiscountError('That code is not valid for this cart.');
}
} catch {
setDiscountError('Could not apply that code. Please try again.');
} finally {
setApplyingDiscount(false);
}
};
const getItemImage = (item: (typeof items)[0]) => {
return item.merchandise.image?.url;
};
const getSelectedOptions = (item: (typeof items)[0]) => {
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
};
return (
<Sheet
open={isOpen}
onOpenChange={(open) => !open && closeCart()}
side="right"
>
<AnimatePresence>
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
Cart
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{itemCount}
</span>
</SheetTitle>
<Button
onClick={closeCart}
variant="ghost"
size="icon"
aria-label="Close cart"
className="rounded-full"
>
<RiCloseLine className="size-5" />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={20} />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={closeCart} className="w-full">
Continue Shopping
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-5">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
const isPending = pendingLineId === item.id;
return (
<div key={item.id} className="flex items-start gap-x-3">
{/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? (
<img
src={image}
alt={item.merchandise.product.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-400">
<RiImageLine size={20} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2">
{item.merchandise.product.title}
</h4>
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
$
{parseFloat(
item.cost?.totalAmount?.amount ??
item.merchandise.price.amount
).toFixed(2)}
</span>
</div>
{selectedOptions.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{selectedOptions
.map((option) => option.value)
.join(' / ')}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary">
<Button
onClick={() =>
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity - 1
)
)
}
disabled={item.quantity <= 1 || isPending}
variant="ghost"
size="icon-sm"
aria-label="Decrease quantity"
className="size-7 rounded-full"
>
<RiSubtractLine size={14} />
</Button>
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
{isPending ? (
<Loader size={12} className="mx-auto" />
) : (
item.quantity
)}
</span>
<Button
onClick={() =>
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity + 1
)
)
}
disabled={isPending}
variant="ghost"
size="icon-sm"
aria-label="Increase quantity"
className="size-7 rounded-full"
>
<RiAddLine size={14} />
</Button>
</div>
<Button
onClick={() =>
runLineAction(item.id, () => removeItem(item.id))
}
disabled={isPending}
variant="link"
aria-label={`Remove ${item.merchandise.product.title}`}
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
>
remove
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</SheetBody>
{/* Footer — discount, total, checkout */}
{items.length > 0 && (
<div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input
type="text"
value={discountCode}
onChange={(event) => {
setDiscountCode(event.target.value);
setDiscountError(null);
}}
placeholder="Discount code"
aria-label="Discount code"
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
/>
<Button
type="submit"
disabled={!discountCode.trim() || applyingDiscount}
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
>
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
</Button>
</form>
{discountError && (
<p className="text-xs text-destructive">{discountError}</p>
)}
{appliedDiscounts.length > 0 && (
<p className="text-xs text-muted-foreground">
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
</p>
)}
{/* Estimated total */}
<div>
<div className="flex items-baseline justify-between">
<span className="text-base text-foreground">
Estimated total
</span>
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
${totalAmount.toFixed(2)}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Taxes and shipping calculated at checkout.
</p>
</div>
<Button
onClick={handleCheckout}
disabled={!checkoutUrl || pendingLineId !== null}
className="h-12 w-full"
>
Go to Checkout
</Button>
<Button
onClick={closeCart}
variant="ghost"
className="w-full font-normal text-muted-foreground hover:text-foreground"
>
Continue Shopping
</Button>
</div>
)}
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default CartDrawer;
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import Link from 'next/link';
interface CollectionImage {
url: string;
altText?: string | null;
}
interface Collection {
id: string;
title: string;
handle: string;
description?: string;
image?: CollectionImage | null;
}
interface CollectionCardProps {
collection: Collection;
}
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
return (
<Link
href={`/collections/${collection.handle}`}
className="group block h-full"
>
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title}
</h3>
</div>
</Link>
);
};
export default CollectionCard;
+208
View File
@@ -0,0 +1,208 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
} from '@/hooks/use-shopify-collections';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
interface SortOption {
label: string;
sortKey: CollectionSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
];
const CollectionDetail: React.FC = () => {
const params = useParams();
const handle = params?.handle as string;
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
const [title, setTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection';
useEffect(() => {
if (!handle) return;
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
if (!page.collection) {
setError('Collection not found');
return;
}
setTitle(page.collection.title);
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
console.error('Error fetching collection products:', err);
setError(err instanceof Error ? err.message : 'Failed to load collection');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [handle, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more products:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{title || formattedTitle}
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={products.length > 0 ? products.length : null}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
<div className="mt-8">
{loading ? (
<div className={GRID_CLASSES}>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
{activeFilters.length > 0
? 'No products matched your filters.'
: "This collection doesn't have any products yet."}
</p>
) : (
<>
<div className={GRID_CLASSES}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
export default CollectionDetail;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card';
import { Button } from '@/components/ui/button';
interface CollectionsProps {
title?: string;
subtitle?: string;
}
const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
title,
subtitle,
}) => (
<>
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
{subtitle && (
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
)}
</>
);
const GRID_CLASSES = 'grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12';
const Collections: React.FC<CollectionsProps> = ({
title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds',
}) => {
const { collections, loading, error, refetch } = useCollections(12);
if (loading) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<SectionHeader title={title} subtitle={subtitle} />
<div className={GRID_CLASSES}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4">
<div className="h-4 bg-zinc-200 w-3/5"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={refetch} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (collections.length === 0) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground">
Collections will appear here once added to your Shopify store.
</p>
</div>
</div>
);
}
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<SectionHeader title={title} subtitle={subtitle} />
<div className={GRID_CLASSES}>
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} />
))}
</div>
</div>
</div>
);
};
export default Collections;
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import {
RiInstagramLine,
RiTiktokLine,
RiFacebookFill,
} from '@remixicon/react';
import Logo from '@/components/logo';
export interface FooterLink {
label: string;
url: string;
}
interface FooterProps {
storeName?: string;
logoUrl?: string;
copyright?: string;
links?: FooterLink[];
instagramUrl?: string;
tiktokUrl?: string;
facebookUrl?: string;
}
const Footer: React.FC<FooterProps> = ({
storeName = 'Shop',
logoUrl,
copyright,
links = [
{ label: 'Terms of Service', url: '/policies/terms-of-service' },
{ label: 'Privacy Policy', url: '/policies/privacy-policy' },
{ label: 'Refund Policy', url: '/policies/refund-policy' },
{ label: 'Shipping Policy', url: '/policies/shipping-policy' },
{ label: 'Subscription Policy', url: '/policies/subscription-policy' },
],
instagramUrl = '#',
tiktokUrl = '#',
facebookUrl = '#',
}) => {
const socials = [
{ label: 'Instagram', url: instagramUrl, Icon: RiInstagramLine },
{ label: 'TikTok', url: tiktokUrl, Icon: RiTiktokLine },
{ label: 'Facebook', url: facebookUrl, Icon: RiFacebookFill },
];
return (
<footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10">
{/* Left-aligned: the bottom-right corner belongs to the floating
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo
src={logoUrl}
storeName={storeName}
imageClassName="h-5"
textClassName="text-base"
/>
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => (
<a
key={link.label}
href={link.url}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5">
<span className="flex items-center gap-x-4">
{socials.map(({ label, url, Icon }) => (
<a
key={label}
href={url}
aria-label={label}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Icon size={18} />
</a>
))}
</span>
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
</div>
</div>
</div>
</footer>
);
};
export default Footer;
+153
View File
@@ -0,0 +1,153 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import Logo from '@/components/logo';
const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart);
const cart = useCartStore((s) => s.cart);
const itemCount =
cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0;
return (
<Button
onClick={toggleCart}
variant="ghost"
size="icon"
aria-label={`Open bag (${itemCount})`}
className="relative rounded-full"
>
<RiShoppingBagLine className="size-5" />
{itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</Button>
);
};
export interface NavLink {
label: string;
url: string;
}
interface HeaderProps {
storeName?: string;
logoUrl?: string;
links?: NavLink[];
/** Thin bar above the nav. Pass null to hide it. */
announcement?: string | null;
announcementUrl?: string;
}
const Header: React.FC<HeaderProps> = ({
storeName = 'Shop',
logoUrl,
links = [
{ label: 'Shop', url: '/' },
{ label: 'Collections', url: '/collections' },
],
announcement = 'Free shipping on orders over $100',
announcementUrl,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && (
<div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
{announcementUrl ? (
<Link
href={announcementUrl}
className="underline-offset-2 hover:underline"
>
{announcement}
</Link>
) : (
<span>{announcement}</span>
)}
</div>
</div>
)}
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Logo src={logoUrl} storeName={storeName} />
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => (
<Link
key={index}
href={link.url}
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Actions */}
<div className="flex items-center">
<SearchDialog />
<AccountMenu />
<CartIcon />
{/* Mobile hamburger */}
<Button
onClick={() => setMenuOpen(!menuOpen)}
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Toggle menu"
>
{menuOpen ? (
<RiCloseLine className="size-5" />
) : (
<RiMenu3Line className="size-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile Menu */}
{menuOpen && (
<div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
{links.map((link, index) => (
<Link
key={index}
href={link.url}
onClick={() => setMenuOpen(false)}
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
))}
</div>
</div>
)}
<CartDrawer />
</nav>
</>
);
};
export default Header;
+166
View File
@@ -0,0 +1,166 @@
'use client';
import React, { useState } from 'react';
import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
interface OrderHistoryProps {
customer: Customer;
}
const formatMoney = (amount: string, currencyCode: string) => {
const value = parseFloat(amount);
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
} catch {
return `$${value.toFixed(2)}`;
}
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
if (!label) return null;
return (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{label.replace(/_/g, ' ').toLowerCase()}
</span>
);
};
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
const orders: CustomerOrder[] =
customer.orders?.edges.map((edge) => edge.node) ?? [];
// The Storefront API has no standalone order-by-id query for customers, so
// the detail is rendered from the order already loaded in this list.
const [selectedId, setSelectedId] = useState<string | null>(
orders[0]?.id ?? null
);
const selected = orders.find((order) => order.id === selectedId) ?? null;
if (orders.length === 0) {
return (
<p className="text-sm text-muted-foreground">
You haven&apos;t placed any orders yet.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
{/* List */}
<div className="lg:col-span-2">
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
<ul className="flex flex-col">
{orders.map((order) => {
const isSelected = order.id === selectedId;
return (
<li key={order.id}>
<button
onClick={() => setSelectedId(order.id)}
aria-current={isSelected}
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
}`}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Order #{order.orderNumber}
</span>
<span className="block text-xs text-muted-foreground">
{formatDate(order.processedAt)}
</span>
</span>
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatMoney(
order.currentTotalPrice.amount,
order.currentTotalPrice.currencyCode
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Detail — same screen, no navigation */}
<div className="lg:col-span-3">
{selected && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 className="text-lg text-foreground">
Order #{selected.orderNumber}
</h2>
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
{formatMoney(
selected.currentTotalPrice.amount,
selected.currentTotalPrice.currencyCode
)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Placed {formatDate(selected.processedAt)}
</span>
<StatusPill label={selected.financialStatus} />
<StatusPill label={selected.fulfillmentStatus} />
</div>
<ul className="mt-6 flex flex-col gap-4">
{selected.lineItems.edges.map(({ node }, index) => (
<li key={index} className="flex items-start gap-x-3">
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
{node.variant?.image ? (
<img
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-5" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{node.title}
</p>
<p className="text-xs text-muted-foreground">
Qty {node.quantity}
</p>
</div>
</li>
))}
</ul>
{selected.statusUrl && (
<a
href={selected.statusUrl}
target="_blank"
rel="noreferrer"
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View order status
</a>
)}
</>
)}
</div>
</div>
);
};
export default OrderHistory;
+116
View File
@@ -0,0 +1,116 @@
import React from 'react';
import Link from 'next/link';
import { truncate } from '@/lib/utils';
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
}
interface Product {
id: string;
title: string;
description?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
}
interface ProductCardProps {
product: Product;
}
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
const firstImage = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const firstVariant = product.variants.edges[0]?.node;
const isAvailable = firstVariant?.availableForSale || false;
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
return (
<Link
href={`/products/${product.handle}`}
className="group block h-full"
>
{/* Product Image */}
<div className="relative aspect-square overflow-hidden">
{firstImage ? (
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-8xl"></i>
</div>
)}
{hasDiscount && compareAtPrice && (
<span className="absolute top-3 left-3 text-[11px] font-mono tracking-widest text-rose-600">
SALE
</span>
)}
{!isAvailable && (
<span className="absolute top-3 right-3 text-[11px] font-mono tracking-widest text-muted-foreground">
SOLD OUT
</span>
)}
</div>
{/* Product Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{truncate(product.title, 65)}
</h3>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono tabular-nums tracking-tight text-sm text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
</div>
</Link>
);
};
export default ProductCard;
+3
View File
@@ -0,0 +1,3 @@
import ProductDetail from './product-detail/index';
export default ProductDetail;
+230
View File
@@ -0,0 +1,230 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
interface ProductVariant {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: {
url: string;
altText?: string | null;
} | null;
}
export type { Product };
interface ProductDetailProps {
handle?: string;
addToCartLabel?: string;
}
const ProductDetail: React.FC<ProductDetailProps> = ({
handle: handleProp,
addToCartLabel = 'Add to Cart',
}) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { addItem, openCart, checkoutUrl } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
null
);
const [selectedOptions, setSelectedOptions] = useState<
Record<string, string>
>({});
const [quantity, setQuantity] = useState(1);
const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads
useEffect(() => {
if (product) {
const firstVariant = product.variants.edges[0]?.node;
if (firstVariant) {
setSelectedVariant(firstVariant);
const initialOptions: Record<string, string> = {};
firstVariant.selectedOptions.forEach(
(option: { name: string; value: string }) => {
initialOptions[option.name] = option.value;
}
);
setSelectedOptions(initialOptions);
}
}
}, [product]);
// A value is available if some in-stock variant carries it alongside the
// other currently-selected options. Options the shopper hasn't chosen yet
// act as wildcards, so nothing is struck through before a full selection.
const isOptionValueAvailable = (optionName: string, value: string) => {
const variants = product?.variants.edges ?? [];
if (variants.length === 0) return true;
return variants.some(({ node }) => {
if (!node.availableForSale) return false;
return node.selectedOptions.every((option) => {
if (option.name === optionName) return option.value === value;
const selected = selectedOptions[option.name];
return selected === undefined || selected === option.value;
});
});
};
const handleOptionChange = (optionName: string, value: string) => {
const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions);
// Find matching variant
const matchingVariant = product?.variants.edges.find(({ node }) => {
return node.selectedOptions.every(
(option) => newOptions[option.name] === option.value
);
});
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
}
};
const handleAddToCart = async () => {
if (!selectedVariant || !product) return;
try {
setAddingToCart(true);
await addItem(selectedVariant.id, quantity);
openCart();
} catch (err) {
console.error('Failed to add item to cart:', err);
} finally {
setAddingToCart(false);
}
};
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => {
if (!selectedVariant || !product) return;
try {
setBuyingNow(true);
const updatedCart = await addItem(selectedVariant.id, quantity);
const url = updatedCart?.checkoutUrl ?? checkoutUrl;
if (url) {
redirectToCheckout(url);
} else {
openCart();
}
} catch (err) {
console.error('Failed to start checkout:', err);
} finally {
setBuyingNow(false);
}
};
if (loading) {
return (
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="aspect-square bg-zinc-100 animate-pulse"
></div>
))}
</div>
{/* Product Info Skeleton */}
<div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
<div className="h-8 bg-zinc-100 w-32 mt-8"></div>
<div className="h-10 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-3"></div>
</div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8">
<Empty className="min-h-[400px]">
<EmptyHeader>
<EmptyTitle>Product Not Found</EmptyTitle>
<EmptyDescription>
{error || 'The requested product could not be found.'}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => window.history.back()} variant="outline">
Go Back
</Button>
</EmptyContent>
</Empty>
</div>
);
}
return (
<div className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
<div className="lg:col-span-3">
<ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)}
/>
</div>
<div className="lg:col-span-2 lg:sticky lg:top-20">
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel}
/>
</div>
</div>
</div>
</div>
);
};
export default ProductDetail;
@@ -0,0 +1,198 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductDetailGalleryProps {
images: ProductImage[];
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images,
}) => {
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null);
const isZoomed = zoomedIndex !== null;
const close = useCallback(() => setZoomedIndex(null), []);
// The slide whose left edge sits closest to the scroller's left edge is the
// one in view. Measuring rects keeps this correct whatever the gap or width.
const handleScroll = useCallback(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const scrollerLeft = scroller.getBoundingClientRect().left;
let nearest = 0;
let smallestOffset = Infinity;
Array.from(scroller.children).forEach((child, index) => {
const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft);
if (offset < smallestOffset) {
smallestOffset = offset;
nearest = index;
}
});
setActiveIndex(nearest);
}, []);
// Touch already scrolls natively; this adds click-and-drag for pointers that
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'touch') return;
const scroller = scrollerRef.current;
if (!scroller) return;
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
scroller.style.scrollSnapType = 'none';
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
event.preventDefault();
scroller.scrollLeft =
drag.current.startScroll - (event.clientX - drag.current.startX);
};
const endDrag = () => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
drag.current = null;
// Restoring snap lets the browser settle on the nearest slide.
scroller.style.scrollSnapType = '';
};
const scrollToIndex = (index: number) => {
const scroller = scrollerRef.current;
const slide = scroller?.children[index];
if (!scroller || !slide) return;
const offset =
slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left;
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
};
// Close on Escape, and keep the page behind the overlay from scrolling.
useEffect(() => {
if (!isZoomed) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
document.addEventListener('keydown', onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [isZoomed, close]);
if (images.length === 0) {
return (
<div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-[120px]"></i>
</div>
);
}
const isSingle = images.length === 1;
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
return (
<>
{/* Swipeable carousel on mobile, grid from sm up */}
<div
ref={scrollerRef}
onScroll={handleScroll}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerCancel={endDrag}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto sm:overflow-visible"
>
{images.map((image, index) => (
<button
key={index}
onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`}
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
isSingle ? 'sm:col-span-2' : ''
}`}
>
<img
src={image.url}
alt={image.altText || 'Product image'}
draggable={false}
className="w-full h-full object-cover select-none"
/>
</button>
))}
</div>
{/* Carousel pagination — the grid needs no dots, so mobile only */}
{!isSingle && (
<div className="flex justify-center gap-2 pt-4 sm:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => scrollToIndex(index)}
aria-label={`Go to image ${index + 1}`}
aria-current={index === activeIndex}
className={`h-1.5 rounded-full transition-all ${
index === activeIndex
? 'w-5 bg-foreground'
: 'w-1.5 bg-border hover:bg-foreground/40'
}`}
/>
))}
</div>
)}
{zoomedImage && (
<div
role="dialog"
aria-modal="true"
aria-label={zoomedImage.altText || 'Product image'}
onClick={close}
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
>
<img
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
onClick={(event) => event.stopPropagation()}
className="max-h-full max-w-full object-contain"
/>
<Button
onClick={close}
variant="ghost"
size="icon-lg"
aria-label="Close"
className="absolute top-4 right-4 rounded-full bg-background shadow-sm hover:bg-secondary"
>
<RiCloseLine size={20} />
</Button>
</div>
)}
</>
);
};
export default ProductDetailGallery;
@@ -0,0 +1,282 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
}
interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
options: ProductOption[];
}
export interface ProductFeature {
icon: string;
label: string;
}
interface ProductDetailInfoProps {
product: Product;
selectedVariant: ProductVariant | null;
selectedOptions: Record<string, string>;
quantity: number;
setQuantity: (quantity: number) => void;
handleAddToCart: () => void;
handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void;
/** Whether an option value still has an in-stock variant behind it. */
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
loading?: boolean;
buyingNow?: boolean;
addToCartLabel?: string;
}
// A swatch comes from the option value's own swatch (colour or image) or the
// colour its name implies (see config/swatches) — never a variant photo.
const swatchStyle = (
value: ProductOptionValue
): { background?: string; image?: string } => {
if (value.swatch?.color) return { background: value.swatch.color };
const swatchImage = value.swatch?.image?.previewImage?.url;
if (swatchImage) return { image: swatchImage };
const namedColor = swatchColorForName(value.name);
if (namedColor) return { background: namedColor };
return {};
};
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product,
selectedVariant,
selectedOptions,
quantity,
setQuantity,
handleAddToCart,
handleBuyNow,
onOptionChange,
isOptionValueAvailable,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
}) => {
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const isAvailable = selectedVariant?.availableForSale ?? false;
const isSwatchOption = (option: ProductOption) =>
isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
const orderedOptions = [...(product.options ?? [])]
.filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
option.optionValues?.length
? option.optionValues
: option.values.map((value) => ({ id: value, name: value }));
return (
<div>
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{product.title}
</h1>
<div className="flex items-baseline gap-x-3 mt-1">
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
{/* Product Options — colour swatches lead, whatever order the API returns */}
{orderedOptions.map((option) => {
const isSwatch = isSwatchOption(option);
const selected = selectedOptions[option.name];
return (
<div key={option.id} className="mt-8">
<div className="text-sm text-muted-foreground mb-2">
{option.name}
{isSwatch && selected && (
<span className="text-foreground font-medium">: {selected}</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => {
const isSelected = selected === value.name;
const isSoldOut =
isOptionValueAvailable?.(option.name, value.name) === false;
if (isSwatch) {
const { background, image } = swatchStyle(value);
return (
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
aria-label={value.name}
aria-pressed={isSelected}
title={
isSoldOut ? `${value.name} — out of stock` : value.name
}
data-available={!isSoldOut}
className={`rounded-full bg-cover bg-center hover:bg-transparent ${
isSelected
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{
// With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundImage: image ? `url(${image})` : undefined,
}}
>
{!background && !image && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.name.at(0)}
</span>
)}
</Button>
);
}
return (
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="outline"
aria-pressed={isSelected}
title={isSoldOut ? `${value.name} — out of stock` : undefined}
className={`min-w-14 px-5 font-normal shadow-none ${
isSelected
? 'border-foreground text-foreground'
: 'text-muted-foreground hover:border-foreground hover:text-foreground'
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
>
{value.name}
</Button>
);
})}
</div>
</div>
);
})}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
variant="ghost"
size="icon"
aria-label="Decrease quantity"
className="h-full rounded-none rounded-l-md"
>
<RiSubtractLine size={16} />
</Button>
<span className="w-8 text-center text-sm tabular-nums">
{quantity}
</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon"
aria-label="Increase quantity"
className="h-full rounded-none rounded-r-md"
>
<RiAddLine size={16} />
</Button>
</div>
<Button
onClick={handleAddToCart}
disabled={!isAvailable || loading}
className="flex-1 h-11"
>
{loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'}
</Button>
</div>
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton
className="mt-3"
variants={
selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
}
disabled={!isAvailable || buyingNow}
loading={buyingNow}
onFallbackClick={handleBuyNow}
/>
{/* Description */}
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
<div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
) : (
<p>{product.description}</p>
)}
</div>
)}
</div>
);
};
export default ProductDetailInfo;
+258
View File
@@ -0,0 +1,258 @@
'use client';
import React, { useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches';
// Shape of a Storefront facet — identical for `search.productFilters` and
// `collection.products.filters`, so both pages share this panel.
export interface ProductFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: ProductFilterValue[];
}
interface ProductFiltersProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: ProductFilterFacet[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: ProductFilterFacet) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
const ProductFilters: React.FC<ProductFiltersProps> = ({
open,
onOpenChange,
filters,
activeFilters,
onActiveFiltersChange,
}) => {
const [priceMin, setPriceMin] = useState('');
const [priceMax, setPriceMax] = useState('');
const listFilters = filters.filter((filter) => filter.type === 'LIST');
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
const activeSet = new Set(activeFilters);
// Price is rebuilt from the inputs rather than toggled, so track it apart.
const activePriceInput = activeFilters.find((input) =>
input.includes('"price"')
);
const toggleValue = (value: ProductFilterValue) => {
onActiveFiltersChange(
activeSet.has(value.input)
? activeFilters.filter((input) => input !== value.input)
: [...activeFilters, value.input]
);
};
const applyPrice = () => {
const min = parseFloat(priceMin);
const max = parseFloat(priceMax);
const withoutPrice = activeFilters.filter(
(input) => !input.includes('"price"')
);
if (Number.isNaN(min) && Number.isNaN(max)) {
onActiveFiltersChange(withoutPrice);
return;
}
const price: { min?: number; max?: number } = {};
if (!Number.isNaN(min)) price.min = min;
if (!Number.isNaN(max)) price.max = max;
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
};
const clearAll = () => {
setPriceMin('');
setPriceMax('');
onActiveFiltersChange([]);
};
// Labels for the chips at the top of the panel.
const activeChips = filters
.flatMap((filter) => filter.values)
.filter((value) => activeSet.has(value.input));
return (
<Sheet open={open} onOpenChange={onOpenChange} side="left">
<AnimatePresence>
{open && (
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
<div className="flex w-full items-center justify-between">
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
size="icon-sm"
aria-label="Close filters"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
<SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => (
<button
key={value.id}
onClick={() => toggleValue(value)}
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
>
{value.label}
<RiCloseLine size={12} />
</button>
))}
<Button
onClick={clearAll}
variant="link"
className="h-auto px-0 text-xs font-normal text-muted-foreground"
>
Clear all
</Button>
</div>
)}
{filters.length === 0 && (
<p className="text-sm text-muted-foreground">
No filters available for these results.
</p>
)}
{/* Price */}
{priceFilter && (
<div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3>
<div className="flex items-center gap-x-2">
<input
type="number"
inputMode="decimal"
value={priceMin}
onChange={(event) => setPriceMin(event.target.value)}
placeholder="$ From"
aria-label="Minimum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
inputMode="decimal"
value={priceMax}
onChange={(event) => setPriceMax(event.target.value)}
placeholder="$ To"
aria-label="Maximum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
onClick={applyPrice}
size="icon"
aria-label="Apply price range"
className="shrink-0"
>
<RiCheckLine size={16} />
</Button>
</div>
</div>
)}
{/* Facets */}
{listFilters.map((filter) => (
<div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground">
{filter.label}
</h3>
{isColorFilter(filter) ? (
<div className="flex flex-wrap gap-2">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
const color = swatchColorForName(value.label);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
title={`${value.label} (${value.count})`}
aria-label={value.label}
aria-pressed={isActive}
className={`h-8 w-8 rounded-full transition-shadow ${
isActive
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)',
}}
>
{!color && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.label.at(0)}
</span>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
aria-pressed={isActive}
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
>
<span>
{value.label} ({value.count})
</span>
{isActive && <RiCheckLine size={16} />}
</button>
);
})}
</div>
)}
</div>
))}
</SheetBody>
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default ProductFilters;
@@ -0,0 +1,73 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import {
useProduct,
useProductRecommendations,
} from '@/hooks/use-shopify-products';
import ProductCard from './product-card';
interface ProductRecommendationsProps {
productId?: string;
title?: string;
limit?: number;
}
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
productId: productIdProp,
title = 'You May Also Like',
limit = 4,
}) => {
const params = useParams();
const handle = params?.handle as string | undefined;
const { product } = useProduct(productIdProp ? null : (handle ?? null));
const resolvedProductId = productIdProp || product?.id || '';
const { recommendations, loading, error } = useProductRecommendations(
resolvedProductId || null
);
if (!loading && (!recommendations || recommendations.length === 0)) {
return null;
}
return (
<section className="bg-background py-16">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-2xl md:text-3xl font-normal text-foreground mb-8">
{title}
</h2>
{error ? (
<p className="text-sm text-muted-foreground">
Recommendations could not be loaded
</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12">
{loading
? Array.from({ length: limit }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))
: recommendations
.slice(0, limit)
.map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</section>
);
};
export default ProductRecommendations;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
RiEqualizerLine,
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react';
export interface ToolbarSortOption {
label: string;
}
interface ProductToolbarProps {
totalCount?: number | null;
onOpenFilters: () => void;
sortOptions: ToolbarSortOption[];
sortIndex: number;
onSortChange: (index: number) => void;
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
sortOptions,
sortIndex,
onSortChange,
activeFilterCount = 0,
}) => {
const [sortOpen, setSortOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<Button
onClick={onOpenFilters}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
{activeFilterCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{activeFilterCount}
</span>
)}
</Button>
<div className="flex items-center gap-x-4">
{typeof totalCount === 'number' && (
<span className="text-sm text-muted-foreground tabular-nums">
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
</span>
)}
<div className="relative">
<Button
onClick={() => setSortOpen((prev) => !prev)}
variant="ghost"
aria-expanded={sortOpen}
className="gap-x-1 px-0 font-normal hover:bg-transparent"
>
Sort
<RiArrowDownSLine size={16} />
</Button>
{sortOpen && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
/>
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
{sortOptions.map((option, index) => (
<button
key={option.label}
onClick={() => {
onSortChange(index);
setSortOpen(false);
}}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
{option.label}
{index === sortIndex && <RiCheckLine size={16} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ProductToolbar;
+209
View File
@@ -0,0 +1,209 @@
'use client';
import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
}
interface Product {
id: string;
title: string;
description?: string;
handle: string;
images: {
edges: Array<{
node: ProductImage;
}>;
};
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
variants: {
edges: Array<{
node: ProductVariant;
}>;
};
}
interface ProductsProps {
title?: string;
subtitle?: string;
limit?: number;
showLoadMore?: boolean;
}
const Products: React.FC<ProductsProps> = ({
title = 'Shopify Hydrogen Storefront',
subtitle = 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
limit = 12,
showLoadMore = true,
}) => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreProducts, setHasMoreProducts] = useState(true);
const [cursor, setCursor] = useState<string | null>(null);
// Paging is cursor-based: without `after`, Shopify returns the same first
// page every time and "load more" appends nothing.
const fetchProducts = async (loadMore = false) => {
try {
if (loadMore) {
setLoadingMore(true);
} else {
setLoading(true);
setError(null);
}
const page = await getProductsPage({
first: limit,
after: loadMore ? cursor : null,
sortKey: 'CREATED_AT',
reverse: true,
});
setProducts((prev) => {
if (!loadMore) return page.products;
const existingIds = new Set(prev.map((p) => p.id));
return [
...prev,
...page.products.filter((p) => !existingIds.has(p.id)),
];
});
setCursor(page.endCursor);
setHasMoreProducts(page.hasNextPage);
} catch (err) {
console.error('Error fetching products:', err);
setError(err instanceof Error ? err.message : 'Failed to load products');
} finally {
setLoading(false);
setLoadingMore(false);
}
};
useEffect(() => {
fetchProducts();
}, [limit]);
const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) {
fetchProducts(true);
}
};
if (loading) {
return (
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error || products.length === 0) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
{subtitle}
</p>
<p className="text-sm text-muted-foreground mb-6">
{error ||
'Our curated collection is being prepared. Please check back shortly.'}
</p>
{error && (
<Button
onClick={() => fetchProducts()}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
Try again
</Button>
)}
</div>
</div>
);
}
return (
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16 mb-20">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{showLoadMore && hasMoreProducts && (
<div className="flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</div>
</div>
);
};
export default Products;
+206
View File
@@ -0,0 +1,206 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import {
searchSuggestions,
type SearchSuggestion,
} from '@/hooks/use-shopify-search';
const DEBOUNCE_MS = 250;
const SUGGESTION_COUNT = 3;
const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`;
const SearchDialog: React.FC = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState('');
const [results, setResults] = useState<SearchSuggestion[]>([]);
const [searching, setSearching] = useState(false);
// Guards against a slow early request overwriting a newer one's results.
const requestId = useRef(0);
useEffect(() => {
const query = term.trim();
if (!query) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
const id = ++requestId.current;
const timer = setTimeout(async () => {
try {
const { products } = await searchSuggestions(query, SUGGESTION_COUNT);
if (id !== requestId.current) return;
setResults(products);
} catch (err) {
console.error('Search failed:', err);
if (id === requestId.current) setResults([]);
} finally {
if (id === requestId.current) setSearching(false);
}
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [term]);
const close = () => {
setOpen(false);
setTerm('');
setResults([]);
};
const goToSearchPage = () => {
const query = term.trim();
close();
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search');
};
const goToProduct = (handle: string) => {
close();
router.push(`/products/${handle}`);
};
const hasQuery = Boolean(term.trim());
return (
<>
<Button
onClick={() => setOpen(true)}
variant="ghost"
size="icon"
aria-label="Search"
className="rounded-full"
>
<RiSearchLine className="size-5" />
</Button>
<CommandDialog
open={open}
onOpenChange={(next) => !next && close()}
title="Search"
description="Search products"
showCloseButton={false}
className="top-24 max-w-xl translate-y-0"
// Results come from the Storefront API, so cmdk must not re-filter them.
shouldFilter={false}
>
<div className="relative">
<CommandInput
value={term}
onValueChange={setTerm}
placeholder="Search products"
className="pr-28"
onKeyDown={(event) => {
if (event.key === 'Enter') goToSearchPage();
}}
/>
<div className="absolute right-2 top-0 flex h-12 items-center gap-1">
{hasQuery && (
<Button
onClick={() => setTerm('')}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
<Button
onClick={close}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine className="size-4" />
</Button>
</div>
</div>
{hasQuery && (
<>
<div className="px-3 pt-3">
<button
onClick={goToSearchPage}
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
>
{term.trim()}
</button>
</div>
<CommandList className="max-h-80">
{searching && results.length === 0 ? (
<div className="flex justify-center py-8">
<Loader size={20} />
</div>
) : results.length === 0 ? (
<CommandEmpty>No products found.</CommandEmpty>
) : (
<CommandGroup heading="Products">
{results.map((product) => (
<CommandItem
key={product.id}
value={product.handle}
onSelect={() => goToProduct(product.handle)}
className="gap-3 py-2"
>
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? (
<img
src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-4" />
</div>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">
{product.title}
</div>
<div className="font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatPrice(
product.priceRange.minVariantPrice.amount
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
{results.length > 0 && (
<div className="flex justify-center px-4 pb-5 pt-2">
<Button onClick={goToSearchPage} className="px-8">
View All
</Button>
</div>
)}
</>
)}
</CommandDialog>
</>
);
};
export default SearchDialog;
+197
View File
@@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
searchProducts,
type SearchFilter,
type SearchSortKey,
} from '@/hooks/use-shopify-search';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
interface SortOption {
label: string;
sortKey: SearchSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
];
const SearchResults: React.FC = () => {
const searchParams = useSearchParams();
const query = searchParams.get('q') ?? '';
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<SearchFilter[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
// Serialised so the effect re-runs when the selection changes, not the array.
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const result = await searchProducts({
// An empty term still returns the catalogue, which is what an
// unqualified /search visit should show.
query,
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
setProducts(result.products);
setTotalCount(result.totalCount);
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
// Facet counts change with the result set, but keep the panel stable
// while filters are applied so options don't vanish mid-selection.
if (activeFilters.length === 0) setFilters(result.filters);
} catch (err) {
if (cancelled) return;
console.error('Search failed:', err);
setError(err instanceof Error ? err.message : 'Search failed');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [query, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const result = await searchProducts({
query,
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...result.products.filter((p) => !seen.has(p.id))];
});
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
} catch (err) {
console.error('Failed to load more results:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
Search
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
{/* Results */}
<div className="mt-8">
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
No products matched{query ? `${query}` : ' your filters'}.
</p>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
export default SearchResults;
+152
View File
@@ -0,0 +1,152 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { RiArrowDownSLine } from '@remixicon/react';
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
import { cn } from '@/lib/utils';
interface ShopMenuProps {
label?: string;
/** Renders inline inside the mobile menu instead of as a floating panel. */
mobile?: boolean;
/** Fires after a collection is picked, so the mobile menu can close itself. */
onNavigate?: () => void;
}
const ShopMenu: React.FC<ShopMenuProps> = ({
label = 'Shop',
mobile = false,
onNavigate,
}) => {
const [open, setOpen] = useState(false);
const { collections, loading, error, load } = useCollectionsOnDemand();
// The collection list is only worth fetching once someone opens the menu.
const toggle = () => {
const next = !open;
setOpen(next);
if (next) load();
};
const close = () => {
setOpen(false);
onNavigate?.();
};
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
const itemClasses = cn(
'block text-sm text-foreground hover:bg-accent transition-colors',
mobile ? 'px-3 py-2' : 'px-4 py-2'
);
const body = (
<>
{loading &&
Array.from({ length: 5 }).map((_, index) => (
<div key={index} className={cn(itemClasses, 'py-2.5')}>
<div className="h-3 w-2/3 animate-pulse bg-muted" />
</div>
))}
{error && (
<div className={cn(itemClasses, 'hover:bg-transparent')}>
<p className="text-muted-foreground">{error}</p>
<button
onClick={load}
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
>
Try again
</button>
</div>
)}
{!loading && !error && collections.length === 0 && (
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
No collections yet.
</p>
)}
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.handle}`}
onClick={close}
className={itemClasses}
>
{collection.title}
</Link>
))}
{collections.length > 0 && (
<>
<div className="my-1 h-px bg-border" />
<Link
href="/collections"
onClick={close}
className={cn(itemClasses, 'text-muted-foreground')}
>
View all collections
</Link>
</>
)}
</>
);
const trigger = (
<button
onClick={toggle}
aria-expanded={open}
className={cn(
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
mobile && 'w-full justify-between'
)}
>
<span>{label}</span>
<RiArrowDownSLine
className={cn('size-4 transition-transform', open && 'rotate-180')}
/>
</button>
);
if (mobile) {
return (
<div>
{trigger}
{open && (
<div className="mt-2 flex flex-col border-l border-border pl-1">
{body}
</div>
)}
</div>
);
}
return (
<div className="relative">
{trigger}
{open && (
<>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body}
</div>
</>
)}
</div>
);
};
export default ShopMenu;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config';
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button';
export interface ShopPayVariant {
id: string;
quantity?: number;
}
interface ShopPayButtonProps {
variants: ShopPayVariant[];
disabled?: boolean;
loading?: boolean;
className?: string;
/** Used when no permalink can be built (missing domain or unusable IDs). */
onFallbackClick?: () => void;
}
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
function toNumericVariantId(id: string): string | null {
const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
if (gid) return gid[1];
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function toStoreUrl(domain?: string): string | null {
if (!domain) return null;
try {
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
.origin;
} catch {
return null;
}
}
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
if (!storeUrl || variants.length === 0) return null;
const lines: string[] = [];
for (const { id, quantity = 1 } of variants) {
const numericId = toNumericVariantId(id);
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
lines.push(`${numericId}:${quantity}`);
}
return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`;
}
const BUTTON_CLASSES =
'flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50';
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
variants,
disabled = false,
loading = false,
className = '',
onFallbackClick,
}) => {
const shopPayUrl = buildShopPayUrl(variants);
const contents = (
<>
<span className="sr-only">Buy with</span>
{loading ? <Loader size={16} /> : <ShopPayLogo />}
</>
);
// An anchor keeps the checkout URL visible, openable in a new tab, and
// navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) {
return (
<a
href={shopPayUrl}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</a>
);
}
return (
<Button
type="button"
onClick={onFallbackClick}
disabled={disabled || (!shopPayUrl && !onFallbackClick)}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</Button>
);
};
export default ShopPayButton;
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
// data, so the button needs no additional text beyond a screen-reader label.
const ShopPayLogo: React.FC<{ className?: string }> = ({
className = 'h-auto w-[98px]',
}) => (
<svg
fill="none"
viewBox="0 0 10885 2079"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={className}
>
<path
d="M158.355 1621V448.811H637.207C856.681 448.811 994.683 565.198 994.683 748.093C994.683 874.457 923.188 967.567 800.15 1004.15V1010.8C943.14 1039.06 1024.61 1145.47 1024.61 1296.78C1024.61 1494.64 884.946 1621 665.473 1621H158.355ZM630.556 1459.72C745.281 1459.72 813.451 1391.55 813.451 1278.49C813.451 1162.1 743.619 1093.93 630.556 1093.93H362.865V1459.72H630.556ZM605.616 939.301C713.69 939.301 781.86 874.457 781.86 774.696C781.86 671.61 713.69 610.091 605.616 610.091H362.865V939.301H605.616ZM1486.02 1645.94C1328.06 1645.94 1200.04 1547.84 1200.04 1323.38V764.72H1394.57V1290.13C1394.57 1411.5 1456.09 1479.67 1557.51 1479.67C1675.56 1479.67 1742.07 1394.88 1742.07 1268.51V764.72H1938.27V1621H1750.38V1501.29H1742.07C1693.85 1599.39 1604.07 1645.94 1486.02 1645.94ZM2229.79 1895.34L2392.74 1537.87L2053.55 764.72H2271.36L2430.98 1167.09C2455.92 1233.6 2474.21 1288.46 2492.5 1356.63H2499.15C2515.78 1290.13 2534.06 1231.93 2557.34 1167.09L2716.96 764.72H2934.77L2445.94 1895.34H2229.79ZM3535.97 1621L3266.62 764.72H3472.79L3585.85 1185.38C3607.47 1266.85 3624.09 1345 3639.06 1429.79H3645.71C3662.34 1343.33 3677.3 1271.84 3702.24 1185.38L3820.29 764.72H4021.47L4139.53 1185.38C4164.47 1273.5 4181.09 1348.32 4196.06 1429.79H4204.37C4217.67 1346.66 4234.3 1270.17 4255.91 1185.38L4368.97 764.72H4576.81L4305.79 1621H4104.61L3984.9 1195.35C3958.29 1102.24 3941.67 1029.09 3925.04 942.627H3918.39C3900.1 1030.75 3883.47 1103.91 3856.87 1195.35L3738.82 1621H3535.97ZM4696.08 1621V764.72H4890.61V1621H4696.08ZM4794.18 633.368C4724.35 633.368 4672.8 578.5 4672.8 510.33C4672.8 442.16 4726.01 388.954 4794.18 388.954C4864.01 388.954 4917.22 442.16 4917.22 510.33C4917.22 580.162 4864.01 633.368 4794.18 633.368ZM5389.5 1637.63C5249.83 1637.63 5163.37 1572.78 5163.37 1426.47V926H5025.37V776.359H5111.83C5160.05 776.359 5176.67 758.069 5176.67 709.851V560.21H5359.57V764.72H5520.85V926H5359.57V1363.28C5359.57 1433.12 5384.51 1461.38 5441.04 1461.38C5465.98 1461.38 5489.26 1458.06 5520.85 1451.41V1616.01C5474.29 1630.98 5437.71 1637.63 5389.5 1637.63ZM5694.66 1621V418.883H5890.86V877.782H5897.51C5947.39 784.672 6040.5 739.78 6153.56 739.78C6316.51 739.78 6444.53 837.878 6444.53 1062.34V1621H6248.34V1095.59C6248.34 974.218 6186.82 906.048 6080.4 906.048C5959.03 906.048 5889.2 990.844 5889.2 1117.21V1621H5694.66Z"
fill="currentColor"
/>
<g clipPath="url(#shop-pay-logo-clip)">
<path
d="M7406 1027.33C7247.3 992.071 7176.6 978.274 7176.6 915.639C7176.6 856.727 7224.44 827.38 7320.13 827.38C7404.29 827.38 7465.8 865.049 7511.08 938.853C7514.5 944.547 7521.55 946.518 7527.32 943.452L7705.88 851.032C7712.29 847.747 7714.64 839.425 7711 833.074C7636.89 701.453 7499.98 629.4 7319.71 629.4C7082.83 629.4 6935.67 748.976 6935.67 939.072C6935.67 1140.99 7114.87 1192.02 7273.78 1227.28C7432.7 1262.54 7503.61 1276.34 7503.61 1338.97C7503.61 1401.61 7451.92 1431.17 7348.75 1431.17C7253.49 1431.17 7182.79 1386.5 7140.08 1299.77C7136.87 1293.42 7129.4 1290.79 7123.2 1294.08L6945.07 1384.53C6938.87 1387.81 6936.31 1395.48 6939.51 1402.05C7010.21 1547.68 7155.24 1629.59 7348.97 1629.59C7595.67 1629.59 7744.75 1511.99 7744.75 1315.98C7744.75 1119.97 7564.69 1063.03 7406 1027.77V1027.33Z"
fill="currentColor"
/>
<path
d="M8362.88 629.4C8261.64 629.4 8172.15 666.193 8107.86 731.675C8103.8 735.617 8097.18 732.77 8097.18 727.076V308.997C8097.18 301.77 8091.62 296.076 8084.57 296.076H7861.16C7854.11 296.076 7848.56 301.77 7848.56 308.997V1606.6C7848.56 1613.82 7854.11 1619.52 7861.16 1619.52H8084.57C8091.62 1619.52 8097.18 1613.82 8097.18 1606.6V1037.41C8097.18 927.465 8179.41 843.148 8290.26 843.148C8401.12 843.148 8481.43 925.713 8481.43 1037.41V1606.6C8481.43 1613.82 8486.98 1619.52 8494.03 1619.52H8717.44C8724.49 1619.52 8730.05 1613.82 8730.05 1606.6V1037.41C8730.05 798.253 8577.11 629.4 8362.88 629.4Z"
fill="currentColor"
/>
<path
d="M9183.28 592.17C9061.97 592.17 8948.34 630.276 8866.74 685.246C8861.19 688.969 8859.27 696.634 8862.69 702.548L8961.15 874.904C8964.78 881.036 8972.47 883.226 8978.45 879.503C9040.39 841.177 9111.3 821.248 9183.71 821.686C9378.72 821.686 9522.04 962.725 9522.04 1149.1C9522.04 1307.88 9407.34 1425.48 9261.89 1425.48C9143.34 1425.48 9061.11 1354.74 9061.11 1254.88C9061.11 1197.72 9084.82 1150.85 9146.55 1117.78C9152.95 1114.28 9155.3 1106.17 9151.46 1099.82L9058.55 938.634C9055.56 933.378 9049.15 930.969 9043.38 933.159C8918.86 980.464 8831.5 1094.35 8831.5 1247.21C8831.5 1478.48 9011.13 1651.05 9261.67 1651.05C9554.29 1651.05 9764.68 1443.22 9764.68 1145.16C9764.68 825.628 9519.9 592.17 9183.28 592.17Z"
fill="currentColor"
/>
<path
d="M10418.3 627.429C10305.3 627.429 10204.2 670.354 10130.6 745.692C10126.5 749.853 10119.9 746.787 10119.9 741.092V650.425C10119.9 643.198 10114.3 637.504 10107.3 637.504H9889.63C9882.58 637.504 9877.03 643.198 9877.03 650.425V1946.05C9877.03 1953.28 9882.58 1958.97 9889.63 1958.97H10113C10120.1 1958.97 10125.6 1953.28 10125.6 1946.05V1521.19C10125.6 1515.49 10132.3 1512.64 10136.3 1516.37C10209.8 1586.45 10307 1627.4 10418.3 1627.4C10680.3 1627.4 10884.7 1409.93 10884.7 1127.42C10884.7 844.9 10680.1 627.429 10418.3 627.429ZM10376 1407.96C10226.9 1407.96 10113.9 1286.41 10113.9 1125.66C10113.9 964.915 10226.7 843.367 10376 843.367C10525.3 843.367 10637.8 962.944 10637.8 1125.66C10637.8 1288.38 10526.8 1407.96 10375.8 1407.96H10376Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="shop-pay-logo-clip">
<rect
width="3948.86"
height="1662.68"
fill="white"
transform="translate(6935.67 296.076)"
/>
</clipPath>
</defs>
</svg>
);
export default ShopPayLogo;
+574
View File
@@ -0,0 +1,574 @@
'use client';
import React, { memo, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputFooter,
PromptInputProvider,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Attachment,
AttachmentHoverCard,
AttachmentHoverCardContent,
AttachmentHoverCardTrigger,
AttachmentInfo,
AttachmentPreview,
AttachmentRemove,
Attachments,
getAttachmentLabel,
getMediaCategory,
type AttachmentData,
} from '@/components/ai-elements/attachments';
import {
Suggestions,
Suggestion,
} from '@/components/ai-elements/suggestion';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Button } from '@/components/ui/button';
import { RainbowButton } from '@/components/ui/rainbow-button';
import {
RiCloseLine,
RiSearchLine,
RiPriceTag3Line,
RiStore2Line,
RiLayoutGridLine,
RiShoppingBag3Line,
} from '@remixicon/react';
// Feature flag. Written as a static member expression so Next inlines it at
// build time; the assistant is off unless the env var is explicitly "1".
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
const SUGGESTIONS = [
'What do you sell?',
'Show me hoodies under $100',
'What collections are there?',
];
interface ToolProduct {
handle: string;
title: string;
image: string | null;
price?: string;
}
interface ToolSummary {
label: string;
icon: React.ReactNode;
products: ToolProduct[];
}
const LOADING_LABELS: Record<string, string> = {
searchCatalogue: 'Searching the catalogue',
getProductDetails: 'Reading product details',
listCollections: 'Listing collections',
getCollectionProducts: 'Browsing a collection',
browseProducts: 'Browsing new arrivals',
};
const toolName = (type: string) =>
type.startsWith('tool-') ? type.slice(5) : type;
const plural = (count: number, noun: string) =>
`${count} ${noun}${count === 1 ? '' : 's'}`;
// Turns a finished tool result into the one-line summary plus any products
// worth previewing.
const summariseTool = (
name: string,
output: Record<string, unknown> | undefined
): ToolSummary => {
const products = (output?.products as ToolProduct[] | undefined) ?? [];
switch (name) {
case 'searchCatalogue': {
const total = (output?.totalCount as number) ?? products.length;
return {
label: `Found ${plural(total, 'product')}`,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
case 'getProductDetails':
return {
label: output?.found
? `Read ${output.title as string}`
: 'Product not found',
icon: <RiPriceTag3Line className="size-3.5" />,
products: output?.found
? [
{
handle: output.handle as string,
title: output.title as string,
image: (output.image as string) ?? null,
},
]
: [],
};
case 'listCollections': {
const collections =
(output?.collections as Array<unknown> | undefined) ?? [];
return {
label: `Found ${plural(collections.length, 'collection')}`,
icon: <RiStore2Line className="size-3.5" />,
products: [],
};
}
case 'getCollectionProducts':
return {
label: output?.found
? `Found ${plural(products.length, 'product')} in ${output.collection}`
: 'Collection not found',
icon: <RiLayoutGridLine className="size-3.5" />,
products,
};
case 'browseProducts':
return {
label: `Browsed ${plural(products.length, 'new arrival')}`,
icon: <RiShoppingBag3Line className="size-3.5" />,
products,
};
default:
return {
label: name,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
};
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true;
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
products,
}) => {
const withImages = products.filter((product) => product.image);
if (withImages.length === 0) return null;
return (
<Attachments variant="grid" className="ml-0 mt-2">
{withImages.slice(0, 6).map((product) => (
<Link
key={product.handle}
href={`/products/${product.handle}`}
title={product.title}
className="group/product w-20"
>
<Attachment
data={{
id: product.handle,
type: 'file',
url: product.image as string,
mediaType: 'image/jpeg',
filename: product.title,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
{product.title}
</span>
</Link>
))}
</Attachments>
);
};
interface AttachmentItemProps {
attachment: AttachmentData;
onRemove: (id: string) => void;
}
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
const handleRemove = useCallback(
() => onRemove(attachment.id),
[onRemove, attachment.id]
);
const mediaCategory = getMediaCategory(attachment);
const label = getAttachmentLabel(attachment);
return (
<AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview />
</div>
<AttachmentRemove className="absolute inset-0" />
</div>
<AttachmentInfo />
</Attachment>
</AttachmentHoverCardTrigger>
<AttachmentHoverCardContent>
<div className="space-y-2">
{mediaCategory === 'image' &&
attachment.type === 'file' &&
attachment.url && (
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
<img
alt={label}
className="max-h-full max-w-full object-contain"
height={384}
src={attachment.url}
width={320}
/>
</div>
)}
<div className="space-y-1 px-0.5">
<h4 className="text-sm font-semibold leading-none">{label}</h4>
{attachment.mediaType && (
<p className="font-mono text-xs text-muted-foreground">
{attachment.mediaType}
</p>
)}
</div>
</div>
</AttachmentHoverCardContent>
</AttachmentHoverCard>
);
});
AttachmentItem.displayName = 'AttachmentItem';
// Pending uploads, shown inline above the textarea.
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
const handleRemove = useCallback(
(id: string) => attachments.remove(id),
[attachments]
);
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
{attachments.files.map((attachment) => (
<AttachmentItem
attachment={attachment}
key={attachment.id}
onRemove={handleRemove}
/>
))}
</Attachments>
);
};
const StoreAssistant: React.FC = () => {
// Returns before any hooks run — safe because the flag is a build-time
// constant and cannot change between renders.
if (!AI_ENABLED) return null;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [input, setInput] = useState('');
// Drives the launcher's slide-in on first paint.
useEffect(() => setMounted(true), []);
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const isBusy = status === 'submitted' || status === 'streaming';
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
}`;
const send = (text: string) => {
const trimmed = text.trim();
if (!trimmed || isBusy) return;
sendMessage({ text: trimmed });
setInput('');
};
// Attachments arrive on the submitted message, so images go out with it.
const handleSubmit = (message: PromptInputMessage) => {
const text = (message.text ?? input).trim();
const files = message.files ?? [];
if ((!text && files.length === 0) || isBusy) return;
sendMessage({ text, files });
setInput('');
};
return (
<>
{/* Popover panel, anchored above the launcher */}
<div
aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
open
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0'
}`}
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
>
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
<span className="text-sm font-medium">Store Assistant</span>
<Button
onClick={() => setOpen(false)}
variant="ghost"
size="icon-sm"
aria-label="Close assistant"
className="rounded-full"
>
<RiCloseLine className="size-4" />
</Button>
</header>
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-3">
{messages.length === 0 && (
<ConversationEmptyState
title="Ask about the store"
description="Find products, compare options, browse collections."
>
{/* w-full + wrap so chips stack in the narrow popover rather
than scrolling off the edge. */}
<Suggestions className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
onClick={send}
suggestion={suggestion}
className="font-normal"
/>
))}
</Suggestions>
</ConversationEmptyState>
)}
{messages.map((message) => {
const fileParts = message.parts.filter(
(part) => part.type === 'file'
);
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<MessageResponse
key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={
status === 'streaming' && part.state === 'streaming'
}
linkSafety={{
enabled: true,
onLinkCheck: isInternalLink,
}}
>
{part.text}
</MessageResponse>
);
}
if (part.type === 'reasoning') {
return (
<Reasoning
key={index}
className="w-full"
isStreaming={
status === 'streaming' &&
part.state === 'streaming'
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type.startsWith('tool-')) {
const toolPart = part as typeof part & {
state: string;
output?: Record<string, unknown>;
errorText?: string;
};
const name = toolName(part.type);
// Shimmer while the call is in flight; a quiet summary
// line once it returns.
if (
toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available'
) {
return (
<Shimmer key={index} className="text-xs">
{LOADING_LABELS[name] ?? name}
</Shimmer>
);
}
if (toolPart.state === 'output-error') {
return (
<p key={index} className="text-xs text-muted-foreground">
Couldn&apos;t load that.
</p>
);
}
const { label, icon, products } = summariseTool(
name,
toolPart.output
);
return (
<div key={index} className="my-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<ProductPreviews products={products} />
</div>
);
}
return null;
})}
{/* Images the shopper attached, shown under their message. */}
{fileParts.length > 0 && (
<Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => (
<Attachment
key={`${message.id}-file-${index}`}
data={{
id: `${message.id}-file-${index}`,
type: 'file',
url: part.url,
mediaType: part.mediaType,
filename: part.filename,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
))}
</Attachments>
)}
</MessageContent>
</Message>
);
})}
{status === 'submitted' && (
<Shimmer className="text-xs">Thinking</Shimmer>
)}
{error && (
<p className="text-xs text-destructive">
Something went wrong. Please try again.
</p>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 p-2">
<PromptInputProvider>
<PromptInput
globalDrop
multiple
accept="image/*"
onSubmit={handleSubmit}
className="rounded-lg"
>
<PromptInputAttachmentsDisplay />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask about products…"
className="min-h-12"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</PromptInputProvider>
</div>
</div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? (
<RainbowButton
onClick={() => setOpen(false)}
aria-label="Close store assistant"
aria-expanded
size="lg"
className={launcherClasses}
>
Ask
</RainbowButton>
) : (
<Button
onClick={() => setOpen(true)}
aria-label="Open store assistant"
aria-expanded={false}
className={`h-11 ${launcherClasses}`}
>
Ask
</Button>
)}
</>
);
};
export default StoreAssistant;