Update to use react-editor <App /> component
This commit is contained in:
176
app.schema.json
176
app.schema.json
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
78
components/AppClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
116
editor/components/commerce/collection-grid.tsx
Normal file
116
editor/components/commerce/collection-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
69
editor/components/commerce/collection.tsx
Normal file
69
editor/components/commerce/collection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
117
editor/components/commerce/featured-product.tsx
Normal file
117
editor/components/commerce/featured-product.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
214
editor/components/commerce/product-details.tsx
Normal file
214
editor/components/commerce/product-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
126
editor/components/commerce/products-carousel.tsx
Normal file
126
editor/components/commerce/products-carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
100
editor/components/commerce/products-grid.tsx
Normal file
100
editor/components/commerce/products-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
63
editor/components/commerce/recommended-products.tsx
Normal file
63
editor/components/commerce/recommended-products.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
83
editor/components/cta/cta.tsx
Normal file
83
editor/components/cta/cta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
61
editor/components/faq/faq.tsx
Normal file
61
editor/components/faq/faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
51
editor/components/features/features.tsx
Normal file
51
editor/components/features/features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
129
editor/components/footer/footer.tsx
Normal file
129
editor/components/footer/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
122
editor/components/hero/hero.tsx
Normal file
122
editor/components/hero/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
32
editor/components/landing/banner.tsx
Normal file
32
editor/components/landing/banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
92
editor/components/landing/image-gallery.tsx
Normal file
92
editor/components/landing/image-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
139
editor/components/landing/newsletter-cta.tsx
Normal file
139
editor/components/landing/newsletter-cta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
44
editor/components/logos/logos.tsx
Normal file
44
editor/components/logos/logos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
217
editor/components/navigation/navigation.tsx
Normal file
217
editor/components/navigation/navigation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
86
editor/components/testimonials/testimonials.tsx
Normal file
86
editor/components/testimonials/testimonials.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
26
yarn.lock
26
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user