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

@@ -1,92 +1,4 @@
{
"/products/*": {
"root": {
"props": {
"title": "Default product",
"headerFont": "Playfair Display",
"bodyFont": "Inter",
"primaryColor": "#0a0a0a",
"accentColor": "#f5f5f5",
"bgColor": "#ffffff",
"fgColor": "#0a0a0a",
"mutedColor": "#f5f5f5",
"roundedness": "md",
"shadowLevel": "sm",
"maxWidth": "xl"
}
},
"content": [
{
"type": "navigation",
"props": {
"id": "nav-product",
"brand": "Maison",
"links": [
{ "label": "Shop", "href": "/collections" },
{ "label": "Lookbook", "href": "/lookbook" },
{ "label": "Journal", "href": "/journal" },
{ "label": "About", "href": "/about" }
],
"showSearch": "yes",
"showAccount": "yes",
"showCart": "yes",
"sticky": "yes",
"tone": "default"
}
},
{
"type": "product-details",
"props": {
"id": "product-details",
"product": null
}
},
{
"type": "products-carousel",
"props": {
"id": "carousel-related",
"tagline": "You might also like",
"heading": "Related pieces",
"limit": 8,
"slidesPerView": "4",
"ctaLabel": "Shop all",
"ctaHref": "/collections"
}
},
{
"type": "footer",
"props": {
"id": "footer-product",
"brand": "Maison",
"tagline": "Considered essentials, made in small batches.",
"columns": [
{
"title": "Shop",
"links": [
{ "label": "All", "href": "/collections" },
{ "label": "New", "href": "/collections/new" }
]
},
{
"title": "Help",
"links": [
{ "label": "Shipping", "href": "/help/shipping" },
{ "label": "Returns", "href": "/help/returns" }
]
}
],
"social": [
{ "label": "Instagram", "href": "#" },
{ "label": "Pinterest", "href": "#" }
],
"showNewsletter": "no",
"newsletterHeading": "Stay in touch",
"newsletterEndpoint": "",
"copyright": "© 2026 Maison. All rights reserved."
}
}
]
},
"/": {
"root": {
"props": {
@@ -273,5 +185,93 @@
}
}
]
},
"/products/:handle": {
"root": {
"props": {
"title": "Default product",
"headerFont": "Playfair Display",
"bodyFont": "Inter",
"primaryColor": "#0a0a0a",
"accentColor": "#f5f5f5",
"bgColor": "#ffffff",
"fgColor": "#0a0a0a",
"mutedColor": "#f5f5f5",
"roundedness": "md",
"shadowLevel": "sm",
"maxWidth": "xl"
}
},
"content": [
{
"type": "navigation",
"props": {
"id": "nav-product",
"brand": "Maison",
"links": [
{ "label": "Shop", "href": "/collections" },
{ "label": "Lookbook", "href": "/lookbook" },
{ "label": "Journal", "href": "/journal" },
{ "label": "About", "href": "/about" }
],
"showSearch": "yes",
"showAccount": "yes",
"showCart": "yes",
"sticky": "yes",
"tone": "default"
}
},
{
"type": "product-details",
"props": {
"id": "product-details",
"product": null
}
},
{
"type": "products-carousel",
"props": {
"id": "carousel-related",
"tagline": "You might also like",
"heading": "Related pieces",
"limit": 8,
"slidesPerView": "4",
"ctaLabel": "Shop all",
"ctaHref": "/collections"
}
},
{
"type": "footer",
"props": {
"id": "footer-product",
"brand": "Maison",
"tagline": "Considered essentials, made in small batches.",
"columns": [
{
"title": "Shop",
"links": [
{ "label": "All", "href": "/collections" },
{ "label": "New", "href": "/collections/new" }
]
},
{
"title": "Help",
"links": [
{ "label": "Shipping", "href": "/help/shipping" },
{ "label": "Returns", "href": "/help/returns" }
]
}
],
"social": [
{ "label": "Instagram", "href": "#" },
{ "label": "Pinterest", "href": "#" }
],
"showNewsletter": "no",
"newsletterHeading": "Stay in touch",
"newsletterEndpoint": "",
"copyright": "© 2026 Maison. All rights reserved."
}
}
]
}
}

View File

@@ -1,7 +1,5 @@
import { notFound } from "next/navigation";
import { readSchema } from "@/lib/schema.server";
import { resolveSchemaEntry } from "@/lib/schema-resolver";
import RenderClient from "@/components/RenderClient";
import AppClient from "@/components/AppClient";
export const dynamic = "force-dynamic";
@@ -12,16 +10,12 @@ export default async function Page({
}) {
const { path } = await params;
const segments = (path ?? []).filter(Boolean);
const currentPath = segments.length === 0 ? "/" : "/" + segments.join("/");
const pages = await readSchema();
// The /edit branch is handled by app/edit; this catch-all only serves view.
if (segments[0] === "edit") return notFound();
const route = "/" + segments.join("/");
const lookup = route === "/" ? "/" : route.replace(/\/$/, "");
const schema = await readSchema();
const resolved = resolveSchemaEntry(schema, lookup);
if (!resolved) return notFound();
return <RenderClient data={resolved.data} route={lookup} />;
return (
<div style={{ height: "100vh", width: "100vw" }}>
<AppClient pages={pages} currentPath={currentPath} />
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { readSchema } from "@/lib/schema.server";
import EditorClient from "@/components/EditorClient";
export const dynamic = "force-dynamic";
export default async function EditPage({
params,
}: {
params: Promise<{ path?: string[] }>;
}) {
const { path } = await params;
const segments = (path ?? []).filter(Boolean);
const route = segments.length === 0 ? "/" : "/" + segments.join("/");
const schema = await readSchema();
return (
<div style={{ height: "100vh", width: "100vw" }}>
<EditorClient initialSchema={schema} initialPath={route} />
</div>
);
}

78
components/AppClient.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useCallback, useMemo } from "react";
import {
App,
blocksPlugin,
outlinePlugin,
} from "@reacteditor/core";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { aiPlugin } from "@reacteditor/plugin-ai";
import "@reacteditor/core/dist/index.css";
import "@/editor/vendor/plugin-ai.css";
import { createConfig } from "@/editor/config";
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
type Pages = Record<string, { root: any; content: any[] }>;
const SHOPIFY_DOMAIN =
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
const STOREFRONT_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
export default function AppClient({
pages,
currentPath,
}: {
pages: Pages;
currentPath: string;
}) {
const config = useMemo(
() =>
createConfig({
domain: SHOPIFY_DOMAIN,
token: STOREFRONT_TOKEN || null,
}),
[],
);
const plugins = useMemo(
() => [
createTailwindCdnPlugin(),
aiPlugin({
api: "/api/chat",
attachments: true,
body: { route: currentPath },
}),
blocksPlugin(),
outlinePlugin(),
],
[currentPath],
);
const handlePublish = useCallback(
(data: any, route?: string) => {
const path = route ?? currentPath;
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: "PUBLISH", path, data },
"*",
);
}
},
[currentPath],
);
return (
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
<App
config={config as any}
pages={pages as any}
currentPath={currentPath}
plugins={plugins}
iframe={{ enabled: true }}
onPublish={handlePublish}
/>
</ShopifyProvider>
);
}

