update components
This commit is contained in:
240
components/shopify/cart-drawer.tsx
Normal file
240
components/shopify/cart-drawer.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
const CartDrawer: React.FC = () => {
|
||||
const {
|
||||
isOpen,
|
||||
closeCart,
|
||||
items,
|
||||
itemCount,
|
||||
totalAmount,
|
||||
checkoutUrl,
|
||||
loading,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
} = useShopifyCart();
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (checkoutUrl) {
|
||||
redirectToCheckout(checkoutUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const getItemImage = (item: (typeof items)[0]) => {
|
||||
return item.merchandise.image?.url;
|
||||
};
|
||||
|
||||
const getSelectedOptions = (item: (typeof items)[0]) => {
|
||||
return item.merchandise.selectedOptions ?? [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base">
|
||||
Shopping Cart ({itemCount})
|
||||
</SheetTitle>
|
||||
<Button onClick={closeCart} variant="ghost" size="icon-sm">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty className="border-0">
|
||||
<EmptyContent>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Add some products to get started!
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
variant="outline"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const image = getItemImage(item);
|
||||
const selectedOptions = getSelectedOptions(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start space-x-4 pb-6 border-b border-border last:border-b-0"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="w-20 h-20 bg-muted rounded-lg overflow-hidden flex-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-muted-foreground">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-foreground mb-1 line-clamp-2">
|
||||
{item.merchandise.product.title}
|
||||
</h4>
|
||||
|
||||
{/* Variant Info */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{selectedOptions.map((option, index) => (
|
||||
<span key={option.name}>
|
||||
{option.value}
|
||||
{index < selectedOptions.length - 1
|
||||
? ' / '
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center mt-3">
|
||||
<div className="flex items-center border border-border rounded-lg">
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity - 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={item.quantity <= 1 || loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity + 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => removeItem(item.id)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-auto w-auto p-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Checkout Section */}
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-border p-6">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-base font-semibold">Subtotal</span>
|
||||
<span className="text-lg font-bold">
|
||||
${totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
Shipping and taxes calculated at checkout
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={loading || !checkoutUrl}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<Loader size={16} />
|
||||
<span>Processing...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Checkout'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onClick={closeCart} variant="link" className="w-full">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartDrawer;
|
||||
68
components/shopify/collection-card.tsx
Normal file
68
components/shopify/collection-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Typography } from '@/components/Typography';
|
||||
|
||||
interface CollectionImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
image?: CollectionImage;
|
||||
}
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
return (
|
||||
<Link href={`/collections/${collection.handle}`} className="block group">
|
||||
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
||||
{/* Collection Image */}
|
||||
<div className="aspect-video overflow-hidden bg-muted">
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<i className="ri-folder-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<CardContent className="p-6">
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="mb-3 font-semibold tracking-tight text-foreground transition-colors group-hover:text-muted-foreground"
|
||||
>
|
||||
{collection.title}
|
||||
</Typography>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-muted-foreground">
|
||||
{collection.description.substring(0, 100)}
|
||||
{collection.description.length > 100 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-foreground font-semibold group-hover:text-muted-foreground transition-colors flex items-center">
|
||||
<span>View Collection</span>
|
||||
<i className="ri-arrow-right-s-line ml-2"></i>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionCard;
|
||||
95
components/shopify/collection-detail.tsx
Normal file
95
components/shopify/collection-detail.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
||||
import ProductCard from './product-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
|
||||
const handle = handleProp ?? '';
|
||||
|
||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
||||
|
||||
// Format title from handle
|
||||
const formattedTitle = handle
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
: 'Collection';
|
||||
|
||||
if (loading || !handle) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 flex justify-center">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
</div>
|
||||
|
||||
<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="flex flex-col gap-3">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load collection</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const products = collection?.products || [];
|
||||
const title = collection?.title || formattedTitle;
|
||||
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Products in Collection
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This collection doesn't have any products yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionDetail;
|
||||
33
components/shopify/collection-grid.editor.tsx
Normal file
33
components/shopify/collection-grid.editor.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionGrid, type CollectionGridProps } from "@/components/shopify/collection-grid";
|
||||
|
||||
const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
|
||||
label: "Collections",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
tagline: "Shop by collection",
|
||||
heading: "Curated edits",
|
||||
subheading: "Bundles built around the way you actually live.",
|
||||
layout: "tiles",
|
||||
limit: 6,
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Tiles", value: "tiles" },
|
||||
{ label: "Editorial", value: "editorial" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 12 },
|
||||
},
|
||||
render: (props) => <CollectionGrid {...props} />,
|
||||
};
|
||||
|
||||
export default collectionGridEditor;
|
||||
119
components/shopify/collection-grid.tsx
Normal file
119
components/shopify/collection-grid.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { shopifyFetch } from "@/services/shopify/client";
|
||||
import { GET_COLLECTIONS_QUERY } from "@/graphql/collections";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type CollectionGridProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
layout: "tiles" | "editorial";
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type CollectionRow = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: { url: string; altText?: string };
|
||||
};
|
||||
|
||||
export function CollectionGrid({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
layout,
|
||||
limit,
|
||||
}: CollectionGridProps) {
|
||||
const [collections, setCollections] = useState<CollectionRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
shopifyFetch<any>({
|
||||
query: GET_COLLECTIONS_QUERY,
|
||||
variables: { first: limit },
|
||||
})
|
||||
.then((res) => {
|
||||
const list = (res.data?.collections?.edges ?? []).map((e: any) => e.node);
|
||||
setCollections(list);
|
||||
})
|
||||
.catch(() => setCollections([]));
|
||||
}, [limit]);
|
||||
|
||||
const isEditorial = layout === "editorial";
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="center"
|
||||
size="lg"
|
||||
className="mx-auto mb-12"
|
||||
maxWidth="max-w-2xl"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isEditorial
|
||||
? "grid grid-cols-1 gap-8 md:grid-cols-2"
|
||||
: "grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4"
|
||||
}
|
||||
>
|
||||
{(collections.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any)
|
||||
: collections
|
||||
).map((c: CollectionRow) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={c.handle ? `/collections/${c.handle}` : "#"}
|
||||
className="group block"
|
||||
>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-md bg-muted ${isEditorial ? "aspect-[3/4] md:aspect-[5/6]" : "aspect-[4/5]"}`}
|
||||
>
|
||||
{c.image?.url ? (
|
||||
<img
|
||||
src={c.image.url}
|
||||
alt={c.image.altText || c.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
{isEditorial ? (
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-8">
|
||||
<div>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="font-semibold tracking-tight text-white"
|
||||
>
|
||||
{c.title}
|
||||
</Typography>
|
||||
<span className="mt-2 inline-flex text-xs uppercase tracking-[0.2em] text-white/80">
|
||||
Shop now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isEditorial ? (
|
||||
<div className="mt-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-medium tracking-tight text-foreground"
|
||||
>
|
||||
{c.title}
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
247
components/shopify/collection.editor.tsx
Normal file
247
components/shopify/collection.editor.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionView, type CollectionProps } from "@/components/shopify/collection";
|
||||
|
||||
const collectionEditor: ComponentConfig<CollectionProps> = {
|
||||
label: "Collection page",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
showDescription: "yes",
|
||||
showCoverImage: "yes",
|
||||
customCoverImage: "",
|
||||
columns: "4",
|
||||
limit: 24,
|
||||
defaultSort: "BEST_SELLING",
|
||||
showAvailability: "yes",
|
||||
showPriceRange: "yes",
|
||||
showProductType: "no",
|
||||
productTypeOptions: [],
|
||||
showVendor: "no",
|
||||
vendorOptions: [],
|
||||
showTags: "no",
|
||||
tagOptions: [],
|
||||
showColor: "yes",
|
||||
colorOptions: [
|
||||
{ label: "Black", color: "#000000" },
|
||||
{ label: "White", color: "#FFFFFF" },
|
||||
{ label: "Navy", color: "#1e3a5f" },
|
||||
],
|
||||
showStyle: "no",
|
||||
styleOptions: [],
|
||||
showSize: "yes",
|
||||
sizeOptions: [
|
||||
{ label: "XS" },
|
||||
{ label: "S" },
|
||||
{ label: "M" },
|
||||
{ label: "L" },
|
||||
{ label: "XL" },
|
||||
],
|
||||
showMaterial: "no",
|
||||
materialOptions: [],
|
||||
metafieldFilters: [],
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
showDescription: {
|
||||
label: "Description",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showCoverImage: {
|
||||
label: "Cover image",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
customCoverImage: {
|
||||
label: "Custom cover image",
|
||||
type: "image",
|
||||
},
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "2", value: "2" },
|
||||
{ label: "3", value: "3" },
|
||||
{ label: "4", value: "4" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Products per page", type: "number", min: 4, max: 48 },
|
||||
defaultSort: {
|
||||
label: "Default sort",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Best Selling", value: "BEST_SELLING" },
|
||||
{ label: "Newest", value: "CREATED" },
|
||||
{ label: "Price: Low to High", value: "PRICE" },
|
||||
{ label: "Alphabetical", value: "TITLE" },
|
||||
],
|
||||
},
|
||||
showAvailability: {
|
||||
label: "Availability filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showPriceRange: {
|
||||
label: "Price range filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showColor: {
|
||||
label: "Color filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
colorOptions: {
|
||||
label: "Colors",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "", color: "#000000" },
|
||||
getItemSummary: (it: any) => it?.label || "Color",
|
||||
arrayFields: {
|
||||
label: { label: "Color name", type: "text" },
|
||||
color: { label: "Color", type: "color" },
|
||||
},
|
||||
},
|
||||
showStyle: {
|
||||
label: "Style filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
styleOptions: {
|
||||
label: "Styles",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Style",
|
||||
arrayFields: {
|
||||
label: { label: "Style name", type: "text" },
|
||||
},
|
||||
},
|
||||
showSize: {
|
||||
label: "Size filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
sizeOptions: {
|
||||
label: "Sizes",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Size",
|
||||
arrayFields: {
|
||||
label: { label: "Size name", type: "text" },
|
||||
},
|
||||
},
|
||||
showMaterial: {
|
||||
label: "Material filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
materialOptions: {
|
||||
label: "Materials",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Material",
|
||||
arrayFields: {
|
||||
label: { label: "Material name", type: "text" },
|
||||
},
|
||||
},
|
||||
showVendor: {
|
||||
label: "Brand / vendor filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
vendorOptions: {
|
||||
label: "Brands",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Brand",
|
||||
arrayFields: {
|
||||
label: { label: "Brand name", type: "text" },
|
||||
},
|
||||
},
|
||||
showProductType: {
|
||||
label: "Product type filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
productTypeOptions: {
|
||||
label: "Product types",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Type",
|
||||
arrayFields: {
|
||||
label: { label: "Type name", type: "text" },
|
||||
},
|
||||
},
|
||||
showTags: {
|
||||
label: "Tags filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
tagOptions: {
|
||||
label: "Tags",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Tag",
|
||||
arrayFields: {
|
||||
label: { label: "Tag name", type: "text" },
|
||||
},
|
||||
},
|
||||
metafieldFilters: {
|
||||
label: "Metafield filters",
|
||||
type: "array",
|
||||
defaultItemProps: { namespace: "", key: "", label: "", values: [{ label: "" }] },
|
||||
getItemSummary: (it: any) => it?.label || it?.key || "Metafield",
|
||||
arrayFields: {
|
||||
namespace: { label: "Namespace", type: "text" },
|
||||
key: { label: "Key", type: "text" },
|
||||
label: { label: "Label", type: "text" },
|
||||
values: {
|
||||
label: "Values",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (v: any) => v?.label || "Value",
|
||||
arrayFields: {
|
||||
label: { label: "Value", type: "text" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <CollectionView {...props} />,
|
||||
};
|
||||
|
||||
export default collectionEditor;
|
||||
552
components/shopify/collection.tsx
Normal file
552
components/shopify/collection.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import type { ShopifyCollection } from '@reacteditor/field-shopify';
|
||||
import {
|
||||
useCollectionProducts,
|
||||
type CollectionSortKey,
|
||||
type ProductFilter,
|
||||
} from '@/hooks/use-shopify-collections';
|
||||
import { ProductCard } from './product-card';
|
||||
import { Typography } from '@/components/Typography';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FilterOption = { label: string };
|
||||
type ColorOption = { label: string; color: string };
|
||||
|
||||
export type CollectionProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
showDescription: 'yes' | 'no';
|
||||
showCoverImage: 'yes' | 'no';
|
||||
customCoverImage: string;
|
||||
columns: '2' | '3' | '4';
|
||||
limit: number;
|
||||
defaultSort: CollectionSortKey;
|
||||
showAvailability: 'yes' | 'no';
|
||||
showPriceRange: 'yes' | 'no';
|
||||
showProductType: 'yes' | 'no';
|
||||
productTypeOptions: FilterOption[];
|
||||
showVendor: 'yes' | 'no';
|
||||
vendorOptions: FilterOption[];
|
||||
showTags: 'yes' | 'no';
|
||||
tagOptions: FilterOption[];
|
||||
showColor: 'yes' | 'no';
|
||||
colorOptions: ColorOption[];
|
||||
showStyle: 'yes' | 'no';
|
||||
styleOptions: FilterOption[];
|
||||
showSize: 'yes' | 'no';
|
||||
sizeOptions: FilterOption[];
|
||||
showMaterial: 'yes' | 'no';
|
||||
materialOptions: FilterOption[];
|
||||
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { label: string; value: CollectionSortKey }[] = [
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Newest', value: 'CREATED' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE' },
|
||||
{ label: 'Alphabetical', value: 'TITLE' },
|
||||
];
|
||||
|
||||
const colClass: Record<CollectionProps['columns'], string> = {
|
||||
'2': 'grid-cols-2',
|
||||
'3': 'grid-cols-2 md:grid-cols-3',
|
||||
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// ─── Filter group (collapsible) ────────────────────────────────────────────────
|
||||
|
||||
function FilterGroup({ label, children, defaultOpen = true }: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn('transition-transform', open ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-3 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, label }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
checked ? 'border-foreground bg-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
|
||||
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveFilters = {
|
||||
availability: boolean;
|
||||
productTypes: string[];
|
||||
vendors: string[];
|
||||
tags: string[];
|
||||
colors: string[];
|
||||
styles: string[];
|
||||
sizes: string[];
|
||||
materials: string[];
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
metafieldValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
props,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
props: CollectionProps;
|
||||
active: ActiveFilters;
|
||||
onChange: (patch: Partial<ActiveFilters>) => void;
|
||||
}) {
|
||||
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const colors = (props.colorOptions ?? []) as ColorOption[];
|
||||
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
|
||||
|
||||
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
|
||||
const arr = active[key];
|
||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.showAvailability === 'yes' && (
|
||||
<FilterGroup label="Availability">
|
||||
<Checkbox
|
||||
checked={active.availability}
|
||||
onChange={(v) => onChange({ availability: v })}
|
||||
label="In stock"
|
||||
/>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showPriceRange === 'yes' && (
|
||||
<FilterGroup label="Price">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
value={active.minPrice}
|
||||
onChange={(e) => onChange({ minPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
value={active.maxPrice}
|
||||
onChange={(e) => onChange({ maxPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showVendor === 'yes' && vendors.length > 0 && (
|
||||
<FilterGroup label="Brand">
|
||||
{vendors.map((v) => (
|
||||
<Checkbox
|
||||
key={v}
|
||||
checked={active.vendors.includes(v)}
|
||||
onChange={() => toggle('vendors', v)}
|
||||
label={v}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showColor === 'yes' && colors.length > 0 && (
|
||||
<FilterGroup label="Color">
|
||||
{colors.filter((c) => c.label).map((c) => (
|
||||
<label
|
||||
key={c.label}
|
||||
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active.colors.includes(c.label)}
|
||||
onChange={() => toggle('colors', c.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 rounded-full border-2',
|
||||
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
|
||||
)}
|
||||
style={{ backgroundColor: c.color || undefined }}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showStyle === 'yes' && styles.length > 0 && (
|
||||
<FilterGroup label="Style">
|
||||
{styles.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.styles.includes(s)}
|
||||
onChange={() => toggle('styles', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showSize === 'yes' && sizes.length > 0 && (
|
||||
<FilterGroup label="Size">
|
||||
{sizes.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.sizes.includes(s)}
|
||||
onChange={() => toggle('sizes', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showMaterial === 'yes' && materials.length > 0 && (
|
||||
<FilterGroup label="Material">
|
||||
{materials.map((m) => (
|
||||
<Checkbox
|
||||
key={m}
|
||||
checked={active.materials.includes(m)}
|
||||
onChange={() => toggle('materials', m)}
|
||||
label={m}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showProductType === 'yes' && productTypes.length > 0 && (
|
||||
<FilterGroup label="Product type">
|
||||
{productTypes.map((pt) => (
|
||||
<Checkbox
|
||||
key={pt}
|
||||
checked={active.productTypes.includes(pt)}
|
||||
onChange={() => toggle('productTypes', pt)}
|
||||
label={pt}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showTags === 'yes' && tags.length > 0 && (
|
||||
<FilterGroup label="Tags">
|
||||
{tags.map((t) => (
|
||||
<Checkbox
|
||||
key={t}
|
||||
checked={active.tags.includes(t)}
|
||||
onChange={() => toggle('tags', t)}
|
||||
label={t}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{metafieldFilters.map((mf, i) => {
|
||||
const mfKey = `${mf.namespace}.${mf.key}`;
|
||||
const selected = active.metafieldValues[mfKey] ?? [];
|
||||
return (
|
||||
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
|
||||
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
|
||||
<Checkbox
|
||||
key={val}
|
||||
checked={selected.includes(val)}
|
||||
onChange={(checked) => {
|
||||
const next = checked
|
||||
? [...selected, val]
|
||||
: selected.filter((v) => v !== val);
|
||||
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
|
||||
}}
|
||||
label={val}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build Shopify ProductFilter array ──────────────────────────────────────────
|
||||
|
||||
function buildProductFilters(active: ActiveFilters): ProductFilter[] {
|
||||
const filters: ProductFilter[] = [];
|
||||
|
||||
if (active.availability) filters.push({ available: true });
|
||||
|
||||
if (active.minPrice !== '' || active.maxPrice !== '') {
|
||||
filters.push({
|
||||
price: {
|
||||
min: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
|
||||
max: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const pt of active.productTypes) filters.push({ productType: pt });
|
||||
for (const v of active.vendors) filters.push({ productVendor: v });
|
||||
for (const t of active.tags) filters.push({ tag: t });
|
||||
|
||||
for (const c of active.colors) filters.push({ variantOption: { name: 'Color', value: c } });
|
||||
for (const s of active.styles) filters.push({ variantOption: { name: 'Style', value: s } });
|
||||
for (const s of active.sizes) filters.push({ variantOption: { name: 'Size', value: s } });
|
||||
for (const m of active.materials) filters.push({ variantOption: { name: 'Material', value: m } });
|
||||
|
||||
for (const [mfKey, vals] of Object.entries(active.metafieldValues)) {
|
||||
const [namespace, key] = mfKey.split('.');
|
||||
for (const value of vals) {
|
||||
filters.push({ productMetafield: { namespace, key, value } });
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function CollectionView(props: CollectionProps) {
|
||||
const { collection: selected, showDescription, showCoverImage, customCoverImage, columns, limit, defaultSort } = props;
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? '';
|
||||
|
||||
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [active, setActive] = useState<ActiveFilters>({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
|
||||
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
|
||||
setActive((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActive({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const productFilters = buildProductFilters(active);
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
if (value === 'PRICE_DESC') {
|
||||
setSort('PRICE');
|
||||
setReverse(true);
|
||||
} else {
|
||||
setSort(value as CollectionSortKey);
|
||||
setReverse(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortValue = sort === 'PRICE' && reverse ? 'PRICE_DESC' : sort;
|
||||
|
||||
const { collection, loading, hasNextPage, fetchMore } = useCollectionProducts(handle, {
|
||||
first: limit,
|
||||
sortKey: sort,
|
||||
reverse,
|
||||
filters: productFilters.length ? productFilters : undefined,
|
||||
});
|
||||
|
||||
const products = collection?.products ?? [];
|
||||
const description = collection?.description ?? (selected as any)?.description;
|
||||
const collectionImage = customCoverImage || collection?.image?.url;
|
||||
|
||||
if (!selected && !routeHandle) {
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<Container>
|
||||
<header className="mx-auto mb-14 flex max-w-2xl flex-col items-center gap-3 text-center">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
</header>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<Container>
|
||||
{/* Cover image */}
|
||||
{showCoverImage === 'yes' && collectionImage && (
|
||||
<div className="mb-10 overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={collectionImage}
|
||||
alt={collection?.title ?? ''}
|
||||
className="h-48 w-full object-cover md:h-72 lg:h-80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Collection
|
||||
</p>
|
||||
<Typography variant="h1">
|
||||
{collection?.title ?? (selected as any)?.title ?? routeHandle}
|
||||
</Typography>
|
||||
{showDescription === 'yes' && description ? (
|
||||
<Typography variant="subtitle1" className="mt-4 max-w-2xl">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{/* Filter + sort bar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||
</div>
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||
Search
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||
</p>
|
||||
<Select value={sortValue} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||
<SelectValue>
|
||||
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
|
||||
{loading
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
|
||||
{!loading && products.length === 0 && (
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
No products found in this collection.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && !loading && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchMore}
|
||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
components/shopify/collections.tsx
Normal file
97
components/shopify/collections.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollections } from '@/hooks/use-shopify-collections';
|
||||
import CollectionCard from './collection-card';
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const { collections, loading, error, refetch } = useCollections(20);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-video bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-4 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load collections</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-5xl font-bold mb-8 font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-folder-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Collections Found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
{/* Collections Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
40
components/shopify/featured-product.editor.tsx
Normal file
40
components/shopify/featured-product.editor.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Star } from "lucide-react";
|
||||
import { FeaturedProductView, type FeaturedProductProps } from "@/components/shopify/featured-product";
|
||||
|
||||
const featuredProductEditor: ComponentConfig<FeaturedProductProps> = {
|
||||
label: "Featured product",
|
||||
icon: <Star size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "Featured",
|
||||
ctaLabel: "Add to bag",
|
||||
align: "left",
|
||||
tone: "default",
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Product", type: "shopifyProduct" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
align: {
|
||||
label: "Image alignment",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Image left", value: "left" },
|
||||
{ label: "Image right", value: "right" },
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <FeaturedProductView {...props} />,
|
||||
};
|
||||
|
||||
export default featuredProductEditor;
|
||||
128
components/shopify/featured-product.tsx
Normal file
128
components/shopify/featured-product.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import { useProduct } from "@/hooks/use-shopify-products";
|
||||
import { useShopifyCart } from "@/hooks/use-shopify-cart";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type FeaturedProductProps = {
|
||||
product: ShopifyProduct | null;
|
||||
tagline: string;
|
||||
ctaLabel: string;
|
||||
align: "left" | "right";
|
||||
tone: "default" | "muted";
|
||||
};
|
||||
|
||||
export function FeaturedProductView({
|
||||
product: selected,
|
||||
tagline,
|
||||
ctaLabel,
|
||||
align,
|
||||
tone,
|
||||
}: FeaturedProductProps) {
|
||||
const { product: full, loading } = useProduct(selected?.handle ?? null);
|
||||
const product: any = full ?? selected;
|
||||
const cart = useShopifyCart();
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"py-20 md:py-28",
|
||||
tone === "muted" ? "bg-muted/40" : "bg-background",
|
||||
)}
|
||||
>
|
||||
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className={cn(align === "right" && "md:order-2")}>
|
||||
<Skeleton className="aspect-[4/5] w-full" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-5">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const image =
|
||||
product.images?.edges?.[0]?.node ?? (selected as any)?.featuredImage ?? null;
|
||||
const variant = product.variants?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
tone === "muted"
|
||||
? "bg-muted/40 py-20 md:py-28"
|
||||
: "bg-background py-20 md:py-28"
|
||||
}
|
||||
>
|
||||
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className={align === "right" ? "md:order-2" : ""}>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[4/5] w-full rounded-md bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-5">
|
||||
{tagline ? (
|
||||
<Typography variant="caption">{tagline}</Typography>
|
||||
) : null}
|
||||
<Typography variant="h2">{product.title}</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="text-foreground font-medium">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
{product.description ? (
|
||||
<Typography variant="body2" className="max-w-md text-muted-foreground">
|
||||
{product.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!variant) return;
|
||||
await cart.addItem(variant.id, 1);
|
||||
cart.openCart();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background hover:opacity-90"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
<Link
|
||||
href={`/products/${product.handle}`}
|
||||
className="inline-flex items-center justify-center rounded-md border border-foreground px-6 py-3 text-sm font-medium tracking-wide hover:opacity-80"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
78
components/shopify/product-card.tsx
Normal file
78
components/shopify/product-card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Typography } from "@/components/Typography";
|
||||
|
||||
type ProductImage = { url: string; altText?: string };
|
||||
type ProductPrice = { amount: string; currencyCode: string };
|
||||
|
||||
export type ProductCardData = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
images?: { edges?: Array<{ node: ProductImage }> };
|
||||
priceRange?: { minVariantPrice?: ProductPrice };
|
||||
compareAtPriceRange?: { minVariantPrice?: ProductPrice };
|
||||
};
|
||||
|
||||
function format(price: ProductPrice) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount));
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
aspect = "portrait",
|
||||
}: {
|
||||
product: ProductCardData;
|
||||
aspect?: "portrait" | "square" | "landscape";
|
||||
}) {
|
||||
const image = product.images?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const compare = product.compareAtPriceRange?.minVariantPrice;
|
||||
const onSale =
|
||||
price && compare && parseFloat(compare.amount) > parseFloat(price.amount);
|
||||
|
||||
const aspectClass: Record<string, string> = {
|
||||
portrait: "aspect-[4/5]",
|
||||
square: "aspect-square",
|
||||
landscape: "aspect-[4/3]",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/products/${product.handle}`} className="group block">
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex items-start justify-between gap-3">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-medium tracking-tight text-foreground"
|
||||
>
|
||||
{product.title}
|
||||
</Typography>
|
||||
{price ? (
|
||||
<div className="flex flex-col items-end text-sm">
|
||||
{onSale && compare ? (
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{format(compare)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-medium">{format(price)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductCard;
|
||||
3
components/shopify/product-detail.tsx
Normal file
3
components/shopify/product-detail.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import ProductDetail from './product-detail/index.tsx';
|
||||
|
||||
export default ProductDetail;
|
||||
205
components/shopify/product-detail/index.tsx
Normal file
205
components/shopify/product-detail/index.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useProduct, type Product } from '@/hooks/use-shopify-products';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { useShopifyCart } from '@/hooks/use-shopify-cart';
|
||||
import ProductDetailGallery from './product-detail-gallery';
|
||||
import ProductDetailInfo from './product-detail-info';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
availableForSale: boolean;
|
||||
selectedOptions: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
image?: {
|
||||
url: string;
|
||||
altText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type { Product };
|
||||
|
||||
interface ProductDetailProps {
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = handleProp || routeHandle || '';
|
||||
const { addItem, openCart } = 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 [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [addingToCart, setAddingToCart] = 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]);
|
||||
|
||||
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);
|
||||
|
||||
// Update image if variant has an associated image
|
||||
if (matchingVariant.node.image && product) {
|
||||
const variantImageUrl = matchingVariant.node.image.url;
|
||||
const imageIndex = product.images.edges.findIndex(
|
||||
edge => edge.node.url === variantImageUrl
|
||||
);
|
||||
if (imageIndex !== -1) {
|
||||
setSelectedImageIndex(imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !handle || !product) {
|
||||
if (error && handle && !loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Product not found</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div>
|
||||
<Skeleton className="aspect-square w-full mb-4" />
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/shop">Shop</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{product.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<ProductDetailGallery
|
||||
images={product.images.edges.map(edge => edge.node)}
|
||||
selectedImageIndex={selectedImageIndex}
|
||||
onImageSelect={setSelectedImageIndex}
|
||||
/>
|
||||
<ProductDetailInfo
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
selectedOptions={selectedOptions}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
handleAddToCart={handleAddToCart}
|
||||
onOptionChange={handleOptionChange}
|
||||
loading={addingToCart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
66
components/shopify/product-detail/product-detail-gallery.tsx
Normal file
66
components/shopify/product-detail/product-detail-gallery.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: ProductImage[];
|
||||
selectedImageIndex?: number;
|
||||
onImageSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
images,
|
||||
selectedImageIndex = 0,
|
||||
onImageSelect
|
||||
}) => {
|
||||
const selectedImage = selectedImageIndex;
|
||||
const setSelectedImage = onImageSelect || (() => {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main Image */}
|
||||
<div className="aspect-square bg-muted rounded-lg overflow-hidden mb-4">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<i className="ri-image-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
selectedImage === index
|
||||
? 'border-foreground'
|
||||
: 'border-border hover:border-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product thumbnail'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailGallery;
|
||||
158
components/shopify/product-detail/product-detail-info.tsx
Normal file
158
components/shopify/product-detail/product-detail-info.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { Product, ProductVariant } from './index.tsx';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface ProductDetailInfoProps {
|
||||
product: Product;
|
||||
selectedVariant: ProductVariant | null;
|
||||
selectedOptions: Record<string, string>;
|
||||
quantity: number;
|
||||
setQuantity: (quantity: number) => void;
|
||||
handleAddToCart: () => void;
|
||||
onOptionChange: (optionName: string, value: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
product,
|
||||
selectedVariant,
|
||||
selectedOptions,
|
||||
quantity,
|
||||
setQuantity,
|
||||
handleAddToCart,
|
||||
onOptionChange,
|
||||
loading = false,
|
||||
}) => {
|
||||
const formatPrice = (price: { amount: string; currencyCode: string }) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(parseFloat(price.amount));
|
||||
};
|
||||
|
||||
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
|
||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
||||
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4 font-heading">
|
||||
{product.title}
|
||||
</h1>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<>
|
||||
<span className="text-xl text-muted-foreground line-through">
|
||||
{formatPrice(compareAtPrice)}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="text-muted-foreground mb-8 text-lg leading-relaxed">
|
||||
{product.descriptionHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
|
||||
) : (
|
||||
<p>{product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Options */}
|
||||
{product.options.map(option => (
|
||||
<div key={option.id} className="mb-6">
|
||||
<label className="block text-sm font-semibold text-foreground mb-2">
|
||||
{option.name}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{option.values.map(value => (
|
||||
<Button
|
||||
key={value}
|
||||
onClick={() => onOptionChange(option.name, value)}
|
||||
variant={selectedOptions[option.name] === value ? 'default' : 'outline'}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold text-foreground mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center border border-border rounded-lg w-fit">
|
||||
<Button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<i className="ri-subtract-line"></i>
|
||||
</Button>
|
||||
<span className="w-10 text-center font-semibold">{quantity}</span>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
>
|
||||
<i className="ri-add-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedVariant?.availableForSale || loading}
|
||||
size="lg"
|
||||
className="w-full text-lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Adding...</span>
|
||||
</span>
|
||||
) : selectedVariant?.availableForSale ? (
|
||||
'Add to Cart'
|
||||
) : (
|
||||
'Out of Stock'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 pt-8 border-t border-border">
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-truck-line"></i>
|
||||
<span>Free shipping on orders over $100</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-arrow-go-back-line"></i>
|
||||
<span>30-day return policy</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-secure-payment-line"></i>
|
||||
<span>Secure payment</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailInfo;
|
||||
14
components/shopify/product-details.editor.tsx
Normal file
14
components/shopify/product-details.editor.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Package } from "lucide-react";
|
||||
import { ProductDetailsView, type ProductDetailsProps } from "@/components/shopify/product-details";
|
||||
|
||||
const productDetailsEditor: ComponentConfig<ProductDetailsProps> = {
|
||||
label: "Product details",
|
||||
icon: <Package size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: { product: null },
|
||||
fields: { product: { label: "Product", type: "shopifyProduct" } as any },
|
||||
render: (props) => <ProductDetailsView {...props} />,
|
||||
};
|
||||
|
||||
export default productDetailsEditor;
|
||||
221
components/shopify/product-details.tsx
Normal file
221
components/shopify/product-details.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteSegment } from "@/hooks/use-route-segment";
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import { useProduct } from "@/hooks/use-shopify-products";
|
||||
import { useShopifyCart } from "@/hooks/use-shopify-cart";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader } from "@/components/ui/loader";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
export type ProductDetailsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
};
|
||||
|
||||
export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? null;
|
||||
const { product, loading } = useProduct(handle);
|
||||
const cart = useShopifyCart();
|
||||
const [activeImage, setActiveImage] = useState(0);
|
||||
const [variant, setVariant] = useState<any>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (product?.variants?.edges?.length) {
|
||||
setVariant(product.variants.edges[0].node);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (!handle || loading || !product) {
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20">
|
||||
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="aspect-[4/5] w-full" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square w-20 flex-shrink-0" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
<Skeleton className="h-11 flex-1 rounded-md" />
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-6">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const images = product.images?.edges?.map((e: any) => e.node) ?? [];
|
||||
const main = images[activeImage];
|
||||
const price = variant?.price ?? product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!variant) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await cart.addItem(variant.id, quantity);
|
||||
cart.openCart();
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20">
|
||||
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="aspect-[4/5] w-full overflow-hidden rounded-md bg-muted">
|
||||
{main ? (
|
||||
<img
|
||||
src={main.url}
|
||||
alt={main.altText || product.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{images.length > 1 ? (
|
||||
<div className="flex gap-3 overflow-x-auto p-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{images.map((img: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveImage(i)}
|
||||
className={cn(
|
||||
"aspect-square w-20 flex-shrink-0 overflow-hidden rounded-md transition-opacity",
|
||||
i === activeImage
|
||||
? "ring-2 ring-foreground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Typography variant="h2" as="h1">
|
||||
{product.title}
|
||||
</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="mt-3 text-foreground">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(product.options ?? []).map((opt: any) => (
|
||||
<div key={opt.id ?? opt.name}>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{opt.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opt.values.map((val: string) => {
|
||||
const matching = product.variants.edges.find((e: any) =>
|
||||
e.node.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
),
|
||||
);
|
||||
const selected = variant?.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => matching && setVariant(matching.node)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-md border px-4 py-2 text-sm transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border hover:border-foreground",
|
||||
)}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border px-4 py-2">
|
||||
<button
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={!variant || adding}
|
||||
className="flex-1 rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{adding ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader size={16} />
|
||||
Adding…
|
||||
</span>
|
||||
) : (
|
||||
"Add to bag"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{product.description ? (
|
||||
<div className="border-t border-border pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Details
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-foreground/80">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
25
components/shopify/product-recommendations.editor.tsx
Normal file
25
components/shopify/product-recommendations.editor.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
ProductRecommendationsView,
|
||||
type ProductRecommendationsProps,
|
||||
} from "@/components/shopify/product-recommendations";
|
||||
|
||||
const productRecommendationsEditor: ComponentConfig<ProductRecommendationsProps> = {
|
||||
label: "Product recommendations",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
heading: "You Might Also Like",
|
||||
limit: 4,
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Source product", type: "shopifyProduct" } as any,
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 8 },
|
||||
},
|
||||
render: (props) => <ProductRecommendationsView {...props} />,
|
||||
};
|
||||
|
||||
export default productRecommendationsEditor;
|
||||
75
components/shopify/product-recommendations.tsx
Normal file
75
components/shopify/product-recommendations.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { ShopifyProduct } from '@reacteditor/field-shopify';
|
||||
import {
|
||||
useProduct,
|
||||
useProductRecommendations,
|
||||
} from '@/hooks/use-shopify-products';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { ProductCard } from './product-card';
|
||||
|
||||
export type ProductRecommendationsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
heading: string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function ProductRecommendationsView({
|
||||
product: selected,
|
||||
heading,
|
||||
limit,
|
||||
}: ProductRecommendationsProps) {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? null;
|
||||
const { product } = useProduct(handle);
|
||||
const { recommendations, loading, error } = useProductRecommendations(
|
||||
product?.id ?? null,
|
||||
);
|
||||
|
||||
// Don't show section if we're not loading and have no recommendations
|
||||
if (!loading && (!recommendations || recommendations.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 text-foreground font-heading">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{Array.from({ length: limit }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-square bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-12 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">Recommendations could not be loaded</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{recommendations.slice(0, limit).map((recommendedProduct) => (
|
||||
<ProductCard
|
||||
key={recommendedProduct.id}
|
||||
product={recommendedProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductRecommendationsView;
|
||||
40
components/shopify/products-carousel.editor.tsx
Normal file
40
components/shopify/products-carousel.editor.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { GalleryHorizontalEnd } from "lucide-react";
|
||||
import { ProductsCarousel, type ProductsCarouselProps } from "@/components/shopify/products-carousel";
|
||||
|
||||
const productsCarouselEditor: ComponentConfig<ProductsCarouselProps> = {
|
||||
label: "Products carousel",
|
||||
icon: <GalleryHorizontalEnd size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "New",
|
||||
heading: "Just dropped",
|
||||
subheading: "Fresh additions to the lineup.",
|
||||
limit: 12,
|
||||
slidesPerView: "4",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 4, max: 24 },
|
||||
slidesPerView: {
|
||||
label: "Slides per view",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "2 per view", value: "2" },
|
||||
{ label: "3 per view", value: "3" },
|
||||
{ label: "4 per view", value: "4" },
|
||||
],
|
||||
},
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsCarousel {...props} />,
|
||||
};
|
||||
|
||||
export default productsCarouselEditor;
|
||||
122
components/shopify/products-carousel.tsx
Normal file
122
components/shopify/products-carousel.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Heading } from "@/components/Heading";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
export type ProductsCarouselProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
limit: number;
|
||||
slidesPerView: "2" | "3" | "4";
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const basisClass: Record<ProductsCarouselProps["slidesPerView"], string> = {
|
||||
"2": "md:basis-1/2",
|
||||
"3": "md:basis-1/3",
|
||||
"4": "md:basis-1/4",
|
||||
};
|
||||
|
||||
export function ProductsCarousel({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
limit,
|
||||
slidesPerView,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsCarouselProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.collection?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({
|
||||
first: limit,
|
||||
sortKey: "CREATED_AT",
|
||||
reverse: true,
|
||||
});
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="left"
|
||||
size="lg"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
href={
|
||||
ctaHref ||
|
||||
(collection?.handle ? `/collections/${collection.handle}` : "/collections")
|
||||
}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Carousel opts={{ align: "start", loop: true }}>
|
||||
<CarouselContent className="-ml-6">
|
||||
{(products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }))
|
||||
: products
|
||||
).map((p: any) => (
|
||||
<CarouselItem
|
||||
key={p.id}
|
||||
className={`pl-6 basis-full sm:basis-1/2 ${basisClass[slidesPerView]}`}
|
||||
>
|
||||
{products.length === 0 ? (
|
||||
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<ProductCard product={p} />
|
||||
)}
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="left-2 md:left-4" />
|
||||
<CarouselNext className="right-2 md:right-4" />
|
||||
</Carousel>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
39
components/shopify/products-grid.editor.tsx
Normal file
39
components/shopify/products-grid.editor.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { ProductsGrid, type ProductsGridProps } from "@/components/shopify/products-grid";
|
||||
|
||||
const productsGridEditor: ComponentConfig<ProductsGridProps> = {
|
||||
label: "Products grid",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "Shop",
|
||||
heading: "Latest arrivals",
|
||||
subheading: "New pieces, fresh in this season.",
|
||||
columns: "4",
|
||||
limit: 8,
|
||||
ctaLabel: "View all",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "3 columns", value: "3" },
|
||||
{ label: "4 columns", value: "4" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 24 },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsGrid {...props} />,
|
||||
};
|
||||
|
||||
export default productsGridEditor;
|
||||
96
components/shopify/products-grid.tsx
Normal file
96
components/shopify/products-grid.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type ProductsGridProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: "3" | "4";
|
||||
limit: number;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const colClass: Record<ProductsGridProps["columns"], string> = {
|
||||
"3": "grid-cols-2 md:grid-cols-3",
|
||||
"4": "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
|
||||
};
|
||||
|
||||
export function ProductsGrid({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
columns,
|
||||
limit,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsGridProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.collection?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({ first: limit, sortKey: "BEST_SELLING" });
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-12 flex flex-col items-end justify-between gap-6 md:flex-row md:items-end">
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="left"
|
||||
size="lg"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
href={ctaHref || (collection?.handle ? `/collections/${collection.handle}` : "/collections")}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-x-6 gap-y-12 ${colClass[columns]}`}>
|
||||
{products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
|
||||
/>
|
||||
))
|
||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
233
components/shopify/products.tsx
Normal file
233
components/shopify/products.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ProductCard from './product-card';
|
||||
import { getProducts } from '@/hooks/use-shopify-products';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
limit?: number;
|
||||
showLoadMore?: boolean;
|
||||
}
|
||||
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = "Our Products",
|
||||
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 fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => {
|
||||
try {
|
||||
if (loadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const newProducts = await getProducts({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out products that already exist
|
||||
const existingIds = new Set(currentProducts.map(p => p.id));
|
||||
const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewProducts.length === 0) {
|
||||
setHasMoreProducts(false);
|
||||
} else {
|
||||
setProducts(prev => [...prev, ...uniqueNewProducts]);
|
||||
}
|
||||
} else {
|
||||
setProducts(newProducts);
|
||||
setHasMoreProducts(newProducts.length === limit);
|
||||
}
|
||||
} 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 handleAddToCart = async (product: Product) => {
|
||||
// Here you would typically integrate with cart functionality
|
||||
console.log('Adding to cart:', product);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMoreProducts) {
|
||||
fetchProducts(products, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<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="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-square bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-12 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load products</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fetchProducts()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-4xl font-bold mb-8 font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Products Found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-12">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{showLoadMore && hasMoreProducts && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
size="lg"
|
||||
className="font-heading"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Load More Products'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
24
components/shopify/recommended-products.editor.tsx
Normal file
24
components/shopify/recommended-products.editor.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { RecommendedProductsView, type RecommendedProductsProps } from "@/components/shopify/recommended-products";
|
||||
|
||||
const recommendedProductsEditor: ComponentConfig<RecommendedProductsProps> = {
|
||||
label: "Recommended products",
|
||||
icon: <Sparkles size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "You may also like",
|
||||
heading: "More to explore",
|
||||
limit: 4,
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Source product", type: "shopifyProduct" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 8 },
|
||||
},
|
||||
render: (props) => <RecommendedProductsView {...props} />,
|
||||
};
|
||||
|
||||
export default recommendedProductsEditor;
|
||||
68
components/shopify/recommended-products.tsx
Normal file
68
components/shopify/recommended-products.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import {
|
||||
useProduct,
|
||||
useProductRecommendations,
|
||||
} from "@/hooks/use-shopify-products";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type RecommendedProductsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function RecommendedProductsView({
|
||||
product: selected,
|
||||
tagline,
|
||||
heading,
|
||||
limit,
|
||||
}: RecommendedProductsProps) {
|
||||
const { product } = useProduct(selected?.handle ?? null);
|
||||
const { recommendations } = useProductRecommendations(product?.id ?? null);
|
||||
const items = (recommendations ?? []).slice(0, limit);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-12 flex max-w-xl flex-col gap-3">
|
||||
{tagline ? <Skeleton className="h-3 w-24" /> : null}
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
|
||||
{Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
align="left"
|
||||
size="md"
|
||||
className="mb-12"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
|
||||
{items.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
244
components/shopify/search-products.editor.tsx
Normal file
244
components/shopify/search-products.editor.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Search } from 'lucide-react';
|
||||
import { SearchProductsView, type SearchProductsProps } from '@/components/shopify/search-products';
|
||||
|
||||
const searchProductsEditor: ComponentConfig<SearchProductsProps> = {
|
||||
label: 'Search & filter',
|
||||
icon: <Search size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
heading: 'Shop',
|
||||
subheading: 'Browse our full collection.',
|
||||
columns: '4',
|
||||
limit: 24,
|
||||
showAvailability: 'yes',
|
||||
showPriceRange: 'yes',
|
||||
showProductType: 'yes',
|
||||
productTypeOptions: [
|
||||
{ label: 'T-Shirts' },
|
||||
{ label: 'Pants' },
|
||||
{ label: 'Outerwear' },
|
||||
{ label: 'Accessories' },
|
||||
{ label: 'Shoes' },
|
||||
],
|
||||
showVendor: 'yes',
|
||||
vendorOptions: [
|
||||
{ label: 'Maison' },
|
||||
{ label: 'Atelier' },
|
||||
{ label: 'Studio' },
|
||||
],
|
||||
showTags: 'yes',
|
||||
tagOptions: [
|
||||
{ label: 'New' },
|
||||
{ label: 'Sale' },
|
||||
{ label: 'Bestseller' },
|
||||
{ label: 'Limited Edition' },
|
||||
],
|
||||
showColor: 'yes',
|
||||
colorOptions: [
|
||||
{ label: 'Black', color: '#000000' },
|
||||
{ label: 'White', color: '#FFFFFF' },
|
||||
{ label: 'Navy', color: '#1e3a5f' },
|
||||
{ label: 'Red', color: '#c0392b' },
|
||||
],
|
||||
showStyle: 'no',
|
||||
styleOptions: [],
|
||||
showSize: 'yes',
|
||||
sizeOptions: [
|
||||
{ label: 'XS' },
|
||||
{ label: 'S' },
|
||||
{ label: 'M' },
|
||||
{ label: 'L' },
|
||||
{ label: 'XL' },
|
||||
],
|
||||
showMaterial: 'no',
|
||||
materialOptions: [],
|
||||
metafieldFilters: [],
|
||||
defaultSort: 'BEST_SELLING',
|
||||
},
|
||||
fields: {
|
||||
heading: { label: 'Heading', type: 'text', contentEditable: true },
|
||||
subheading: { label: 'Subheading', type: 'textarea', contentEditable: true },
|
||||
columns: {
|
||||
label: 'Columns',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
],
|
||||
},
|
||||
limit: { label: 'Products per page', type: 'number', min: 4, max: 48 },
|
||||
defaultSort: {
|
||||
label: 'Default sort',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Relevance', value: 'RELEVANCE' },
|
||||
{ label: 'Newest', value: 'NEWEST' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
|
||||
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
|
||||
{ label: 'Alphabetical', value: 'TITLE_ASC' },
|
||||
],
|
||||
},
|
||||
showAvailability: {
|
||||
label: 'Availability filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
showPriceRange: {
|
||||
label: 'Price range filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
showProductType: {
|
||||
label: 'Product type filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
productTypeOptions: {
|
||||
label: 'Product types',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Type',
|
||||
arrayFields: {
|
||||
label: { label: 'Type name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showVendor: {
|
||||
label: 'Brand / vendor filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
vendorOptions: {
|
||||
label: 'Brands',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Brand',
|
||||
arrayFields: {
|
||||
label: { label: 'Brand name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showTags: {
|
||||
label: 'Tags filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
tagOptions: {
|
||||
label: 'Tags',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Tag',
|
||||
arrayFields: {
|
||||
label: { label: 'Tag name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showColor: {
|
||||
label: 'Color filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
colorOptions: {
|
||||
label: 'Colors',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '', color: '#000000' },
|
||||
getItemSummary: (it: any) => it?.label || 'Color',
|
||||
arrayFields: {
|
||||
label: { label: 'Color name', type: 'text' },
|
||||
color: { label: 'Color', type: 'color' },
|
||||
},
|
||||
},
|
||||
showStyle: {
|
||||
label: 'Style filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
styleOptions: {
|
||||
label: 'Styles',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Style',
|
||||
arrayFields: {
|
||||
label: { label: 'Style name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showSize: {
|
||||
label: 'Size filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
sizeOptions: {
|
||||
label: 'Sizes',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Size',
|
||||
arrayFields: {
|
||||
label: { label: 'Size name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showMaterial: {
|
||||
label: 'Material filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
materialOptions: {
|
||||
label: 'Materials',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Material',
|
||||
arrayFields: {
|
||||
label: { label: 'Material name', type: 'text' },
|
||||
},
|
||||
},
|
||||
metafieldFilters: {
|
||||
label: 'Metafield filters',
|
||||
type: 'array',
|
||||
defaultItemProps: { namespace: '', key: '', label: '', values: [{ label: '' }] },
|
||||
getItemSummary: (it: any) => it?.label || it?.key || 'Metafield',
|
||||
arrayFields: {
|
||||
namespace: { label: 'Namespace', type: 'text' },
|
||||
key: { label: 'Key', type: 'text' },
|
||||
label: { label: 'Label', type: 'text' },
|
||||
values: {
|
||||
label: 'Values',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (v: any) => v?.label || 'Value',
|
||||
arrayFields: {
|
||||
label: { label: 'Value', type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <SearchProductsView {...props} />,
|
||||
};
|
||||
|
||||
export default searchProductsEditor;
|
||||
533
components/shopify/search-products.tsx
Normal file
533
components/shopify/search-products.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import { useShopifySearch, type SearchFilters, type SortOption } from '@/hooks/use-shopify-search';
|
||||
|
||||
import { ProductCard } from './product-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FilterOption = { label: string };
|
||||
type ColorOption = { label: string; color: string };
|
||||
|
||||
export type SearchProductsProps = {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: '2' | '3' | '4';
|
||||
limit: number;
|
||||
showAvailability: 'yes' | 'no';
|
||||
showPriceRange: 'yes' | 'no';
|
||||
showProductType: 'yes' | 'no';
|
||||
productTypeOptions: FilterOption[];
|
||||
showVendor: 'yes' | 'no';
|
||||
vendorOptions: FilterOption[];
|
||||
showTags: 'yes' | 'no';
|
||||
tagOptions: FilterOption[];
|
||||
showColor: 'yes' | 'no';
|
||||
colorOptions: ColorOption[];
|
||||
showStyle: 'yes' | 'no';
|
||||
styleOptions: FilterOption[];
|
||||
showSize: 'yes' | 'no';
|
||||
sizeOptions: FilterOption[];
|
||||
showMaterial: 'yes' | 'no';
|
||||
materialOptions: FilterOption[];
|
||||
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
|
||||
defaultSort: SortOption;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { label: string; value: SortOption }[] = [
|
||||
{ label: 'Relevance', value: 'RELEVANCE' },
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Newest', value: 'NEWEST' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
|
||||
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
|
||||
{ label: 'Alphabetical', value: 'TITLE_ASC' },
|
||||
];
|
||||
|
||||
const colClass: Record<SearchProductsProps['columns'], string> = {
|
||||
'2': 'grid-cols-2',
|
||||
'3': 'grid-cols-2 md:grid-cols-3',
|
||||
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// ─── Filter group (collapsible) ────────────────────────────────────────────────
|
||||
|
||||
function FilterGroup({ label, children, defaultOpen = true }: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn('transition-transform', open ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-3 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, label }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
checked ? 'border-foreground bg-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
|
||||
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveFilters = {
|
||||
availability: boolean;
|
||||
productTypes: string[];
|
||||
vendors: string[];
|
||||
tags: string[];
|
||||
colors: string[];
|
||||
styles: string[];
|
||||
sizes: string[];
|
||||
materials: string[];
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
metafieldValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
props,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
props: SearchProductsProps;
|
||||
active: ActiveFilters;
|
||||
onChange: (patch: Partial<ActiveFilters>) => void;
|
||||
}) {
|
||||
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const colors = (props.colorOptions ?? []) as ColorOption[];
|
||||
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
|
||||
|
||||
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
|
||||
const arr = active[key];
|
||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.showAvailability === 'yes' && (
|
||||
<FilterGroup label="Availability">
|
||||
<Checkbox
|
||||
checked={active.availability}
|
||||
onChange={(v) => onChange({ availability: v })}
|
||||
label="In stock"
|
||||
/>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showPriceRange === 'yes' && (
|
||||
<FilterGroup label="Price">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
value={active.minPrice}
|
||||
onChange={(e) => onChange({ minPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
value={active.maxPrice}
|
||||
onChange={(e) => onChange({ maxPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showVendor === 'yes' && vendors.length > 0 && (
|
||||
<FilterGroup label="Brand">
|
||||
{vendors.map((v) => (
|
||||
<Checkbox
|
||||
key={v}
|
||||
checked={active.vendors.includes(v)}
|
||||
onChange={() => toggle('vendors', v)}
|
||||
label={v}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showColor === 'yes' && colors.length > 0 && (
|
||||
<FilterGroup label="Color">
|
||||
{colors.filter((c) => c.label).map((c) => (
|
||||
<label
|
||||
key={c.label}
|
||||
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active.colors.includes(c.label)}
|
||||
onChange={() => toggle('colors', c.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 rounded-full border-2',
|
||||
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
|
||||
)}
|
||||
style={{ backgroundColor: c.color || undefined }}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showStyle === 'yes' && styles.length > 0 && (
|
||||
<FilterGroup label="Style">
|
||||
{styles.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.styles.includes(s)}
|
||||
onChange={() => toggle('styles', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showSize === 'yes' && sizes.length > 0 && (
|
||||
<FilterGroup label="Size">
|
||||
{sizes.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.sizes.includes(s)}
|
||||
onChange={() => toggle('sizes', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showMaterial === 'yes' && materials.length > 0 && (
|
||||
<FilterGroup label="Material">
|
||||
{materials.map((m) => (
|
||||
<Checkbox
|
||||
key={m}
|
||||
checked={active.materials.includes(m)}
|
||||
onChange={() => toggle('materials', m)}
|
||||
label={m}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showProductType === 'yes' && productTypes.length > 0 && (
|
||||
<FilterGroup label="Product type">
|
||||
{productTypes.map((pt) => (
|
||||
<Checkbox
|
||||
key={pt}
|
||||
checked={active.productTypes.includes(pt)}
|
||||
onChange={() => toggle('productTypes', pt)}
|
||||
label={pt}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showTags === 'yes' && tags.length > 0 && (
|
||||
<FilterGroup label="Tags">
|
||||
{tags.map((t) => (
|
||||
<Checkbox
|
||||
key={t}
|
||||
checked={active.tags.includes(t)}
|
||||
onChange={() => toggle('tags', t)}
|
||||
label={t}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{metafieldFilters.map((mf, i) => {
|
||||
const mfKey = `${mf.namespace}.${mf.key}`;
|
||||
const selected = active.metafieldValues[mfKey] ?? [];
|
||||
return (
|
||||
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
|
||||
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
|
||||
<Checkbox
|
||||
key={val}
|
||||
checked={selected.includes(val)}
|
||||
onChange={(checked) => {
|
||||
const next = checked
|
||||
? [...selected, val]
|
||||
: selected.filter((v) => v !== val);
|
||||
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
|
||||
}}
|
||||
label={val}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SearchProductsView(props: SearchProductsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const initialQ = searchParams.get('q') ?? '';
|
||||
|
||||
const [query, setQuery] = useState(initialQ);
|
||||
const [inputValue, setInputValue] = useState(initialQ);
|
||||
const [sort, setSort] = useState<SortOption>(props.defaultSort);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [active, setActive] = useState<ActiveFilters>({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
|
||||
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
|
||||
setActive((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActive({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filters: SearchFilters = {
|
||||
q: query,
|
||||
sort,
|
||||
availability: active.availability || undefined,
|
||||
productTypes: active.productTypes.length ? active.productTypes : undefined,
|
||||
vendors: active.vendors.length ? active.vendors : undefined,
|
||||
tags: active.tags.length ? active.tags : undefined,
|
||||
colors: active.colors.length ? active.colors : undefined,
|
||||
styles: active.styles.length ? active.styles : undefined,
|
||||
sizes: active.sizes.length ? active.sizes : undefined,
|
||||
materials: active.materials.length ? active.materials : undefined,
|
||||
minPrice: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
|
||||
maxPrice: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
|
||||
metafields: (() => {
|
||||
const mfs = Object.entries(active.metafieldValues).flatMap(([mfKey, vals]) => {
|
||||
const [namespace, key] = mfKey.split('.');
|
||||
return vals.map((value) => ({ namespace, key, value }));
|
||||
});
|
||||
return mfs.length ? mfs : undefined;
|
||||
})(),
|
||||
};
|
||||
|
||||
const { products, loading, error, hasNextPage, fetchMore } = useShopifySearch(filters, {
|
||||
first: props.limit,
|
||||
});
|
||||
|
||||
// Sync ?q= param when query changes
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (query) params.set('q', query); else params.delete('q');
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
}, [query]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setQuery(inputValue.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-16">
|
||||
<Container>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-10">
|
||||
{props.heading && (
|
||||
<h1 className="mb-2 font-heading text-4xl font-bold tracking-tight text-foreground md:text-5xl">
|
||||
{props.heading}
|
||||
</h1>
|
||||
)}
|
||||
{props.subheading && (
|
||||
<p className="text-muted-foreground">{props.subheading}</p>
|
||||
)}
|
||||
{/* Search bar (mobile only – desktop version lives in the product area) */}
|
||||
<form onSubmit={handleSearch} className="mt-6 flex gap-2 md:hidden">
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Search bar (desktop only — mobile lives in header above) */}
|
||||
<form onSubmit={handleSearch} className="mb-4 hidden gap-2 md:flex">
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Filter + sort bar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||
</div>
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||
Search
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||
</p>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[props.columns])}>
|
||||
{loading
|
||||
? Array.from({ length: props.limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
No products found.{query ? ` Try a different search term.` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && !loading && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchMore}
|
||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user