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": {
|
"root": {
|
||||||
"props": {
|
"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 { readSchema } from "@/lib/schema.server";
|
||||||
import { resolveSchemaEntry } from "@/lib/schema-resolver";
|
import AppClient from "@/components/AppClient";
|
||||||
import RenderClient from "@/components/RenderClient";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,16 +10,12 @@ export default async function Page({
|
|||||||
}) {
|
}) {
|
||||||
const { path } = await params;
|
const { path } = await params;
|
||||||
const segments = (path ?? []).filter(Boolean);
|
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.
|
return (
|
||||||
if (segments[0] === "edit") return notFound();
|
<div style={{ height: "100vh", width: "100vw" }}>
|
||||||
|
<AppClient pages={pages} currentPath={currentPath} />
|
||||||
const route = "/" + segments.join("/");
|
</div>
|
||||||
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} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
currentPath={currentPath}
|
||||||
onRouteChange={(next) => {
|
onRouteChange={(next) => {
|
||||||
setCurrentPath(next);
|
setCurrentPath(next);
|
||||||
router.push(`/edit${next === "/" ? "" : next}`);
|
router.push(next === "/" ? "/edit" : `${next}/edit`);
|
||||||
}}
|
}}
|
||||||
onPublish={async (next) => {
|
onPublish={async (next) => {
|
||||||
await persist(editKey, next);
|
await persist(editKey, next);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetBody,
|
SheetBody,
|
||||||
AnimatePresence,
|
SheetFooter,
|
||||||
} from '@/editor/components/ui/sheet';
|
} from '@/editor/components/ui/sheet';
|
||||||
|
|
||||||
const CartDrawer: React.FC = () => {
|
const CartDrawer: React.FC = () => {
|
||||||
@@ -32,184 +32,171 @@ const CartDrawer: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()} side="right">
|
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()} side="right">
|
||||||
<AnimatePresence>
|
<SheetContent className="w-full sm:max-w-md">
|
||||||
{isOpen && (
|
{/* Header */}
|
||||||
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
<SheetHeader>
|
||||||
{/* Header */}
|
<SheetTitle className="text-base">
|
||||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
|
Shopping Cart ({itemCount})
|
||||||
<div className="flex items-center justify-between w-full">
|
</SheetTitle>
|
||||||
<SheetTitle className="text-base">
|
</SheetHeader>
|
||||||
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 */}
|
{/* Cart Items */}
|
||||||
<SheetBody>
|
<SheetBody>
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<i className="ri-shopping-cart-line text-6xl text-gray-300 mb-4 block"></i>
|
<i className="ri-shopping-cart-line text-6xl text-gray-300 mb-4 block"></i>
|
||||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">Your cart is empty</h3>
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">Your cart is empty</h3>
|
||||||
<p className="text-gray-500 mb-6">Add some products to get started!</p>
|
<p className="text-gray-500 mb-6">Add some products to get started!</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
className="font-heading"
|
className="font-heading"
|
||||||
>
|
>
|
||||||
Continue Shopping
|
Continue Shopping
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const image = getItemImage(item);
|
const image = getItemImage(item);
|
||||||
const selectedOptions = getSelectedOptions(item);
|
const selectedOptions = getSelectedOptions(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0">
|
<div key={item.id} className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0">
|
||||||
{/* Product Image */}
|
{/* Product Image */}
|
||||||
<div className="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
|
<div className="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
alt={item.merchandise.product.title}
|
alt={item.merchandise.product.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
<i className="ri-image-line text-2xl"></i>
|
<i className="ri-image-line text-2xl"></i>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Product Details */}
|
{/* Product Details */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2">
|
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2">
|
||||||
{item.merchandise.product.title}
|
{item.merchandise.product.title}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Variant Info */}
|
{/* Variant Info */}
|
||||||
{selectedOptions.length > 0 && (
|
{selectedOptions.length > 0 && (
|
||||||
<div className="text-sm text-gray-500 mb-2">
|
<div className="text-sm text-gray-500 mb-2">
|
||||||
{selectedOptions.map((option, index) => (
|
{selectedOptions.map((option, index) => (
|
||||||
<span key={option.name}>
|
<span key={option.name}>
|
||||||
{option.value}
|
{option.value}
|
||||||
{index < selectedOptions.length - 1 ? ' / ' : ''}
|
{index < selectedOptions.length - 1 ? ' / ' : ''}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quantity Controls */}
|
|
||||||
<div className="flex items-center mt-3">
|
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
|
||||||
<Button
|
|
||||||
onClick={() => updateItemQuantity(item.id, item.quantity - 1)}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={item.quantity <= 1 || loading}
|
|
||||||
className="h-7 w-7"
|
|
||||||
>
|
|
||||||
<i className="ri-subtract-line text-sm"></i>
|
|
||||||
</Button>
|
|
||||||
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
|
||||||
{item.quantity}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateItemQuantity(item.id, item.quantity + 1)}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={loading}
|
|
||||||
className="h-7 w-7"
|
|
||||||
>
|
|
||||||
<i className="ri-add-line text-sm"></i>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Price */}
|
{/* Quantity Controls */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-center mt-3">
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||||
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remove Button */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => updateItemQuantity(item.id, item.quantity - 1)}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={item.quantity <= 1 || loading}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<i className="ri-subtract-line text-sm"></i>
|
||||||
|
</Button>
|
||||||
|
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateItemQuantity(item.id, item.quantity + 1)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="text-gray-400 hover:text-red-500"
|
className="h-7 w-7"
|
||||||
>
|
>
|
||||||
<i className="ri-close-line text-lg font-bold"></i>
|
<i className="ri-add-line text-sm"></i>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SheetBody>
|
|
||||||
|
|
||||||
{/* Footer - Checkout Section */}
|
{/* Price */}
|
||||||
{items.length > 0 && (
|
<div className="flex-shrink-0">
|
||||||
<div className="border-t border-border p-6">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{/* Subtotal */}
|
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<span className="text-base font-semibold">Subtotal</span>
|
|
||||||
<span className="text-lg font-bold">
|
|
||||||
${totalAmount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 mb-4">
|
|
||||||
Shipping and taxes calculated at checkout
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button
|
|
||||||
onClick={handleCheckout}
|
|
||||||
disabled={loading || !checkoutUrl}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span className="flex items-center justify-center space-x-2">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span>Processing...</span>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</div>
|
||||||
'Checkout'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{/* Remove Button */}
|
||||||
onClick={closeCart}
|
<div className="flex-shrink-0">
|
||||||
variant="link"
|
<Button
|
||||||
className="w-full"
|
onClick={() => removeItem(item.id)}
|
||||||
>
|
variant="ghost"
|
||||||
Continue Shopping
|
size="icon-sm"
|
||||||
</Button>
|
disabled={loading}
|
||||||
</div>
|
className="text-gray-400 hover:text-red-500"
|
||||||
</div>
|
>
|
||||||
)}
|
<i className="ri-delete-bin-line text-lg"></i>
|
||||||
</SheetContent>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetBody>
|
||||||
|
|
||||||
|
{/* Footer - Checkout Section */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<SheetFooter className="flex-col sm:flex-col sm:justify-start gap-0">
|
||||||
|
{/* Subtotal */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-base font-semibold">Subtotal</span>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
${totalAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500 mb-4">
|
||||||
|
Shipping and taxes calculated at checkout
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleCheckout}
|
||||||
|
disabled={loading || !checkoutUrl}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center space-x-2">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>Processing...</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Checkout'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={closeCart}
|
||||||
|
variant="link"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
// local link - plain anchor
|
import { Link } from 'react-router';
|
||||||
import { Card, CardContent } from '@/editor/components/ui/card';
|
import { Card, CardContent } from '@/editor/components/ui/card';
|
||||||
|
|
||||||
interface CollectionImage {
|
interface CollectionImage {
|
||||||
@@ -21,7 +21,7 @@ interface CollectionCardProps {
|
|||||||
|
|
||||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||||
return (
|
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">
|
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
||||||
{/* Collection Image */}
|
{/* Collection Image */}
|
||||||
<div className="aspect-video overflow-hidden bg-gray-100">
|
<div className="aspect-video overflow-hidden bg-gray-100">
|
||||||
@@ -57,7 +57,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +1,6 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
import { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { shopifyFetch } from "@/editor/services/shopify/client";
|
import { CollectionGrid, type CollectionGridProps } from "@/editor/components/commerce/collection-grid";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
|
export const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
|
||||||
label: "Collections",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
import { CollectionView, type CollectionProps } from "@/editor/components/commerce/collection";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCollectionEditor(opts: {
|
export function createCollectionEditor(opts: {
|
||||||
collectionField: any;
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Star } from "lucide-react";
|
import { Star } from "lucide-react";
|
||||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
import { FeaturedProductView, type FeaturedProductProps } from "@/editor/components/commerce/featured-product";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFeaturedProductEditor(opts: {
|
export function createFeaturedProductEditor(opts: {
|
||||||
productField: any;
|
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 * as React from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
type ProductImage = { url: string; altText?: string };
|
type ProductImage = { url: string; altText?: string };
|
||||||
type ProductPrice = { amount: string; currencyCode: string };
|
type ProductPrice = { amount: string; currencyCode: string };
|
||||||
@@ -39,7 +40,7 @@ export function ProductCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={`/products/${product.handle}`} className="group block">
|
<Link to={`/products/${product.handle}`} className="group block">
|
||||||
<div
|
<div
|
||||||
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
||||||
>
|
>
|
||||||
@@ -64,7 +65,7 @@ export function ProductCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { useProduct, type Product } from '@/editor/hooks/use-shopify-products';
|
||||||
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
||||||
import ProductDetailGallery from './product-detail-gallery';
|
import ProductDetailGallery from './product-detail-gallery';
|
||||||
@@ -165,13 +165,13 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
|||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<a href="/">Home</a>
|
<Link to="/">Home</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink asChild>
|
<BreadcrumbLink asChild>
|
||||||
<a href="/shop">Shop</a>
|
<Link to="/shop">Shop</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
|
|||||||
@@ -1,210 +1,6 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
import { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
import { ProductDetailsView, type ProductDetailsProps } from "@/editor/components/commerce/product-details";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProductDetailsEditor(opts: {
|
export function createProductDetailsEditor(opts: {
|
||||||
productField: any;
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { GalleryHorizontalEnd } from "lucide-react";
|
import { GalleryHorizontalEnd } from "lucide-react";
|
||||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
import { ProductsCarousel, type ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProductsCarouselEditor(opts: {
|
export function createProductsCarouselEditor(opts: {
|
||||||
collectionField: any;
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
import { ProductsGrid, type ProductsGridProps } from "@/editor/components/commerce/products-grid";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProductsGridEditor(opts: {
|
export function createProductsGridEditor(opts: {
|
||||||
collectionField: any;
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
import { RecommendedProductsView, type RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRecommendedProductsEditor(opts: {
|
export function createRecommendedProductsEditor(opts: {
|
||||||
productField: any;
|
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';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
// local link - plain anchor
|
import { Link } from 'react-router';
|
||||||
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
||||||
import config from '@/editor/lib/config.json';
|
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="container mx-auto px-4 h-full">
|
||||||
<div className="flex justify-between items-center h-full">
|
<div className="flex justify-between items-center h-full">
|
||||||
{/* Logo */}
|
{/* 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 ? (
|
{config.brand.logo.url ? (
|
||||||
<img
|
<img
|
||||||
src={config.brand.logo.url}
|
src={config.brand.logo.url}
|
||||||
@@ -39,22 +39,22 @@ const Header: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
'Store'
|
'Store'
|
||||||
)}
|
)}
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
<a
|
<Link
|
||||||
href="/"
|
to="/"
|
||||||
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Products
|
Products
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href="/collections"
|
to="/collections"
|
||||||
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Collections
|
Collections
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
{/* Cart Icon */}
|
{/* Cart Icon */}
|
||||||
<CartIcon />
|
<CartIcon />
|
||||||
|
|||||||
@@ -1,87 +1,6 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
import { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { cn } from "@/editor/lib/utils";
|
import { CTA, type CTAProps } from "@/editor/components/cta/cta";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ctaEditor: ComponentConfig<CTAProps> = {
|
export const ctaEditor: ComponentConfig<CTAProps> = {
|
||||||
label: "Call to action",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useState } from "react";
|
import { HelpCircle } from "lucide-react";
|
||||||
import { HelpCircle, Plus, Minus } from "lucide-react";
|
import { FAQ, type FAQProps } from "@/editor/components/faq/faq";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const faqEditor: ComponentConfig<FAQProps> = {
|
export const faqEditor: ComponentConfig<FAQProps> = {
|
||||||
label: "FAQ",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { Typography } from "@/editor/theme/Typography";
|
import { Features, type FeaturesProps } from "@/editor/components/features/features";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const featuresEditor: ComponentConfig<FeaturesProps> = {
|
export const featuresEditor: ComponentConfig<FeaturesProps> = {
|
||||||
label: "Features",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useState } from "react";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
import { Typography } from "@/editor/theme/Typography";
|
import { Footer, type FooterProps } from "@/editor/components/footer/footer";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const footerEditor: ComponentConfig<FooterProps> = {
|
export const footerEditor: ComponentConfig<FooterProps> = {
|
||||||
label: "Footer",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { LayoutTemplate } from "lucide-react";
|
import { LayoutTemplate } from "lucide-react";
|
||||||
import { cn } from "@/editor/lib/utils";
|
import { Hero, type HeroProps } from "@/editor/components/hero/hero";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const heroEditor: ComponentConfig<HeroProps> = {
|
export const heroEditor: ComponentConfig<HeroProps> = {
|
||||||
label: "Hero",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { cn } from "@/editor/lib/utils";
|
import { Banner, type BannerProps } from "@/editor/components/landing/banner";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bannerEditor: ComponentConfig<BannerProps> = {
|
export const bannerEditor: ComponentConfig<BannerProps> = {
|
||||||
label: "Announcement bar",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Images } from "lucide-react";
|
import { Images } from "lucide-react";
|
||||||
import { cn } from "@/editor/lib/utils";
|
import { ImageGallery, type ImageGalleryProps } from "@/editor/components/landing/image-gallery";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
|
export const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
|
||||||
label: "Image gallery",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useState } from "react";
|
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { cn } from "@/editor/lib/utils";
|
import { NewsletterCta, type NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
|
export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
|
||||||
label: "Newsletter",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Award } from "lucide-react";
|
import { Award } from "lucide-react";
|
||||||
|
import { Logos, type LogosProps } from "@/editor/components/logos/logos";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const logosEditor: ComponentConfig<LogosProps> = {
|
export const logosEditor: ComponentConfig<LogosProps> = {
|
||||||
label: "Press / Logos",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { Menu as MenuIcon, ShoppingBag, Search, User, X } from "lucide-react";
|
import { Menu as MenuIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { Navigation, type NavigationProps } from "@/editor/components/navigation/navigation";
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const navigationEditor: ComponentConfig<NavigationProps> = {
|
export const navigationEditor: ComponentConfig<NavigationProps> = {
|
||||||
label: "Navigation",
|
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 { ComponentConfig } from "@reacteditor/core";
|
||||||
import { useState } from "react";
|
import { Quote } from "lucide-react";
|
||||||
import { Quote, ArrowLeft, ArrowRight } from "lucide-react";
|
import { Testimonials, type TestimonialsProps } from "@/editor/components/testimonials/testimonials";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testimonialsEditor: ComponentConfig<TestimonialsProps> = {
|
export const testimonialsEditor: ComponentConfig<TestimonialsProps> = {
|
||||||
label: "Testimonials",
|
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 { Config, Data } from "@reacteditor/core";
|
||||||
|
|
||||||
import { HeroProps } from "@/editor/components/hero/hero.editor";
|
import { HeroProps } from "@/editor/components/hero/hero";
|
||||||
import { LogosProps } from "@/editor/components/logos/logos.editor";
|
import { LogosProps } from "@/editor/components/logos/logos";
|
||||||
import { FeaturesProps } from "@/editor/components/features/features.editor";
|
import { FeaturesProps } from "@/editor/components/features/features";
|
||||||
import { TestimonialsProps } from "@/editor/components/testimonials/testimonials.editor";
|
import { TestimonialsProps } from "@/editor/components/testimonials/testimonials";
|
||||||
import { CTAProps } from "@/editor/components/cta/cta.editor";
|
import { CTAProps } from "@/editor/components/cta/cta";
|
||||||
import { FAQProps } from "@/editor/components/faq/faq.editor";
|
import { FAQProps } from "@/editor/components/faq/faq";
|
||||||
import { NavigationProps } from "@/editor/components/navigation/navigation.editor";
|
import { NavigationProps } from "@/editor/components/navigation/navigation";
|
||||||
import { FooterProps } from "@/editor/components/footer/footer.editor";
|
import { FooterProps } from "@/editor/components/footer/footer";
|
||||||
|
|
||||||
import { ProductsGridProps } from "@/editor/components/commerce/products-grid.editor";
|
import { ProductsGridProps } from "@/editor/components/commerce/products-grid";
|
||||||
import { ProductsCarouselProps } from "@/editor/components/commerce/products-carousel.editor";
|
import { ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
|
||||||
import { CollectionGridProps } from "@/editor/components/commerce/collection-grid.editor";
|
import { CollectionGridProps } from "@/editor/components/commerce/collection-grid";
|
||||||
import { CollectionProps } from "@/editor/components/commerce/collection.editor";
|
import { CollectionProps } from "@/editor/components/commerce/collection";
|
||||||
import { ProductDetailsProps } from "@/editor/components/commerce/product-details.editor";
|
import { ProductDetailsProps } from "@/editor/components/commerce/product-details";
|
||||||
import { RecommendedProductsProps } from "@/editor/components/commerce/recommended-products.editor";
|
import { RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
|
||||||
import { FeaturedProductProps } from "@/editor/components/commerce/featured-product.editor";
|
import { FeaturedProductProps } from "@/editor/components/commerce/featured-product";
|
||||||
|
|
||||||
import { BannerProps } from "@/editor/components/landing/banner.editor";
|
import { BannerProps } from "@/editor/components/landing/banner";
|
||||||
import { NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta.editor";
|
import { NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
|
||||||
import { ImageGalleryProps } from "@/editor/components/landing/image-gallery.editor";
|
import { ImageGalleryProps } from "@/editor/components/landing/image-gallery";
|
||||||
|
|
||||||
import { RootProps } from "./root";
|
import { RootProps } from "./root";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
const resolveEditorPath = (editorPath: string[] = []) => {
|
const TEMPLATE_PATTERNS: { key: string; prefix: string; param: string }[] = [
|
||||||
const hasPath = editorPath.length > 0;
|
{ 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>;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
const resolveEditorPath = (editorPath: string[] = []): ResolvedEditorPath => {
|
||||||
isEdit,
|
const isEdit =
|
||||||
path: `/${(isEdit
|
editorPath.length > 0 && editorPath[editorPath.length - 1] === "edit";
|
||||||
? [...editorPath].slice(0, editorPath.length - 1)
|
|
||||||
: [...editorPath]
|
const segments = isEdit ? editorPath.slice(0, -1) : editorPath;
|
||||||
).join("/")}`,
|
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,
|
||||||
|
templateKey: key,
|
||||||
|
params: { [param]: path.slice(prefix.length) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isEdit, path, templateKey: null, params: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default resolveEditorPath;
|
export default resolveEditorPath;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@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-google-fonts": "^0.0.1",
|
||||||
"@reacteditor/field-shopify": "^0.0.1",
|
"@reacteditor/field-shopify": "^0.0.1",
|
||||||
"@reacteditor/plugin-ai": "^0.0.1",
|
"@reacteditor/plugin-ai": "^0.0.1",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router": "^7.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"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"
|
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
|
||||||
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
|
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
|
||||||
|
|
||||||
"@reacteditor/core@^0.0.8":
|
"@reacteditor/core@0.0.10":
|
||||||
version "0.0.8"
|
version "0.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/@reacteditor/core/-/core-0.0.8.tgz#3eaf162297c1892d7913d31f3930f81f7c547caa"
|
resolved "https://registry.yarnpkg.com/@reacteditor/core/-/core-0.0.10.tgz#7396295389f8f97d26e700a8dec9a8f56b9add31"
|
||||||
integrity sha512-LvoM7xrNXb3TAuKSOc0Z+Y2g8tQcKoQ9n/KXDrGjXRZpTsifAjZ3xMAXuZIpOAU6Wpcn/rUW/rxE+10QvaZU7w==
|
integrity sha512-/GZy27jN9rZZYy5YiapmCo1cr3F4tjiWLTsbsgFj22MG0ZIp6xN7qlB8Yhzfkp3UzJRPTuggXq/l5T5I2lgq9Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@base-ui/react" "^1.4.1"
|
"@base-ui/react" "^1.4.1"
|
||||||
"@dnd-kit/abstract" "0.4.0"
|
"@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"
|
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||||
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
|
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:
|
cross-spawn@^7.0.6:
|
||||||
version "7.0.6"
|
version "7.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
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-callback-ref "^1.3.3"
|
||||||
use-sidecar "^1.1.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:
|
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||||
version "2.2.3"
|
version "2.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
|
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"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
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:
|
set-function-length@^1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||||
|
|||||||
Reference in New Issue
Block a user