Update to use react-editor <App /> component

This commit is contained in:
Rami Bitar
2026-05-03 16:22:24 -04:00
parent a78249846a
commit 757118706e
51 changed files with 2294 additions and 2190 deletions

View File

@@ -10,7 +10,7 @@ import {
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
SheetFooter,
} from '@/editor/components/ui/sheet';
const CartDrawer: React.FC = () => {
@@ -32,184 +32,171 @@ const CartDrawer: React.FC = () => {
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()} side="right">
<AnimatePresence>
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
<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"
>
<i className="ri-close-line text-xl"></i>
</Button>
</div>
</SheetHeader>
<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);
{/* 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>
)}
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>
{/* 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>
{/* 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>
)}
{/* 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">
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-gray-300 rounded-lg">
<Button
onClick={() => removeItem(item.id)}
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="text-gray-400 hover:text-red-500"
className="h-7 w-7"
>
<i className="ri-close-line text-lg font-bold"></i>
<i className="ri-add-line text-sm"></i>
</Button>
</div>
</div>
);
})}
</div>
)}
</SheetBody>
</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-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>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm font-semibold text-gray-900">
${parseFloat(item.merchandise.price.amount).toFixed(2)}
</span>
) : (
'Checkout'
)}
</Button>
</div>
<Button
onClick={closeCart}
variant="link"
className="w-full"
>
Continue Shopping
</Button>
</div>
</div>
)}
</SheetContent>
{/* 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>
)}
</AnimatePresence>
</SheetContent>
</Sheet>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
// local link - plain anchor
import { Link } from 'react-router';
import { Card, CardContent } from '@/editor/components/ui/card';
interface CollectionImage {
@@ -21,7 +21,7 @@ interface CollectionCardProps {
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
return (
<a href={`/collections/${collection.handle}`} className="block group">
<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">
@@ -57,7 +57,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
</div>
</CardContent>
</Card>
</a>
</Link>
);
};

View File

@@ -1,120 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useEffect, useState } from "react";
import { FolderOpen } from "lucide-react";
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 };
};
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) => (
<a
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="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}
</a>
))}
</div>
</div>
</section>
);
}
import { CollectionGrid, type CollectionGridProps } from "@/editor/components/commerce/collection-grid";
export const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
label: "Collections",

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

@@ -1,75 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
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";
export type CollectionProps = {
collection: ShopifyCollection | null;
showDescription: "yes" | "no";
};
function CollectionView({
collection: selected,
showDescription,
}: CollectionProps) {
const handle = selected?.handle ?? "";
const { collection, loading } = useCollectionProducts(handle, { first: 24 });
if (!selected) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a collection to render this page.
</div>
</div>
</section>
);
}
// The hook flattens collection.products.edges into a plain Product[].
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) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
))
: 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>
);
}
import { CollectionView, type CollectionProps } from "@/editor/components/commerce/collection";
export function createCollectionEditor(opts: {
collectionField: any;

View File

@@ -0,0 +1,69 @@
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";
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="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a collection to render this page.
</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) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
))
: 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

@@ -1,122 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Star } from "lucide-react";
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";
export type FeaturedProductProps = {
product: ShopifyProduct | null;
tagline: string;
ctaLabel: string;
align: "left" | "right";
tone: "default" | "muted";
};
function FeaturedProductView({
product: selected,
tagline,
ctaLabel,
align,
tone,
}: FeaturedProductProps) {
// Re-fetch full product (variants, full image set) by handle for cart wiring.
const { product: full, loading } = useProduct(selected?.handle ?? null);
const product: any = full ?? selected;
const cart = useShopifyCart();
if (!selected && loading) {
return (
<section className="py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
<div className="aspect-[2/1] w-full animate-pulse rounded-lg bg-muted" />
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to feature here.
</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>
<a
href={`/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
</a>
</div>
</div>
</div>
</section>
);
}
import { FeaturedProductView, type FeaturedProductProps } from "@/editor/components/commerce/featured-product";
export function createFeaturedProductEditor(opts: {
productField: any;

View File

@@ -0,0 +1,117 @@
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";
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 (!selected && loading) {
return (
<section className="py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
<div className="aspect-[2/1] w-full animate-pulse rounded-lg bg-muted" />
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to feature here.
</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

@@ -1,4 +1,5 @@
import * as React from "react";
import { Link } from "react-router";
type ProductImage = { url: string; altText?: string };
type ProductPrice = { amount: string; currencyCode: string };
@@ -39,7 +40,7 @@ export function ProductCard({
};
return (
<a href={`/products/${product.handle}`} className="group block">
<Link to={`/products/${product.handle}`} className="group block">
<div
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
>
@@ -64,7 +65,7 @@ export function ProductCard({
</div>
) : null}
</div>
</a>
</Link>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
// local link - plain anchor
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';
@@ -165,13 +165,13 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<a href="/">Home</a>
<Link to="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<a href="/shop">Shop</a>
<Link to="/shop">Shop</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />

View File

@@ -1,210 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useEffect, useState } from "react";
import { Package } from "lucide-react";
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";
export type ProductDetailsProps = {
product: ShopifyProduct | null;
};
function ProductDetailsView({ product: selected }: ProductDetailsProps) {
const handle = selected?.handle ?? 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 (!selected) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to render this page.
</div>
</div>
</section>
);
}
if (loading) {
return (
<section className="py-20">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 md:grid-cols-2">
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
<div className="space-y-4">
<div className="h-8 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-5 w-1/4 animate-pulse rounded bg-muted" />
<div className="h-32 w-full animate-pulse rounded bg-muted" />
</div>
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-border p-6 text-sm">
<p className="font-medium">Product not found</p>
<p className="mt-2 text-muted-foreground">
The requested product could not be found.
</p>
</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">
{images.map((img: any, i: number) => (
<button
key={i}
onClick={() => setActiveImage(i)}
className={`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={`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>
);
}
import { ProductDetailsView, type ProductDetailsProps } from "@/editor/components/commerce/product-details";
export function createProductDetailsEditor(opts: {
productField: any;

View File

@@ -0,0 +1,214 @@
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";
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) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to render this page.
</div>
</div>
</section>
);
}
if (loading) {
return (
<section className="py-20">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 md:grid-cols-2">
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
<div className="space-y-4">
<div className="h-8 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-5 w-1/4 animate-pulse rounded bg-muted" />
<div className="h-32 w-full animate-pulse rounded bg-muted" />
</div>
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-border p-6 text-sm">
<p className="font-medium">Product not found</p>
<p className="mt-2 text-muted-foreground">
The requested product could not be found.
</p>
</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

@@ -1,130 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useEffect, useState } from "react";
import { GalleryHorizontalEnd } from "lucide-react";
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",
};
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 ? (
<a
href={
ctaHref ||
(collection?.handle ? `/collections/${collection.handle}` : "/collections")
}
className="text-sm font-medium tracking-wide hover:opacity-70"
>
{ctaLabel}
</a>
) : 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>
);
}
import { ProductsCarousel, type ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
export function createProductsCarouselEditor(opts: {
collectionField: any;

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

@@ -1,104 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useEffect, useState } from "react";
import { LayoutGrid } from "lucide-react";
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",
};
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 ? (
<a
href={ctaHref || (collection?.handle ? `/collections/${collection.handle}` : "/collections")}
className="text-sm font-medium tracking-wide hover:opacity-70"
>
{ctaLabel}
</a>
) : 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>
);
}
import { ProductsGrid, type ProductsGridProps } from "@/editor/components/commerce/products-grid";
export function createProductsGridEditor(opts: {
collectionField: any;

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

@@ -1,68 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Sparkles } from "lucide-react";
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";
export type RecommendedProductsProps = {
product: ShopifyProduct | null;
tagline: string;
heading: string;
limit: number;
};
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="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to load recommendations.
</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) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
))
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>
</div>
</section>
);
}
import { RecommendedProductsView, type RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
export function createRecommendedProductsEditor(opts: {
productField: any;

View File

@@ -0,0 +1,63 @@
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";
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="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to load recommendations.
</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) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
))
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>
</div>
</section>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
// local link - plain anchor
import { Link } from 'react-router';
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
import config from '@/editor/lib/config.json';
@@ -29,7 +29,7 @@ const Header: React.FC = () => {
<div className="container mx-auto px-4 h-full">
<div className="flex justify-between items-center h-full">
{/* Logo */}
<a href="/" className="text-lg font-bold text-black font-heading">
<Link to="/" className="text-lg font-bold text-black font-heading">
{config.brand.logo.url ? (
<img
src={config.brand.logo.url}
@@ -39,22 +39,22 @@ const Header: React.FC = () => {
) : (
'Store'
)}
</a>
</Link>
{/* Navigation Links */}
<div className="flex items-center space-x-6">
<a
href="/"
<Link
to="/"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Products
</a>
<a
href="/collections"
</Link>
<Link
to="/collections"
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
>
Collections
</a>
</Link>
{/* Cart Icon */}
<CartIcon />