From 7348e430c05ba2556b159ae8d5e13d3e07dcb25a Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sat, 2 May 2026 09:18:15 -0400 Subject: [PATCH] Initial commit --- .env.local.example | 6 + .gitignore | 8 + README.md | 32 + app.schema.json | 189 + app/[[...path]]/page.tsx | 26 + app/api/chat/route.ts | 244 + app/api/save-schema/route.ts | 21 + app/edit/[[...path]]/page.tsx | 21 + app/globals.css | 81 + app/layout.tsx | 22 + components/EditorClient.tsx | 138 + components/RenderClient.tsx | 35 + editor/components/commerce/cart-drawer.tsx | 217 + .../components/commerce/collection-card.tsx | 64 + .../components/commerce/collection-detail.tsx | 99 + .../commerce/collection-grid.editor.tsx | 145 + .../components/commerce/collection.editor.tsx | 95 + editor/components/commerce/collections.tsx | 97 + .../commerce/featured-product.editor.tsx | 158 + editor/components/commerce/product-card.tsx | 71 + editor/components/commerce/product-detail.tsx | 3 + .../commerce/product-detail/index.tsx | 207 + .../product-detail/product-detail-gallery.tsx | 66 + .../product-detail/product-detail-info.tsx | 158 + .../product-recommendations.tsx | 59 + .../commerce/product-details.editor.tsx | 220 + .../commerce/products-carousel.editor.tsx | 166 + .../commerce/products-grid.editor.tsx | 139 + editor/components/commerce/products.tsx | 233 + .../commerce/recommended-products.editor.tsx | 88 + editor/components/commerce/shop-footer.tsx | 24 + editor/components/commerce/shop-header.tsx | 68 + editor/components/cta/cta.editor.tsx | 132 + editor/components/faq/faq.editor.tsx | 111 + .../components/features/features.editor.tsx | 104 + editor/components/footer/footer.editor.tsx | 228 + editor/components/hero/hero.editor.tsx | 190 + editor/components/landing/banner.editor.tsx | 60 + .../landing/image-gallery.editor.tsx | 139 + .../landing/newsletter-cta.editor.tsx | 175 + editor/components/logos/logos.editor.tsx | 86 + .../navigation/navigation.editor.tsx | 301 + .../testimonials/testimonials.editor.tsx | 140 + editor/components/ui/accordion.tsx | 66 + editor/components/ui/alert.tsx | 60 + editor/components/ui/avatar.tsx | 53 + editor/components/ui/badge.tsx | 48 + editor/components/ui/breadcrumb.tsx | 148 + editor/components/ui/button-group.tsx | 97 + editor/components/ui/button.tsx | 58 + editor/components/ui/card.tsx | 92 + editor/components/ui/carousel.tsx | 245 + editor/components/ui/dialog.tsx | 266 + editor/components/ui/empty.tsx | 105 + editor/components/ui/input-otp.tsx | 86 + editor/components/ui/input.tsx | 21 + editor/components/ui/item.tsx | 213 + editor/components/ui/navigation-menu.tsx | 151 + editor/components/ui/pagination.tsx | 127 + editor/components/ui/progress.tsx | 44 + editor/components/ui/select.tsx | 287 + editor/components/ui/separator.tsx | 28 + editor/components/ui/sheet.tsx | 313 + editor/components/ui/skeleton.tsx | 13 + editor/components/ui/sonner.tsx | 23 + editor/components/ui/spinner.tsx | 38 + editor/components/ui/switch.tsx | 74 + editor/components/ui/table.tsx | 113 + editor/components/ui/tabs.tsx | 66 + editor/components/ui/textarea.tsx | 18 + editor/config/icons.tsx | 51 + editor/config/index.tsx | 88 + editor/config/initial-data.ts | 205 + editor/config/options.ts | 22 + editor/config/root.tsx | 112 + editor/config/types.ts | 61 + editor/contexts/shopify-context.tsx | 301 + editor/graphql/cart.js | 137 + editor/graphql/collections.js | 61 + editor/graphql/products.js | 116 + editor/hooks/use-shopify-cart.ts | 165 + editor/hooks/use-shopify-collections.ts | 127 + editor/hooks/use-shopify-products.ts | 205 + editor/lib/resolve-editor-path.ts | 15 + editor/lib/use-demo-data.ts | 54 + editor/lib/utils.ts | 11 + editor/services/shopify/client.ts | 63 + editor/theme/ThemeProvider.tsx | 163 + editor/theme/Typography.tsx | 94 + editor/vendor/plugin-ai.css | 427 ++ lib/schema.server.ts | 39 + next.config.js | 24 + package.json | 57 + postcss.config.mjs | 5 + tsconfig.json | 45 + yarn.lock | 5386 +++++++++++++++++ 96 files changed, 15753 insertions(+) create mode 100644 .env.local.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.schema.json create mode 100644 app/[[...path]]/page.tsx create mode 100644 app/api/chat/route.ts create mode 100644 app/api/save-schema/route.ts create mode 100644 app/edit/[[...path]]/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components/EditorClient.tsx create mode 100644 components/RenderClient.tsx create mode 100644 editor/components/commerce/cart-drawer.tsx create mode 100644 editor/components/commerce/collection-card.tsx create mode 100644 editor/components/commerce/collection-detail.tsx create mode 100644 editor/components/commerce/collection-grid.editor.tsx create mode 100644 editor/components/commerce/collection.editor.tsx create mode 100644 editor/components/commerce/collections.tsx create mode 100644 editor/components/commerce/featured-product.editor.tsx create mode 100644 editor/components/commerce/product-card.tsx create mode 100644 editor/components/commerce/product-detail.tsx create mode 100644 editor/components/commerce/product-detail/index.tsx create mode 100644 editor/components/commerce/product-detail/product-detail-gallery.tsx create mode 100644 editor/components/commerce/product-detail/product-detail-info.tsx create mode 100644 editor/components/commerce/product-detail/product-recommendations.tsx create mode 100644 editor/components/commerce/product-details.editor.tsx create mode 100644 editor/components/commerce/products-carousel.editor.tsx create mode 100644 editor/components/commerce/products-grid.editor.tsx create mode 100644 editor/components/commerce/products.tsx create mode 100644 editor/components/commerce/recommended-products.editor.tsx create mode 100644 editor/components/commerce/shop-footer.tsx create mode 100644 editor/components/commerce/shop-header.tsx create mode 100644 editor/components/cta/cta.editor.tsx create mode 100644 editor/components/faq/faq.editor.tsx create mode 100644 editor/components/features/features.editor.tsx create mode 100644 editor/components/footer/footer.editor.tsx create mode 100644 editor/components/hero/hero.editor.tsx create mode 100644 editor/components/landing/banner.editor.tsx create mode 100644 editor/components/landing/image-gallery.editor.tsx create mode 100644 editor/components/landing/newsletter-cta.editor.tsx create mode 100644 editor/components/logos/logos.editor.tsx create mode 100644 editor/components/navigation/navigation.editor.tsx create mode 100644 editor/components/testimonials/testimonials.editor.tsx create mode 100644 editor/components/ui/accordion.tsx create mode 100644 editor/components/ui/alert.tsx create mode 100644 editor/components/ui/avatar.tsx create mode 100644 editor/components/ui/badge.tsx create mode 100644 editor/components/ui/breadcrumb.tsx create mode 100644 editor/components/ui/button-group.tsx create mode 100644 editor/components/ui/button.tsx create mode 100644 editor/components/ui/card.tsx create mode 100644 editor/components/ui/carousel.tsx create mode 100644 editor/components/ui/dialog.tsx create mode 100644 editor/components/ui/empty.tsx create mode 100644 editor/components/ui/input-otp.tsx create mode 100644 editor/components/ui/input.tsx create mode 100644 editor/components/ui/item.tsx create mode 100644 editor/components/ui/navigation-menu.tsx create mode 100644 editor/components/ui/pagination.tsx create mode 100644 editor/components/ui/progress.tsx create mode 100644 editor/components/ui/select.tsx create mode 100644 editor/components/ui/separator.tsx create mode 100644 editor/components/ui/sheet.tsx create mode 100644 editor/components/ui/skeleton.tsx create mode 100644 editor/components/ui/sonner.tsx create mode 100644 editor/components/ui/spinner.tsx create mode 100644 editor/components/ui/switch.tsx create mode 100644 editor/components/ui/table.tsx create mode 100644 editor/components/ui/tabs.tsx create mode 100644 editor/components/ui/textarea.tsx create mode 100644 editor/config/icons.tsx create mode 100644 editor/config/index.tsx create mode 100644 editor/config/initial-data.ts create mode 100644 editor/config/options.ts create mode 100644 editor/config/root.tsx create mode 100644 editor/config/types.ts create mode 100644 editor/contexts/shopify-context.tsx create mode 100644 editor/graphql/cart.js create mode 100644 editor/graphql/collections.js create mode 100644 editor/graphql/products.js create mode 100644 editor/hooks/use-shopify-cart.ts create mode 100644 editor/hooks/use-shopify-collections.ts create mode 100644 editor/hooks/use-shopify-products.ts create mode 100644 editor/lib/resolve-editor-path.ts create mode 100644 editor/lib/use-demo-data.ts create mode 100644 editor/lib/utils.ts create mode 100644 editor/services/shopify/client.ts create mode 100644 editor/theme/ThemeProvider.tsx create mode 100644 editor/theme/Typography.tsx create mode 100644 editor/vendor/plugin-ai.css create mode 100644 lib/schema.server.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..2ca9ac4 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,6 @@ +# Shopify (defaults to mock.shop if unset) +NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop +NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN= + +# AI Gateway (Vercel) — used by /api/chat. Required for the AI panel. +AI_GATEWAY_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..432a2a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +.DS_Store +.env +.env.local +.env.*.local +next-env.d.ts +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..9317bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# React Editor Demo (Shopify) + +Standalone Next.js 16 (app router) demo wiring up the Shopify-aware React Editor. + +## What's here + +- **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css. +- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`. The save endpoint writes back to this file. +- **`app/[[...path]]/page.tsx`** — view route. Reads the schema and mounts ``. +- **`app/edit/[[...path]]/page.tsx`** — edit route. Reads the schema and mounts ``. +- **`app/api/save-schema/route.ts`** — POST `{ route, data }` → patches `app.schema.json` on disk. +- **`app/api/chat/route.ts`** — `aiPlugin` endpoint. Uses the Vercel AI Gateway (`AI_GATEWAY_API_KEY`) to talk to Claude, with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.). + +## Run + +```bash +cp .env.local.example .env.local # optional — defaults to mock.shop +yarn install +yarn dev # → http://localhost:3001 +``` + +- View any route: `http://localhost:3001/`, `/about`, `/products/handle`, etc. +- Edit any route: `http://localhost:3001/edit`, `/edit/about`, etc. +- 404 in view mode means the route isn't in `app.schema.json` yet — add it via the editor's **New page** button. + +## Shopify + +Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `NEXT_PUBLIC_SHOPIFY_DOMAIN` and `NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`. + +## AI + +`/api/chat` mirrors the host app's pattern — pass a string-form model id (`anthropic/claude-sonnet-4.6`) and the AI SDK routes through the Vercel AI Gateway using `AI_GATEWAY_API_KEY`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. diff --git a/app.schema.json b/app.schema.json new file mode 100644 index 0000000..143bc01 --- /dev/null +++ b/app.schema.json @@ -0,0 +1,189 @@ +{ + "/": { + "root": { + "props": { + "title": "Maison — Considered essentials", + "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-home", + "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": "hero", + "props": { + "id": "hero-home", + "tagline": "Spring 2026", + "heading": "Made for the way you move", + "subheading": "A considered wardrobe of essentials, cut from natural fibers and designed to last.", + "primaryCta": { "label": "Shop the collection", "href": "/collections" }, + "secondaryCta": { "label": "Our story", "href": "/about" }, + "imageUrl": "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80", + "align": "left", + "height": "lg", + "tone": "dark" + } + }, + { + "type": "products-carousel", + "props": { + "id": "carousel-home", + "tagline": "New", + "heading": "Just dropped", + "subheading": "Fresh additions to the lineup.", + "limit": 12, + "slidesPerView": "4", + "ctaLabel": "Shop new", + "ctaHref": "/collections/new" + } + }, + { + "type": "featured-product", + "props": { + "id": "featured-home", + "product": null, + "tagline": "Featured", + "ctaLabel": "Add to bag", + "align": "left", + "tone": "muted" + } + }, + { + "type": "collection-grid", + "props": { + "id": "collections-home", + "tagline": "Shop by collection", + "heading": "Curated edits", + "subheading": "Bundles built around the way you actually live.", + "layout": "tiles", + "limit": 6 + } + }, + { + "type": "features", + "props": { + "id": "features-home", + "tagline": "Why us", + "heading": "Built with intention", + "subheading": "A small set of values that shape every piece we make.", + "columns": "3", + "items": [ + { "title": "Natural fibers", "body": "Linen, organic cotton, and merino — sourced from mills with traceable supply chains." }, + { "title": "Small batches", "body": "Made in considered quantities so nothing goes to waste." }, + { "title": "Built to last", "body": "Reinforced seams and finishes that age into something better." } + ] + } + }, + { + "type": "testimonials", + "props": { + "id": "testimonials-home", + "tagline": "Reviews", + "heading": "What our customers say", + "items": [ + { + "quote": "I've been wearing the same linen shirt for two summers now and it's somehow gotten better with every wash.", + "author": "Mara K.", + "role": "Berlin", + "avatar": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80" + }, + { + "quote": "Considered cuts, neutral palette, real fabric. Exactly what I want when I'm getting dressed in the dark.", + "author": "Theo R.", + "role": "Brooklyn", + "avatar": "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80" + } + ] + } + }, + { + "type": "newsletter-cta", + "props": { + "id": "newsletter-home", + "tagline": "Stay in the loop", + "heading": "Letters from the studio", + "subheading": "New collections, mill stories, and the occasional invitation. Twice a month.", + "buttonLabel": "Subscribe", + "endpoint": "", + "imageUrl": "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80", + "layout": "split" + } + }, + { + "type": "footer", + "props": { + "id": "footer-home", + "brand": "Maison", + "tagline": "Considered essentials, made in small batches and built to last beyond the season.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All", "href": "/collections" }, + { "label": "New", "href": "/collections/new" }, + { "label": "Best sellers", "href": "/collections/best" } + ] + }, + { + "title": "About", + "links": [ + { "label": "Our story", "href": "/about" }, + { "label": "Materials", "href": "/materials" }, + { "label": "Journal", "href": "/journal" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" }, + { "label": "Contact", "href": "/contact" } + ] + }, + { + "title": "Legal", + "links": [ + { "label": "Terms", "href": "/terms" }, + { "label": "Privacy", "href": "/privacy" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Pinterest", "href": "#" }, + { "label": "TikTok", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "Stay in touch", + "newsletterEndpoint": "", + "copyright": "© 2026 Maison. All rights reserved." + } + } + ] + } +} diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx new file mode 100644 index 0000000..5f215c0 --- /dev/null +++ b/app/[[...path]]/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation"; +import { readSchema } from "~/lib/schema.server"; +import RenderClient from "~/components/RenderClient"; + +export const dynamic = "force-dynamic"; + +export default async function Page({ + params, +}: { + params: Promise<{ path?: string[] }>; +}) { + const { path } = await params; + const segments = (path ?? []).filter(Boolean); + + // The /edit branch is handled by app/edit; this catch-all only serves view. + if (segments[0] === "edit") return notFound(); + + const route = "/" + segments.join("/"); + const lookup = route === "/" ? "/" : route.replace(/\/$/, ""); + + const schema = await readSchema(); + const data = schema[lookup]; + if (!data) return notFound(); + + return ; +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..0eeb581 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,244 @@ +import { + convertToModelMessages, + stepCountIs, + streamText, + tool, + type UIMessage, +} from "ai"; +import { z } from "zod"; +import { + reactEditorTools, + getEditorContext, +} from "@reacteditor/plugin-ai/server"; +import { patchRoute, readSchema } from "~/lib/schema.server"; +import { shopifyFetch } from "~/editor/services/shopify/client"; +import { + GET_PRODUCTS_QUERY, + GET_PRODUCT_QUERY, +} from "~/editor/graphql/products"; +import { + GET_COLLECTIONS_QUERY, + GET_COLLECTION_PRODUCTS_QUERY, +} from "~/editor/graphql/collections"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type Body = { + messages: UIMessage[]; + editorContext?: Parameters[0]; + route?: string; +}; + +export async function POST(req: Request) { + const { messages, editorContext, route } = (await req.json()) as Body; + + const updatePage = tool({ + description: + "Persist the page schema (root + content) for a given route to app.schema.json.", + inputSchema: z.object({ + data: z.object({ root: z.any(), content: z.any() }), + }), + execute: async ({ data }) => { + const target = route || "/"; + await patchRoute(target, data); + return { ok: true, route: target }; + }, + }); + + const generateImage = tool({ + description: "Generate an image from a prompt and return its URL.", + inputSchema: z.object({ + prompt: z.string(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }), + execute: async ({ width = 768, height = 768 }) => ({ + url: `https://picsum.photos/${width}/${height}?random=${Math.floor( + Math.random() * 1_000_000, + )}`, + }), + }); + + const credentials = { + domain: + process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? + process.env.SHOPIFY_DOMAIN ?? + "mock.shop", + token: + process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? + process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? + "", + }; + + const searchProducts = tool({ + description: + "Search the Shopify store for products. Returns up to `limit` products matching `query`.", + inputSchema: z.object({ + query: z.string().optional(), + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ query, limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_PRODUCTS_QUERY, + variables: { + first: limit, + query: query ?? null, + sortKey: query ? "RELEVANCE" : "BEST_SELLING", + reverse: false, + }, + credentials, + }); + const products = (res.data?.products?.edges ?? []).map((e: any) => { + const n = e.node; + return { + id: n.id, + handle: n.handle, + title: n.title, + description: n.description, + featuredImage: n.images?.edges?.[0]?.node ?? null, + priceRange: n.priceRange, + }; + }); + return { ok: true, products }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const getProductByHandle = tool({ + description: "Fetch a single product by its handle.", + inputSchema: z.object({ handle: z.string() }), + execute: async ({ handle }) => { + try { + const res = await shopifyFetch({ + query: GET_PRODUCT_QUERY, + variables: { handle }, + credentials, + }); + const p = res.data?.product ?? null; + if (!p) return { ok: false, error: "not_found" }; + return { + ok: true, + product: { + id: p.id, + handle: p.handle, + title: p.title, + description: p.description, + featuredImage: p.images?.edges?.[0]?.node ?? null, + priceRange: p.priceRange, + }, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const searchCollections = tool({ + description: "List up to `limit` collections from the Shopify store.", + inputSchema: z.object({ + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_COLLECTIONS_QUERY, + variables: { first: limit }, + credentials, + }); + const collections = (res.data?.collections?.edges ?? []).map( + (e: any) => { + const n = e.node; + return { + id: n.id, + handle: n.handle, + title: n.title, + description: n.description, + image: n.image ?? null, + }; + }, + ); + return { ok: true, collections }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const getCollectionByHandle = tool({ + description: + "Fetch a single collection by its handle, including a small page of its products.", + inputSchema: z.object({ + handle: z.string(), + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ handle, limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_COLLECTION_PRODUCTS_QUERY, + variables: { + handle, + first: limit, + sortKey: "BEST_SELLING", + reverse: false, + }, + credentials, + }); + const c = res.data?.collection ?? null; + if (!c) return { ok: false, error: "not_found" }; + return { + ok: true, + collection: { + id: c.id, + handle: c.handle, + title: c.title, + description: c.description, + image: c.image ?? null, + products: (c.products?.edges ?? []).map((e: any) => ({ + id: e.node.id, + handle: e.node.handle, + title: e.node.title, + featuredImage: e.node.images?.edges?.[0]?.node ?? null, + priceRange: e.node.priceRange, + })), + }, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const result = streamText({ + model: "anthropic/claude-sonnet-4.6", + system: getEditorContext(editorContext), + messages: await convertToModelMessages(messages), + tools: { + ...reactEditorTools, + updatePage, + generateImage, + searchProducts, + getProductByHandle, + searchCollections, + getCollectionByHandle, + }, + stopWhen: stepCountIs(50), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/app/api/save-schema/route.ts b/app/api/save-schema/route.ts new file mode 100644 index 0000000..9dd3d73 --- /dev/null +++ b/app/api/save-schema/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { patchRoute } from "~/lib/schema.server"; + +export async function POST(request: Request) { + try { + const { route, data } = await request.json(); + if (typeof route !== "string" || !data || typeof data !== "object") { + return NextResponse.json( + { ok: false, error: "expected { route, data }" }, + { status: 400 }, + ); + } + const schema = await patchRoute(route, data); + return NextResponse.json({ ok: true, routes: Object.keys(schema) }); + } catch (err) { + return NextResponse.json( + { ok: false, error: err instanceof Error ? err.message : "save failed" }, + { status: 500 }, + ); + } +} diff --git a/app/edit/[[...path]]/page.tsx b/app/edit/[[...path]]/page.tsx new file mode 100644 index 0000000..8df4e0d --- /dev/null +++ b/app/edit/[[...path]]/page.tsx @@ -0,0 +1,21 @@ +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 ( +
+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..bf59790 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,81 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-family-heading: var(--font-header); + --font-family-header: var(--font-header); + --font-family-body: var(--font-body); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --animate-marquee: marquee var(--duration) infinite linear; + --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + + @keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(calc(-100% - var(--gap))); } + } + @keyframes marquee-vertical { + from { transform: translateY(0); } + to { transform: translateY(calc(-100% - var(--gap))); } + } +} + +:root { + --radius: 0.625rem; + --font-header: system-ui, -apple-system, sans-serif; + --font-body: system-ui, -apple-system, sans-serif; + --background: #ffffff; + --foreground: #0a0a0a; + --card: #ffffff; + --card-foreground: #0a0a0a; + --popover: #ffffff; + --popover-foreground: #0a0a0a; + --primary: #0a0a0a; + --primary-foreground: #fafafa; + --secondary: #f5f5f5; + --secondary-foreground: #0a0a0a; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #f5f5f5; + --accent-foreground: #0a0a0a; + --destructive: #ef4444; + --destructive-foreground: #fafafa; + --border: #e5e5e5; + --input: #e5e5e5; + --ring: #a3a3a3; +} + +@layer base { + * { border-color: var(--border); } + html, body { background-color: var(--background); color: var(--foreground); } + body { + font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji"; + margin: 0; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..e20f6db --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "React Editor — Shopify Demo", + description: "Standalone Next.js demo wiring the Shopify-aware React Editor.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + ); +} diff --git a/components/EditorClient.tsx b/components/EditorClient.tsx new file mode 100644 index 0000000..ce136cf --- /dev/null +++ b/components/EditorClient.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Editor, + blocksPlugin, + outlinePlugin, + useGetEditor, + type Route, +} 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"; +import { Button } from "~/editor/components/ui/button"; + +type Schema = Record; + +const SHOPIFY_DOMAIN = + process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop"; +const STOREFRONT_TOKEN = + process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? ""; + +function SaveButton({ + currentPath, + onSave, +}: { + currentPath: string; + onSave: (route: string, data: any) => Promise; +}) { + const getEditor = useGetEditor(); + const [saving, setSaving] = useState(false); + return ( + + ); +} + +export default function EditorClient({ + initialSchema, + initialPath, +}: { + initialSchema: Schema; + initialPath: string; +}) { + const router = useRouter(); + const [schema, setSchema] = useState(initialSchema); + const [currentPath, setCurrentPath] = useState(initialPath); + + const data = useMemo( + () => schema[currentPath] ?? { root: { props: {} }, content: [] }, + [schema, currentPath], + ); + + const routes: Route[] = useMemo( + () => + Object.entries(schema).map(([path, page]) => ({ + path, + title: + (page?.root as any)?.props?.title ?? + (path === "/" ? "Home" : path), + })), + [schema], + ); + + const config = useMemo( + () => + createConfig({ + domain: SHOPIFY_DOMAIN, + token: STOREFRONT_TOKEN || null, + }), + [], + ); + + const persist = useCallback(async (route: string, next: any) => { + setSchema((s) => ({ ...s, [route]: next })); + await fetch("/api/save-schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ route, data: next }), + }); + }, []); + + const plugins = useMemo( + () => [ + createTailwindCdnPlugin(), + aiPlugin({ + api: "/api/chat", + attachments: true, + body: { route: currentPath }, + }), + blocksPlugin(), + outlinePlugin(), + ], + [currentPath], + ); + + return ( + + { + setCurrentPath(next); + router.push(`/edit${next === "/" ? "" : next}`); + }} + onPublish={async (next) => { + await persist(currentPath, next); + }} + iframe={{ enabled: true }} + overrides={{ + headerActions: () => ( + + ), + }} + /> + + ); +} diff --git a/components/RenderClient.tsx b/components/RenderClient.tsx new file mode 100644 index 0000000..60d7e55 --- /dev/null +++ b/components/RenderClient.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMemo } from "react"; +import { Render } from "@reacteditor/core"; +import "@reacteditor/core/dist/index.css"; +import { createConfig } from "~/editor/config"; +import { ShopifyProvider } from "~/editor/contexts/shopify-context"; + +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 RenderClient({ + data, + route, +}: { + data: { root: any; content: any[] }; + route: string; +}) { + const config = useMemo( + () => + createConfig({ + domain: SHOPIFY_DOMAIN, + token: STOREFRONT_TOKEN || null, + }), + [], + ); + + return ( + + + + ); +} diff --git a/editor/components/commerce/cart-drawer.tsx b/editor/components/commerce/cart-drawer.tsx new file mode 100644 index 0000000..8c6eb85 --- /dev/null +++ b/editor/components/commerce/cart-drawer.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React from 'react'; +import { useShopifyCart, redirectToCheckout } from '~/editor/hooks/use-shopify-cart'; +import { Button } from '~/editor/components/ui/button'; +import { Spinner } from '~/editor/components/ui/spinner'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetBody, + AnimatePresence, +} from '~/editor/components/ui/sheet'; + +const CartDrawer: React.FC = () => { + const { isOpen, closeCart, items, itemCount, totalAmount, checkoutUrl, loading, removeItem, updateItemQuantity } = useShopifyCart(); + + const handleCheckout = () => { + if (checkoutUrl) { + redirectToCheckout(checkoutUrl); + } + }; + + const getItemImage = (item: typeof items[0]) => { + return item.merchandise.image?.url; + }; + + const getSelectedOptions = (item: typeof items[0]) => { + return item.merchandise.selectedOptions ?? []; + }; + + return ( + !open && closeCart()} side="right"> + + {isOpen && ( + + {/* Header */} + +
+ + Shopping Cart ({itemCount}) + + +
+
+ + {/* Cart Items */} + + {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

Your cart is empty

+

Add some products to get started!

+ +
+ ) : ( +
+ {items.map((item) => { + const image = getItemImage(item); + const selectedOptions = getSelectedOptions(item); + + return ( +
+ {/* Product Image */} +
+ {image ? ( + {item.merchandise.product.title} + ) : ( +
+ +
+ )} +
+ + {/* Product Details */} +
+

+ {item.merchandise.product.title} +

+ + {/* Variant Info */} + {selectedOptions.length > 0 && ( +
+ {selectedOptions.map((option, index) => ( + + {option.value} + {index < selectedOptions.length - 1 ? ' / ' : ''} + + ))} +
+ )} + + {/* Quantity Controls */} +
+
+ + + {item.quantity} + + +
+
+
+ + {/* Price */} +
+ + ${parseFloat(item.merchandise.price.amount).toFixed(2)} + +
+ + {/* Remove Button */} +
+ +
+
+ ); + })} +
+ )} +
+ + {/* Footer - Checkout Section */} + {items.length > 0 && ( +
+ {/* Subtotal */} +
+ Subtotal + + ${totalAmount.toFixed(2)} + +
+ +
+ Shipping and taxes calculated at checkout +
+ + {/* Action Buttons */} +
+ + + +
+
+ )} +
+ )} +
+
+ ); +}; + +export default CartDrawer; diff --git a/editor/components/commerce/collection-card.tsx b/editor/components/commerce/collection-card.tsx new file mode 100644 index 0000000..2c78ad3 --- /dev/null +++ b/editor/components/commerce/collection-card.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +// local link - plain anchor +import { Card, CardContent } from '~/editor/components/ui/card'; + +interface CollectionImage { + url: string; + altText?: string; +} + +interface Collection { + id: string; + title: string; + handle: string; + description?: string; + image?: CollectionImage; +} + +interface CollectionCardProps { + collection: Collection; +} + +const CollectionCard: React.FC = ({ collection }) => { + return ( + + + {/* Collection Image */} +
+ {collection.image ? ( + {collection.image.altText + ) : ( +
+ +
+ )} +
+ + {/* Collection Info */} + +

+ {collection.title} +

+ + {collection.description && ( +

+ {collection.description.substring(0, 100)} + {collection.description.length > 100 ? '...' : ''} +

+ )} + +
+ View Collection + +
+
+
+
+ ); +}; + +export default CollectionCard; \ No newline at end of file diff --git a/editor/components/commerce/collection-detail.tsx b/editor/components/commerce/collection-detail.tsx new file mode 100644 index 0000000..6a928a0 --- /dev/null +++ b/editor/components/commerce/collection-detail.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React from 'react'; +import { useCollectionProducts } from '~/editor/hooks/use-shopify-collections'; +import ProductCard from './product-card'; + +const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => { + const handle = handleProp ?? ''; + + const { collection, loading, error, refetch } = useCollectionProducts(handle); + + // Format title from handle + const formattedTitle = handle + ? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + : 'Collection'; + + if (loading) { + return ( +
+
+

+ {formattedTitle} +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

Could not load collection

+

+ {error} +

+ +
+
+
+ ); + } + + const products = collection?.products || []; + const title = collection?.title || formattedTitle; + + return ( +
+
+

+ {title} +

+ + {products.length === 0 ? ( +
+
+ +

+ No Products in Collection +

+

+ This collection doesn't have any products yet. +

+
+
+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default CollectionDetail; diff --git a/editor/components/commerce/collection-grid.editor.tsx b/editor/components/commerce/collection-grid.editor.tsx new file mode 100644 index 0000000..cc4c366 --- /dev/null +++ b/editor/components/commerce/collection-grid.editor.tsx @@ -0,0 +1,145 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useEffect, useState } from "react"; +import { FolderOpen } from "lucide-react"; +import { shopifyFetch } from "~/editor/services/shopify/client"; +import { GET_COLLECTIONS_QUERY } from "~/editor/graphql/collections"; +import { Typography } from "~/editor/theme/Typography"; + +export type CollectionGridProps = { + tagline: string; + heading: string; + subheading: string; + layout: "tiles" | "editorial"; + limit: number; +}; + +type CollectionRow = { + id: string; + handle: string; + title: string; + description?: string; + image?: { url: string; altText?: string }; +}; + +function CollectionGrid({ + tagline, + heading, + subheading, + layout, + limit, +}: CollectionGridProps) { + const [collections, setCollections] = useState([]); + + useEffect(() => { + shopifyFetch({ + 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 ( +
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ +
+ {(collections.length === 0 + ? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any) + : collections + ).map((c: CollectionRow) => ( + +
+ {c.image?.url ? ( + {c.image.altText + ) : null} + {isEditorial ? ( +
+
+ + {c.title} + + + Shop now → + +
+
+ ) : null} +
+ {!isEditorial ? ( +
+

{c.title}

+ + → + +
+ ) : null} +
+ ))} +
+
+
+ ); +} + +export const collectionGridEditor: ComponentConfig = { + label: "Collections", + icon: , + category: "commerce", + defaultProps: { + tagline: "Shop by collection", + heading: "Curated edits", + subheading: "Bundles built around the way you actually live.", + layout: "tiles", + limit: 6, + }, + fields: { + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + layout: { + label: "Layout", + type: "radio", + options: [ + { label: "Tiles", value: "tiles" }, + { label: "Editorial", value: "editorial" }, + ], + }, + limit: { label: "Limit", type: "number", min: 2, max: 12 }, + }, + render: (props) => , +}; diff --git a/editor/components/commerce/collection.editor.tsx b/editor/components/commerce/collection.editor.tsx new file mode 100644 index 0000000..66acf3e --- /dev/null +++ b/editor/components/commerce/collection.editor.tsx @@ -0,0 +1,95 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { FolderOpen } from "lucide-react"; +import type { ShopifyCollection } from "@reacteditor/field-shopify"; +import { useCollectionProducts } from "~/editor/hooks/use-shopify-collections"; +import { ProductCard } from "./product-card"; +import { Typography } from "~/editor/theme/Typography"; + +export type CollectionProps = { + collection: ShopifyCollection | null; + showDescription: "yes" | "no"; +}; + +function CollectionView({ + collection: selected, + showDescription, +}: CollectionProps) { + const handle = selected?.handle ?? ""; + const { collection, loading } = useCollectionProducts(handle, { first: 24 }); + + if (!selected) { + return ( +
+
+
+ Pick a collection to render this page. +
+
+
+ ); + } + + // The hook flattens collection.products.edges into a plain Product[]. + const products = (collection?.products as any[] | undefined) ?? []; + const description = collection?.description ?? selected.description; + + return ( +
+
+
+

+ Collection +

+ + {collection?.title ?? selected.title} + + {showDescription === "yes" && description ? ( + + {description} + + ) : null} +
+ +
+ {loading + ? Array.from({ length: 8 }).map((_, i) => ( +
+ )) + : products.map((p: any) => )} +
+ + {!loading && products.length === 0 ? ( +
+ This collection has no products yet. +
+ ) : null} +
+
+ ); +} + +export function createCollectionEditor(opts: { + collectionField: any; +}): ComponentConfig { + return { + label: "Collection page", + icon: , + category: "commerce", + defaultProps: { collection: null, showDescription: "no" }, + fields: { + collection: { label: "Collection", ...opts.collectionField }, + showDescription: { + label: "Description", + type: "radio", + options: [ + { label: "Hide description", value: "no" }, + { label: "Show description", value: "yes" }, + ], + }, + }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/collections.tsx b/editor/components/commerce/collections.tsx new file mode 100644 index 0000000..a65b3d9 --- /dev/null +++ b/editor/components/commerce/collections.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { useCollections } from '~/editor/hooks/use-shopify-collections'; +import CollectionCard from './collection-card'; + +const Collections: React.FC = () => { + const { collections, loading, error, refetch } = useCollections(20); + + if (loading) { + return ( +
+
+

+ Our Collections +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

Could not load collections

+

+ {error} +

+ +
+
+
+ ); + } + + if (collections.length === 0) { + return ( +
+
+

+ Our Collections +

+ +
+ +

+ No Collections Found +

+

+ Check back later or configure your Shopify store connection. +

+
+
+
+ ); + } + + return ( +
+
+

+ Our Collections +

+ + {/* Collections Grid */} +
+ {collections.map((collection) => ( + + ))} +
+
+
+ ); +}; + +export default Collections; diff --git a/editor/components/commerce/featured-product.editor.tsx b/editor/components/commerce/featured-product.editor.tsx new file mode 100644 index 0000000..42e1151 --- /dev/null +++ b/editor/components/commerce/featured-product.editor.tsx @@ -0,0 +1,158 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { Star } from "lucide-react"; +import type { ShopifyProduct } from "@reacteditor/field-shopify"; +import { useProduct } from "~/editor/hooks/use-shopify-products"; +import { useShopifyCart } from "~/editor/hooks/use-shopify-cart"; +import { Typography } from "~/editor/theme/Typography"; + +export type FeaturedProductProps = { + product: ShopifyProduct | null; + tagline: string; + ctaLabel: string; + align: "left" | "right"; + tone: "default" | "muted"; +}; + +function FeaturedProductView({ + product: selected, + tagline, + ctaLabel, + align, + tone, +}: FeaturedProductProps) { + // Re-fetch full product (variants, full image set) by handle for cart wiring. + const { product: full, loading } = useProduct(selected?.handle ?? null); + const product: any = full ?? selected; + const cart = useShopifyCart(); + + if (!selected && loading) { + return ( +
+
+
+
+
+ ); + } + if (!product) { + return ( +
+
+
+ Pick a product to feature here. +
+
+
+ ); + } + + 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 ( +
+
+
+ {image ? ( + {image.altText + ) : ( +
+ )} +
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {product.title} + {formatted ? ( + + {formatted} + + ) : null} + {product.description ? ( + + {product.description} + + ) : null} +
+ + + View details + +
+
+
+
+ ); +} + +export function createFeaturedProductEditor(opts: { + productField: any; +}): ComponentConfig { + return { + label: "Featured product", + icon: , + category: "commerce", + defaultProps: { + product: null, + tagline: "Featured", + ctaLabel: "Add to bag", + align: "left", + tone: "default", + }, + fields: { + product: { label: "Product", ...opts.productField }, + tagline: { label: "Tagline", type: "text", contentEditable: true }, + ctaLabel: { label: "CTA label", type: "text", contentEditable: true }, + align: { + label: "Image alignment", + type: "radio", + options: [ + { label: "Image left", value: "left" }, + { label: "Image right", value: "right" }, + ], + }, + tone: { + label: "Tone", + type: "radio", + options: [ + { label: "Default", value: "default" }, + { label: "Muted", value: "muted" }, + ], + }, + }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/product-card.tsx b/editor/components/commerce/product-card.tsx new file mode 100644 index 0000000..65e6ddc --- /dev/null +++ b/editor/components/commerce/product-card.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +type ProductImage = { url: string; altText?: string }; +type ProductPrice = { amount: string; currencyCode: string }; + +export type ProductCardData = { + id: string; + handle: string; + title: string; + images?: { edges?: Array<{ node: ProductImage }> }; + priceRange?: { minVariantPrice?: ProductPrice }; + compareAtPriceRange?: { minVariantPrice?: ProductPrice }; +}; + +function format(price: ProductPrice) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: price.currencyCode, + }).format(parseFloat(price.amount)); +} + +export function ProductCard({ + product, + aspect = "portrait", +}: { + product: ProductCardData; + aspect?: "portrait" | "square" | "landscape"; +}) { + const image = product.images?.edges?.[0]?.node; + const price = product.priceRange?.minVariantPrice; + const compare = product.compareAtPriceRange?.minVariantPrice; + const onSale = + price && compare && parseFloat(compare.amount) > parseFloat(price.amount); + + const aspectClass: Record = { + portrait: "aspect-[4/5]", + square: "aspect-square", + landscape: "aspect-[4/3]", + }; + + return ( + +
+ {image ? ( + {image.altText + ) : null} +
+
+

{product.title}

+ {price ? ( +
+ {onSale && compare ? ( + + {format(compare)} + + ) : null} + {format(price)} +
+ ) : null} +
+
+ ); +} + +export default ProductCard; diff --git a/editor/components/commerce/product-detail.tsx b/editor/components/commerce/product-detail.tsx new file mode 100644 index 0000000..5f51fed --- /dev/null +++ b/editor/components/commerce/product-detail.tsx @@ -0,0 +1,3 @@ +import ProductDetail from './product-detail/index.tsx'; + +export default ProductDetail; \ No newline at end of file diff --git a/editor/components/commerce/product-detail/index.tsx b/editor/components/commerce/product-detail/index.tsx new file mode 100644 index 0000000..1cf3fad --- /dev/null +++ b/editor/components/commerce/product-detail/index.tsx @@ -0,0 +1,207 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +// local link - plain anchor +import { useProduct, type Product } from '~/editor/hooks/use-shopify-products'; +import { useShopifyCart } from '~/editor/hooks/use-shopify-cart'; +import ProductDetailGallery from './product-detail-gallery'; +import ProductDetailInfo from './product-detail-info'; +import ProductRecommendations from './product-recommendations'; +import { Button } from '~/editor/components/ui/button'; +import { Alert, AlertTitle, AlertDescription } from '~/editor/components/ui/alert'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '~/editor/components/ui/breadcrumb'; + +interface ProductVariant { + id: string; + title: string; + price: { + amount: string; + currencyCode: string; + }; + availableForSale: boolean; + selectedOptions: Array<{ + name: string; + value: string; + }>; + image?: { + url: string; + altText?: string; + }; +} + +export type { Product }; + +interface ProductDetailProps { + handle?: string; +} + +const ProductDetail: React.FC = ({ handle: handleProp }) => { + const handle = handleProp || ''; + const { addItem, openCart } = useShopifyCart(); + + const { product, loading, error } = useProduct(handle); + + const [selectedVariant, setSelectedVariant] = useState(null); + const [selectedOptions, setSelectedOptions] = useState>({}); + const [quantity, setQuantity] = useState(1); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [addingToCart, setAddingToCart] = useState(false); + + // Initialize variant when product loads + useEffect(() => { + if (product) { + const firstVariant = product.variants.edges[0]?.node; + if (firstVariant) { + setSelectedVariant(firstVariant); + + const initialOptions: Record = {}; + firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => { + initialOptions[option.name] = option.value; + }); + setSelectedOptions(initialOptions); + } + } + }, [product]); + + const handleOptionChange = (optionName: string, value: string) => { + const newOptions = { ...selectedOptions, [optionName]: value }; + setSelectedOptions(newOptions); + + // Find matching variant + const matchingVariant = product?.variants.edges.find(({ node }) => { + return node.selectedOptions.every(option => + newOptions[option.name] === option.value + ); + }); + + if (matchingVariant) { + setSelectedVariant(matchingVariant.node); + + // Update image if variant has an associated image + if (matchingVariant.node.image && product) { + const variantImageUrl = matchingVariant.node.image.url; + const imageIndex = product.images.edges.findIndex( + edge => edge.node.url === variantImageUrl + ); + if (imageIndex !== -1) { + setSelectedImageIndex(imageIndex); + } + } + } + }; + + const handleAddToCart = async () => { + if (!selectedVariant || !product) return; + + try { + setAddingToCart(true); + await addItem(selectedVariant.id, quantity); + openCart(); + } catch (err) { + console.error('Failed to add item to cart:', err); + } finally { + setAddingToCart(false); + } + }; + + if (loading) { + return ( +
+
+ {/* Image Gallery Skeleton */} +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ + {/* Product Info Skeleton */} +
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !product) { + return ( +
+
+

Product not found

+

+ {error || "The requested product could not be found."} +

+ +
+
+ ); + } + + return ( +
+
+ + + + + Home + + + + + + Shop + + + + + {product.title} + + + +
+ edge.node)} + selectedImageIndex={selectedImageIndex} + onImageSelect={setSelectedImageIndex} + /> + +
+
+ + +
+ ); +}; + +export default ProductDetail; diff --git a/editor/components/commerce/product-detail/product-detail-gallery.tsx b/editor/components/commerce/product-detail/product-detail-gallery.tsx new file mode 100644 index 0000000..8fee6db --- /dev/null +++ b/editor/components/commerce/product-detail/product-detail-gallery.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button } from '~/editor/components/ui/button'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductDetailGalleryProps { + images: ProductImage[]; + selectedImageIndex?: number; + onImageSelect?: (index: number) => void; +} + +const ProductDetailGallery: React.FC = ({ + images, + selectedImageIndex = 0, + onImageSelect +}) => { + const selectedImage = selectedImageIndex; + const setSelectedImage = onImageSelect || (() => {}); + + return ( +
+ {/* Main Image */} +
+ {images.length > 0 ? ( + {images[selectedImage].altText + ) : ( +
+ +
+ )} +
+ + {/* Image Thumbnails */} + {images.length > 1 && ( +
+ {images.map((image, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProductDetailGallery; \ No newline at end of file diff --git a/editor/components/commerce/product-detail/product-detail-info.tsx b/editor/components/commerce/product-detail/product-detail-info.tsx new file mode 100644 index 0000000..0b2af30 --- /dev/null +++ b/editor/components/commerce/product-detail/product-detail-info.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { Product, ProductVariant } from './index.tsx'; +import { Button } from '~/editor/components/ui/button'; +import { Badge } from '~/editor/components/ui/badge'; +import { Spinner } from '~/editor/components/ui/spinner'; + +interface ProductDetailInfoProps { + product: Product; + selectedVariant: ProductVariant | null; + selectedOptions: Record; + quantity: number; + setQuantity: (quantity: number) => void; + handleAddToCart: () => void; + onOptionChange: (optionName: string, value: string) => void; + loading?: boolean; +} + +const ProductDetailInfo: React.FC = ({ + product, + selectedVariant, + selectedOptions, + quantity, + setQuantity, + handleAddToCart, + onOptionChange, + loading = false, +}) => { + const formatPrice = (price: { amount: string; currencyCode: string }) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(parseFloat(price.amount)); + }; + + const price = selectedVariant?.price || product.priceRange.minVariantPrice; + const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; + const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount); + + return ( +
+

+ {product.title} +

+ + {/* Price */} +
+ + {formatPrice(price)} + + {hasDiscount && compareAtPrice && ( + <> + + {formatPrice(compareAtPrice)} + + + {Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF + + + )} +
+ + {/* Description */} + {product.description && ( +
+ {product.descriptionHtml ? ( +
+ ) : ( +

{product.description}

+ )} +
+ )} + + {/* Product Options */} + {product.options.map(option => ( +
+ +
+ {option.values.map(value => ( + + ))} +
+
+ ))} + + {/* Quantity Selector */} +
+ +
+ + {quantity} + +
+
+ + {/* Add to Cart Button */} + + + {/* Additional Info */} +
+
+
+ + Free shipping on orders over $100 +
+
+ + 30-day return policy +
+
+ + Secure payment +
+
+
+
+ ); +}; + +export default ProductDetailInfo; \ No newline at end of file diff --git a/editor/components/commerce/product-detail/product-recommendations.tsx b/editor/components/commerce/product-detail/product-recommendations.tsx new file mode 100644 index 0000000..c3e9f36 --- /dev/null +++ b/editor/components/commerce/product-detail/product-recommendations.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import { useProductRecommendations } from '~/editor/hooks/use-shopify-products'; +import ProductCard from '../product-card'; + +interface ProductRecommendationsProps { + productId: string; +} + +const ProductRecommendations: React.FC = ({ productId }) => { + const { recommendations, loading, error } = useProductRecommendations(productId); + + // Don't show section if we're not loading and have no recommendations + if (!loading && (!recommendations || recommendations.length === 0)) { + return null; + } + + return ( +
+
+

+ You Might Also Like +

+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +
+

Recommendations could not be loaded

+
+ ) : ( +
+ {recommendations.slice(0, 4).map((recommendedProduct) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default ProductRecommendations; diff --git a/editor/components/commerce/product-details.editor.tsx b/editor/components/commerce/product-details.editor.tsx new file mode 100644 index 0000000..c18a113 --- /dev/null +++ b/editor/components/commerce/product-details.editor.tsx @@ -0,0 +1,220 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useEffect, useState } from "react"; +import { Package } from "lucide-react"; +import type { ShopifyProduct } from "@reacteditor/field-shopify"; +import { useProduct } from "~/editor/hooks/use-shopify-products"; +import { useShopifyCart } from "~/editor/hooks/use-shopify-cart"; +import { Typography } from "~/editor/theme/Typography"; + +export type ProductDetailsProps = { + product: ShopifyProduct | null; +}; + +function ProductDetailsView({ product: selected }: ProductDetailsProps) { + const handle = selected?.handle ?? null; + const { product, loading } = useProduct(handle); + const cart = useShopifyCart(); + const [activeImage, setActiveImage] = useState(0); + const [variant, setVariant] = useState(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 ( +
+
+
+ Pick a product to render this page. +
+
+
+ ); + } + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (!product) { + return ( +
+
+
+

Product not found

+

+ The requested product could not be found. +

+
+
+
+ ); + } + + 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 ( +
+
+
+
+ {main ? ( + {main.altText + ) : null} +
+ {images.length > 1 ? ( +
+ {images.map((img: any, i: number) => ( + + ))} +
+ ) : null} +
+ +
+
+ + {product.title} + + {formatted ? ( + + {formatted} + + ) : null} +
+ + {(product.options ?? []).map((opt: any) => ( +
+

+ {opt.name} +

+
+ {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 ( + + ); + })} +
+
+ ))} + +
+
+ + {quantity} + +
+ +
+ + {product.description ? ( +
+

+ Details +

+

+ {product.description} +

+
+ ) : null} +
+
+
+ ); +} + +export function createProductDetailsEditor(opts: { + productField: any; +}): ComponentConfig { + return { + label: "Product details", + icon: , + category: "commerce", + defaultProps: { product: null }, + fields: { product: { label: "Product", ...opts.productField } }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/products-carousel.editor.tsx b/editor/components/commerce/products-carousel.editor.tsx new file mode 100644 index 0000000..9f25927 --- /dev/null +++ b/editor/components/commerce/products-carousel.editor.tsx @@ -0,0 +1,166 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useEffect, useState } from "react"; +import { GalleryHorizontalEnd } from "lucide-react"; +import type { ShopifyCollection } from "@reacteditor/field-shopify"; +import { getProducts } from "~/editor/hooks/use-shopify-products"; +import { getCollectionProducts } from "~/editor/hooks/use-shopify-collections"; +import { ProductCard } from "./product-card"; +import { Typography } from "~/editor/theme/Typography"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "~/editor/components/ui/carousel"; + +export type ProductsCarouselProps = { + collection: ShopifyCollection | null; + tagline: string; + heading: string; + subheading: string; + limit: number; + slidesPerView: "2" | "3" | "4"; + ctaLabel: string; + ctaHref: string; +}; + +const basisClass: Record = { + "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([]); + + 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 ( +
+
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ {ctaLabel ? ( + + {ctaLabel} → + + ) : null} +
+ + + + {(products.length === 0 + ? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` })) + : products + ).map((p: any) => ( + + {products.length === 0 ? ( +
+ ) : ( + + )} + + ))} + + + + +
+
+ ); +} + +export function createProductsCarouselEditor(opts: { + collectionField: any; +}): ComponentConfig { + return { + label: "Products carousel", + icon: , + category: "commerce", + defaultProps: { + collection: null, + tagline: "New", + heading: "Just dropped", + subheading: "Fresh additions to the lineup.", + limit: 12, + slidesPerView: "4", + ctaLabel: "Shop new", + ctaHref: "", + }, + fields: { + collection: { label: "Collection", ...opts.collectionField }, + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + limit: { label: "Limit", type: "number", min: 4, max: 24 }, + slidesPerView: { + label: "Slides per view", + type: "select", + options: [ + { label: "2 per view", value: "2" }, + { label: "3 per view", value: "3" }, + { label: "4 per view", value: "4" }, + ], + }, + ctaLabel: { label: "CTA label", type: "text", contentEditable: true }, + ctaHref: { label: "CTA link", type: "text" }, + }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/products-grid.editor.tsx b/editor/components/commerce/products-grid.editor.tsx new file mode 100644 index 0000000..e4fb668 --- /dev/null +++ b/editor/components/commerce/products-grid.editor.tsx @@ -0,0 +1,139 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useEffect, useState } from "react"; +import { LayoutGrid } from "lucide-react"; +import type { ShopifyCollection } from "@reacteditor/field-shopify"; +import { getProducts } from "~/editor/hooks/use-shopify-products"; +import { getCollectionProducts } from "~/editor/hooks/use-shopify-collections"; +import { ProductCard } from "./product-card"; +import { Typography } from "~/editor/theme/Typography"; + +export type ProductsGridProps = { + collection: ShopifyCollection | null; + tagline: string; + heading: string; + subheading: string; + columns: "3" | "4"; + limit: number; + ctaLabel: string; + ctaHref: string; +}; + +const colClass: Record = { + "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([]); + + 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 ( +
+
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ {ctaLabel ? ( + + {ctaLabel} → + + ) : null} +
+ +
+ {products.length === 0 + ? Array.from({ length: limit }).map((_, i) => ( +
+ )) + : products.map((p) => )} +
+
+
+ ); +} + +export function createProductsGridEditor(opts: { + collectionField: any; +}): ComponentConfig { + return { + label: "Products grid", + icon: , + category: "commerce", + defaultProps: { + collection: null, + tagline: "Shop", + heading: "Latest arrivals", + subheading: "New pieces, fresh in this season.", + columns: "4", + limit: 8, + ctaLabel: "View all", + ctaHref: "", + }, + fields: { + collection: { label: "Collection", ...opts.collectionField }, + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + columns: { + label: "Columns", + type: "radio", + options: [ + { label: "3 columns", value: "3" }, + { label: "4 columns", value: "4" }, + ], + }, + limit: { label: "Limit", type: "number", min: 2, max: 24 }, + ctaLabel: { label: "CTA label", type: "text", contentEditable: true }, + ctaHref: { label: "CTA link", type: "text" }, + }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/products.tsx b/editor/components/commerce/products.tsx new file mode 100644 index 0000000..94c5b2b --- /dev/null +++ b/editor/components/commerce/products.tsx @@ -0,0 +1,233 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import ProductCard from './product-card'; +import { getProducts } from '~/editor/hooks/use-shopify-products'; +import { Button } from '~/editor/components/ui/button'; +import { Spinner } from '~/editor/components/ui/spinner'; + +interface ProductImage { + url: string; + altText?: string; +} + +interface ProductPrice { + amount: string; + currencyCode: string; +} + +interface ProductVariant { + id: string; + title: string; + price: ProductPrice; + availableForSale: boolean; +} + +interface Product { + id: string; + title: string; + description?: string; + handle: string; + images: { + edges: Array<{ + node: ProductImage; + }>; + }; + priceRange: { + minVariantPrice: ProductPrice; + }; + compareAtPriceRange?: { + minVariantPrice: ProductPrice; + }; + variants: { + edges: Array<{ + node: ProductVariant; + }>; + }; +} + +interface ProductsProps { + title?: string; + limit?: number; + showLoadMore?: boolean; +} + +const Products: React.FC = ({ + title = "Our Products", + limit = 12, + showLoadMore = true +}) => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMoreProducts, setHasMoreProducts] = useState(true); + + const fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => { + try { + if (loadMore) { + setLoadingMore(true); + } else { + setLoading(true); + setError(null); + } + + const newProducts = await getProducts({ + first: limit, + sortKey: 'CREATED_AT', + reverse: true + }); + + if (loadMore) { + // Filter out products that already exist + const existingIds = new Set(currentProducts.map(p => p.id)); + const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id)); + + if (uniqueNewProducts.length === 0) { + setHasMoreProducts(false); + } else { + setProducts(prev => [...prev, ...uniqueNewProducts]); + } + } else { + setProducts(newProducts); + setHasMoreProducts(newProducts.length === limit); + } + } catch (err) { + console.error('Error fetching products:', err); + setError(err instanceof Error ? err.message : 'Failed to load products'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + useEffect(() => { + fetchProducts(); + }, [limit]); + + const handleAddToCart = async (product: Product) => { + // Here you would typically integrate with cart functionality + console.log('Adding to cart:', product); + }; + + const handleLoadMore = () => { + if (!loadingMore && hasMoreProducts) { + fetchProducts(products, true); + } + }; + + if (loading) { + return ( +
+
+

+ {title} +

+ + {/* Loading Skeleton */} +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

Could not load products

+

+ {error} +

+ +
+
+
+ ); + } + + if (products.length === 0) { + return ( +
+
+

+ {title} +

+ +
+ +

+ No Products Found +

+

+ Check back later or configure your Shopify store connection. +

+
+
+
+ ); + } + + return ( +
+
+

+ {title} +

+ + {/* Products Grid */} +
+ {products.map((product) => ( + + ))} +
+ + {/* Load More Button */} + {showLoadMore && hasMoreProducts && ( +
+ +
+ )} +
+
+ ); +}; + +export default Products; \ No newline at end of file diff --git a/editor/components/commerce/recommended-products.editor.tsx b/editor/components/commerce/recommended-products.editor.tsx new file mode 100644 index 0000000..a56d016 --- /dev/null +++ b/editor/components/commerce/recommended-products.editor.tsx @@ -0,0 +1,88 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { Sparkles } from "lucide-react"; +import type { ShopifyProduct } from "@reacteditor/field-shopify"; +import { + useProduct, + useProductRecommendations, +} from "~/editor/hooks/use-shopify-products"; +import { ProductCard } from "./product-card"; +import { Typography } from "~/editor/theme/Typography"; + +export type RecommendedProductsProps = { + product: ShopifyProduct | null; + tagline: string; + heading: string; + limit: number; +}; + +function RecommendedProductsView({ + product: selected, + tagline, + heading, + limit, +}: RecommendedProductsProps) { + const { product } = useProduct(selected?.handle ?? null); + const { recommendations } = useProductRecommendations(product?.id ?? null); + const items = (recommendations ?? []).slice(0, limit); + + if (!selected) { + return ( +
+
+
+ Pick a product to load recommendations. +
+
+
+ ); + } + + return ( +
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} +
+ +
+ {items.length === 0 + ? Array.from({ length: limit }).map((_, i) => ( +
+ )) + : items.map((p: any) => )} +
+
+
+ ); +} + +export function createRecommendedProductsEditor(opts: { + productField: any; +}): ComponentConfig { + return { + label: "Recommended products", + icon: , + category: "commerce", + defaultProps: { + product: null, + tagline: "You may also like", + heading: "More to explore", + limit: 4, + }, + fields: { + product: { label: "Source product", ...opts.productField }, + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + limit: { label: "Limit", type: "number", min: 2, max: 8 }, + }, + render: (props) => , + }; +} diff --git a/editor/components/commerce/shop-footer.tsx b/editor/components/commerce/shop-footer.tsx new file mode 100644 index 0000000..452ff07 --- /dev/null +++ b/editor/components/commerce/shop-footer.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const Footer: React.FC = () => { + return ( +
+
+

+ Store +

+

+ Your premium shopping destination +

+
+

© 2025 Store. All rights reserved.

+
+
+
+ ); +}; + +export default Footer; diff --git a/editor/components/commerce/shop-header.tsx b/editor/components/commerce/shop-header.tsx new file mode 100644 index 0000000..7bee6fa --- /dev/null +++ b/editor/components/commerce/shop-header.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +// local link - plain anchor +import { useShopifyCart } from '~/editor/hooks/use-shopify-cart'; +import config from '~/editor/lib/config.json'; + +const CartIcon: React.FC = () => { + const { toggleCart, itemCount } = useShopifyCart(); + + return ( + + ); +}; + +const Header: React.FC = () => { + return ( + + ); +}; + +export default Header; diff --git a/editor/components/cta/cta.editor.tsx b/editor/components/cta/cta.editor.tsx new file mode 100644 index 0000000..5d0d6a8 --- /dev/null +++ b/editor/components/cta/cta.editor.tsx @@ -0,0 +1,132 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { Megaphone } from "lucide-react"; +import { cn } from "~/editor/lib/utils"; +import { Typography } from "~/editor/theme/Typography"; + +export type CTAProps = { + tagline: string; + heading: string; + subheading: string; + primaryCta: { label: string; href: string }; + secondaryCta: { label: string; href: string }; + imageUrl: string; + align: "left" | "center"; +}; + +function CTA({ + tagline, + heading, + subheading, + primaryCta, + secondaryCta, + imageUrl, + align, +}: CTAProps) { + return ( +
+
+ {imageUrl ? ( + <> + +
+ + ) : ( +
+ )} +
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ {primaryCta?.label ? ( + + {primaryCta.label} + + ) : null} + {secondaryCta?.label ? ( + + {secondaryCta.label} + + ) : null} +
+
+
+ ); +} + +export const ctaEditor: ComponentConfig = { + label: "Call to action", + icon: , + category: "content", + defaultProps: { + tagline: "", + heading: "Designed once. Worn for years.", + subheading: + "Join 40,000 people building a wardrobe they actually reach for.", + primaryCta: { label: "Shop now", href: "/collections" }, + secondaryCta: { label: "Read our story", href: "/about" }, + imageUrl: + "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=2400&q=80", + align: "center", + }, + fields: { + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "textarea", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + primaryCta: { + label: "Primary CTA", + type: "object", + objectFields: { + label: { label: "Label", type: "text", contentEditable: true }, + href: { label: "Link", type: "text" }, + }, + }, + secondaryCta: { + label: "Secondary CTA", + type: "object", + objectFields: { + label: { label: "Label", type: "text", contentEditable: true }, + href: { label: "Link", type: "text" }, + }, + }, + imageUrl: { label: "Background image URL", type: "text" }, + align: { + label: "Alignment", + type: "radio", + options: [ + { label: "Left", value: "left" }, + { label: "Center", value: "center" }, + ], + }, + }, + render: (props) => , +}; diff --git a/editor/components/faq/faq.editor.tsx b/editor/components/faq/faq.editor.tsx new file mode 100644 index 0000000..979e7ce --- /dev/null +++ b/editor/components/faq/faq.editor.tsx @@ -0,0 +1,111 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useState } from "react"; +import { HelpCircle, Plus, Minus } from "lucide-react"; +import { Typography } from "~/editor/theme/Typography"; + +export type FAQProps = { + tagline: string; + heading: string; + subheading: string; + items: Array<{ question: string; answer: string }>; +}; + +function FAQ({ tagline, heading, subheading, items }: FAQProps) { + const [open, setOpen] = useState(0); + return ( +
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ +
+ {items.map((item, i) => { + const isOpen = open === i; + return ( +
+ + {isOpen ? ( +

+ {item.answer} +

+ ) : null} +
+ ); + })} +
+
+
+ ); +} + +export const faqEditor: ComponentConfig = { + label: "FAQ", + icon: , + category: "content", + defaultProps: { + tagline: "Help", + heading: "Common questions", + subheading: "", + items: [ + { + question: "What's your return policy?", + answer: + "Free returns within 30 days of delivery. Items should be unworn with original tags attached.", + }, + { + question: "Where do you ship?", + answer: + "We ship worldwide. Free standard shipping on orders over $150 in the US, $250 international.", + }, + { + question: "How are your products made?", + answer: + "In small batches at family-run mills in Portugal, Italy, and Japan. Every piece is sampled and approved by our team.", + }, + { + question: "How do I care for my pieces?", + answer: + "Cold wash, lay flat to dry, iron when damp. Care details are on every product page and on the inner label.", + }, + ], + }, + fields: { + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + items: { + label: "Items", + type: "array", + defaultItemProps: { question: "", answer: "" }, + getItemSummary: (it) => it?.question || "Question", + arrayFields: { + question: { label: "Question", type: "text", contentEditable: true }, + answer: { label: "Answer", type: "textarea", contentEditable: true }, + }, + }, + }, + render: (props) => , +}; diff --git a/editor/components/features/features.editor.tsx b/editor/components/features/features.editor.tsx new file mode 100644 index 0000000..161ea80 --- /dev/null +++ b/editor/components/features/features.editor.tsx @@ -0,0 +1,104 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { Sparkles } from "lucide-react"; +import { Typography } from "~/editor/theme/Typography"; + +export type FeaturesProps = { + tagline: string; + heading: string; + subheading: string; + columns: "2" | "3" | "4"; + items: Array<{ title: string; body: string }>; +}; + +const colClass: Record = { + "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 ( +
+
+
+ {tagline ? ( +

+ {tagline} +

+ ) : null} + {heading} + {subheading ? ( + + {subheading} + + ) : null} +
+ +
+ {items.map((item, i) => ( +
+

+ {String(i + 1).padStart(2, "0")} +

+ {item.title} + + {item.body} + +
+ ))} +
+
+
+ ); +} + +export const featuresEditor: ComponentConfig = { + label: "Features", + icon: , + category: "content", + defaultProps: { + tagline: "Why us", + heading: "Built with intention", + subheading: "A small set of values that shape every piece we make.", + columns: "3", + items: [ + { + title: "Natural fibers", + body: "Linen, organic cotton, and merino — sourced from mills with traceable supply chains.", + }, + { + title: "Small batches", + body: "Made in considered quantities so nothing goes to waste — and nothing gets discounted into the bin.", + }, + { + title: "Built to last", + body: "Reinforced seams, double-stitched edges, and finishes that age into something better.", + }, + ], + }, + fields: { + tagline: { label: "Tagline", type: "text", contentEditable: true }, + heading: { label: "Heading", type: "text", contentEditable: true }, + subheading: { label: "Subheading", type: "textarea", contentEditable: true }, + columns: { + label: "Columns", + type: "select", + options: [ + { label: "2 columns", value: "2" }, + { label: "3 columns", value: "3" }, + { label: "4 columns", value: "4" }, + ], + }, + items: { + label: "Items", + type: "array", + defaultItemProps: { title: "Feature", body: "Description." }, + getItemSummary: (it) => it?.title || "Feature", + arrayFields: { + title: { label: "Title", type: "text", contentEditable: true }, + body: { label: "Body", type: "textarea", contentEditable: true }, + }, + }, + }, + render: (props) => , +}; diff --git a/editor/components/footer/footer.editor.tsx b/editor/components/footer/footer.editor.tsx new file mode 100644 index 0000000..ee2fee8 --- /dev/null +++ b/editor/components/footer/footer.editor.tsx @@ -0,0 +1,228 @@ +import { ComponentConfig } from "@reacteditor/core"; +import { useState } from "react"; +import { LayoutGrid } from "lucide-react"; +import { Typography } from "~/editor/theme/Typography"; + +export type FooterProps = { + brand: string; + tagline: string; + columns: Array<{ + title: string; + links: Array<{ label: string; href: string }>; + }>; + social: Array<{ label: string; href: string }>; + showNewsletter: "yes" | "no"; + newsletterHeading: string; + newsletterEndpoint: string; + copyright: string; +}; + +function Footer({ + brand, + tagline, + columns, + social, + showNewsletter, + newsletterHeading, + newsletterEndpoint, + copyright, +}: FooterProps) { + const [email, setEmail] = useState(""); + const [submitted, setSubmitted] = useState(false); + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) return; + if (newsletterEndpoint) { + try { + await fetch(newsletterEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + } catch {} + } + setSubmitted(true); + }; + + return ( +
+
+
+
+ + {brand} + + {tagline ? ( + + {tagline} + + ) : null} + + {showNewsletter === "yes" ? ( +
+

{newsletterHeading}

+ {submitted ? ( +

+ Thanks — we'll be in touch. +

+ ) : ( +
+ setEmail(e.target.value)} + placeholder="you@example.com" + className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none" + /> + +
+ )} +
+ ) : null} +
+ +
+ {columns.map((col, i) => ( +
+

+ {col.title} +

+ +
+ ))} +
+
+ +
+

{copyright}

+
+ {social.map((s, i) => ( + + {s.label} + + ))} +
+
+
+
+ ); +} + +export const footerEditor: ComponentConfig = { + label: "Footer", + icon: , + category: "footer", + defaultProps: { + brand: "Maison", + tagline: + "Considered essentials, made in small batches and built to last beyond the season.", + columns: [ + { + title: "Shop", + links: [ + { label: "All", href: "/collections" }, + { label: "New", href: "/collections/new" }, + { label: "Best sellers", href: "/collections/best" }, + ], + }, + { + title: "About", + links: [ + { label: "Our story", href: "/about" }, + { label: "Materials", href: "/materials" }, + { label: "Journal", href: "/journal" }, + ], + }, + { + title: "Help", + links: [ + { label: "Shipping", href: "/help/shipping" }, + { label: "Returns", href: "/help/returns" }, + { label: "Contact", href: "/contact" }, + ], + }, + { + title: "Legal", + links: [ + { label: "Terms", href: "/terms" }, + { label: "Privacy", href: "/privacy" }, + ], + }, + ], + social: [ + { label: "Instagram", href: "#" }, + { label: "Pinterest", href: "#" }, + { label: "TikTok", href: "#" }, + ], + showNewsletter: "yes", + newsletterHeading: "Stay in touch", + newsletterEndpoint: "", + copyright: "© 2026 Maison. All rights reserved.", + }, + fields: { + brand: { label: "Brand", type: "text", contentEditable: true }, + tagline: { label: "Tagline", type: "textarea", contentEditable: true }, + columns: { + label: "Columns", + type: "array", + defaultItemProps: { title: "Column", links: [] }, + getItemSummary: (it) => it?.title || "Column", + arrayFields: { + title: { label: "Title", type: "text", contentEditable: true }, + links: { + label: "Links", + type: "array", + defaultItemProps: { label: "Link", href: "/" }, + getItemSummary: (it) => it?.label || "Link", + arrayFields: { + label: { label: "Label", type: "text", contentEditable: true }, + href: { label: "Link", type: "text" }, + }, + }, + }, + }, + social: { + label: "Social links", + type: "array", + defaultItemProps: { label: "Instagram", href: "#" }, + getItemSummary: (it) => it?.label || "Social", + arrayFields: { + label: { label: "Label", type: "text", contentEditable: true }, + href: { label: "Link", type: "text" }, + }, + }, + showNewsletter: { + label: "Newsletter form", + type: "radio", + options: [ + { label: "Show", value: "yes" }, + { label: "Hide", value: "no" }, + ], + }, + newsletterHeading: { label: "Newsletter heading", type: "text", contentEditable: true }, + newsletterEndpoint: { label: "Newsletter endpoint", type: "text" }, + copyright: { label: "Copyright", type: "text", contentEditable: true }, + }, + render: (props) =>