Initial commit

This commit is contained in:
Rami Bitar
2026-05-03 20:12:12 -04:00
commit 3a3ca1c72a
169 changed files with 22320 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
'use client';
import React from 'react';
import { useShopifyCart, redirectToCheckout } from '@/editor/hooks/use-shopify-cart';
import { Button } from '@/editor/components/ui/button';
import { Spinner } from '@/editor/components/ui/spinner';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
SheetFooter,
} from '@/editor/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()} side="right">
<SheetContent className="w-full sm:max-w-md">
{/* Header */}
<SheetHeader>
<SheetTitle className="text-base">
Shopping Cart ({itemCount})
</SheetTitle>
</SheetHeader>
{/* Cart Items */}
<SheetBody>
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12">
<i className="ri-shopping-cart-line text-6xl text-gray-300 mb-4 block"></i>
<h3 className="text-lg font-semibold text-gray-600 mb-2">Your cart is empty</h3>
<p className="text-gray-500 mb-6">Add some products to get started!</p>
<Button
onClick={closeCart}
className="font-heading"
>
Continue Shopping
</Button>
</div>
) : (
<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-gray-200 last:border-b-0">
{/* Product Image */}
<div className="w-20 h-20 bg-gray-100 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-gray-400">
<i className="ri-image-line text-2xl"></i>
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2">
{item.merchandise.product.title}
</h4>
{/* Variant Info */}
{selectedOptions.length > 0 && (
<div className="text-sm text-gray-500 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-gray-300 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"
>
<i className="ri-subtract-line text-sm"></i>
</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"
>
<i className="ri-add-line text-sm"></i>
</Button>
</div>
</div>
</div>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm font-semibold text-gray-900">
${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="text-gray-400 hover:text-red-500"
>
<i className="ri-delete-bin-line text-lg"></i>
</Button>
</div>
</div>
);
})}
</div>
)}
</SheetBody>
{/* Footer - Checkout Section */}
{items.length > 0 && (
<SheetFooter className="flex-col sm:flex-col sm:justify-start gap-0">
{/* 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-gray-500 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">
<Spinner size="sm" />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
</Button>
<Button
onClick={closeCart}
variant="link"
className="w-full"
>
Continue Shopping
</Button>
</div>
</SheetFooter>
)}
</SheetContent>
</Sheet>
);
};
export default CartDrawer;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { Link } from 'react-router';
import { Card, CardContent } from '@/editor/components/ui/card';
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 to={`/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-gray-100">
{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-gray-400">
<i className="ri-folder-line text-6xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<CardContent className="p-6">
<h3 className="text-2xl font-bold text-gray-900 mb-3 group-hover:text-gray-600 transition-colors font-heading">
{collection.title}
</h3>
{collection.description && (
<p className="text-gray-600">
{collection.description.substring(0, 100)}
{collection.description.length > 100 ? '...' : ''}
</p>
)}
<div className="mt-4 text-black font-semibold group-hover:text-gray-600 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;

View File

@@ -0,0 +1,95 @@
'use client';
import React from 'react';
import { useCollectionProducts } from '@/editor/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-gray-900 font-heading">
{title}
</h2>
{products.length === 0 ? (
<div className="text-center py-12">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-semibold text-gray-600 mb-2">
No Products in Collection
</h3>
<p className="text-gray-500">
This collection doesn&apos;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;

View File

@@ -0,0 +1,31 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionGrid, type CollectionGridProps } from "@/editor/components/commerce/collection-grid";
export 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} />,
};

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { shopifyFetch } from "@/editor/services/shopify/client";
import { GET_COLLECTIONS_QUERY } from "@/editor/graphql/collections";
import { Typography } from "@/editor/theme/Typography";
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">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto mb-12 max-w-2xl text-center">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
<Typography variant="h2">{heading}</Typography>
{subheading ? (
<Typography variant="subtitle1" className="mt-3">
{subheading}
</Typography>
) : null}
</div>
<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}
to={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="h4" className="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 flex items-center justify-between">
<h3 className="text-sm font-medium tracking-tight">{c.title}</h3>
<span className="text-xs text-muted-foreground transition-opacity group-hover:opacity-100">
</span>
</div>
) : null}
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,26 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionView, type CollectionProps } from "@/editor/components/commerce/collection";
export function createCollectionEditor(opts: {
collectionField: any;
}): ComponentConfig<CollectionProps> {
return {
label: "Collection page",
icon: <FolderOpen size={16} />,
category: "commerce",
defaultProps: { collection: null, showDescription: "no" },
fields: {
collection: { label: "Collection", ...opts.collectionField },
showDescription: {
label: "Description",
type: "radio",
options: [
{ label: "Hide description", value: "no" },
{ label: "Show description", value: "yes" },
],
},
},
render: (props) => <CollectionView {...props} />,
};
}

View File

@@ -0,0 +1,76 @@
import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { useCollectionProducts } from "@/editor/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
export type CollectionProps = {
collection: ShopifyCollection | null;
showDescription: "yes" | "no";
};
export function CollectionView({
collection: selected,
showDescription,
}: CollectionProps) {
const handle = selected?.handle ?? "";
const { collection, loading } = useCollectionProducts(handle, { first: 24 });
if (!selected) {
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<div className="container mx-auto max-w-7xl px-6">
<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" />
{showDescription === "yes" ? (
<Skeleton className="mt-1 h-5 w-2/3" />
) : null}
</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>
</div>
</section>
);
}
const products = (collection?.products as any[] | undefined) ?? [];
const description = collection?.description ?? selected.description;
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<div className="container mx-auto max-w-7xl px-6">
<header className="mx-auto mb-14 max-w-2xl text-center">
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
Collection
</p>
<Typography variant="h1">
{collection?.title ?? selected.title}
</Typography>
{showDescription === "yes" && description ? (
<Typography variant="subtitle1" className="mt-4">
{description}
</Typography>
) : null}
</header>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
{loading
? Array.from({ length: 8 }).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="mx-auto mt-12 max-w-md text-center text-sm text-muted-foreground">
This collection has no products yet.
</div>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import React from 'react';
import { useCollections } from '@/editor/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-gray-900 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-white rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-video bg-gray-200"></div>
<div className="p-6">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 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-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
<i className="ri-folder-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-semibold text-gray-600 mb-2">
No Collections Found
</h3>
<p className="text-gray-500">
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-gray-900 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;

View File

@@ -0,0 +1,42 @@
import { ComponentConfig } from "@reacteditor/core";
import { Star } from "lucide-react";
import { FeaturedProductView, type FeaturedProductProps } from "@/editor/components/commerce/featured-product";
export function createFeaturedProductEditor(opts: {
productField: any;
}): ComponentConfig<FeaturedProductProps> {
return {
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", ...opts.productField },
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} />,
};
}

View File

@@ -0,0 +1,129 @@
import { Link } from "react-router";
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/editor/hooks/use-shopify-products";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
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",
)}
>
<div className="container mx-auto grid max-w-7xl grid-cols-1 items-center gap-10 px-6 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-full" />
<Skeleton className="h-11 w-32 rounded-full" />
</div>
</div>
</div>
</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"
}
>
<div className="container mx-auto grid max-w-7xl grid-cols-1 items-center gap-10 px-6 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 ? (
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : 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-full bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background hover:opacity-90"
>
{ctaLabel}
</button>
<Link
to={`/products/${product.handle}`}
className="inline-flex items-center justify-center rounded-full border border-foreground px-6 py-3 text-sm font-medium tracking-wide hover:opacity-80"
>
View details
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { Link } from "react-router";
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 to={`/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">
<h3 className="text-sm font-medium tracking-tight">{product.title}</h3>
{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;

View File

@@ -0,0 +1,3 @@
import ProductDetail from './product-detail/index.tsx';
export default ProductDetail;

View File

@@ -0,0 +1,206 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { useProduct, type Product } from '@/editor/hooks/use-shopify-products';
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import ProductRecommendations from './product-recommendations';
import { Button } from '@/editor/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/editor/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/editor/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 handle = handleProp || '';
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-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/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>
<ProductRecommendations productId={product.id} />
</div>
);
};
export default ProductDetail;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Button } from '@/editor/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-gray-100 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-gray-400">
<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-black'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<img
src={image.url}
alt={image.altText || 'Product thumbnail'}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
);
};
export default ProductDetailGallery;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { Product, ProductVariant } from './index.tsx';
import { Button } from '@/editor/components/ui/button';
import { Badge } from '@/editor/components/ui/badge';
import { Spinner } from '@/editor/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-gray-900 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-gray-900">
{formatPrice(price)}
</span>
{hasDiscount && compareAtPrice && (
<>
<span className="text-xl text-gray-500 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-gray-600 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-gray-700 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-gray-700 mb-2">
Quantity
</label>
<div className="flex items-center border border-gray-300 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-gray-200">
<div className="space-y-3 text-sm text-gray-600">
<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;

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { useProductRecommendations } from '@/editor/hooks/use-shopify-products';
import ProductCard from '../product-card';
interface ProductRecommendationsProps {
productId: string;
}
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({ productId }) => {
const { recommendations, loading, error } = useProductRecommendations(productId);
// 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-gray-50 py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-bold text-center mb-12 text-gray-900 font-heading">
You Might Also Like
</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: 4 }).map((_, index) => (
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="p-6">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-12 bg-gray-200 rounded"></div>
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-gray-500">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, 4).map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</div>
);
};
export default ProductRecommendations;

View File

@@ -0,0 +1,16 @@
import { ComponentConfig } from "@reacteditor/core";
import { Package } from "lucide-react";
import { ProductDetailsView, type ProductDetailsProps } from "@/editor/components/commerce/product-details";
export function createProductDetailsEditor(opts: {
productField: any;
}): ComponentConfig<ProductDetailsProps> {
return {
label: "Product details",
icon: <Package size={16} />,
category: "commerce",
defaultProps: { product: null },
fields: { product: { label: "Product", ...opts.productField } },
render: (props) => <ProductDetailsView {...props} />,
};
}

View File

@@ -0,0 +1,212 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/editor/hooks/use-shopify-products";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography";
import { cn } from "@/editor/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
export type ProductDetailsProps = {
product: ShopifyProduct | null;
};
export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
const { handle: paramHandle } = useParams<{ handle?: string }>();
const handle = selected?.handle ?? paramHandle ?? 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">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 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-full" />
<Skeleton className="h-10 w-16 rounded-full" />
<Skeleton className="h-10 w-16 rounded-full" />
</div>
</div>
<div className="flex items-center gap-4 pt-2">
<Skeleton className="h-11 w-32 rounded-full" />
<Skeleton className="h-11 flex-1 rounded-full" />
</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>
</div>
</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">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 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-full 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-full 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-full bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background transition-opacity hover:opacity-90 disabled:opacity-50"
>
{adding ? "Adding…" : "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>
</div>
</section>
);
}

View File

@@ -0,0 +1,42 @@
import { ComponentConfig } from "@reacteditor/core";
import { GalleryHorizontalEnd } from "lucide-react";
import { ProductsCarousel, type ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
export function createProductsCarouselEditor(opts: {
collectionField: any;
}): ComponentConfig<ProductsCarouselProps> {
return {
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", ...opts.collectionField },
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} />,
};
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { getProducts } from "@/editor/hooks/use-shopify-products";
import { getCollectionProducts } from "@/editor/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/editor/components/ui/carousel";
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?.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">
<div className="container mx-auto max-w-7xl px-6">
<div className="mb-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div className="max-w-xl">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
<Typography variant="h2">{heading}</Typography>
{subheading ? (
<Typography variant="subtitle1" className="mt-3">
{subheading}
</Typography>
) : null}
</div>
{ctaLabel ? (
<Link
to={
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-3/4 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="hidden md:inline-flex" />
<CarouselNext className="hidden md:inline-flex" />
</Carousel>
</div>
</section>
);
}

View File

@@ -0,0 +1,41 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutGrid } from "lucide-react";
import { ProductsGrid, type ProductsGridProps } from "@/editor/components/commerce/products-grid";
export function createProductsGridEditor(opts: {
collectionField: any;
}): ComponentConfig<ProductsGridProps> {
return {
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", ...opts.collectionField },
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} />,
};
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { getProducts } from "@/editor/hooks/use-shopify-products";
import { getCollectionProducts } from "@/editor/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
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?.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">
<div className="container mx-auto max-w-7xl px-6">
<div className="mb-12 flex flex-col items-end justify-between gap-6 md:flex-row md:items-end">
<div className="max-w-xl">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
<Typography variant="h2">{heading}</Typography>
{subheading ? (
<Typography variant="subtitle1" className="mt-3">
{subheading}
</Typography>
) : null}
</div>
{ctaLabel ? (
<Link
to={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>
</div>
</section>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProducts } from '@/editor/hooks/use-shopify-products';
import { Button } from '@/editor/components/ui/button';
import { Spinner } from '@/editor/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-white rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="p-6">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-12 bg-gray-200 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-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-semibold text-gray-600 mb-2">
No Products Found
</h3>
<p className="text-gray-500">
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-gray-900 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;

View File

@@ -0,0 +1,26 @@
import { ComponentConfig } from "@reacteditor/core";
import { Sparkles } from "lucide-react";
import { RecommendedProductsView, type RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
export function createRecommendedProductsEditor(opts: {
productField: any;
}): ComponentConfig<RecommendedProductsProps> {
return {
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", ...opts.productField },
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} />,
};
}

View File

@@ -0,0 +1,67 @@
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import {
useProduct,
useProductRecommendations,
} from "@/editor/hooks/use-shopify-products";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
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">
<div className="container mx-auto max-w-7xl px-6">
<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>
</div>
</section>
);
}
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
<div className="mb-12 max-w-xl">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
<Typography variant="h3">{heading}</Typography>
</div>
<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>
</div>
</section>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="bg-black text-white py-12">
<div className="container mx-auto px-4 text-center">
<h3
className="text-2xl font-bold mb-4"
style={{fontFamily: 'Space Grotesk, sans-serif'}}
>
Store
</h3>
<p className="text-gray-400 mb-6">
Your premium shopping destination
</p>
<div className="mt-8 pt-8 border-t border-gray-800 text-gray-400">
<p>&copy; 2025 Store. All rights reserved.</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { Link } from 'react-router';
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
import config from '@/editor/lib/config.json';
const CartIcon: React.FC = () => {
const { toggleCart, itemCount } = useShopifyCart();
return (
<button
onClick={toggleCart}
className="relative p-1 text-black hover:text-gray-600 transition-colors"
>
<i className="ri-shopping-cart-line text-xl"></i>
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 bg-black text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center font-semibold">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</button>
);
};
const Header: React.FC = () => {
return (
<nav className="bg-white shadow-sm sticky top-0 z-30 h-14">
<div className="container mx-auto px-4 h-full">
<div className="flex justify-between items-center h-full">
{/* Logo */}
<Link to="/" className="text-lg font-bold text-black font-heading">
{config.brand.logo.url ? (
<img
src={config.brand.logo.url}
alt={config.brand.logo.alt || 'Store'}
className="h-8"
/>
) : (
'Store'
)}
</Link>
{/* Navigation Links */}
<div className="flex items-center space-x-6">
<Link
to="/"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Products
</Link>
<Link
to="/collections"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Collections
</Link>
{/* Cart Icon */}
<CartIcon />
</div>
</div>
</div>
</nav>
);
};
export default Header;