From 47b773444e862848bd0462f2d514989dd22d8323 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Wed, 3 Jun 2026 13:58:11 -0400 Subject: [PATCH] Initial commit --- .gitignore | 8 + README.md | 28 + api/chat.ts | 227 + app.schema.json | 705 +++ app/_components/editor-shell.tsx | 44 + app/collections/[handle]/page.tsx | 10 + app/editor/collections/[handle]/page.tsx | 16 + app/editor/page.tsx | 10 + app/editor/products/[handle]/page.tsx | 16 + app/editor/search/page.tsx | 10 + app/globals.css | 149 + app/layout.tsx | 17 + app/page.tsx | 10 + app/products/[handle]/page.tsx | 10 + app/providers.tsx | 21 + app/search/page.tsx | 10 + components.json | 25 + components/Heading.tsx | 117 + components/ThemeProvider.tsx | 215 + components/Typography.tsx | 105 + components/commerce/cart-drawer.tsx | 240 + components/commerce/collection-card.tsx | 68 + components/commerce/collection-detail.tsx | 95 + .../commerce/collection-grid.editor.tsx | 31 + components/commerce/collection-grid.tsx | 119 + components/commerce/collection.editor.tsx | 251 + components/commerce/collection.tsx | 554 +++ components/commerce/collections.tsx | 97 + .../commerce/featured-product.editor.tsx | 42 + components/commerce/featured-product.tsx | 128 + components/commerce/product-card.tsx | 78 + components/commerce/product-detail.tsx | 3 + components/commerce/product-detail/index.tsx | 206 + .../product-detail/product-detail-gallery.tsx | 66 + .../product-detail/product-detail-info.tsx | 158 + .../product-recommendations.tsx | 59 + .../commerce/product-details.editor.tsx | 16 + components/commerce/product-details.tsx | 223 + .../commerce/products-carousel.editor.tsx | 42 + components/commerce/products-carousel.tsx | 122 + components/commerce/products-grid.editor.tsx | 41 + components/commerce/products-grid.tsx | 96 + components/commerce/products.tsx | 233 + .../commerce/recommended-products.editor.tsx | 26 + components/commerce/recommended-products.tsx | 68 + .../commerce/search-products.editor.tsx | 242 + components/commerce/search-products.tsx | 533 +++ components/cta/cta.editor.tsx | 53 + components/cta/cta.tsx | 81 + components/faq/faq.editor.tsx | 52 + components/faq/faq.tsx | 56 + components/features/features.editor.tsx | 54 + components/features/features.tsx | 49 + components/footer/footer.editor.tsx | 102 + components/footer/footer.tsx | 130 + components/hero/hero.editor.tsx | 82 + components/hero/hero.tsx | 141 + components/landing/banner.editor.tsx | 30 + components/landing/banner.tsx | 32 + components/landing/image-gallery.editor.tsx | 50 + components/landing/image-gallery.tsx | 87 + components/landing/newsletter-cta.editor.tsx | 80 + components/landing/newsletter-cta.tsx | 190 + components/layout/Container.tsx | 21 + components/logos/logos.editor.tsx | 44 + components/logos/logos.tsx | 47 + components/navigation/navigation.editor.tsx | 74 + components/navigation/navigation.tsx | 139 + .../testimonials/testimonials.editor.tsx | 58 + components/testimonials/testimonials.tsx | 100 + components/ui/accordion.tsx | 66 + components/ui/alert.tsx | 60 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 48 + components/ui/breadcrumb.tsx | 148 + components/ui/button-group.tsx | 97 + components/ui/button.tsx | 58 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 245 + components/ui/dialog.tsx | 266 ++ components/ui/empty.tsx | 105 + components/ui/input-otp.tsx | 86 + components/ui/input.tsx | 21 + components/ui/item.tsx | 213 + components/ui/loader.tsx | 96 + components/ui/navigation-menu.tsx | 151 + components/ui/pagination.tsx | 127 + components/ui/progress.tsx | 44 + components/ui/select.tsx | 287 ++ components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 143 + components/ui/skeleton.tsx | 13 + components/ui/sonner.tsx | 23 + components/ui/spinner.tsx | 38 + components/ui/switch.tsx | 74 + components/ui/table.tsx | 113 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + config/configs.ts | 108 + config/icons.tsx | 51 + config/initial-data.ts | 206 + config/options.ts | 22 + config/root.tsx | 134 + config/types.ts | 61 + contexts/shopify-context.tsx | 300 ++ graphql/cart.js | 137 + graphql/collections.js | 61 + graphql/products.js | 140 + hooks/use-shopify-cart.ts | 165 + hooks/use-shopify-collections.ts | 174 + hooks/use-shopify-products.ts | 209 + hooks/use-shopify-search.ts | 178 + lib/resolve-editor-path.ts | 34 + lib/use-demo-data.ts | 54 + lib/utils.ts | 11 + next-env.d.ts | 6 + next.config.ts | 10 + package.json | 57 + postcss.config.mjs | 5 + services/media-adapter.ts | 65 + services/shopify/client.ts | 63 + tsconfig.json | 64 + tsconfig.tsbuildinfo | 1 + vendor/plugin-ai.css | 427 ++ yarn.lock | 4107 +++++++++++++++++ 125 files changed, 16971 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/chat.ts create mode 100644 app.schema.json create mode 100644 app/_components/editor-shell.tsx create mode 100644 app/collections/[handle]/page.tsx create mode 100644 app/editor/collections/[handle]/page.tsx create mode 100644 app/editor/page.tsx create mode 100644 app/editor/products/[handle]/page.tsx create mode 100644 app/editor/search/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/products/[handle]/page.tsx create mode 100644 app/providers.tsx create mode 100644 app/search/page.tsx create mode 100644 components.json create mode 100644 components/Heading.tsx create mode 100644 components/ThemeProvider.tsx create mode 100644 components/Typography.tsx create mode 100644 components/commerce/cart-drawer.tsx create mode 100644 components/commerce/collection-card.tsx create mode 100644 components/commerce/collection-detail.tsx create mode 100644 components/commerce/collection-grid.editor.tsx create mode 100644 components/commerce/collection-grid.tsx create mode 100644 components/commerce/collection.editor.tsx create mode 100644 components/commerce/collection.tsx create mode 100644 components/commerce/collections.tsx create mode 100644 components/commerce/featured-product.editor.tsx create mode 100644 components/commerce/featured-product.tsx create mode 100644 components/commerce/product-card.tsx create mode 100644 components/commerce/product-detail.tsx create mode 100644 components/commerce/product-detail/index.tsx create mode 100644 components/commerce/product-detail/product-detail-gallery.tsx create mode 100644 components/commerce/product-detail/product-detail-info.tsx create mode 100644 components/commerce/product-detail/product-recommendations.tsx create mode 100644 components/commerce/product-details.editor.tsx create mode 100644 components/commerce/product-details.tsx create mode 100644 components/commerce/products-carousel.editor.tsx create mode 100644 components/commerce/products-carousel.tsx create mode 100644 components/commerce/products-grid.editor.tsx create mode 100644 components/commerce/products-grid.tsx create mode 100644 components/commerce/products.tsx create mode 100644 components/commerce/recommended-products.editor.tsx create mode 100644 components/commerce/recommended-products.tsx create mode 100644 components/commerce/search-products.editor.tsx create mode 100644 components/commerce/search-products.tsx create mode 100644 components/cta/cta.editor.tsx create mode 100644 components/cta/cta.tsx create mode 100644 components/faq/faq.editor.tsx create mode 100644 components/faq/faq.tsx create mode 100644 components/features/features.editor.tsx create mode 100644 components/features/features.tsx create mode 100644 components/footer/footer.editor.tsx create mode 100644 components/footer/footer.tsx create mode 100644 components/hero/hero.editor.tsx create mode 100644 components/hero/hero.tsx create mode 100644 components/landing/banner.editor.tsx create mode 100644 components/landing/banner.tsx create mode 100644 components/landing/image-gallery.editor.tsx create mode 100644 components/landing/image-gallery.tsx create mode 100644 components/landing/newsletter-cta.editor.tsx create mode 100644 components/landing/newsletter-cta.tsx create mode 100644 components/layout/Container.tsx create mode 100644 components/logos/logos.editor.tsx create mode 100644 components/logos/logos.tsx create mode 100644 components/navigation/navigation.editor.tsx create mode 100644 components/navigation/navigation.tsx create mode 100644 components/testimonials/testimonials.editor.tsx create mode 100644 components/testimonials/testimonials.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/loader.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 config/configs.ts create mode 100644 config/icons.tsx create mode 100644 config/initial-data.ts create mode 100644 config/options.ts create mode 100644 config/root.tsx create mode 100644 config/types.ts create mode 100644 contexts/shopify-context.tsx create mode 100644 graphql/cart.js create mode 100644 graphql/collections.js create mode 100644 graphql/products.js create mode 100644 hooks/use-shopify-cart.ts create mode 100644 hooks/use-shopify-collections.ts create mode 100644 hooks/use-shopify-products.ts create mode 100644 hooks/use-shopify-search.ts create mode 100644 lib/resolve-editor-path.ts create mode 100644 lib/use-demo-data.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 services/media-adapter.ts create mode 100644 services/shopify/client.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo create mode 100644 vendor/plugin-ai.css create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1010f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.next +.DS_Store +.env +.env.local +.env.*.local +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef38390 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# React Editor Demo (Shopify) + +Standalone Vite SPA wiring up the Shopify-aware React Editor via `` from `@reacteditor/core`. + +## What's here + +- **`index.html`** — Vite entrypoint. +- **`src/main.tsx`** — mounts `` into `#root`. +- **`src/App.tsx`** — wires `` from `@reacteditor/core` with the schema, plugins, and the Shopify provider. +- **`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": { ... }, ... }`. +- **`api/chat.ts`** — `aiPlugin` endpoint exposed in dev via a Vite middleware. Talks to Claude via `@ai-sdk/anthropic` 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 +``` + +## Shopify + +Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `VITE_SHOPIFY_DOMAIN` and `VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`. + +## AI + +`/api/chat` is served in dev by a Vite middleware that loads `api/chat.ts` via `ssrLoadModule`. Set `ANTHROPIC_API_KEY` in `.env.local`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. For production deployment you'll need to host `api/chat.ts` behind your own Node server (the handler exports a standard `POST(req: Request): Promise`). diff --git a/api/chat.ts b/api/chat.ts new file mode 100644 index 0000000..0041efa --- /dev/null +++ b/api/chat.ts @@ -0,0 +1,227 @@ +import { + convertToModelMessages, + stepCountIs, + streamText, + tool, + type UIMessage, +} from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { + reactEditorTools, + getEditorContext, +} from "@reacteditor/plugin-ai/server"; +import { shopifyFetch } from "@/services/shopify/client"; +import { + GET_PRODUCTS_QUERY, + GET_PRODUCT_QUERY, +} from "@/graphql/products"; +import { + GET_COLLECTIONS_QUERY, + GET_COLLECTION_PRODUCTS_QUERY, +} from "@/graphql/collections"; + +type Body = { + messages: UIMessage[]; + editorContext?: Parameters[0]; + route?: string; +}; + +export async function POST(req: Request) { + const { messages, editorContext } = (await req.json()) as Body; + + 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.VITE_SHOPIFY_DOMAIN ?? + process.env.SHOPIFY_DOMAIN ?? + "mock.shop", + token: + process.env.VITE_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-5"), + system: getEditorContext(editorContext), + messages: await convertToModelMessages(messages), + tools: { + ...reactEditorTools, + generateImage, + searchProducts, + getProductByHandle, + searchCollections, + getCollectionByHandle, + }, + stopWhen: stepCountIs(50), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/app.schema.json b/app.schema.json new file mode 100644 index 0000000..d8108d3 --- /dev/null +++ b/app.schema.json @@ -0,0 +1,705 @@ +{ + "/": { + "root": { + "props": { + "title": "Pulse — Made to Move", + "headerFont": "Archivo Black", + "headerFontWeight": "400", + "bodyFont": "Inter", + "primaryColor": "#111111", + "secondaryColor": "#707072", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#111111", + "mutedColor": "#f5f5f5", + "radius": "xl", + "buttonRadius": "full", + "shadow": "none", + "maxWidth": "2xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-home", + "brand": "PULSE", + "links": [ + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Recover", "href": "/collections/recover" }, + { "label": "Sale", "href": "/collections/sale" }, + { "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 to move.", + "subheading": "Performance gear engineered for athletes who train like they mean it. Built light. Built honest. Built for the next mile.", + "buttons": [ + { "label": "Shop the kit", "href": "/search", "variant": "primary" }, + { "label": "Our story", "href": "/about", "variant": "secondary" } + ], + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/athletic-assortment.jpeg", + "align": "left", + "height": "lg", + "tone": "light" + } + }, + { + "type": "products-carousel", + "props": { + "id": "carousel-home", + "tagline": "Just dropped", + "heading": "New arrivals", + "subheading": "Fresh kit for the season ahead.", + "limit": 12, + "slidesPerView": "4", + "ctaLabel": "Shop new", + "ctaHref": "/collections/new" + } + }, + { + "type": "hero", + "props": { + "id": "cover-home-performance", + "tagline": "Performance Series", + "heading": "Engineered to outlast you.", + "subheading": "Tested on track, trail, and treadmill. Every piece earns its place.", + "buttons": [], + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg", + "align": "left", + "height": "lg", + "tone": "light" + } + }, + { + "type": "features", + "props": { + "id": "features-home", + "tagline": "Why Pulse", + "heading": "Three rules. No exceptions.", + "subheading": "", + "columns": "3", + "items": [ + { + "title": "Engineered", + "body": "Every fabric, every seam, every fit decision starts with athletes on a track. Lab work comes after." + }, + { + "title": "Field tested", + "body": "Twelve weeks. Two race blocks. One full season. Nothing ships without that on the record." + }, + { + "title": "Guaranteed", + "body": "If it fails before its time, we replace it. If it fails on race day, we apologize and replace it." + } + ] + } + }, + { + "type": "hero", + "props": { + "id": "cover-home-discipline", + "tagline": "Discipline", + "heading": "The work happens before sunrise.", + "subheading": "We make the kit. You do the work. Five-thirty isn't early — it's the only honest hour.", + "buttons": [], + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-discipline.jpeg", + "align": "left", + "height": "lg", + "tone": "light" + } + }, + { + "type": "collection-grid", + "props": { + "id": "collections-home", + "tagline": "Shop by sport", + "heading": "Built for what you actually do.", + "subheading": "", + "layout": "tiles", + "limit": 6 + } + }, + { + "type": "testimonials", + "props": { + "id": "testimonials-home", + "tagline": "From the field", + "heading": "Athletes on Pulse", + "items": [ + { + "quote": "Took the shell to Chamonix and back. Still smells, still works, still cheaper than therapy.", + "author": "Mateo S.", + "role": "Sub-3 marathoner / Boulder", + "avatar": "" + }, + { + "quote": "I have one drawer of running gear. The Pulse half is the half I actually wear.", + "author": "Priya N.", + "role": "Ultra runner / Portland", + "avatar": "" + }, + { + "quote": "Wore the shorts for an entire 16-week block. They held. That's the whole review.", + "author": "Jonas R.", + "role": "Track coach / Berlin", + "avatar": "" + } + ] + } + }, + { + "type": "newsletter-cta", + "props": { + "id": "newsletter-home", + "tagline": "Field notes", + "heading": "Weekly notes from the lab.", + "subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.", + "buttonLabel": "Subscribe", + "endpoint": "", + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png", + "layout": "split" + } + }, + { + "type": "footer", + "props": { + "id": "footer-home", + "brand": "PULSE", + "tagline": "Performance gear for athletes who break their kit before they break themselves.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All gear", "href": "/search" }, + { "label": "New", "href": "/collections/new" }, + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Sale", "href": "/collections/sale" } + ] + }, + { + "title": "About", + "links": [ + { "label": "Our story", "href": "/about" }, + { "label": "The lab", "href": "/lab" }, + { "label": "Athletes", "href": "/athletes" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" }, + { "label": "Lifetime guarantee", "href": "/help/guarantee" }, + { "label": "Contact", "href": "/contact" } + ] + }, + { + "title": "Legal", + "links": [ + { "label": "Terms", "href": "/terms" }, + { "label": "Privacy", "href": "/privacy" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Strava", "href": "#" }, + { "label": "YouTube", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "", + "newsletterEndpoint": "", + "copyright": "© 2026 Pulse. Built to be replaced when used up." + } + } + ] + }, + "/products/:handle": { + "root": { + "props": { + "title": "Pulse", + "headerFont": "Archivo Black", + "headerFontWeight": "400", + "bodyFont": "Inter", + "primaryColor": "#111111", + "secondaryColor": "#707072", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#111111", + "mutedColor": "#f5f5f5", + "radius": "xl", + "buttonRadius": "full", + "shadow": "none", + "maxWidth": "2xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-product", + "brand": "PULSE", + "links": [ + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Recover", "href": "/collections/recover" }, + { "label": "Sale", "href": "/collections/sale" }, + { "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": "Pairs well with", + "heading": "Complete the kit", + "limit": 8, + "slidesPerView": "4", + "ctaLabel": "Shop all", + "ctaHref": "/collections" + } + }, + { + "type": "footer", + "props": { + "id": "footer-product", + "brand": "PULSE", + "tagline": "Performance gear for athletes who break their kit before they break themselves.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All gear", "href": "/search" }, + { "label": "New", "href": "/collections/new" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" }, + { "label": "Lifetime guarantee", "href": "/help/guarantee" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Strava", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "", + "newsletterEndpoint": "", + "copyright": "© 2026 Pulse. Built to be replaced when used up." + } + } + ] + }, + "/collections/:handle": { + "root": { + "props": { + "title": "Collection — Pulse", + "headerFont": "Archivo Black", + "headerFontWeight": "400", + "bodyFont": "Inter", + "primaryColor": "#111111", + "secondaryColor": "#707072", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#111111", + "mutedColor": "#f5f5f5", + "radius": "xl", + "buttonRadius": "full", + "shadow": "none", + "maxWidth": "2xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-collection", + "brand": "PULSE", + "links": [ + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Recover", "href": "/collections/recover" }, + { "label": "Sale", "href": "/collections/sale" }, + { "label": "About", "href": "/about" } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "collection", + "props": { + "id": "collection-detail", + "collection": null, + "showDescription": "yes", + "showCoverImage": "yes", + "customCoverImage": "", + "columns": "4", + "limit": 24, + "defaultSort": "BEST_SELLING", + "showAvailability": "yes", + "showPriceRange": "yes", + "showProductType": "yes", + "productTypeOptions": [ + { "label": "Tops" }, + { "label": "Shorts" }, + { "label": "Tights" }, + { "label": "Outerwear" }, + { "label": "Shoes" }, + { "label": "Accessories" } + ], + "showVendor": "no", + "vendorOptions": [], + "showTags": "no", + "tagOptions": [], + "showColor": "yes", + "colorOptions": [ + { "label": "Black", "color": "#111111" }, + { "label": "White", "color": "#ffffff" }, + { "label": "Grey", "color": "#707072" }, + { "label": "Volt", "color": "#d6ff3f" }, + { "label": "Sodium", "color": "#fa5400" } + ], + "showStyle": "no", + "styleOptions": [], + "showSize": "yes", + "sizeOptions": [ + { "label": "XS" }, + { "label": "S" }, + { "label": "M" }, + { "label": "L" }, + { "label": "XL" }, + { "label": "XXL" } + ], + "showMaterial": "no", + "materialOptions": [], + "metafieldFilters": [] + } + }, + { + "type": "footer", + "props": { + "id": "footer-collection", + "brand": "PULSE", + "tagline": "Performance gear for athletes who break their kit before they break themselves.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All gear", "href": "/search" }, + { "label": "New", "href": "/collections/new" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Strava", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "", + "newsletterEndpoint": "", + "copyright": "© 2026 Pulse. Built to be replaced when used up." + } + } + ] + }, + "/search": { + "root": { + "props": { + "title": "Shop — Pulse", + "headerFont": "Archivo Black", + "headerFontWeight": "400", + "bodyFont": "Inter", + "primaryColor": "#111111", + "secondaryColor": "#707072", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#111111", + "mutedColor": "#f5f5f5", + "radius": "xl", + "buttonRadius": "full", + "shadow": "none", + "maxWidth": "2xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-search", + "brand": "PULSE", + "links": [ + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Recover", "href": "/collections/recover" }, + { "label": "Sale", "href": "/collections/sale" }, + { "label": "About", "href": "/about" } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "search-products", + "props": { + "id": "search-products", + "heading": "All gear.", + "subheading": "Filter by sport, fabric, fit, color. Or sort by what's been beaten the hardest.", + "columns": "4", + "limit": 24, + "showAvailability": "yes", + "showPriceRange": "yes", + "showProductType": "yes", + "showVendor": "no", + "showTags": "yes", + "metafieldFilters": [], + "defaultSort": "BEST_SELLING", + "productTypeOptions": [ + { "label": "Tops" }, + { "label": "Shorts" }, + { "label": "Tights" }, + { "label": "Outerwear" }, + { "label": "Shoes" }, + { "label": "Accessories" } + ], + "vendorOptions": [], + "tagOptions": [ + { "label": "New" }, + { "label": "Race day" }, + { "label": "Field tested" }, + { "label": "Sale" } + ] + } + }, + { + "type": "footer", + "props": { + "id": "footer-search", + "brand": "PULSE", + "tagline": "Performance gear for athletes who break their kit before they break themselves.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All gear", "href": "/search" }, + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Strava", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "", + "newsletterEndpoint": "", + "copyright": "© 2026 Pulse. Built to be replaced when used up." + } + } + ] + }, + "/about": { + "root": { + "props": { + "title": "About — Pulse", + "headerFont": "Archivo Black", + "headerFontWeight": "400", + "bodyFont": "Inter", + "primaryColor": "#111111", + "secondaryColor": "#707072", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#111111", + "mutedColor": "#f5f5f5", + "radius": "xl", + "buttonRadius": "full", + "shadow": "none", + "maxWidth": "2xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-about", + "brand": "PULSE", + "links": [ + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" }, + { "label": "Recover", "href": "/collections/recover" }, + { "label": "Sale", "href": "/collections/sale" }, + { "label": "About", "href": "/about" } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "hero", + "props": { + "id": "hero-about", + "tagline": "The lab", + "heading": "We don't make activewear. We make tools.", + "subheading": "Pulse started in a converted warehouse with three coaches, two athletes, and a whiteboard full of things they wished worked better. Eight years later, the whiteboard is still there. So is the standard.", + "buttons": [ + { "label": "Shop the kit", "href": "/search", "variant": "primary" } + ], + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/hero-about.png", + "align": "left", + "height": "lg", + "tone": "light" + } + }, + { + "type": "features", + "props": { + "id": "features-about", + "tagline": "What we believe", + "heading": "Three rules. No exceptions.", + "subheading": "", + "columns": "3", + "items": [ + { + "title": "Engineered", + "body": "Every fabric, every seam, every fit decision starts with athletes on a track. Lab work comes after, if at all." + }, + { + "title": "Field tested", + "body": "Twelve weeks of training. Two race blocks. One full season. Nothing leaves the lab without that on the record." + }, + { + "title": "Guaranteed", + "body": "If it fails before its time, we replace it. If it fails on race day, we apologize and replace it. That's the deal." + } + ] + } + }, + { + "type": "hero", + "props": { + "id": "cover-about-performance", + "tagline": "Used up is the goal", + "heading": "Designed to be worn out, not preserved.", + "subheading": "If you're babying your kit, we did something wrong. Bring it back when it's done — we'll send the next one.", + "buttons": [], + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg", + "align": "left", + "height": "lg", + "tone": "light" + } + }, + { + "type": "newsletter-cta", + "props": { + "id": "newsletter-about", + "tagline": "Field notes", + "heading": "Weekly notes from the lab.", + "subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.", + "buttonLabel": "Subscribe", + "endpoint": "", + "imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png", + "layout": "split" + } + }, + { + "type": "footer", + "props": { + "id": "footer-about", + "brand": "PULSE", + "tagline": "Performance gear for athletes who break their kit before they break themselves.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All gear", "href": "/search" }, + { "label": "New", "href": "/collections/new" }, + { "label": "Run", "href": "/collections/run" }, + { "label": "Train", "href": "/collections/train" } + ] + }, + { + "title": "About", + "links": [ + { "label": "Our story", "href": "/about" }, + { "label": "The lab", "href": "/lab" }, + { "label": "Athletes", "href": "/athletes" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" }, + { "label": "Lifetime guarantee", "href": "/help/guarantee" }, + { "label": "Contact", "href": "/contact" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Strava", "href": "#" }, + { "label": "YouTube", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "", + "newsletterEndpoint": "", + "copyright": "© 2026 Pulse. Built to be replaced when used up." + } + } + ] + } +} diff --git a/app/_components/editor-shell.tsx b/app/_components/editor-shell.tsx new file mode 100644 index 0000000..edd2fdf --- /dev/null +++ b/app/_components/editor-shell.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useMemo } from "react"; +import { Editor } from "@reacteditor/core"; +import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn"; +import type { UserConfig } from "@/config/types"; + +export type EditorShellProps = { + config: UserConfig; + data: any; + routeKey?: string; +}; + +export function EditorShell({ config, data, routeKey }: EditorShellProps) { + const plugins = useMemo(() => [createTailwindCdnPlugin()], []); + + const handlePublish = async (nextData: any, route?: any) => { + const resolved = route ?? (routeKey ? { key: routeKey } : undefined); + console.log({ + type: "PUBLISH", + data: { data: nextData, route: JSON.stringify(resolved) }, + }); + if (typeof window !== "undefined" && window.parent !== window) { + window.parent.postMessage( + { type: "PUBLISH", data: { data: nextData, route: resolved } }, + "*", + ); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + }; + + return ( +
+ +
+ ); +} diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx new file mode 100644 index 0000000..1ea4982 --- /dev/null +++ b/app/collections/[handle]/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Render } from "@reacteditor/core"; +import { collectionsConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function CollectionPage() { + const data = (schema as any)["/collections/:handle"]; + return ; +} diff --git a/app/editor/collections/[handle]/page.tsx b/app/editor/collections/[handle]/page.tsx new file mode 100644 index 0000000..4966492 --- /dev/null +++ b/app/editor/collections/[handle]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { EditorShell } from "@/app/_components/editor-shell"; +import { collectionsConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function CollectionEditorPage() { + const data = (schema as any)["/collections/:handle"]; + return ( + + ); +} diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..b56e264 --- /dev/null +++ b/app/editor/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { EditorShell } from "@/app/_components/editor-shell"; +import { homeConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function HomeEditorPage() { + const data = (schema as any)["/"]; + return ; +} diff --git a/app/editor/products/[handle]/page.tsx b/app/editor/products/[handle]/page.tsx new file mode 100644 index 0000000..d6531d3 --- /dev/null +++ b/app/editor/products/[handle]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { EditorShell } from "@/app/_components/editor-shell"; +import { productConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function ProductEditorPage() { + const data = (schema as any)["/products/:handle"]; + return ( + + ); +} diff --git a/app/editor/search/page.tsx b/app/editor/search/page.tsx new file mode 100644 index 0000000..9964b84 --- /dev/null +++ b/app/editor/search/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { EditorShell } from "@/app/_components/editor-shell"; +import { searchConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function SearchEditorPage() { + const data = (schema as any)["/search"]; + return ; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a116567 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,149 @@ +@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) * 0.5); + --radius-md: calc(var(--radius) * 0.75); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.5); + + --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))); } + } + --font-heading: var(--font-sans); + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --radius-2xl: calc(var(--radius) * 2); + --radius-3xl: calc(var(--radius) * 3); + --radius-4xl: calc(var(--radius) * 4) +} + +:root { + --radius: 0.625rem; + --font-header: system-ui, -apple-system, sans-serif; + --font-body: system-ui, -apple-system, sans-serif; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: #fafafa; + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +@layer base { + * { @apply outline-ring/50; } + html, body { background-color: var(--background); color: var(--foreground); } + body { + font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji"; + margin: 0; + @apply bg-background text-foreground; + } + html { + @apply font-sans;} +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..dd3ec4d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import "./globals.css"; +import { Providers } from "./providers"; + +export const metadata = { + title: "Shopify Storefront", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f0bbf43 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Render } from "@reacteditor/core"; +import { homeConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function HomePage() { + const data = (schema as any)["/"]; + return ; +} diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx new file mode 100644 index 0000000..6ebffe8 --- /dev/null +++ b/app/products/[handle]/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Render } from "@reacteditor/core"; +import { productConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function ProductPage() { + const data = (schema as any)["/products/:handle"]; + return ; +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..32090a3 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { ShopifyProvider } from "@/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 function Providers({ children }: { children: ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) return null; + + return ( + + {children} + + ); +} diff --git a/app/search/page.tsx b/app/search/page.tsx new file mode 100644 index 0000000..cb4e512 --- /dev/null +++ b/app/search/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Render } from "@reacteditor/core"; +import { searchConfig } from "@/config/configs"; +import schema from "@/app.schema.json"; + +export default function SearchPage() { + const data = (schema as any)["/search"]; + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/components/Heading.tsx b/components/Heading.tsx new file mode 100644 index 0000000..bbcfc7f --- /dev/null +++ b/components/Heading.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { Typography, type TypographyVariant } from "@/components/Typography"; + +export type HeadingSize = "sm" | "md" | "lg" | "xl"; +export type HeadingAlign = "left" | "center"; +export type HeadingTone = "default" | "light"; + +type SizeMap = { + title: TypographyVariant; + subtitle: TypographyVariant; + taglineGap: string; + subtitleGap: string; +}; + +const sizeMap: Record = { + sm: { + title: "h4", + subtitle: "subtitle2", + taglineGap: "mb-2", + subtitleGap: "mt-2", + }, + md: { + title: "h3", + subtitle: "subtitle1", + taglineGap: "mb-3", + subtitleGap: "mt-3", + }, + lg: { + title: "h2", + subtitle: "subtitle1", + taglineGap: "mb-3", + subtitleGap: "mt-3", + }, + xl: { + title: "h1", + subtitle: "subtitle1", + taglineGap: "mb-4", + subtitleGap: "mt-4", + }, +}; + +const alignClasses: Record = { + left: "items-start text-left", + center: "items-center text-center", +}; + +export type HeadingProps = { + tagline?: React.ReactNode; + title?: React.ReactNode; + subtitle?: React.ReactNode; + size?: HeadingSize; + align?: HeadingAlign; + tone?: HeadingTone; + className?: string; + titleClassName?: string; + subtitleClassName?: string; + taglineClassName?: string; + maxWidth?: string; +}; + +export function Heading({ + tagline, + title, + subtitle, + size = "lg", + align = "left", + tone = "default", + className, + titleClassName, + subtitleClassName, + taglineClassName, + maxWidth, +}: HeadingProps) { + if (!tagline && !title && !subtitle) return null; + const map = sizeMap[size]; + const isLight = tone === "light"; + + return ( +
+ {tagline ? ( + + {tagline} + + ) : null} + {title ? ( + + {title} + + ) : null} + {subtitle ? ( + + {subtitle} + + ) : null} +
+ ); +} + +export default Heading; diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx new file mode 100644 index 0000000..d5ce364 --- /dev/null +++ b/components/ThemeProvider.tsx @@ -0,0 +1,215 @@ +import * as React from "react"; +import { useEffect, useMemo, useRef } from "react"; + +export type ThemeProps = { + headerFont?: string; + headerFontWeight?: string; + bodyFont?: string; + primaryColor?: string; + primaryForegroundColor?: string; + secondaryColor?: string; + accentColor?: string; + bgColor?: string; + fgColor?: string; + mutedColor?: string; + mutedForegroundColor?: string; + borderColor?: string; + radius?: "none" | "sm" | "md" | "lg" | "xl"; + shadow?: "none" | "sm" | "md" | "lg" | "xl"; + maxWidth?: "sm" | "md" | "lg" | "xl" | "full"; +}; + +const radiusMap: Record, string> = { + none: "0px", + sm: "0.25rem", + md: "0.5rem", + lg: "0.75rem", + xl: "1rem", +}; + +const maxWidthMap: Record, string> = { + sm: "64rem", + md: "72rem", + lg: "80rem", + xl: "96rem", + full: "100%", +}; + +const shadowMap: Record, string> = { + none: "0 0 #0000", + sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + md: "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)", + lg: "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", + xl: "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", +}; + +function googleFontsHref( + headerFont?: string, + bodyFont?: string, + headerFontWeight?: string, +): string | null { + const valid = (f?: string): f is string => !!f && f !== "system-ui"; + const families: string[] = []; + const seen = new Set(); + + const headerWeight = headerFontWeight || "400"; + const bodyWeights = "400;500;600;700"; + + if (valid(headerFont)) { + seen.add(headerFont); + // Header font uses the configured weight only — avoids HTTP 400 from + // Google Fonts when a single-weight family (e.g. Archivo Black) is paired + // with a multi-weight default request. + families.push( + `family=${encodeURIComponent(headerFont)}:wght@${headerWeight}`, + ); + } + if (valid(bodyFont) && !seen.has(bodyFont)) { + seen.add(bodyFont); + families.push( + `family=${encodeURIComponent(bodyFont)}:wght@${bodyWeights}`, + ); + } + if (families.length === 0) return null; + return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`; +} + +export function ThemeProvider({ + headerFont, + headerFontWeight, + bodyFont, + primaryColor, + primaryForegroundColor, + secondaryColor, + accentColor, + bgColor, + fgColor, + mutedColor, + mutedForegroundColor, + borderColor, + radius, + shadow, + maxWidth, + children, +}: ThemeProps & { children?: React.ReactNode }) { + // Recompute CSS-variable map only when a relevant prop changes. + const cssVars = useMemo>(() => { + const vars: Record = {}; + if (primaryColor) vars["--primary"] = primaryColor; + if (primaryForegroundColor) vars["--primary-foreground"] = primaryForegroundColor; + if (secondaryColor) vars["--secondary"] = secondaryColor; + if (accentColor) vars["--accent"] = accentColor; + if (bgColor) vars["--background"] = bgColor; + if (fgColor) vars["--foreground"] = fgColor; + if (mutedColor) vars["--muted"] = mutedColor; + if (mutedForegroundColor) vars["--muted-foreground"] = mutedForegroundColor; + if (borderColor) vars["--border"] = borderColor; + if (radius) vars["--radius"] = radiusMap[radius]; + if (shadow) vars["--shadow"] = shadowMap[shadow]; + if (maxWidth) vars["--container-max-width"] = maxWidthMap[maxWidth]; + if (headerFont) vars["--font-header"] = `"${headerFont}", system-ui, sans-serif`; + if (bodyFont) vars["--font-body"] = `"${bodyFont}", system-ui, sans-serif`; + if (headerFontWeight) vars["--font-weight-header"] = headerFontWeight; + return vars; + }, [ + headerFont, + headerFontWeight, + bodyFont, + primaryColor, + primaryForegroundColor, + secondaryColor, + accentColor, + bgColor, + fgColor, + mutedColor, + mutedForegroundColor, + borderColor, + radius, + shadow, + maxWidth, + ]); + + // Imperatively push every CSS var onto :root inside the host document + // (which is the iframe's document for the editor preview, and the page + // for the published render). This guarantees descendants pick up + // updates even if React's style-prop diffing missed something or the + // base-layer rules need access via :root inheritance. + const rootRef = useRef(null); + useEffect(() => { + const doc = + rootRef.current?.ownerDocument ?? + (typeof document !== "undefined" ? document : null); + if (!doc) return; + const target = doc.documentElement; + const previous: Record = {}; + for (const [key, value] of Object.entries(cssVars)) { + previous[key] = target.style.getPropertyValue(key); + target.style.setProperty(key, value); + } + return () => { + // Restore prior values so unmount doesn't leak our overrides. + for (const [key, value] of Object.entries(previous)) { + if (value) target.style.setProperty(key, value); + else target.style.removeProperty(key); + } + }; + }, [cssVars]); + + const fontsHref = useMemo( + () => googleFontsHref(headerFont, bodyFont, headerFontWeight), + [headerFont, bodyFont, headerFontWeight], + ); + + // Plain CSS rules — applied directly, no Tailwind CDN runtime needed. + // Body font is set on `body` once and inherited by descendants (span, a, + // p, li, etc. don't need explicit rules — applying one to `span/a` would + // break heading children, anchors inside headings, and any element using + // `as="span"` to render a heading variant). + // Form controls have user-agent defaults, so they need an explicit override. + // Tailwind preflight resets h1..h6 to font-family: inherit, so we restore + // the heading font + weight here using the per-page CSS vars. + const css = ` + body { + font-family: var(--font-body), system-ui, -apple-system, sans-serif; + } + button, input, textarea, select { + font-family: inherit; + } + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-header), system-ui, -apple-system, sans-serif; + font-weight: var(--font-weight-header, 600); + } + `; + + // Tailwind theme directives — only useful if/when the CDN compiles + // font-heading / font-body utilities. The plain CSS above handles the + // common case so headers always render with the right font. + const tailwindCss = ` + @theme { + --font-family-heading: var(--font-header), system-ui, -apple-system, sans-serif; + --font-family-body: var(--font-body), system-ui, -apple-system, sans-serif; + --radius-sm: calc(var(--radius) * 0.5); + --radius-md: calc(var(--radius) * 0.75); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.5); + --radius-2xl: calc(var(--radius) * 2); + --radius-3xl: calc(var(--radius) * 3); + } + `; + + return ( + <> + {fontsHref ? : null} +