View File

@@ -132,7 +132,7 @@ export default function EditorClient({
currentPath={currentPath}
onRouteChange={(next) => {
setCurrentPath(next);
router.push(`/edit${next === "/" ? "" : next}`);
router.push(next === "/" ? "/edit" : `${next}/edit`);
}}
onPublish={async (next) => {
await persist(editKey, next);

View File

@@ -10,7 +10,7 @@ import {
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
SheetFooter,
} from '@/editor/components/ui/sheet';
const CartDrawer: React.FC = () => {
@@ -32,23 +32,12 @@ 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}>
<SheetContent className="w-full sm:max-w-md">
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
<div className="flex items-center justify-between w-full">
<SheetHeader>
<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>
{/* Cart Items */}
@@ -154,7 +143,7 @@ const CartDrawer: React.FC = () => {
disabled={loading}
className="text-gray-400 hover:text-red-500"
>
<i className="ri-close-line text-lg font-bold"></i>
<i className="ri-delete-bin-line text-lg"></i>
</Button>
</div>
</div>
@@ -166,7 +155,7 @@ const CartDrawer: React.FC = () => {
{/* Footer - Checkout Section */}
{items.length > 0 && (
<div className="border-t border-border p-6">
<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>
@@ -205,11 +194,9 @@ const CartDrawer: React.FC = () => {
Continue Shopping
</Button>
</div>
</div>
</SheetFooter>
)}
</SheetContent>
)}
</AnimatePresence>
</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 />

View File

@@ -1,87 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Megaphone } from "lucide-react";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type CTAProps = {
tagline: string;
heading: string;
subheading: string;
primaryCta: { label: string; href: string };
secondaryCta: { label: string; href: string };
imageUrl: string;
align: "left" | "center";
};
function CTA({
tagline,
heading,
subheading,
primaryCta,
secondaryCta,
imageUrl,
align,
}: CTAProps) {
return (
<section className="relative overflow-hidden py-24 md:py-32">
<div className="absolute inset-0 -z-10">
{imageUrl ? (
<>
<img
src={imageUrl}
alt=""
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/45" />
</>
) : (
<div className="h-full w-full bg-foreground" />
)}
</div>
<div
className={cn(
"container mx-auto flex max-w-4xl flex-col px-6 text-white",
align === "center" ? "items-center text-center" : "items-start",
)}
>
{tagline ? (
<p className="mb-4 text-xs uppercase tracking-[0.2em] text-white/80">
{tagline}
</p>
) : null}
<Typography variant="h2">{heading}</Typography>
{subheading ? (
<Typography variant="subtitle1" className="mt-5 max-w-xl text-white/80">
{subheading}
</Typography>
) : null}
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{primaryCta?.label ? (
<a
href={primaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium tracking-wide text-black hover:opacity-90"
>
{primaryCta.label}
</a>
) : null}
{secondaryCta?.label ? (
<a
href={secondaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-full border border-white px-6 py-3 text-sm font-medium tracking-wide text-white hover:bg-white/10"
>
{secondaryCta.label}
</a>
) : null}
</div>
</div>
</section>
);
}
import { CTA, type CTAProps } from "@/editor/components/cta/cta";
export const ctaEditor: ComponentConfig<CTAProps> = {
label: "Call to action",

View File

@@ -0,0 +1,83 @@
import { Link } from "react-router";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type CTAProps = {
tagline: string;
heading: string;
subheading: string;
primaryCta: { label: string; href: string };
secondaryCta: { label: string; href: string };
imageUrl: string;
align: "left" | "center";
};
export function CTA({
tagline,
heading,
subheading,
primaryCta,
secondaryCta,
imageUrl,
align,
}: CTAProps) {
return (
<section className="relative overflow-hidden py-24 md:py-32">
<div className="absolute inset-0 -z-10">
{imageUrl ? (
<>
<img
src={imageUrl}
alt=""
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/45" />
</>
) : (
<div className="h-full w-full bg-foreground" />
)}
</div>
<div
className={cn(
"container mx-auto flex max-w-4xl flex-col px-6 text-white",
align === "center" ? "items-center text-center" : "items-start",
)}
>
{tagline ? (
<p className="mb-4 text-xs uppercase tracking-[0.2em] text-white/80">
{tagline}
</p>
) : null}
<Typography variant="h2">{heading}</Typography>
{subheading ? (
<Typography variant="subtitle1" className="mt-5 max-w-xl text-white/80">
{subheading}
</Typography>
) : null}
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{primaryCta?.label ? (
<Link
to={primaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium tracking-wide text-black hover:opacity-90"
>
{primaryCta.label}
</Link>
) : null}
{secondaryCta?.label ? (
<Link
to={secondaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-full border border-white px-6 py-3 text-sm font-medium tracking-wide text-white hover:bg-white/10"
>
{secondaryCta.label}
</Link>
) : null}
</div>
</div>
</section>
);
}

View File

@@ -1,65 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useState } from "react";
import { HelpCircle, Plus, Minus } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type FAQProps = {
tagline: string;
heading: string;
subheading: string;
items: Array<{ question: string; answer: string }>;
};
function FAQ({ tagline, heading, subheading, items }: FAQProps) {
const [open, setOpen] = useState<number | null>(0);
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-3xl px-6">
<div className="mb-12 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="divide-y divide-border border-y border-border">
{items.map((item, i) => {
const isOpen = open === i;
return (
<div key={i}>
<button
onClick={() => setOpen(isOpen ? null : i)}
className="flex w-full items-center justify-between py-6 text-left"
>
<span className="text-base font-medium tracking-tight md:text-lg">
{item.question}
</span>
{isOpen ? (
<Minus size={18} strokeWidth={1.5} className="flex-shrink-0" />
) : (
<Plus size={18} strokeWidth={1.5} className="flex-shrink-0" />
)}
</button>
{isOpen ? (
<p className="pb-6 pr-8 text-sm leading-relaxed text-muted-foreground md:text-base">
{item.answer}
</p>
) : null}
</div>
);
})}
</div>
</div>
</section>
);
}
import { HelpCircle } from "lucide-react";
import { FAQ, type FAQProps } from "@/editor/components/faq/faq";
export const faqEditor: ComponentConfig<FAQProps> = {
label: "FAQ",

View File

@@ -0,0 +1,61 @@
import { useState } from "react";
import { Plus, Minus } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type FAQProps = {
tagline: string;
heading: string;
subheading: string;
items: Array<{ question: string; answer: string }>;
};
export function FAQ({ tagline, heading, subheading, items }: FAQProps) {
const [open, setOpen] = useState<number | null>(0);
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-3xl px-6">
<div className="mb-12 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="divide-y divide-border border-y border-border">
{items.map((item, i) => {
const isOpen = open === i;
return (
<div key={i}>
<button
onClick={() => setOpen(isOpen ? null : i)}
className="flex w-full items-center justify-between py-6 text-left"
>
<span className="text-base font-medium tracking-tight md:text-lg">
{item.question}
</span>
{isOpen ? (
<Minus size={18} strokeWidth={1.5} className="flex-shrink-0" />
) : (
<Plus size={18} strokeWidth={1.5} className="flex-shrink-0" />
)}
</button>
{isOpen ? (
<p className="pb-6 pr-8 text-sm leading-relaxed text-muted-foreground md:text-base">
{item.answer}
</p>
) : null}
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -1,56 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Sparkles } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type FeaturesProps = {
tagline: string;
heading: string;
subheading: string;
columns: "2" | "3" | "4";
items: Array<{ title: string; body: string }>;
};
const colClass: Record<FeaturesProps["columns"], string> = {
"2": "md:grid-cols-2",
"3": "md:grid-cols-3",
"4": "md:grid-cols-2 lg:grid-cols-4",
};
function Features({ tagline, heading, subheading, columns, items }: FeaturesProps) {
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-16 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={`grid grid-cols-1 gap-x-10 gap-y-12 ${colClass[columns]}`}>
{items.map((item, i) => (
<div key={i} className="border-t border-border pt-6">
<p className="mb-3 text-xs tracking-[0.18em] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</p>
<Typography variant="h5">{item.title}</Typography>
<Typography variant="body2" className="mt-3 text-muted-foreground">
{item.body}
</Typography>
</div>
))}
</div>
</div>
</section>
);
}
import { Features, type FeaturesProps } from "@/editor/components/features/features";
export const featuresEditor: ComponentConfig<FeaturesProps> = {
label: "Features",

View File

@@ -0,0 +1,51 @@
import { Typography } from "@/editor/theme/Typography";
export type FeaturesProps = {
tagline: string;
heading: string;
subheading: string;
columns: "2" | "3" | "4";
items: Array<{ title: string; body: string }>;
};
const colClass: Record<FeaturesProps["columns"], string> = {
"2": "md:grid-cols-2",
"3": "md:grid-cols-3",
"4": "md:grid-cols-2 lg:grid-cols-4",
};
export function Features({ tagline, heading, subheading, columns, items }: FeaturesProps) {
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-16 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={`grid grid-cols-1 gap-x-10 gap-y-12 ${colClass[columns]}`}>
{items.map((item, i) => (
<div key={i} className="border-t border-border pt-6">
<p className="mb-3 text-xs tracking-[0.18em] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</p>
<Typography variant="h5">{item.title}</Typography>
<Typography variant="body2" className="mt-3 text-muted-foreground">
{item.body}
</Typography>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,133 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useState } from "react";
import { LayoutGrid } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type FooterProps = {
brand: string;
tagline: string;
columns: Array<{
title: string;
links: Array<{ label: string; href: string }>;
}>;
social: Array<{ label: string; href: string }>;
showNewsletter: "yes" | "no";
newsletterHeading: string;
newsletterEndpoint: string;
copyright: string;
};
function Footer({
brand,
tagline,
columns,
social,
showNewsletter,
newsletterHeading,
newsletterEndpoint,
copyright,
}: FooterProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (newsletterEndpoint) {
try {
await fetch(newsletterEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {}
}
setSubmitted(true);
};
return (
<footer className="border-t border-border bg-background">
<div className="container mx-auto max-w-7xl px-6 py-20 md:py-24">
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
<div className="md:col-span-4">
<Typography variant="h5" as="p">
{brand}
</Typography>
{tagline ? (
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
{tagline}
</Typography>
) : null}
{showNewsletter === "yes" ? (
<form onSubmit={submit} className="mt-8 max-w-sm">
<p className="text-sm font-medium">{newsletterHeading}</p>
{submitted ? (
<p className="mt-3 text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
>
Subscribe
</button>
</div>
)}
</form>
) : null}
</div>
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
{columns.map((col, i) => (
<div key={i}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{col.title}
</p>
<ul className="mt-4 space-y-2.5">
{col.links.map((l, j) => (
<li key={j}>
<a
href={l.href}
className="text-sm text-foreground/80 hover:text-foreground"
>
{l.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
<p className="text-xs text-muted-foreground">{copyright}</p>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{social.map((s, i) => (
<a
key={i}
href={s.href}
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
>
{s.label}
</a>
))}
</div>
</div>
</div>
</footer>
);
}
import { Footer, type FooterProps } from "@/editor/components/footer/footer";
export const footerEditor: ComponentConfig<FooterProps> = {
label: "Footer",

View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { Link } from "react-router";
import { Typography } from "@/editor/theme/Typography";
export type FooterProps = {
brand: string;
tagline: string;
columns: Array<{
title: string;
links: Array<{ label: string; href: string }>;
}>;
social: Array<{ label: string; href: string }>;
showNewsletter: "yes" | "no";
newsletterHeading: string;
newsletterEndpoint: string;
copyright: string;
};
export function Footer({
brand,
tagline,
columns,
social,
showNewsletter,
newsletterHeading,
newsletterEndpoint,
copyright,
}: FooterProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (newsletterEndpoint) {
try {
await fetch(newsletterEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {}
}
setSubmitted(true);
};
return (
<footer className="border-t border-border bg-background">
<div className="container mx-auto max-w-7xl px-6 py-20 md:py-24">
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
<div className="md:col-span-4">
<Typography variant="h5" as="p">
{brand}
</Typography>
{tagline ? (
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
{tagline}
</Typography>
) : null}
{showNewsletter === "yes" ? (
<form onSubmit={submit} className="mt-8 max-w-sm">
<p className="text-sm font-medium">{newsletterHeading}</p>
{submitted ? (
<p className="mt-3 text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
>
Subscribe
</button>
</div>
)}
</form>
) : null}
</div>
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
{columns.map((col, i) => (
<div key={i}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{col.title}
</p>
<ul className="mt-4 space-y-2.5">
{col.links.map((l, j) => (
<li key={j}>
<Link
to={l.href}
className="text-sm text-foreground/80 hover:text-foreground"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
<p className="text-xs text-muted-foreground">{copyright}</p>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{social.map((s, i) => (
<a
key={i}
href={s.href}
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
>
{s.label}
</a>
))}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,126 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutTemplate } from "lucide-react";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type HeroProps = {
tagline: string;
heading: string;
subheading: string;
primaryCta: { label: string; href: string };
secondaryCta: { label: string; href: string };
imageUrl: string;
align: "left" | "center";
height: "md" | "lg" | "full";
tone: "light" | "dark";
};
const heightClass: Record<HeroProps["height"], string> = {
md: "min-h-[60vh]",
lg: "min-h-[80vh]",
full: "min-h-screen",
};
function Hero({
tagline,
heading,
subheading,
primaryCta,
secondaryCta,
imageUrl,
align,
height,
tone,
}: HeroProps) {
const isDark = tone === "dark";
return (
<section
className={cn(
"relative flex w-full items-end overflow-hidden isolate",
heightClass[height],
)}
>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="absolute inset-0 z-0 h-full w-full object-cover"
/>
) : null}
<div
className="absolute inset-0 z-[1]"
style={{
background: isDark
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
}}
/>
<div
className={cn(
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
align === "center" ? "items-center text-center" : "items-start",
isDark ? "text-white" : "text-foreground",
)}
>
{tagline ? (
<p
className={cn(
"mb-5 text-xs uppercase tracking-[0.2em]",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{tagline}
</p>
) : null}
<Typography variant="h1" className="max-w-3xl">
{heading}
</Typography>
{subheading ? (
<Typography
variant="subtitle1"
className={cn(
"mt-6 max-w-xl",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{subheading}
</Typography>
) : null}
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{primaryCta?.label ? (
<a
href={primaryCta.href || "#"}
className={cn(
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
isDark ? "bg-white text-black" : "bg-foreground text-background",
)}
>
{primaryCta.label}
</a>
) : null}
{secondaryCta?.label ? (
<a
href={secondaryCta.href || "#"}
className={cn(
"inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "border-white text-white" : "border-foreground text-foreground",
)}
>
{secondaryCta.label}
</a>
) : null}
</div>
</div>
</section>
);
}
import { Hero, type HeroProps } from "@/editor/components/hero/hero";
export const heroEditor: ComponentConfig<HeroProps> = {
label: "Hero",

View File

@@ -0,0 +1,122 @@
import { Link } from "react-router";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type HeroProps = {
tagline: string;
heading: string;
subheading: string;
primaryCta: { label: string; href: string };
secondaryCta: { label: string; href: string };
imageUrl: string;
align: "left" | "center";
height: "md" | "lg" | "full";
tone: "light" | "dark";
};
const heightClass: Record<HeroProps["height"], string> = {
md: "min-h-[60vh]",
lg: "min-h-[80vh]",
full: "min-h-screen",
};
export function Hero({
tagline,
heading,
subheading,
primaryCta,
secondaryCta,
imageUrl,
align,
height,
tone,
}: HeroProps) {
const isDark = tone === "dark";
return (
<section
className={cn(
"relative flex w-full items-end overflow-hidden isolate",
heightClass[height],
)}
>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="absolute inset-0 z-0 h-full w-full object-cover"
/>
) : null}
<div
className="absolute inset-0 z-[1]"
style={{
background: isDark
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
}}
/>
<div
className={cn(
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
align === "center" ? "items-center text-center" : "items-start",
isDark ? "text-white" : "text-foreground",
)}
>
{tagline ? (
<p
className={cn(
"mb-5 text-xs uppercase tracking-[0.2em]",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{tagline}
</p>
) : null}
<Typography variant="h1" className="max-w-3xl">
{heading}
</Typography>
{subheading ? (
<Typography
variant="subtitle1"
className={cn(
"mt-6 max-w-xl",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{subheading}
</Typography>
) : null}
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{primaryCta?.label ? (
<Link
to={primaryCta.href || "#"}
className={cn(
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
isDark ? "bg-white text-black" : "bg-foreground text-background",
)}
>
{primaryCta.label}
</Link>
) : null}
{secondaryCta?.label ? (
<Link
to={secondaryCta.href || "#"}
className={cn(
"inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "border-white text-white" : "border-foreground text-foreground",
)}
>
{secondaryCta.label}
</Link>
) : null}
</div>
</div>
</section>
);
}

View File

@@ -1,36 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Megaphone } from "lucide-react";
import { cn } from "@/editor/lib/utils";
export type BannerProps = {
text: string;
ctaLabel: string;
ctaHref: string;
tone: "default" | "inverse" | "muted";
};
function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
const toneClass: Record<BannerProps["tone"], string> = {
default: "bg-foreground text-background",
inverse: "bg-background text-foreground border-y border-border",
muted: "bg-muted text-foreground border-y border-border",
};
return (
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
<span>{text}</span>
{ctaLabel ? (
<a
href={ctaHref || "#"}
className="underline-offset-4 hover:underline"
>
{ctaLabel}
</a>
) : null}
</div>
</div>
);
}
import { Banner, type BannerProps } from "@/editor/components/landing/banner";
export const bannerEditor: ComponentConfig<BannerProps> = {
label: "Announcement bar",

View File

@@ -0,0 +1,32 @@
import { Link } from "react-router";
import { cn } from "@/editor/lib/utils";
export type BannerProps = {
text: string;
ctaLabel: string;
ctaHref: string;
tone: "default" | "inverse" | "muted";
};
export function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
const toneClass: Record<BannerProps["tone"], string> = {
default: "bg-foreground text-background",
inverse: "bg-background text-foreground border-y border-border",
muted: "bg-muted text-foreground border-y border-border",
};
return (
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
<span>{text}</span>
{ctaLabel ? (
<Link
to={ctaHref || "#"}
className="underline-offset-4 hover:underline"
>
{ctaLabel}
</Link>
) : null}
</div>
</div>
);
}

View File

@@ -1,97 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Images } from "lucide-react";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type ImageGalleryProps = {
tagline: string;
heading: string;
subheading: string;
layout: "grid" | "masonry" | "editorial";
items: Array<{ src: string; alt: string; caption?: string }>;
};
function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
{(tagline || heading || subheading) && (
<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}
{heading ? <Typography variant="h2">{heading}</Typography> : null}
{subheading ? (
<Typography variant="subtitle1" className="mt-3">
{subheading}
</Typography>
) : null}
</div>
)}
{layout === "masonry" ? (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{items.map((it, i) => (
<figure key={i} className="mb-4 break-inside-avoid">
<img
src={it.src}
alt={it.alt}
className="w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
) : layout === "editorial" ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{items.slice(0, 5).map((it, i) => (
<figure
key={i}
className={cn(
"overflow-hidden rounded-md bg-muted",
i === 0 && "md:col-span-7 md:row-span-2",
i === 1 && "md:col-span-5",
i === 2 && "md:col-span-5",
i === 3 && "md:col-span-6",
i === 4 && "md:col-span-6",
)}
>
<img
src={it.src}
alt={it.alt}
className="h-full w-full object-cover"
/>
</figure>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it, i) => (
<figure key={i}>
<img
src={it.src}
alt={it.alt}
className="aspect-[4/5] w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
)}
</div>
</section>
);
}
import { ImageGallery, type ImageGalleryProps } from "@/editor/components/landing/image-gallery";
export const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
label: "Image gallery",

View File

@@ -0,0 +1,92 @@
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type ImageGalleryProps = {
tagline: string;
heading: string;
subheading: string;
layout: "grid" | "masonry" | "editorial";
items: Array<{ src: string; alt: string; caption?: string }>;
};
export function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
{(tagline || heading || subheading) && (
<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}
{heading ? <Typography variant="h2">{heading}</Typography> : null}
{subheading ? (
<Typography variant="subtitle1" className="mt-3">
{subheading}
</Typography>
) : null}
</div>
)}
{layout === "masonry" ? (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{items.map((it, i) => (
<figure key={i} className="mb-4 break-inside-avoid">
<img
src={it.src}
alt={it.alt}
className="w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
) : layout === "editorial" ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{items.slice(0, 5).map((it, i) => (
<figure
key={i}
className={cn(
"overflow-hidden rounded-md bg-muted",
i === 0 && "md:col-span-7 md:row-span-2",
i === 1 && "md:col-span-5",
i === 2 && "md:col-span-5",
i === 3 && "md:col-span-6",
i === 4 && "md:col-span-6",
)}
>
<img
src={it.src}
alt={it.alt}
className="h-full w-full object-cover"
/>
</figure>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it, i) => (
<figure key={i}>
<img
src={it.src}
alt={it.alt}
className="aspect-[4/5] w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -1,144 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useState } from "react";
import { Mail } from "lucide-react";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type NewsletterCtaProps = {
tagline: string;
heading: string;
subheading: string;
buttonLabel: string;
endpoint: string;
imageUrl: string;
layout: "split" | "stacked";
};
function NewsletterCta({
tagline,
heading,
subheading,
buttonLabel,
endpoint,
imageUrl,
layout,
}: NewsletterCtaProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setSubmitting(true);
try {
if (endpoint) {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
setSubmitted(true);
} catch {
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
const Form = (
<form
onSubmit={submit}
className="flex w-full max-w-md items-center border-b border-foreground/30 focus-within:border-foreground"
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-3 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
disabled={submitting}
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70 disabled:opacity-40"
>
{submitting ? "…" : buttonLabel}
</button>
</form>
);
if (layout === "split") {
return (
<section className="bg-background">
<div className="container mx-auto max-w-7xl px-6 py-16 md:py-24">
<div className="grid grid-cols-1 items-center gap-12 md:grid-cols-2">
<div>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="aspect-[5/4] w-full rounded-md object-cover"
/>
) : null}
</div>
<div className="flex flex-col items-start">
{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 max-w-md">
{subheading}
</Typography>
) : null}
<div className="mt-8 w-full">
{submitted ? (
<p className="text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
Form
)}
</div>
</div>
</div>
</div>
</section>
);
}
return (
<section className="bg-muted/40 py-20 md:py-28">
<div className="container mx-auto max-w-2xl px-6 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 className={cn("mx-auto mt-10 flex w-full max-w-md justify-center")}>
{submitted ? (
<p className="text-sm text-muted-foreground">
Thanks — we'll be in touch.
</p>
) : (
Form
)}
</div>
</div>
</section>
);
}
import { NewsletterCta, type NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
label: "Newsletter",

View File

@@ -0,0 +1,139 @@
import { useState } from "react";
import { cn } from "@/editor/lib/utils";
import { Typography } from "@/editor/theme/Typography";
export type NewsletterCtaProps = {
tagline: string;
heading: string;
subheading: string;
buttonLabel: string;
endpoint: string;
imageUrl: string;
layout: "split" | "stacked";
};
export function NewsletterCta({
tagline,
heading,
subheading,
buttonLabel,
endpoint,
imageUrl,
layout,
}: NewsletterCtaProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setSubmitting(true);
try {
if (endpoint) {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
setSubmitted(true);
} catch {
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
const Form = (
<form
onSubmit={submit}
className="flex w-full max-w-md items-center border-b border-foreground/30 focus-within:border-foreground"
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-3 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
disabled={submitting}
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70 disabled:opacity-40"
>
{submitting ? "…" : buttonLabel}
</button>
</form>
);
if (layout === "split") {
return (
<section className="bg-background">
<div className="container mx-auto max-w-7xl px-6 py-16 md:py-24">
<div className="grid grid-cols-1 items-center gap-12 md:grid-cols-2">
<div>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="aspect-[5/4] w-full rounded-md object-cover"
/>
) : null}
</div>
<div className="flex flex-col items-start">
{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 max-w-md">
{subheading}
</Typography>
) : null}
<div className="mt-8 w-full">
{submitted ? (
<p className="text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
Form
)}
</div>
</div>
</div>
</div>
</section>
);
}
return (
<section className="bg-muted/40 py-20 md:py-28">
<div className="container mx-auto max-w-2xl px-6 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 className={cn("mx-auto mt-10 flex w-full max-w-md justify-center")}>
{submitted ? (
<p className="text-sm text-muted-foreground">
Thanks — we'll be in touch.
</p>
) : (
Form
)}
</div>
</div>
</section>
);
}

View File

@@ -1,50 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Award } from "lucide-react";
export type LogosProps = {
tagline: string;
items: Array<{ src: string; alt: string }>;
layout: "row" | "marquee";
};
function Logos({ tagline, items, layout }: LogosProps) {
return (
<section className="border-y border-border bg-muted/40 py-12">
<div className="container mx-auto max-w-7xl px-6">
{tagline ? (
<p className="mb-8 text-center text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
{layout === "marquee" ? (
<div className="overflow-hidden">
<div className="flex animate-[marquee_30s_linear_infinite] gap-16 [--gap:4rem]">
{[...items, ...items].map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
{items.map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
)}
</div>
</section>
);
}
import { Logos, type LogosProps } from "@/editor/components/logos/logos";
export const logosEditor: ComponentConfig<LogosProps> = {
label: "Press / Logos",

View File

@@ -0,0 +1,44 @@
export type LogosProps = {
tagline: string;
items: Array<{ src: string; alt: string }>;
layout: "row" | "marquee";
};
export function Logos({ tagline, items, layout }: LogosProps) {
return (
<section className="border-y border-border bg-muted/40 py-12">
<div className="container mx-auto max-w-7xl px-6">
{tagline ? (
<p className="mb-8 text-center text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
{layout === "marquee" ? (
<div className="overflow-hidden">
<div className="flex animate-[marquee_30s_linear_infinite] gap-16 [--gap:4rem]">
{[...items, ...items].map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
{items.map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -1,229 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { Menu as MenuIcon, ShoppingBag, Search, User, X } from "lucide-react";
import { useState } from "react";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/editor/components/ui/sheet";
import { cn } from "@/editor/lib/utils";
export type NavigationProps = {
brand: string;
links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no";
showAccount: "yes" | "no";
showCart: "yes" | "no";
sticky: "yes" | "no";
tone: "default" | "muted" | "inverse";
};
function Navigation({
brand,
links,
showSearch,
showAccount,
showCart,
sticky,
tone,
}: NavigationProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
const cart = useShopifyCart();
const itemCount = cart?.itemCount ?? 0;
const toneClass: Record<NavigationProps["tone"], string> = {
default: "bg-background text-foreground border-b border-border",
muted: "bg-muted/40 text-foreground border-b border-border",
inverse: "bg-foreground text-background",
};
return (
<>
<header
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40 backdrop-blur",
toneClass[tone],
)}
>
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20">
<a
href="/"
className="font-semibold tracking-tight"
style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }}
>
{brand}
</a>
<nav className="hidden items-center gap-8 md:flex">
{links.map((l) => (
<a
key={l.href + l.label}
href={l.href}
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
>
{l.label}
</a>
))}
</nav>
<div className="flex items-center gap-1">
{showSearch === "yes" && (
<button
aria-label="Search"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<Search size={18} strokeWidth={1.5} />
</button>
)}
{showAccount === "yes" && (
<a
href="/account"
aria-label="Account"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<User size={18} strokeWidth={1.5} />
</a>
)}
{showCart === "yes" && (
<button
onClick={() => setCartOpen(true)}
aria-label="Cart"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5"
>
<ShoppingBag size={18} strokeWidth={1.5} />
{itemCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-medium text-background">
{itemCount}
</span>
)}
</button>
)}
<button
onClick={() => setMobileOpen(true)}
aria-label="Menu"
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:hidden"
>
<MenuIcon size={20} strokeWidth={1.5} />
</button>
</div>
</div>
</header>
{/* Mobile menu */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="w-[88vw] max-w-sm">
<SheetHeader>
<SheetTitle className="text-left">{brand}</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-1">
{links.map((l) => (
<a
key={l.href + l.label}
href={l.href}
className="rounded-md px-3 py-3 text-base hover:bg-muted"
>
{l.label}
</a>
))}
</nav>
</SheetContent>
</Sheet>
{/* Cart drawer */}
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="flex w-[92vw] max-w-md flex-col">
<SheetHeader>
<SheetTitle className="flex items-center justify-between text-left">
<span>Cart</span>
<button
onClick={() => setCartOpen(false)}
className="rounded-full p-1 hover:bg-muted"
aria-label="Close cart"
>
<X size={16} />
</button>
</SheetTitle>
</SheetHeader>
<div className="-mx-6 mt-4 flex-1 overflow-y-auto px-6">
{cart?.items?.length ? (
<ul className="divide-y divide-border">
{cart.items.map((line: any) => (
<li key={line.id} className="flex gap-4 py-4">
<div className="aspect-square h-20 flex-shrink-0 overflow-hidden rounded-md bg-muted">
{line.merchandise?.product?.images?.edges?.[0]?.node?.url ? (
<img
src={line.merchandise.product.images.edges[0].node.url}
alt={line.merchandise.product.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="flex flex-1 flex-col">
<p className="text-sm font-medium">{line.merchandise?.product?.title}</p>
{line.merchandise?.title && line.merchandise.title !== "Default Title" ? (
<p className="text-xs text-muted-foreground">{line.merchandise.title}</p>
) : null}
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<button
onClick={() => cart.updateItemQuantity(line.id, line.quantity - 1)}
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
>
</button>
<span>{line.quantity}</span>
<button
onClick={() => cart.updateItemQuantity(line.id, line.quantity + 1)}
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
>
+
</button>
</div>
<span className="text-sm">
{line.merchandise?.price
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: line.merchandise.price.currencyCode,
}).format(parseFloat(line.merchandise.price.amount) * line.quantity)
: null}
</span>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<ShoppingBag size={28} strokeWidth={1.25} />
<p>Your cart is empty.</p>
</div>
)}
</div>
{cart?.items?.length ? (
<div className="border-t border-border pt-4">
<div className="mb-4 flex items-center justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: cart.cart?.cost?.totalAmount?.currencyCode ?? "USD",
}).format(cart.totalAmount)}
</span>
</div>
<a
href={cart.checkoutUrl ?? "#"}
className="inline-flex w-full items-center justify-center rounded-full bg-foreground px-4 py-3 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Checkout
</a>
</div>
) : null}
</SheetContent>
</Sheet>
</>
);
}
import { Menu as MenuIcon } from "lucide-react";
import { Navigation, type NavigationProps } from "@/editor/components/navigation/navigation";
export const navigationEditor: ComponentConfig<NavigationProps> = {
label: "Navigation",

View File

@@ -0,0 +1,217 @@
import { Menu as MenuIcon, ShoppingBag, Search, User } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/editor/components/ui/sheet";
import { cn } from "@/editor/lib/utils";
export type NavigationProps = {
brand: string;
links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no";
showAccount: "yes" | "no";
showCart: "yes" | "no";
sticky: "yes" | "no";
tone: "default" | "muted" | "inverse";
};
export function Navigation({
brand,
links,
showSearch,
showAccount,
showCart,
sticky,
tone,
}: NavigationProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
const cart = useShopifyCart();
const itemCount = cart?.itemCount ?? 0;
const toneClass: Record<NavigationProps["tone"], string> = {
default: "bg-background text-foreground border-b border-border",
muted: "bg-muted/40 text-foreground border-b border-border",
inverse: "bg-foreground text-background",
};
return (
<>
<header
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40 backdrop-blur",
toneClass[tone],
)}
>
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20">
<Link
to="/"
className="font-semibold tracking-tight"
style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }}
>
{brand}
</Link>
<nav className="hidden items-center gap-8 md:flex">
{links.map((l) => (
<Link
key={l.href + l.label}
to={l.href}
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
>
{l.label}
</Link>
))}
</nav>
<div className="flex items-center gap-1">
{showSearch === "yes" && (
<button
aria-label="Search"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<Search size={18} strokeWidth={1.5} />
</button>
)}
{showAccount === "yes" && (
<Link
to="/account"
aria-label="Account"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<User size={18} strokeWidth={1.5} />
</Link>
)}
{showCart === "yes" && (
<button
onClick={() => setCartOpen(true)}
aria-label="Cart"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5"
>
<ShoppingBag size={18} strokeWidth={1.5} />
{itemCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-medium text-background">
{itemCount}
</span>
)}
</button>
)}
<button
onClick={() => setMobileOpen(true)}
aria-label="Menu"
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:hidden"
>
<MenuIcon size={20} strokeWidth={1.5} />
</button>
</div>
</div>
</header>
{/* Mobile menu */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="w-[88vw] max-w-sm">
<SheetHeader>
<SheetTitle className="text-left">{brand}</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-1">
{links.map((l) => (
<Link
key={l.href + l.label}
to={l.href}
className="rounded-md px-3 py-3 text-base hover:bg-muted"
>
{l.label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
{/* Cart drawer */}
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="flex w-[92vw] max-w-md flex-col">
<SheetHeader>
<SheetTitle className="text-left">Cart</SheetTitle>
</SheetHeader>
<div className="-mx-6 mt-4 flex-1 overflow-y-auto px-6">
{cart?.items?.length ? (
<ul className="divide-y divide-border">
{cart.items.map((line: any) => (
<li key={line.id} className="flex gap-4 py-4">
<div className="aspect-square h-20 flex-shrink-0 overflow-hidden rounded-md bg-muted">
{line.merchandise?.product?.images?.edges?.[0]?.node?.url ? (
<img
src={line.merchandise.product.images.edges[0].node.url}
alt={line.merchandise.product.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="flex flex-1 flex-col">
<p className="text-sm font-medium">{line.merchandise?.product?.title}</p>
{line.merchandise?.title && line.merchandise.title !== "Default Title" ? (
<p className="text-xs text-muted-foreground">{line.merchandise.title}</p>
) : null}
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<button
onClick={() => cart.updateItemQuantity(line.id, line.quantity - 1)}
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
>
</button>
<span>{line.quantity}</span>
<button
onClick={() => cart.updateItemQuantity(line.id, line.quantity + 1)}
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
>
+
</button>
</div>
<span className="text-sm">
{line.merchandise?.price
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: line.merchandise.price.currencyCode,
}).format(parseFloat(line.merchandise.price.amount) * line.quantity)
: null}
</span>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<ShoppingBag size={28} strokeWidth={1.25} />
<p>Your cart is empty.</p>
</div>
)}
</div>
{cart?.items?.length ? (
<div className="border-t border-border pt-4">
<div className="mb-4 flex items-center justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: cart.cart?.cost?.totalAmount?.currencyCode ?? "USD",
}).format(cart.totalAmount)}
</span>
</div>
<a
href={cart.checkoutUrl ?? "#"}
className="inline-flex w-full items-center justify-center rounded-full bg-foreground px-4 py-3 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Checkout
</a>
</div>
) : null}
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -1,90 +1,6 @@
import { ComponentConfig } from "@reacteditor/core";
import { useState } from "react";
import { Quote, ArrowLeft, ArrowRight } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type TestimonialsProps = {
tagline: string;
heading: string;
items: Array<{
quote: string;
author: string;
role: string;
avatar?: string;
}>;
};
function Testimonials({ tagline, heading, items }: TestimonialsProps) {
const [i, setI] = useState(0);
const total = items.length;
const item = items[i];
return (
<section className="bg-muted/40 py-20 md:py-28">
<div className="container mx-auto max-w-4xl px-6 text-center">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
{heading ? <Typography variant="h2">{heading}</Typography> : null}
{item ? (
<figure className="mx-auto mt-12 flex max-w-2xl flex-col items-center">
<Quote
size={28}
strokeWidth={1.25}
className="mb-6 text-muted-foreground"
/>
<blockquote
className="text-balance text-foreground"
style={{ fontSize: "clamp(1.25rem, 2.4vw, 1.75rem)", lineHeight: 1.4 }}
>
"{item.quote}"
</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
{item.avatar ? (
<img
src={item.avatar}
alt={item.author}
className="h-10 w-10 rounded-full object-cover"
/>
) : null}
<div className="text-left">
<p className="text-sm font-medium">{item.author}</p>
{item.role ? (
<p className="text-xs text-muted-foreground">{item.role}</p>
) : null}
</div>
</figcaption>
</figure>
) : null}
{total > 1 ? (
<div className="mt-10 flex items-center justify-center gap-3">
<button
onClick={() => setI((p) => (p - 1 + total) % total)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
aria-label="Previous"
>
<ArrowLeft size={16} />
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{i + 1} / {total}
</span>
<button
onClick={() => setI((p) => (p + 1) % total)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
aria-label="Next"
>
<ArrowRight size={16} />
</button>
</div>
) : null}
</div>
</section>
);
}
import { Quote } from "lucide-react";
import { Testimonials, type TestimonialsProps } from "@/editor/components/testimonials/testimonials";
export const testimonialsEditor: ComponentConfig<TestimonialsProps> = {
label: "Testimonials",

View File

@@ -0,0 +1,86 @@
import { useState } from "react";
import { Quote, ArrowLeft, ArrowRight } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type TestimonialsProps = {
tagline: string;
heading: string;
items: Array<{
quote: string;
author: string;
role: string;
avatar?: string;
}>;
};
export function Testimonials({ tagline, heading, items }: TestimonialsProps) {
const [i, setI] = useState(0);
const total = items.length;
const item = items[i];
return (
<section className="bg-muted/40 py-20 md:py-28">
<div className="container mx-auto max-w-4xl px-6 text-center">
{tagline ? (
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{tagline}
</p>
) : null}
{heading ? <Typography variant="h2">{heading}</Typography> : null}
{item ? (
<figure className="mx-auto mt-12 flex max-w-2xl flex-col items-center">
<Quote
size={28}
strokeWidth={1.25}
className="mb-6 text-muted-foreground"
/>
<blockquote
className="text-balance text-foreground"
style={{ fontSize: "clamp(1.25rem, 2.4vw, 1.75rem)", lineHeight: 1.4 }}
>
"{item.quote}"
</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
{item.avatar ? (
<img
src={item.avatar}
alt={item.author}
className="h-10 w-10 rounded-full object-cover"
/>
) : null}
<div className="text-left">
<p className="text-sm font-medium">{item.author}</p>
{item.role ? (
<p className="text-xs text-muted-foreground">{item.role}</p>
) : null}
</div>
</figcaption>
</figure>
) : null}
{total > 1 ? (
<div className="mt-10 flex items-center justify-center gap-3">
<button
onClick={() => setI((p) => (p - 1 + total) % total)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
aria-label="Previous"
>
<ArrowLeft size={16} />
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{i + 1} / {total}
</span>
<button
onClick={() => setI((p) => (p + 1) % total)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
aria-label="Next"
>
<ArrowRight size={16} />
</button>
</div>
) : null}
</div>
</section>
);
}

View File

@@ -1,25 +1,25 @@
import { Config, Data } from "@reacteditor/core";
import { HeroProps } from "@/editor/components/hero/hero.editor";
import { LogosProps } from "@/editor/components/logos/logos.editor";
import { FeaturesProps } from "@/editor/components/features/features.editor";
import { TestimonialsProps } from "@/editor/components/testimonials/testimonials.editor";
import { CTAProps } from "@/editor/components/cta/cta.editor";
import { FAQProps } from "@/editor/components/faq/faq.editor";
import { NavigationProps } from "@/editor/components/navigation/navigation.editor";
import { FooterProps } from "@/editor/components/footer/footer.editor";
import { HeroProps } from "@/editor/components/hero/hero";
import { LogosProps } from "@/editor/components/logos/logos";
import { FeaturesProps } from "@/editor/components/features/features";
import { TestimonialsProps } from "@/editor/components/testimonials/testimonials";
import { CTAProps } from "@/editor/components/cta/cta";
import { FAQProps } from "@/editor/components/faq/faq";
import { NavigationProps } from "@/editor/components/navigation/navigation";
import { FooterProps } from "@/editor/components/footer/footer";
import { ProductsGridProps } from "@/editor/components/commerce/products-grid.editor";
import { ProductsCarouselProps } from "@/editor/components/commerce/products-carousel.editor";
import { CollectionGridProps } from "@/editor/components/commerce/collection-grid.editor";
import { CollectionProps } from "@/editor/components/commerce/collection.editor";
import { ProductDetailsProps } from "@/editor/components/commerce/product-details.editor";
import { RecommendedProductsProps } from "@/editor/components/commerce/recommended-products.editor";
import { FeaturedProductProps } from "@/editor/components/commerce/featured-product.editor";
import { ProductsGridProps } from "@/editor/components/commerce/products-grid";
import { ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
import { CollectionGridProps } from "@/editor/components/commerce/collection-grid";
import { CollectionProps } from "@/editor/components/commerce/collection";
import { ProductDetailsProps } from "@/editor/components/commerce/product-details";
import { RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
import { FeaturedProductProps } from "@/editor/components/commerce/featured-product";
import { BannerProps } from "@/editor/components/landing/banner.editor";
import { NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta.editor";
import { ImageGalleryProps } from "@/editor/components/landing/image-gallery.editor";
import { BannerProps } from "@/editor/components/landing/banner";
import { NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
import { ImageGalleryProps } from "@/editor/components/landing/image-gallery";
import { RootProps } from "./root";

View File

@@ -1,15 +1,34 @@
const resolveEditorPath = (editorPath: string[] = []) => {
const hasPath = editorPath.length > 0;
const TEMPLATE_PATTERNS: { key: string; prefix: string; param: string }[] = [
{ key: "/products/*", prefix: "/products/", param: "handle" },
{ key: "/collections/*", prefix: "/collections/", param: "handle" },
];
const isEdit = hasPath ? editorPath[editorPath.length - 1] === "edit" : false;
export type ResolvedEditorPath = {
isEdit: boolean;
path: string;
templateKey: string | null;
params: Record<string, string>;
};
const resolveEditorPath = (editorPath: string[] = []): ResolvedEditorPath => {
const isEdit =
editorPath.length > 0 && editorPath[editorPath.length - 1] === "edit";
const segments = isEdit ? editorPath.slice(0, -1) : editorPath;
const path = segments.length === 0 ? "/" : `/${segments.join("/")}`;
for (const { key, prefix, param } of TEMPLATE_PATTERNS) {
if (path.startsWith(prefix) && path.length > prefix.length) {
return {
isEdit,
path: `/${(isEdit
? [...editorPath].slice(0, editorPath.length - 1)
: [...editorPath]
).join("/")}`,
path,
templateKey: key,
params: { [param]: path.slice(prefix.length) },
};
}
}
return { isEdit, path, templateKey: null, params: {} };
};
export default resolveEditorPath;

View File

@@ -24,7 +24,7 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@reacteditor/core": "^0.0.8",
"@reacteditor/core": "0.0.10",
"@reacteditor/field-google-fonts": "^0.0.1",
"@reacteditor/field-shopify": "^0.0.1",
"@reacteditor/plugin-ai": "^0.0.1",
@@ -40,6 +40,7 @@
"next": "^16.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",

File diff suppressed because one or more lines are too long

View File

@@ -1203,10 +1203,10 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
"@reacteditor/core@^0.0.8":
version "0.0.8"
resolved "https://registry.yarnpkg.com/@reacteditor/core/-/core-0.0.8.tgz#3eaf162297c1892d7913d31f3930f81f7c547caa"
integrity sha512-LvoM7xrNXb3TAuKSOc0Z+Y2g8tQcKoQ9n/KXDrGjXRZpTsifAjZ3xMAXuZIpOAU6Wpcn/rUW/rxE+10QvaZU7w==
"@reacteditor/core@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@reacteditor/core/-/core-0.0.10.tgz#7396295389f8f97d26e700a8dec9a8f56b9add31"
integrity sha512-/GZy27jN9rZZYy5YiapmCo1cr3F4tjiWLTsbsgFj22MG0ZIp6xN7qlB8Yhzfkp3UzJRPTuggXq/l5T5I2lgq9Q==
dependencies:
"@base-ui/react" "^1.4.1"
"@dnd-kit/abstract" "0.4.0"
@@ -2234,6 +2234,11 @@ convert-source-map@^2.0.0:
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
cookie@^1.0.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
@@ -4532,6 +4537,14 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-router@^7.0.0:
version "7.14.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.14.2.tgz#d86e5b01049365b2c982363ebd2baa4928824603"
integrity sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
@@ -4700,6 +4713,11 @@ semver@^7.7.1, semver@^7.7.3:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
set-cookie-parser@^2.6.0:
version "2.7.2"
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"