Compare commits

...
11 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5 f11a764426 Load page.json by import, publish via fs, drop cookie auth
The editor no longer round-trips through /api/pages. Each route's
editor/page.tsx imports its own `../page.json` and hands it to
PageEditor as a prop, so the editor opens with the page already in
hand — no fetch, no loading state, no undo history seeded from a
placeholder. Globals still come from app.globals.json.

Publishing moves from `PUT /api/pages` to a `publishPage` server
action that writes the route's page.json with node:fs directly.
The target path is still built from the lib/pages.ts registry rather
than from the caller, so an unknown route key is rejected instead of
escaping app/.

Removes the customer account auth entirely: the httpOnly cookie
session, the /api/account/* handlers, the customer service and
GraphQL documents, the account-* blocks, and the /account/* routes.
The template has no auth, so nothing reads a cookie now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STGDvL4X7FhHHnxdRE2ayo
2026-08-09 16:36:18 -04:00
Rami Bitar 63ecc5e284 Add React editor project 2026-08-09 16:00:10 -04:00
Rami Bitar 909d99251a Update packages 2026-06-08 11:52:16 -04:00
Rami Bitar 528f2d2ab3 update render 2026-06-08 11:51:48 -04:00
Rami Bitar ff2de13f99 remove editor routes, these will be served by the platform 2026-06-06 18:56:07 -04:00
Rami Bitar ef9e10256d update components 2026-06-06 14:21:55 -04:00
Rami Bitar ed346476e5 bump reacteditor 2026-06-06 14:06:26 -04:00
Rami Bitar a813d3705e update 2026-06-06 13:58:43 -04:00
Rami Bitar 8db5baf673 Fix carousel 2026-06-06 13:51:08 -04:00
Rami Bitar aa58af410d add nextjs style routes 2026-06-06 13:46:56 -04:00
Rami Bitar ef550e9b55 Fix app schema 2026-06-06 12:13:53 -04:00
204 changed files with 19187 additions and 13848 deletions
+12
View File
@@ -0,0 +1,12 @@
# Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0
# Store assistant (/api/chat) — https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Optional; defaults to openai/gpt-5.6-luna-pro
# OPENROUTER_MODEL=
+11
View File
@@ -6,3 +6,14 @@ dist
.env.local
.env.*.local
*.log
# Build caches
tsconfig.tsbuildinfo
next-env.d.ts
# Yarn (Berry) — keep the lockfile and .yarnrc.yml, ignore local state
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.pnp.*
+9
View File
@@ -0,0 +1,9 @@
nodeLinker: node-modules
# Yarn quarantines npm releases younger than `npmMinimalAgeGate` (24h by
# default). The @reacteditor alpha channel and the Hydrogen preview build are
# both pinned to deliberately-recent versions, so preapprove just those rather
# than dropping the age gate for the whole dependency tree.
npmPreapprovedPackages:
- "@reacteditor/*"
- "@shopify/hydrogen"
+53 -1
View File
@@ -1 +1,53 @@
{}
{
"header": {
"props": {
"storeName": "Shop",
"logoUrl": "",
"showAnnouncement": "yes",
"announcement": "Free shipping on orders over $200",
"announcementUrl": "",
"links": [
{
"label": "Products",
"url": "/"
},
{
"label": "Collections",
"url": "/collections"
}
]
}
},
"footer": {
"props": {
"storeName": "Shop",
"logoUrl": "",
"copyright": "\u00a9 2026 Shop. All rights reserved.",
"links": [
{
"label": "Terms of Service",
"url": "/policies/terms-of-service"
},
{
"label": "Privacy Policy",
"url": "/policies/privacy-policy"
},
{
"label": "Refund Policy",
"url": "/policies/refund-policy"
},
{
"label": "Shipping Policy",
"url": "/policies/shipping-policy"
},
{
"label": "Subscription Policy",
"url": "/policies/subscription-policy"
}
],
"instagramUrl": "#",
"tiktokUrl": "#",
"facebookUrl": "#"
}
}
}
-705
View File
@@ -1,705 +0,0 @@
{
"/": {
"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."
}
}
]
}
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { useParams } from "next/navigation";
import { Render } from "@reacteditor/core";
import { appConfig } from "@/editor.config";
import resolveRoute from "@/lib/resolve-route";
import schema from "@/app.schema.json";
import globals from "@/app.globals.json";
export default function Page() {
const params = useParams();
const segments = Array.isArray(params?.slug) ? (params.slug as string[]) : [];
const { key } = resolveRoute(segments);
const { root, content } = (schema as any)[key] ?? {};
const data = { root, content, globals };
return <Render config={appConfig as any} data={data} />;
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /about. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/about" page={pageData} />;
}
+42
View File
@@ -0,0 +1,42 @@
{
"root": {
"props": {
"title": "About — Shop",
"description": "About Shop.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "content-section",
"props": {
"id": "about-intro",
"eyebrow": "Our story",
"heading": "About",
"body": "Tell your stores story here.\n\nBlank lines start a new paragraph. Everything on this page is editable at /editor?route=/about.",
"imageUrl": "",
"imageAlt": ""
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/about' });
export default function Page() {
return <PageRender page={page} />;
}
+207
View File
@@ -0,0 +1,207 @@
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { z } from 'zod';
import {
searchProducts,
getProduct,
getProductsPage,
getCollections,
getCollectionProductsPage,
isDefaultTitleOption,
isDefaultTitleSelection,
} from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run
// server-side on each tool call.
export const maxDuration = 30;
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
You help shoppers find products, compare options, and understand what the store
carries. You have tools that read live catalogue data — always use them rather
than guessing, and never invent products, prices, availability, or policies.
Guidelines:
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
something vague like "what do you have?", call listCollections or
searchCatalogue to ground your answer.
- Prices returned by the tools are in the store's currency; show them as given.
- Link products as /products/{handle} and collections as /collections/{handle}
so the shopper can click through.
- Keep replies short and conversational — a sentence or two plus a compact list.
Do not repeat the raw tool output; the interface already shows it.
- If a tool returns nothing, say so plainly and suggest a different search.
- You cannot place orders, change carts, process payments, or look up customer
or order data. Say so and point the shopper to the relevant page instead.`;
// Trim the Storefront payloads to what the model actually needs to answer.
const summariseProduct = (product: {
id: string;
title: string;
handle: string;
description?: string;
productType?: string;
tags?: string[];
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
variants?: {
edges: Array<{
node: {
title: string;
availableForSale: boolean;
selectedOptions?: Array<{ name: string; value: string }>;
};
}>;
};
images?: { edges: Array<{ node: { url: string } }> };
options?: Array<{ name: string; values: string[] }>;
}) => ({
title: product.title,
handle: product.handle,
url: `/products/${product.handle}`,
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
image: product.images?.edges[0]?.node.url ?? null,
description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null,
tags: product.tags ?? [],
options: product.options
?.filter((option) => !isDefaultTitleOption(option))
.map((option) => ({
name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
});
export async function POST(req: Request) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
const { messages } = await req.json();
const openrouter = createOpenRouter({ apiKey });
const result = streamText({
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
// renders it via the Reasoning component.
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
// Let the model call a tool, read the result, then answer.
stopWhen: stepCountIs(5),
tools: {
searchCatalogue: tool({
description:
'Search the store for products matching a term. Use for any question about what the store sells.',
inputSchema: z.object({
query: z
.string()
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
limit: z.number().int().min(1).max(10).default(5),
}),
execute: async ({ query, limit }) => {
const { products, totalCount } = await searchProducts({
query,
first: limit,
});
return {
totalCount,
products: products.map(summariseProduct),
};
},
}),
getProductDetails: tool({
description:
'Get full details for one product by its handle, including options, variants, and stock.',
inputSchema: z.object({
handle: z
.string()
.describe('The product handle, e.g. "flowguard-jacket".'),
}),
execute: async ({ handle }) => {
const product = await getProduct(handle);
if (!product) return { found: false, handle };
return {
found: true,
...summariseProduct(product),
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
title: node.title,
available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions?.filter(
(option) => !isDefaultTitleSelection(option)
),
})),
};
},
}),
listCollections: tool({
description:
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
inputSchema: z.object({
limit: z.number().int().min(1).max(25).default(10),
}),
execute: async ({ limit }) => {
const collections = await getCollections(limit);
return {
collections: collections.map((collection) => ({
title: collection.title,
handle: collection.handle,
url: `/collections/${collection.handle}`,
description: collection.description?.slice(0, 200) ?? null,
})),
};
},
}),
getCollectionProducts: tool({
description:
'List the products inside one collection, by collection handle.',
inputSchema: z.object({
handle: z.string().describe('The collection handle, e.g. "men".'),
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ handle, limit }) => {
const page = await getCollectionProductsPage(handle, { first: limit });
if (!page.collection) return { found: false, handle };
return {
found: true,
collection: page.collection.title,
url: `/collections/${handle}`,
products: page.products.map(summariseProduct),
};
},
}),
browseProducts: tool({
description:
'Browse the newest products when the shopper has no specific search term.',
inputSchema: z.object({
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ limit }) => {
const page = await getProductsPage({
first: limit,
sortKey: 'CREATED_AT',
reverse: true,
});
return { products: page.products.map(summariseProduct) };
},
}),
},
});
// sendReasoning forwards reasoning parts to the client; without it the
// stream carries text and tool calls only.
return result.toUIMessageStreamResponse({ sendReasoning: true });
}
-25
View File
@@ -1,25 +0,0 @@
import { NextRequest } from "next/server";
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
// DELETE /api/media/:id — remove a media asset.
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const upstream = await fetch(
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`,
{
method: "DELETE",
headers: { "x-api-key": FRONTEND_API_KEY },
},
);
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}
-47
View File
@@ -1,47 +0,0 @@
import { NextRequest } from "next/server";
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
// GET /api/media — list/search media. Forwards query + cursor params.
export async function GET(req: NextRequest) {
const incoming = new URL(req.url);
const url = new URL("/api/media", CLOUD_BASE);
const query = incoming.searchParams.get("query");
const cursor = incoming.searchParams.get("cursor");
if (query) url.searchParams.set("query", query);
if (cursor) url.searchParams.set("cursor", cursor);
const upstream = await fetch(url, {
method: "GET",
headers: { "x-api-key": FRONTEND_API_KEY },
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}
// POST /api/media — upload. Forwards the multipart body as-is.
export async function POST(req: NextRequest) {
const upstream = await fetch(`${CLOUD_BASE}/api/media`, {
method: "POST",
headers: {
"content-type": req.headers.get("content-type") ?? "",
"x-api-key": FRONTEND_API_KEY,
},
body: req.body,
// @ts-expect-error - duplex is valid but missing from the lib types.
duplex: "half",
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections/[handle]" page={pageData} />;
}
+39
View File
@@ -0,0 +1,39 @@
{
"root": {
"props": {
"title": "Collection",
"description": "Shop the collection.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "collection-detail",
"props": {
"id": "collection-detail",
"collection": null,
"title": ""
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import { getCollectionProductsPage } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
import page from './page.json';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// No collection-only query exists, so ask for the smallest page of products
// and use just the collection node off it.
const { collection } = await getCollectionProductsPage(handle, {
first: 1,
}).catch(() => ({ collection: null }));
// The blocks fetch client-side and render their own empty state, so a failed
// lookup falls back to the page.json tags rather than a 500.
if (!collection) return pageMetadata(page);
return pageMetadata(page, {
title: collection.title,
description: collection.description
? truncate(collection.description, 160)
: `Shop the ${collection.title} collection.`,
image: collection.image?.url,
path: `/collections/${collection.handle}`,
});
}
export default function Page() {
return <PageRender page={page} />;
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections" page={pageData} />;
}
+39
View File
@@ -0,0 +1,39 @@
{
"root": {
"props": {
"title": "Collections",
"description": "Browse every collection in the store.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "collections",
"props": {
"id": "collections-grid",
"title": "Our Collections",
"subtitle": "Discover our carefully crafted worlds"
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/collections' });
export default function Page() {
return <PageRender page={page} />;
}
-61
View File
@@ -1,61 +0,0 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import { Editor, blocksPlugin, outlinePlugin } from "@reacteditor/core";
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
import { createShopifyPlugin } from "@reacteditor/plugin-shopify";
import { mediaPlugin } from "@reacteditor/plugin-media";
import { mediaAdapter } from "@/lib/adapters/media-adapter";
import { appConfig } from "@/editor.config";
import resolveRoute from "@/lib/resolve-route";
import schema from "@/app.schema.json";
import globals from "@/app.globals.json";
export default function EditorPage() {
const params = useParams();
const segments = Array.isArray(params?.slug) ? (params.slug as string[]) : [];
const { key, path, params: routeParams } = resolveRoute(segments);
const { root, content } = (schema as any)[key] ?? {};
const data = { root, content, globals };
const plugins = useMemo(
() => [
blocksPlugin(),
outlinePlugin(),
mediaPlugin({
adapter: mediaAdapter,
}),
createTailwindCdnPlugin(),
createShopifyPlugin({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop",
publicAccessToken:
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN,
}),
],
[],
);
const handlePublish = async (nextData: any, route?: any) => {
const resolved = route ?? { key };
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 (
<div className="h-screen w-screen">
<Editor
config={appConfig as any}
data={data}
route={{ key, path, params: routeParams }}
plugins={plugins}
onPublish={handlePublish}
/>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/" page={pageData} />;
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import React from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
{error.message}
</div>
</div>
</div>
);
}
+218 -137
View File
@@ -1,149 +1,230 @@
@import "tailwindcss";
@import "tw-animate-css";
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme {
/* Refined neutral modern palette */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
/* Card */
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 3.9%);
/* Popover */
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 3.9%);
/* Primary - Deep neutral slate for professional look */
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
/* Secondary */
--color-secondary: hsl(240 4.8% 95.9%);
--color-secondary-foreground: hsl(240 5.9% 10%);
/* Muted */
--color-muted: hsl(240 4.8% 95.9%);
--color-muted-foreground: hsl(240 3.8% 46.1%);
/* Accent - Subtle warm gray */
--color-accent: hsl(30 6.7% 95%);
--color-accent-foreground: hsl(240 5.9% 10%);
/* Destructive */
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%);
/* Shop (accelerated checkout) purple */
--color-shop: #5a31f4;
/* Border */
--color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%);
/* Radius - Modern larger corners */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
/* Typography — Geist Sans / Geist Mono */
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
}
/* Base styles for light, modern Shopify storefront */
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
components (e.g. Button's outline variant) rely on this base layer to pick up
the theme's border colour instead of the text colour. */
@layer base {
* {
border-color: var(--color-border);
}
}
body {
font-feature-settings:
'kern' 1,
'tnum' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Modern card design for products and collections */
.card-modern {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--color-border);
}
.card-modern:hover {
transform: translateY(-8px);
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
border-color: hsl(240 5.9% 85%);
}
/* Heading styles — regular weight, slightly tightened like the Geist reference */
h1,
h2,
.font-heading {
font-weight: 400;
letter-spacing: -0.02em;
}
/* Swipeable rows (mobile galleries) without a visible scrollbar */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
.option-unavailable {
background-image: linear-gradient(
to top right,
transparent calc(50% - 0.5px),
currentColor calc(50% - 0.5px),
currentColor calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
}
/* Product description (HTML returned by the Storefront API) */
.product-description p {
margin-bottom: 1rem;
}
.product-description > :last-child {
margin-bottom: 0;
}
/* Shop policy body (HTML returned by the Storefront API) */
.policy-body p,
.policy-body ul,
.policy-body ol {
margin-bottom: 1.5rem;
}
.policy-body ul,
.policy-body ol {
padding-left: 1.25rem;
list-style: revert;
}
.policy-body li {
margin-bottom: 0.5rem;
}
.policy-body h2,
.policy-body h3,
.policy-body h4 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: 1.25rem;
font-weight: 500;
}
.policy-body a {
text-decoration: underline;
text-underline-offset: 2px;
}
.policy-body strong {
font-weight: 500;
}
.policy-body > :last-child {
margin-bottom: 0;
}
/* Enhanced button styles */
button,
.btn-modern {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-modern:hover {
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
/* Hero section styles */
.hero-bg {
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
}
/* Product gallery improvements */
.product-gallery-main {
transition: box-shadow 0.3s ease;
}
.product-gallery-main:hover {
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
}
/* Clean price display */
.price-display {
font-feature-settings: 'tnum';
}
@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))); }
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--color-color-5: var(--color-5);
--color-color-4: var(--color-4);
--color-color-3: var(--color-3);
--color-color-2: var(--color-2);
--color-color-1: var(--color-1);
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
@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;}
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.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);
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
+47 -9
View File
@@ -1,16 +1,54 @@
import type { ReactNode } from "react";
import "./globals.css";
import { Providers } from "./providers";
import React from 'react';
import type { Metadata } from 'next';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
import { site } from '@/config/site';
export const metadata = {
title: "Shopify Storefront",
const geist = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
// Per-page tags come from each page.json's root props via pageMetadata(); this
// is the fallback for routes that don't set their own.
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: {
default: site.storeName,
template: `%s — ${site.storeName}`,
},
description: site.description,
openGraph: {
type: 'website',
siteName: site.storeName,
title: site.storeName,
description: site.description,
url: '/',
},
twitter: {
card: 'summary_large_image',
title: site.storeName,
description: site.description,
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
{/* Header, footer and the store assistant are editable blocks now, so
each page.json carries its own chrome rather than the layout doing
it. Keeps what the editor shows identical to what the route ships. */}
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
<div className="flex-1">{children}</div>
</body>
</html>
);
+20
View File
@@ -0,0 +1,20 @@
"use client";
import React from 'react';
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
This page could not be found.
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
{
"root": {
"props": {
"title": "Shop",
"description": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "products",
"props": {
"id": "home-products",
"title": "Shopify Hydrogen Storefront",
"subtitle": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
"collection": null,
"limit": 12
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/' });
export default function Page() {
return <PageRender page={page} />;
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/policies/[handle]" page={pageData} />;
}
+40
View File
@@ -0,0 +1,40 @@
{
"root": {
"props": {
"title": "Policy",
"description": "Store policies.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "policy",
"props": {
"id": "policy-body",
"handle": "",
"title": "",
"notFoundMessage": "This policy has not been published yet."
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
import { site } from '@/config/site';
import page from './page.json';
export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
const policy = await getShopPolicy(handle).catch(() => null);
if (!policy) return pageMetadata(page);
return pageMetadata(page, {
title: policy.title,
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
path: `/policies/${policy.handle}`,
});
}
export default async function Page({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
// Keep the 404 for handles Shopify doesn't know, rather than rendering the
// policy block's "not published yet" state for a genuinely bad URL.
if (!POLICY_HANDLES.includes(handle as (typeof POLICY_HANDLES)[number])) {
notFound();
}
return <PageRender page={page} />;
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /products/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/products/[handle]" page={pageData} />;
}
+48
View File
@@ -0,0 +1,48 @@
{
"root": {
"props": {
"title": "Product",
"description": "Shop this product.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "product-detail",
"props": {
"id": "product-detail",
"product": null,
"addToCartLabel": "Add to Cart"
}
},
{
"type": "product-recommendations",
"props": {
"id": "product-recommendations",
"title": "You May Also Like",
"product": null,
"limit": 4
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import { getProduct } from '@/services/shopify/catalog';
import { truncate } from '@/lib/utils';
import page from './page.json';
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}): Promise<Metadata> {
const { handle } = await params;
// The blocks fetch client-side and render their own empty state, so a failed
// lookup falls back to the page.json tags rather than a 500.
const product = await getProduct(handle).catch(() => null);
if (!product) return pageMetadata(page);
return pageMetadata(page, {
title: product.title,
description: product.description
? truncate(product.description, 160)
: undefined,
image: product.images.edges[0]?.node.url,
path: `/products/${product.handle}`,
});
}
export default function Page() {
return <PageRender page={page} />;
}
-21
View File
@@ -1,21 +0,0 @@
"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 (
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
{children}
</ShopifyProvider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /search. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/search" page={pageData} />;
}
+38
View File
@@ -0,0 +1,38 @@
{
"root": {
"props": {
"title": "Search",
"description": "Search the catalogue by product name, description or type.",
"ogImage": ""
}
},
"content": [
{
"type": "header",
"props": {
"id": "header"
},
"synced": true
},
{
"type": "search-results",
"props": {
"id": "search-results",
"title": "Search"
}
},
{
"type": "footer",
"props": {
"id": "footer"
},
"synced": true
},
{
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
}
+16
View File
@@ -0,0 +1,16 @@
import { Suspense } from 'react';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
import page from './page.json';
export const metadata = pageMetadata(page, { path: '/search' });
export default function Page() {
return (
// The search-results block reads useSearchParams, which needs a Suspense
// boundary to prerender this route.
<Suspense fallback={<div className="py-10" />}>
<PageRender page={page} />
</Suspense>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata';
// Alias of /collections — same page.json, so editing that route updates both.
import page from '../../collections/page.json';
export const metadata: Metadata = {
...pageMetadata(page),
// Point crawlers at the canonical listing.
alternates: { canonical: '/collections' },
};
export default function Page() {
return <PageRender page={page} />;
}
+4 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
@@ -11,7 +11,6 @@
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -19,7 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
-117
View File
@@ -1,117 +0,0 @@
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<HeadingSize, SizeMap> = {
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<HeadingAlign, string> = {
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 (
<div className={cn("flex flex-col", alignClasses[align], maxWidth, className)}>
{tagline ? (
<Typography
variant="caption"
className={cn(
map.taglineGap,
isLight && "text-background/70",
taglineClassName,
)}
>
{tagline}
</Typography>
) : null}
{title ? (
<Typography
variant={map.title}
className={cn(isLight && "text-background", titleClassName)}
>
{title}
</Typography>
) : null}
{subtitle ? (
<Typography
variant={map.subtitle}
className={cn(
map.subtitleGap,
isLight && "text-background/70",
subtitleClassName,
)}
>
{subtitle}
</Typography>
) : null}
</div>
);
}
export default Heading;
-215
View File
@@ -1,215 +0,0 @@
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<NonNullable<ThemeProps["radius"]>, string> = {
none: "0px",
sm: "0.25rem",
md: "0.5rem",
lg: "0.75rem",
xl: "1rem",
};
const maxWidthMap: Record<NonNullable<ThemeProps["maxWidth"]>, string> = {
sm: "64rem",
md: "72rem",
lg: "80rem",
xl: "96rem",
full: "100%",
};
const shadowMap: Record<NonNullable<ThemeProps["shadow"]>, 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<string>();
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<Record<string, string>>(() => {
const vars: Record<string, string> = {};
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
// <html> 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<HTMLDivElement>(null);
useEffect(() => {
const doc =
rootRef.current?.ownerDocument ??
(typeof document !== "undefined" ? document : null);
if (!doc) return;
const target = doc.documentElement;
const previous: Record<string, string> = {};
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 ? <link rel="stylesheet" href={fontsHref} /> : null}
<style dangerouslySetInnerHTML={{ __html: css }} />
<style type="text/tailwindcss" dangerouslySetInnerHTML={{ __html: tailwindCss }} />
<div
ref={rootRef}
data-theme-root
style={cssVars as React.CSSProperties}
className="flex min-h-screen flex-col bg-background text-foreground"
>
{children}
</div>
</>
);
}
-105
View File
@@ -1,105 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type TypographyVariant =
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "subtitle1"
| "subtitle2"
| "body1"
| "body2"
| "caption";
const sizeClasses: Record<TypographyVariant, string> = {
h1: "text-5xl md:text-6xl lg:text-7xl tracking-tight leading-[1.05]",
h2: "text-4xl md:text-5xl tracking-tight leading-[1.1]",
h3: "text-3xl md:text-4xl tracking-tight leading-tight",
h4: "text-2xl md:text-3xl tracking-tight leading-snug",
h5: "text-xl md:text-2xl leading-snug",
h6: "text-lg md:text-xl leading-snug",
subtitle1: "text-lg md:text-xl leading-relaxed text-muted-foreground",
subtitle2: "text-base md:text-lg leading-relaxed text-muted-foreground",
body1: "text-lg leading-relaxed",
body2: "text-base leading-relaxed",
caption: "text-xs font-bold uppercase tracking-[0.2em] text-muted-foreground",
};
// Inline-style fallbacks so headings size correctly even when Tailwind
// preflight resets <h1>..<h6> to font-size: inherit and the iframe CDN
// hasn't compiled utility classes yet. The Tailwind classes above still
// apply on top once available (responsive breakpoints, leading, etc.).
const sizeStyles: Record<TypographyVariant, React.CSSProperties> = {
h1: { fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, letterSpacing: "-0.02em" },
h2: { fontSize: "clamp(2rem, 4vw, 3rem)", lineHeight: 1.1, letterSpacing: "-0.02em" },
h3: { fontSize: "clamp(1.75rem, 3.5vw, 2.25rem)", lineHeight: 1.15, letterSpacing: "-0.015em" },
h4: { fontSize: "clamp(1.5rem, 3vw, 1.875rem)", lineHeight: 1.2, letterSpacing: "-0.01em" },
h5: { fontSize: "1.5rem", lineHeight: 1.25 },
h6: { fontSize: "1.25rem", lineHeight: 1.3 },
subtitle1: { fontSize: "1.125rem", lineHeight: 1.6 },
subtitle2: { fontSize: "1rem", lineHeight: 1.6 },
body1: { fontSize: "1.125rem", lineHeight: 1.6 },
body2: { fontSize: "1rem", lineHeight: 1.6 },
caption: { fontSize: "0.75rem", lineHeight: 1.5, fontWeight: 700, letterSpacing: "0.2em", textTransform: "uppercase" },
};
const defaultTag: Record<TypographyVariant, keyof JSX.IntrinsicElements> = {
h1: "h1",
h2: "h2",
h3: "h3",
h4: "h4",
h5: "h5",
h6: "h6",
subtitle1: "p",
subtitle2: "p",
body1: "p",
body2: "p",
caption: "p",
};
type Props<C extends keyof JSX.IntrinsicElements = "p"> = {
variant: TypographyVariant;
as?: C;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<C>, "className" | "children" | "style">;
export function Typography<C extends keyof JSX.IntrinsicElements = "p">({
variant,
as,
className,
style,
children,
...rest
}: Props<C>) {
const Tag = (as ?? defaultTag[variant]) as keyof JSX.IntrinsicElements;
const isHeading = variant.startsWith("h");
const fontClass = isHeading ? "font-heading" : "font-body";
// Apply the heading font + weight inline so the styling holds even when
// the rendered element isn't h1h6 (e.g. <span> via the `as` prop). The
// ThemeProvider's element-scoped CSS rule only matches real h-tags, and
// the `font-heading` Tailwind utility can't be relied on in CDN mode.
const fontStyles: React.CSSProperties = isHeading
? {
fontFamily: "var(--font-header), system-ui, sans-serif",
fontWeight: "var(--font-weight-header, 600)",
}
: {};
return React.createElement(
Tag,
{
className: cn(fontClass, sizeClasses[variant], className),
style: { ...fontStyles, ...sizeStyles[variant], ...style },
...rest,
},
children,
);
}
export default Typography;
+426
View File
@@ -0,0 +1,426 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
+339
View File
@@ -0,0 +1,339 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Markdown isAnimating={isStreaming}>{children}</Markdown>
</CollapsibleContent>
);
}
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
-240
View File
@@ -1,240 +0,0 @@
'use client';
import React from 'react';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Loader } from '@/components/ui/loader';
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/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 (
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
<SheetContent
side="right"
className="w-full max-w-md"
showCloseButton={false}
>
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base">
Shopping Cart ({itemCount})
</SheetTitle>
<Button onClick={closeCart} variant="ghost" size="icon-sm">
<X size={20} />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
) : items.length === 0 ? (
<Empty className="border-0">
<EmptyContent>
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
</EmptyHeader>
<Button
onClick={closeCart}
variant="outline"
>
Continue Shopping
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-6">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
return (
<div
key={item.id}
className="flex items-start space-x-4 pb-6 border-b border-border last:border-b-0"
>
{/* Product Image */}
<div className="w-20 h-20 bg-muted rounded-lg overflow-hidden flex-shrink-0">
{image ? (
<img
src={image}
alt={item.merchandise.product.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<ImageIcon size={24} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-foreground mb-1 line-clamp-2">
{item.merchandise.product.title}
</h4>
{/* Variant Info */}
{selectedOptions.length > 0 && (
<div className="text-sm text-muted-foreground mb-2">
{selectedOptions.map((option, index) => (
<span key={option.name}>
{option.value}
{index < selectedOptions.length - 1
? ' / '
: ''}
</span>
))}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-border rounded-lg">
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity - 1)
}
variant="ghost"
size="icon-sm"
disabled={item.quantity <= 1 || loading}
className="h-7 w-7"
>
<Minus size={14} />
</Button>
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
{item.quantity}
</span>
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity + 1)
}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-7 w-7"
>
<Plus size={14} />
</Button>
</div>
</div>
</div>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm font-semibold text-foreground">
${parseFloat(item.merchandise.price.amount).toFixed(2)}
</span>
</div>
{/* Remove Button */}
<div className="flex-shrink-0">
<Button
onClick={() => removeItem(item.id)}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-auto w-auto p-2 text-muted-foreground hover:text-foreground"
>
<X size={18} />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer - Checkout Section */}
{items.length > 0 && (
<div className="border-t border-border p-6">
{/* Subtotal */}
<div className="flex items-center justify-between mb-4">
<span className="text-base font-semibold">Subtotal</span>
<span className="text-lg font-bold">
${totalAmount.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-4">
Shipping and taxes calculated at checkout
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Button
onClick={handleCheckout}
disabled={loading || !checkoutUrl}
className="w-full"
size="lg"
>
{loading ? (
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
</Button>
<Button onClick={closeCart} variant="link" className="w-full">
Continue Shopping
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
);
};
export default CartDrawer;
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Typography } from '@/components/Typography';
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<CollectionCardProps> = ({ collection }) => {
return (
<Link href={`/collections/${collection.handle}`} className="block group">
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
{/* Collection Image */}
<div className="aspect-video overflow-hidden bg-muted">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<i className="ri-folder-line text-6xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<CardContent className="p-6">
<Typography
variant="subtitle1"
className="mb-3 font-semibold tracking-tight text-foreground transition-colors group-hover:text-muted-foreground"
>
{collection.title}
</Typography>
{collection.description && (
<p className="text-muted-foreground">
{collection.description.substring(0, 100)}
{collection.description.length > 100 ? '...' : ''}
</p>
)}
<div className="mt-4 text-foreground font-semibold group-hover:text-muted-foreground transition-colors flex items-center">
<span>View Collection</span>
<i className="ri-arrow-right-s-line ml-2"></i>
</div>
</CardContent>
</Card>
</Link>
);
};
export default CollectionCard;
-95
View File
@@ -1,95 +0,0 @@
'use client';
import React from 'react';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card';
import { Skeleton } from '@/components/ui/skeleton';
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 || !handle) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<div className="mb-16 flex justify-center">
<Skeleton className="h-12 w-64" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="flex flex-col gap-3">
<Skeleton className="aspect-square w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Could not load collection</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
</p>
<button
onClick={() => refetch()}
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
>
Retry
</button>
</div>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
{title}
</h2>
{products.length === 0 ? (
<div className="text-center py-12">
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Products in Collection
</h3>
<p className="text-muted-foreground">
This collection doesn&apos;t have any products yet.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</div>
);
};
export default CollectionDetail;
@@ -1,33 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionGrid, type CollectionGridProps } from "@/components/commerce/collection-grid";
const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
label: "Collections",
icon: <FolderOpen size={16} />,
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) => <CollectionGrid {...props} />,
};
export default collectionGridEditor;
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { shopifyFetch } from "@/services/shopify/client";
import { GET_COLLECTIONS_QUERY } from "@/graphql/collections";
import { Container } from "@/components/layout/Container";
import { Typography } from "@/components/Typography";
import { Heading } from "@/components/Heading";
export type CollectionGridProps = {
tagline: string;
heading: string;
subheading: string;
layout: "tiles" | "editorial";
limit: number;
};
type CollectionRow = {
id: string;
handle: string;
title: string;
description?: string;
image?: { url: string; altText?: string };
};
export function CollectionGrid({
tagline,
heading,
subheading,
layout,
limit,
}: CollectionGridProps) {
const [collections, setCollections] = useState<CollectionRow[]>([]);
useEffect(() => {
shopifyFetch<any>({
query: GET_COLLECTIONS_QUERY,
variables: { first: limit },
})
.then((res) => {
const list = (res.data?.collections?.edges ?? []).map((e: any) => e.node);
setCollections(list);
})
.catch(() => setCollections([]));
}, [limit]);
const isEditorial = layout === "editorial";
return (
<section className="bg-background py-20 md:py-28">
<Container>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="center"
size="lg"
className="mx-auto mb-12"
maxWidth="max-w-2xl"
/>
<div
className={
isEditorial
? "grid grid-cols-1 gap-8 md:grid-cols-2"
: "grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4"
}
>
{(collections.length === 0
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any)
: collections
).map((c: CollectionRow) => (
<Link
key={c.id}
href={c.handle ? `/collections/${c.handle}` : "#"}
className="group block"
>
<div
className={`relative overflow-hidden rounded-md bg-muted ${isEditorial ? "aspect-[3/4] md:aspect-[5/6]" : "aspect-[4/5]"}`}
>
{c.image?.url ? (
<img
src={c.image.url}
alt={c.image.altText || c.title}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
) : null}
{isEditorial ? (
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-8">
<div>
<Typography
variant="subtitle1"
className="font-semibold tracking-tight text-white"
>
{c.title}
</Typography>
<span className="mt-2 inline-flex text-xs uppercase tracking-[0.2em] text-white/80">
Shop now
</span>
</div>
</div>
) : null}
</div>
{!isEditorial ? (
<div className="mt-4">
<Typography
variant="subtitle2"
className="font-medium tracking-tight text-foreground"
>
{c.title}
</Typography>
</div>
) : null}
</Link>
))}
</div>
</Container>
</section>
);
}
-247
View File
@@ -1,247 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionView, type CollectionProps } from "@/components/commerce/collection";
const collectionEditor: ComponentConfig<CollectionProps> = {
label: "Collection page",
icon: <FolderOpen size={16} />,
category: "commerce",
defaultProps: {
collection: null,
showDescription: "yes",
showCoverImage: "yes",
customCoverImage: "",
columns: "4",
limit: 24,
defaultSort: "BEST_SELLING",
showAvailability: "yes",
showPriceRange: "yes",
showProductType: "no",
productTypeOptions: [],
showVendor: "no",
vendorOptions: [],
showTags: "no",
tagOptions: [],
showColor: "yes",
colorOptions: [
{ label: "Black", color: "#000000" },
{ label: "White", color: "#FFFFFF" },
{ label: "Navy", color: "#1e3a5f" },
],
showStyle: "no",
styleOptions: [],
showSize: "yes",
sizeOptions: [
{ label: "XS" },
{ label: "S" },
{ label: "M" },
{ label: "L" },
{ label: "XL" },
],
showMaterial: "no",
materialOptions: [],
metafieldFilters: [],
},
fields: {
collection: { label: "Collection", type: "shopifyCollection" } as any,
showDescription: {
label: "Description",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showCoverImage: {
label: "Cover image",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
customCoverImage: {
label: "Custom cover image",
type: "image",
},
columns: {
label: "Columns",
type: "radio",
options: [
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "4", value: "4" },
],
},
limit: { label: "Products per page", type: "number", min: 4, max: 48 },
defaultSort: {
label: "Default sort",
type: "select",
options: [
{ label: "Best Selling", value: "BEST_SELLING" },
{ label: "Newest", value: "CREATED" },
{ label: "Price: Low to High", value: "PRICE" },
{ label: "Alphabetical", value: "TITLE" },
],
},
showAvailability: {
label: "Availability filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showPriceRange: {
label: "Price range filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showColor: {
label: "Color filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
colorOptions: {
label: "Colors",
type: "array",
defaultItemProps: { label: "", color: "#000000" },
getItemSummary: (it: any) => it?.label || "Color",
arrayFields: {
label: { label: "Color name", type: "text" },
color: { label: "Color", type: "color" },
},
},
showStyle: {
label: "Style filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
styleOptions: {
label: "Styles",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Style",
arrayFields: {
label: { label: "Style name", type: "text" },
},
},
showSize: {
label: "Size filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
sizeOptions: {
label: "Sizes",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Size",
arrayFields: {
label: { label: "Size name", type: "text" },
},
},
showMaterial: {
label: "Material filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
materialOptions: {
label: "Materials",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Material",
arrayFields: {
label: { label: "Material name", type: "text" },
},
},
showVendor: {
label: "Brand / vendor filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
vendorOptions: {
label: "Brands",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Brand",
arrayFields: {
label: { label: "Brand name", type: "text" },
},
},
showProductType: {
label: "Product type filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
productTypeOptions: {
label: "Product types",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Type",
arrayFields: {
label: { label: "Type name", type: "text" },
},
},
showTags: {
label: "Tags filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
tagOptions: {
label: "Tags",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Tag",
arrayFields: {
label: { label: "Tag name", type: "text" },
},
},
metafieldFilters: {
label: "Metafield filters",
type: "array",
defaultItemProps: { namespace: "", key: "", label: "", values: [{ label: "" }] },
getItemSummary: (it: any) => it?.label || it?.key || "Metafield",
arrayFields: {
namespace: { label: "Namespace", type: "text" },
key: { label: "Key", type: "text" },
label: { label: "Label", type: "text" },
values: {
label: "Values",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (v: any) => v?.label || "Value",
arrayFields: {
label: { label: "Value", type: "text" },
},
},
},
},
},
render: (props) => <CollectionView {...props} />,
};
export default collectionEditor;
-552
View File
@@ -1,552 +0,0 @@
import { useState, useCallback } from 'react';
import { useRouteSegment } from '@/hooks/use-route-segment';
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import type { ShopifyCollection } from '@reacteditor/field-shopify';
import {
useCollectionProducts,
type CollectionSortKey,
type ProductFilter,
} from '@/hooks/use-shopify-collections';
import { ProductCard } from './product-card';
import { Typography } from '@/components/Typography';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Container } from '@/components/layout/Container';
import { cn } from '@/lib/utils';
type FilterOption = { label: string };
type ColorOption = { label: string; color: string };
export type CollectionProps = {
collection: ShopifyCollection | null;
showDescription: 'yes' | 'no';
showCoverImage: 'yes' | 'no';
customCoverImage: string;
columns: '2' | '3' | '4';
limit: number;
defaultSort: CollectionSortKey;
showAvailability: 'yes' | 'no';
showPriceRange: 'yes' | 'no';
showProductType: 'yes' | 'no';
productTypeOptions: FilterOption[];
showVendor: 'yes' | 'no';
vendorOptions: FilterOption[];
showTags: 'yes' | 'no';
tagOptions: FilterOption[];
showColor: 'yes' | 'no';
colorOptions: ColorOption[];
showStyle: 'yes' | 'no';
styleOptions: FilterOption[];
showSize: 'yes' | 'no';
sizeOptions: FilterOption[];
showMaterial: 'yes' | 'no';
materialOptions: FilterOption[];
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
};
const SORT_OPTIONS: { label: string; value: CollectionSortKey }[] = [
{ label: 'Best Selling', value: 'BEST_SELLING' },
{ label: 'Newest', value: 'CREATED' },
{ label: 'Price: Low to High', value: 'PRICE' },
{ label: 'Alphabetical', value: 'TITLE' },
];
const colClass: Record<CollectionProps['columns'], string> = {
'2': 'grid-cols-2',
'3': 'grid-cols-2 md:grid-cols-3',
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
};
// ─── Filter group (collapsible) ────────────────────────────────────────────────
function FilterGroup({ label, children, defaultOpen = true }: {
label: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-b border-border py-4">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
>
{label}
<ChevronDown
size={14}
className={cn('transition-transform', open ? 'rotate-180' : '')}
/>
</button>
{open && <div className="mt-3 space-y-2">{children}</div>}
</div>
);
}
function Checkbox({ checked, onChange, label }: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
checked ? 'border-foreground bg-foreground' : 'border-border',
)}
>
{checked && (
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{label}
</label>
);
}
// ─── Sidebar ────────────────────────────────────────────────────────────────────
type ActiveFilters = {
availability: boolean;
productTypes: string[];
vendors: string[];
tags: string[];
colors: string[];
styles: string[];
sizes: string[];
materials: string[];
minPrice: string;
maxPrice: string;
metafieldValues: Record<string, string[]>;
};
function Sidebar({
props,
active,
onChange,
}: {
props: CollectionProps;
active: ActiveFilters;
onChange: (patch: Partial<ActiveFilters>) => void;
}) {
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
const colors = (props.colorOptions ?? []) as ColorOption[];
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
const arr = active[key];
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
}
return (
<div className="w-full">
{props.showAvailability === 'yes' && (
<FilterGroup label="Availability">
<Checkbox
checked={active.availability}
onChange={(v) => onChange({ availability: v })}
label="In stock"
/>
</FilterGroup>
)}
{props.showPriceRange === 'yes' && (
<FilterGroup label="Price">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
placeholder="Min"
value={active.minPrice}
onChange={(e) => onChange({ minPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
min={0}
placeholder="Max"
value={active.maxPrice}
onChange={(e) => onChange({ maxPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
</FilterGroup>
)}
{props.showVendor === 'yes' && vendors.length > 0 && (
<FilterGroup label="Brand">
{vendors.map((v) => (
<Checkbox
key={v}
checked={active.vendors.includes(v)}
onChange={() => toggle('vendors', v)}
label={v}
/>
))}
</FilterGroup>
)}
{props.showColor === 'yes' && colors.length > 0 && (
<FilterGroup label="Color">
{colors.filter((c) => c.label).map((c) => (
<label
key={c.label}
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
>
<input
type="checkbox"
checked={active.colors.includes(c.label)}
onChange={() => toggle('colors', c.label)}
className="sr-only"
/>
<span
className={cn(
'flex h-5 w-5 shrink-0 rounded-full border-2',
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
)}
style={{ backgroundColor: c.color || undefined }}
/>
{c.label}
</label>
))}
</FilterGroup>
)}
{props.showStyle === 'yes' && styles.length > 0 && (
<FilterGroup label="Style">
{styles.map((s) => (
<Checkbox
key={s}
checked={active.styles.includes(s)}
onChange={() => toggle('styles', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showSize === 'yes' && sizes.length > 0 && (
<FilterGroup label="Size">
{sizes.map((s) => (
<Checkbox
key={s}
checked={active.sizes.includes(s)}
onChange={() => toggle('sizes', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showMaterial === 'yes' && materials.length > 0 && (
<FilterGroup label="Material">
{materials.map((m) => (
<Checkbox
key={m}
checked={active.materials.includes(m)}
onChange={() => toggle('materials', m)}
label={m}
/>
))}
</FilterGroup>
)}
{props.showProductType === 'yes' && productTypes.length > 0 && (
<FilterGroup label="Product type">
{productTypes.map((pt) => (
<Checkbox
key={pt}
checked={active.productTypes.includes(pt)}
onChange={() => toggle('productTypes', pt)}
label={pt}
/>
))}
</FilterGroup>
)}
{props.showTags === 'yes' && tags.length > 0 && (
<FilterGroup label="Tags">
{tags.map((t) => (
<Checkbox
key={t}
checked={active.tags.includes(t)}
onChange={() => toggle('tags', t)}
label={t}
/>
))}
</FilterGroup>
)}
{metafieldFilters.map((mf, i) => {
const mfKey = `${mf.namespace}.${mf.key}`;
const selected = active.metafieldValues[mfKey] ?? [];
return (
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
<Checkbox
key={val}
checked={selected.includes(val)}
onChange={(checked) => {
const next = checked
? [...selected, val]
: selected.filter((v) => v !== val);
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
}}
label={val}
/>
))}
</FilterGroup>
);
})}
</div>
);
}
// ─── Build Shopify ProductFilter array ──────────────────────────────────────────
function buildProductFilters(active: ActiveFilters): ProductFilter[] {
const filters: ProductFilter[] = [];
if (active.availability) filters.push({ available: true });
if (active.minPrice !== '' || active.maxPrice !== '') {
filters.push({
price: {
min: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
max: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
},
});
}
for (const pt of active.productTypes) filters.push({ productType: pt });
for (const v of active.vendors) filters.push({ productVendor: v });
for (const t of active.tags) filters.push({ tag: t });
for (const c of active.colors) filters.push({ variantOption: { name: 'Color', value: c } });
for (const s of active.styles) filters.push({ variantOption: { name: 'Style', value: s } });
for (const s of active.sizes) filters.push({ variantOption: { name: 'Size', value: s } });
for (const m of active.materials) filters.push({ variantOption: { name: 'Material', value: m } });
for (const [mfKey, vals] of Object.entries(active.metafieldValues)) {
const [namespace, key] = mfKey.split('.');
for (const value of vals) {
filters.push({ productMetafield: { namespace, key, value } });
}
}
return filters;
}
// ─── Main component ──────────────────────────────────────────────────────────
export function CollectionView(props: CollectionProps) {
const { collection: selected, showDescription, showCoverImage, customCoverImage, columns, limit, defaultSort } = props;
const routeHandle = useRouteSegment();
const handle = selected?.handle ?? routeHandle ?? '';
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
const [reverse, setReverse] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [active, setActive] = useState<ActiveFilters>({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
setActive((prev) => ({ ...prev, ...patch }));
}, []);
const clearAll = useCallback(() => {
setActive({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
}, []);
const productFilters = buildProductFilters(active);
const handleSortChange = (value: string) => {
if (value === 'PRICE_DESC') {
setSort('PRICE');
setReverse(true);
} else {
setSort(value as CollectionSortKey);
setReverse(false);
}
};
const sortValue = sort === 'PRICE' && reverse ? 'PRICE_DESC' : sort;
const { collection, loading, hasNextPage, fetchMore } = useCollectionProducts(handle, {
first: limit,
sortKey: sort,
reverse,
filters: productFilters.length ? productFilters : undefined,
});
const products = collection?.products ?? [];
const description = collection?.description ?? (selected as any)?.description;
const collectionImage = customCoverImage || collection?.image?.url;
if (!selected && !routeHandle) {
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<Container>
<header className="mx-auto mb-14 flex max-w-2xl flex-col items-center gap-3 text-center">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-3/4" />
</header>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))}
</div>
</Container>
</section>
);
}
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<Container>
{/* Cover image */}
{showCoverImage === 'yes' && collectionImage && (
<div className="mb-10 overflow-hidden rounded-lg">
<img
src={collectionImage}
alt={collection?.title ?? ''}
className="h-48 w-full object-cover md:h-72 lg:h-80"
/>
</div>
)}
{/* Header */}
<header className="mb-10">
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
Collection
</p>
<Typography variant="h1">
{collection?.title ?? (selected as any)?.title ?? routeHandle}
</Typography>
{showDescription === 'yes' && description ? (
<Typography variant="subtitle1" className="mt-4 max-w-2xl">
{description}
</Typography>
) : null}
</header>
{/* Filter + sort bar */}
<div className="mb-6 flex items-center justify-between">
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<SheetTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
>
<SlidersHorizontal size={14} />
Filters
</button>
</SheetTrigger>
<SheetContent side="left" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4">
<Sidebar props={props} active={active} onChange={patchActive} />
</div>
<SheetFooter className="flex-row gap-2 border-t border-border">
<Button variant="outline" className="flex-1" onClick={clearAll}>
Clear
</Button>
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
Search
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<div className="flex items-center gap-4">
<p className="hidden text-sm text-muted-foreground sm:block">
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
</p>
<Select value={sortValue} onValueChange={handleSortChange}>
<SelectTrigger className="h-auto px-3 py-2 text-sm">
<SelectValue>
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Grid */}
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
{loading
? Array.from({ length: limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>
{!loading && products.length === 0 && (
<div className="mt-16 text-center text-sm text-muted-foreground">
No products found in this collection.
</div>
)}
{hasNextPage && !loading && (
<div className="mt-12 flex justify-center">
<button
type="button"
onClick={fetchMore}
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
>
Load more
</button>
</div>
)}
</Container>
</section>
);
}
-97
View File
@@ -1,97 +0,0 @@
'use client';
import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card';
const Collections: React.FC = () => {
const { collections, loading, error, refetch } = useCollections(20);
if (loading) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
Our Collections
</h2>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-video bg-muted"></div>
<div className="p-6">
<div className="h-8 bg-muted rounded mb-4"></div>
<div className="h-4 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded w-3/4"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Could not load collections</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
</p>
<button
onClick={() => refetch()}
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
>
Retry
</button>
</div>
</div>
</div>
);
}
if (collections.length === 0) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-5xl font-bold mb-8 font-heading">
Our Collections
</h2>
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
<i className="ri-folder-line text-4xl text-muted-foreground mb-4"></i>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Collections Found
</h3>
<p className="text-muted-foreground">
Check back later or configure your Shopify store connection.
</p>
</div>
</div>
</div>
);
}
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
Our Collections
</h2>
{/* Collections Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} />
))}
</div>
</div>
</div>
);
};
export default Collections;
@@ -1,40 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Star } from "lucide-react";
import { FeaturedProductView, type FeaturedProductProps } from "@/components/commerce/featured-product";
const featuredProductEditor: ComponentConfig<FeaturedProductProps> = {
label: "Featured product",
icon: <Star size={16} />,
category: "commerce",
defaultProps: {
product: null,
tagline: "Featured",
ctaLabel: "Add to bag",
align: "left",
tone: "default",
},
fields: {
product: { label: "Product", type: "shopifyProduct" } as any,
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) => <FeaturedProductView {...props} />,
};
export default featuredProductEditor;
-128
View File
@@ -1,128 +0,0 @@
import Link from "next/link";
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/hooks/use-shopify-products";
import { useShopifyCart } from "@/hooks/use-shopify-cart";
import { Typography } from "@/components/Typography";
import { Skeleton } from "@/components/ui/skeleton";
import { Container } from "@/components/layout/Container";
import { cn } from "@/lib/utils";
export type FeaturedProductProps = {
product: ShopifyProduct | null;
tagline: string;
ctaLabel: string;
align: "left" | "right";
tone: "default" | "muted";
};
export function FeaturedProductView({
product: selected,
tagline,
ctaLabel,
align,
tone,
}: FeaturedProductProps) {
const { product: full, loading } = useProduct(selected?.handle ?? null);
const product: any = full ?? selected;
const cart = useShopifyCart();
if (!product) {
return (
<section
className={cn(
"py-20 md:py-28",
tone === "muted" ? "bg-muted/40" : "bg-background",
)}
>
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
<div className={cn(align === "right" && "md:order-2")}>
<Skeleton className="aspect-[4/5] w-full" />
</div>
<div className="flex w-full flex-col items-start gap-5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-32" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<div className="mt-2 flex flex-wrap gap-3">
<Skeleton className="h-11 w-32 rounded-md" />
<Skeleton className="h-11 w-32 rounded-md" />
</div>
</div>
</Container>
</section>
);
}
const image =
product.images?.edges?.[0]?.node ?? (selected as any)?.featuredImage ?? null;
const variant = product.variants?.edges?.[0]?.node;
const price = product.priceRange?.minVariantPrice;
const formatted = price
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currencyCode,
}).format(parseFloat(price.amount))
: null;
return (
<section
className={
tone === "muted"
? "bg-muted/40 py-20 md:py-28"
: "bg-background py-20 md:py-28"
}
>
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
<div className={align === "right" ? "md:order-2" : ""}>
{image ? (
<img
src={image.url}
alt={image.altText || product.title}
className="aspect-[4/5] w-full rounded-md object-cover"
/>
) : (
<div className="aspect-[4/5] w-full rounded-md bg-muted" />
)}
</div>
<div className="flex flex-col items-start gap-5">
{tagline ? (
<Typography variant="caption">{tagline}</Typography>
) : null}
<Typography variant="h2">{product.title}</Typography>
{formatted ? (
<Typography variant="subtitle1" className="text-foreground font-medium">
{formatted}
</Typography>
) : null}
{product.description ? (
<Typography variant="body2" className="max-w-md text-muted-foreground">
{product.description}
</Typography>
) : null}
<div className="mt-2 flex flex-wrap gap-3">
<button
onClick={async () => {
if (!variant) return;
await cart.addItem(variant.id, 1);
cart.openCart();
}}
className="inline-flex items-center justify-center rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background hover:opacity-90"
>
{ctaLabel}
</button>
<Link
href={`/products/${product.handle}`}
className="inline-flex items-center justify-center rounded-md border border-foreground px-6 py-3 text-sm font-medium tracking-wide hover:opacity-80"
>
View details
</Link>
</div>
</div>
</Container>
</section>
);
}
-78
View File
@@ -1,78 +0,0 @@
import * as React from "react";
import Link from "next/link";
import { Typography } from "@/components/Typography";
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<string, string> = {
portrait: "aspect-[4/5]",
square: "aspect-square",
landscape: "aspect-[4/3]",
};
return (
<Link href={`/products/${product.handle}`} className="group block">
<div
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
>
{image ? (
<img
src={image.url}
alt={image.altText || product.title}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
) : null}
</div>
<div className="mt-4 flex items-start justify-between gap-3">
<Typography
variant="subtitle2"
className="font-medium tracking-tight text-foreground"
>
{product.title}
</Typography>
{price ? (
<div className="flex flex-col items-end text-sm">
{onSale && compare ? (
<span className="text-xs text-muted-foreground line-through">
{format(compare)}
</span>
) : null}
<span className="font-medium">{format(price)}</span>
</div>
) : null}
</div>
</Link>
);
}
export default ProductCard;
-3
View File
@@ -1,3 +0,0 @@
import ProductDetail from './product-detail/index.tsx';
export default ProductDetail;
@@ -1,205 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useRouteSegment } from '@/hooks/use-route-segment';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/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<ProductDetailProps> = ({ handle: handleProp }) => {
const routeHandle = useRouteSegment();
const handle = handleProp || routeHandle || '';
const { addItem, openCart } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
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<string, string> = {};
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 || !handle || !product) {
if (error && handle && !loading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Product not found</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
</p>
<Button
size="sm"
variant="outline"
onClick={() => window.history.back()}
>
Go back
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<Skeleton className="aspect-square w-full mb-4" />
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full" />
))}
</div>
</div>
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/shop">Shop</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{product.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<ProductDetailGallery
images={product.images.edges.map(edge => edge.node)}
selectedImageIndex={selectedImageIndex}
onImageSelect={setSelectedImageIndex}
/>
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
onOptionChange={handleOptionChange}
loading={addingToCart}
/>
</div>
</div>
</div>
);
};
export default ProductDetail;
@@ -1,66 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
altText?: string;
}
interface ProductDetailGalleryProps {
images: ProductImage[];
selectedImageIndex?: number;
onImageSelect?: (index: number) => void;
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images,
selectedImageIndex = 0,
onImageSelect
}) => {
const selectedImage = selectedImageIndex;
const setSelectedImage = onImageSelect || (() => {});
return (
<div>
{/* Main Image */}
<div className="aspect-square bg-muted rounded-lg overflow-hidden mb-4">
{images.length > 0 ? (
<img
src={images[selectedImage].url}
alt={images[selectedImage].altText || 'Product image'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<i className="ri-image-line text-6xl"></i>
</div>
)}
</div>
{/* Image Thumbnails */}
{images.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImage(index)}
className={`aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
selectedImage === index
? 'border-foreground'
: 'border-border hover:border-muted-foreground'
}`}
>
<img
src={image.url}
alt={image.altText || 'Product thumbnail'}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
);
};
export default ProductDetailGallery;
@@ -1,158 +0,0 @@
import React from 'react';
import { Product, ProductVariant } from './index.tsx';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
interface ProductDetailInfoProps {
product: Product;
selectedVariant: ProductVariant | null;
selectedOptions: Record<string, string>;
quantity: number;
setQuantity: (quantity: number) => void;
handleAddToCart: () => void;
onOptionChange: (optionName: string, value: string) => void;
loading?: boolean;
}
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
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 (
<div>
<h1 className="text-4xl font-bold text-foreground mb-4 font-heading">
{product.title}
</h1>
{/* Price */}
<div className="flex items-center space-x-4 mb-6">
<span className="text-2xl font-bold text-foreground">
{formatPrice(price)}
</span>
{hasDiscount && compareAtPrice && (
<>
<span className="text-xl text-muted-foreground line-through">
{formatPrice(compareAtPrice)}
</span>
<Badge variant="destructive">
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
</Badge>
</>
)}
</div>
{/* Description */}
{product.description && (
<div className="text-muted-foreground mb-8 text-lg leading-relaxed">
{product.descriptionHtml ? (
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
) : (
<p>{product.description}</p>
)}
</div>
)}
{/* Product Options */}
{product.options.map(option => (
<div key={option.id} className="mb-6">
<label className="block text-sm font-semibold text-foreground mb-2">
{option.name}
</label>
<div className="flex flex-wrap gap-2">
{option.values.map(value => (
<Button
key={value}
onClick={() => onOptionChange(option.name, value)}
variant={selectedOptions[option.name] === value ? 'default' : 'outline'}
>
{value}
</Button>
))}
</div>
</div>
))}
{/* Quantity Selector */}
<div className="mb-8">
<label className="block text-sm font-semibold text-foreground mb-2">
Quantity
</label>
<div className="flex items-center border border-border rounded-lg w-fit">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
variant="ghost"
size="icon-sm"
disabled={quantity <= 1}
>
<i className="ri-subtract-line"></i>
</Button>
<span className="w-10 text-center font-semibold">{quantity}</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon-sm"
>
<i className="ri-add-line"></i>
</Button>
</div>
</div>
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
disabled={!selectedVariant?.availableForSale || loading}
size="lg"
className="w-full text-lg"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<Spinner size="sm" />
<span>Adding...</span>
</span>
) : selectedVariant?.availableForSale ? (
'Add to Cart'
) : (
'Out of Stock'
)}
</Button>
{/* Additional Info */}
<div className="mt-8 pt-8 border-t border-border">
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<i className="ri-truck-line"></i>
<span>Free shipping on orders over $100</span>
</div>
<div className="flex items-center space-x-2">
<i className="ri-arrow-go-back-line"></i>
<span>30-day return policy</span>
</div>
<div className="flex items-center space-x-2">
<i className="ri-secure-payment-line"></i>
<span>Secure payment</span>
</div>
</div>
</div>
</div>
);
};
export default ProductDetailInfo;
@@ -1,14 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Package } from "lucide-react";
import { ProductDetailsView, type ProductDetailsProps } from "@/components/commerce/product-details";
const productDetailsEditor: ComponentConfig<ProductDetailsProps> = {
label: "Product details",
icon: <Package size={16} />,
category: "commerce",
defaultProps: { product: null },
fields: { product: { label: "Product", type: "shopifyProduct" } as any },
render: (props) => <ProductDetailsView {...props} />,
};
export default productDetailsEditor;
-221
View File
@@ -1,221 +0,0 @@
import { useEffect, useState } from "react";
import { useRouteSegment } from "@/hooks/use-route-segment";
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/hooks/use-shopify-products";
import { useShopifyCart } from "@/hooks/use-shopify-cart";
import { Typography } from "@/components/Typography";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Loader } from "@/components/ui/loader";
import { Container } from "@/components/layout/Container";
export type ProductDetailsProps = {
product: ShopifyProduct | null;
};
export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
const routeHandle = useRouteSegment();
const handle = selected?.handle ?? routeHandle ?? null;
const { product, loading } = useProduct(handle);
const cart = useShopifyCart();
const [activeImage, setActiveImage] = useState(0);
const [variant, setVariant] = useState<any>(null);
const [quantity, setQuantity] = useState(1);
const [adding, setAdding] = useState(false);
useEffect(() => {
if (product?.variants?.edges?.length) {
setVariant(product.variants.edges[0].node);
}
}, [product]);
if (!handle || loading || !product) {
return (
<section className="bg-background py-12 md:py-20">
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
<div className="flex flex-col gap-4">
<Skeleton className="aspect-[4/5] w-full" />
<div className="flex gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-20 flex-shrink-0" />
))}
</div>
</div>
<div className="flex flex-col gap-6">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-1/4" />
<div className="flex flex-col gap-3">
<Skeleton className="h-3 w-16" />
<div className="flex gap-2">
<Skeleton className="h-10 w-16 rounded-md" />
<Skeleton className="h-10 w-16 rounded-md" />
<Skeleton className="h-10 w-16 rounded-md" />
</div>
</div>
<div className="flex items-center gap-4 pt-2">
<Skeleton className="h-11 w-32 rounded-md" />
<Skeleton className="h-11 flex-1 rounded-md" />
</div>
<div className="space-y-2 border-t border-border pt-6">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</Container>
</section>
);
}
const images = product.images?.edges?.map((e: any) => e.node) ?? [];
const main = images[activeImage];
const price = variant?.price ?? product.priceRange?.minVariantPrice;
const formatted = price
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currencyCode,
}).format(parseFloat(price.amount))
: null;
const onAdd = async () => {
if (!variant) return;
setAdding(true);
try {
await cart.addItem(variant.id, quantity);
cart.openCart();
} finally {
setAdding(false);
}
};
return (
<section className="bg-background py-12 md:py-20">
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
<div className="flex flex-col gap-4">
<div className="aspect-[4/5] w-full overflow-hidden rounded-md bg-muted">
{main ? (
<img
src={main.url}
alt={main.altText || product.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
{images.length > 1 ? (
<div className="flex gap-3 overflow-x-auto p-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{images.map((img: any, i: number) => (
<button
key={i}
onClick={() => setActiveImage(i)}
className={cn(
"aspect-square w-20 flex-shrink-0 overflow-hidden rounded-md transition-opacity",
i === activeImage
? "ring-2 ring-foreground"
: "opacity-60 hover:opacity-100",
)}
>
<img
src={img.url}
alt=""
className="h-full w-full object-cover"
/>
</button>
))}
</div>
) : null}
</div>
<div className="flex flex-col gap-6">
<div>
<Typography variant="h2" as="h1">
{product.title}
</Typography>
{formatted ? (
<Typography variant="subtitle1" className="mt-3 text-foreground">
{formatted}
</Typography>
) : null}
</div>
{(product.options ?? []).map((opt: any) => (
<div key={opt.id ?? opt.name}>
<p className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
{opt.name}
</p>
<div className="flex flex-wrap gap-2">
{opt.values.map((val: string) => {
const matching = product.variants.edges.find((e: any) =>
e.node.selectedOptions?.some(
(o: any) => o.name === opt.name && o.value === val,
),
);
const selected = variant?.selectedOptions?.some(
(o: any) => o.name === opt.name && o.value === val,
);
return (
<button
key={val}
onClick={() => matching && setVariant(matching.node)}
className={cn(
"min-w-12 rounded-md border px-4 py-2 text-sm transition-colors",
selected
? "border-foreground bg-foreground text-background"
: "border-border hover:border-foreground",
)}
>
{val}
</button>
);
})}
</div>
</div>
))}
<div className="flex items-center gap-4 pt-2">
<div className="flex items-center gap-3 rounded-md border border-border px-4 py-2">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="text-base hover:opacity-60"
>
</button>
<span className="w-6 text-center text-sm">{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="text-base hover:opacity-60"
>
+
</button>
</div>
<button
onClick={onAdd}
disabled={!variant || adding}
className="flex-1 rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background transition-opacity hover:opacity-90 disabled:opacity-50"
>
{adding ? (
<span className="flex items-center justify-center gap-2">
<Loader size={16} />
Adding
</span>
) : (
"Add to bag"
)}
</button>
</div>
{product.description ? (
<div className="border-t border-border pt-6">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Details
</p>
<p className="mt-3 text-sm leading-relaxed text-foreground/80">
{product.description}
</p>
</div>
) : null}
</div>
</Container>
</section>
);
}
@@ -1,25 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutGrid } from "lucide-react";
import {
ProductRecommendationsView,
type ProductRecommendationsProps,
} from "@/components/commerce/product-recommendations";
const productRecommendationsEditor: ComponentConfig<ProductRecommendationsProps> = {
label: "Product recommendations",
icon: <LayoutGrid size={16} />,
category: "commerce",
defaultProps: {
product: null,
heading: "You Might Also Like",
limit: 4,
},
fields: {
product: { label: "Source product", type: "shopifyProduct" } as any,
heading: { label: "Heading", type: "text", contentEditable: true },
limit: { label: "Limit", type: "number", min: 2, max: 8 },
},
render: (props) => <ProductRecommendationsView {...props} />,
};
export default productRecommendationsEditor;
@@ -1,75 +0,0 @@
'use client';
import React from 'react';
import type { ShopifyProduct } from '@reacteditor/field-shopify';
import {
useProduct,
useProductRecommendations,
} from '@/hooks/use-shopify-products';
import { useRouteSegment } from '@/hooks/use-route-segment';
import { ProductCard } from './product-card';
export type ProductRecommendationsProps = {
product: ShopifyProduct | null;
heading: string;
limit: number;
};
export function ProductRecommendationsView({
product: selected,
heading,
limit,
}: ProductRecommendationsProps) {
const routeHandle = useRouteSegment();
const handle = selected?.handle ?? routeHandle ?? null;
const { product } = useProduct(handle);
const { recommendations, loading, error } = useProductRecommendations(
product?.id ?? null,
);
// Don't show section if we're not loading and have no recommendations
if (!loading && (!recommendations || recommendations.length === 0)) {
return null;
}
return (
<div className="bg-muted py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-bold text-center mb-12 text-foreground font-heading">
{heading}
</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: limit }).map((_, index) => (
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-square bg-muted"></div>
<div className="p-6">
<div className="h-6 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-4"></div>
<div className="h-8 bg-muted rounded mb-4"></div>
<div className="h-12 bg-muted rounded"></div>
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-muted-foreground">Recommendations could not be loaded</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{recommendations.slice(0, limit).map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</div>
);
}
export default ProductRecommendationsView;
@@ -1,40 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { GalleryHorizontalEnd } from "lucide-react";
import { ProductsCarousel, type ProductsCarouselProps } from "@/components/commerce/products-carousel";
const productsCarouselEditor: ComponentConfig<ProductsCarouselProps> = {
label: "Products carousel",
icon: <GalleryHorizontalEnd size={16} />,
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", type: "shopifyCollection" } as any,
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) => <ProductsCarousel {...props} />,
};
export default productsCarouselEditor;
-122
View File
@@ -1,122 +0,0 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { getProducts } from "@/hooks/use-shopify-products";
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Heading } from "@/components/Heading";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Container } from "@/components/layout/Container";
export type ProductsCarouselProps = {
collection: ShopifyCollection | null;
tagline: string;
heading: string;
subheading: string;
limit: number;
slidesPerView: "2" | "3" | "4";
ctaLabel: string;
ctaHref: string;
};
const basisClass: Record<ProductsCarouselProps["slidesPerView"], string> = {
"2": "md:basis-1/2",
"3": "md:basis-1/3",
"4": "md:basis-1/4",
};
export function ProductsCarousel({
collection,
tagline,
heading,
subheading,
limit,
slidesPerView,
ctaLabel,
ctaHref,
}: ProductsCarouselProps) {
const [products, setProducts] = useState<any[]>([]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
if (collection?.handle) {
const data = await getCollectionProducts(collection.handle, {
first: limit,
});
if (!cancelled) setProducts(data?.collection?.products ?? []);
} else {
const data = await getProducts({
first: limit,
sortKey: "CREATED_AT",
reverse: true,
});
if (!cancelled) setProducts(data ?? []);
}
} catch {
if (!cancelled) setProducts([]);
}
};
load();
return () => {
cancelled = true;
};
}, [collection?.handle, limit]);
return (
<section className="bg-background py-20 md:py-28">
<Container>
<div className="mb-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="left"
size="lg"
maxWidth="max-w-xl"
/>
{ctaLabel ? (
<Link
href={
ctaHref ||
(collection?.handle ? `/collections/${collection.handle}` : "/collections")
}
className="text-sm font-medium tracking-wide hover:opacity-70"
>
{ctaLabel}
</Link>
) : null}
</div>
<Carousel opts={{ align: "start", loop: true }}>
<CarouselContent className="-ml-6">
{(products.length === 0
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }))
: products
).map((p: any) => (
<CarouselItem
key={p.id}
className={`pl-6 basis-3/4 sm:basis-1/2 ${basisClass[slidesPerView]}`}
>
{products.length === 0 ? (
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
) : (
<ProductCard product={p} />
)}
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="hidden md:inline-flex" />
<CarouselNext className="hidden md:inline-flex" />
</Carousel>
</Container>
</section>
);
}
@@ -1,39 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutGrid } from "lucide-react";
import { ProductsGrid, type ProductsGridProps } from "@/components/commerce/products-grid";
const productsGridEditor: ComponentConfig<ProductsGridProps> = {
label: "Products grid",
icon: <LayoutGrid size={16} />,
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", type: "shopifyCollection" } as any,
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) => <ProductsGrid {...props} />,
};
export default productsGridEditor;
-96
View File
@@ -1,96 +0,0 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { getProducts } from "@/hooks/use-shopify-products";
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Container } from "@/components/layout/Container";
import { Heading } from "@/components/Heading";
export type ProductsGridProps = {
collection: ShopifyCollection | null;
tagline: string;
heading: string;
subheading: string;
columns: "3" | "4";
limit: number;
ctaLabel: string;
ctaHref: string;
};
const colClass: Record<ProductsGridProps["columns"], string> = {
"3": "grid-cols-2 md:grid-cols-3",
"4": "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
};
export function ProductsGrid({
collection,
tagline,
heading,
subheading,
columns,
limit,
ctaLabel,
ctaHref,
}: ProductsGridProps) {
const [products, setProducts] = useState<any[]>([]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
if (collection?.handle) {
const data = await getCollectionProducts(collection.handle, {
first: limit,
});
if (!cancelled) setProducts(data?.collection?.products ?? []);
} else {
const data = await getProducts({ first: limit, sortKey: "BEST_SELLING" });
if (!cancelled) setProducts(data ?? []);
}
} catch {
if (!cancelled) setProducts([]);
}
};
load();
return () => {
cancelled = true;
};
}, [collection?.handle, limit]);
return (
<section className="bg-background py-20 md:py-28">
<Container>
<div className="mb-12 flex flex-col items-end justify-between gap-6 md:flex-row md:items-end">
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="left"
size="lg"
maxWidth="max-w-xl"
/>
{ctaLabel ? (
<Link
href={ctaHref || (collection?.handle ? `/collections/${collection.handle}` : "/collections")}
className="text-sm font-medium tracking-wide hover:opacity-70"
>
{ctaLabel}
</Link>
) : null}
</div>
<div className={`grid gap-x-6 gap-y-12 ${colClass[columns]}`}>
{products.length === 0
? Array.from({ length: limit }).map((_, i) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
))
: products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
</Container>
</section>
);
}
-233
View File
@@ -1,233 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProducts } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/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<ProductsProps> = ({
title = "Our Products",
limit = 12,
showLoadMore = true
}) => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-bold text-center mb-12 font-heading">
{title}
</h2>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-square bg-muted"></div>
<div className="p-6">
<div className="h-6 bg-muted rounded mb-2"></div>
<div className="h-4 bg-muted rounded mb-4"></div>
<div className="h-8 bg-muted rounded mb-4"></div>
<div className="h-12 bg-muted rounded"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Could not load products</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
</p>
<Button
size="sm"
variant="outline"
onClick={() => fetchProducts()}
>
Retry
</Button>
</div>
</div>
</div>
);
}
if (products.length === 0) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-bold mb-8 font-heading">
{title}
</h2>
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Products Found
</h3>
<p className="text-muted-foreground">
Check back later or configure your Shopify store connection.
</p>
</div>
</div>
</div>
);
}
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
{title}
</h2>
{/* Products Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-12">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
{/* Load More Button */}
{showLoadMore && hasMoreProducts && (
<div className="text-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
size="lg"
className="font-heading"
>
{loadingMore ? (
<span className="flex items-center space-x-2">
<Spinner size="sm" />
<span>Loading...</span>
</span>
) : (
'Load More Products'
)}
</Button>
</div>
)}
</div>
</div>
);
};
export default Products;
@@ -1,24 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Sparkles } from "lucide-react";
import { RecommendedProductsView, type RecommendedProductsProps } from "@/components/commerce/recommended-products";
const recommendedProductsEditor: ComponentConfig<RecommendedProductsProps> = {
label: "Recommended products",
icon: <Sparkles size={16} />,
category: "commerce",
defaultProps: {
product: null,
tagline: "You may also like",
heading: "More to explore",
limit: 4,
},
fields: {
product: { label: "Source product", type: "shopifyProduct" } as any,
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) => <RecommendedProductsView {...props} />,
};
export default recommendedProductsEditor;
@@ -1,68 +0,0 @@
import type { ShopifyProduct } from "@reacteditor/field-shopify";
import {
useProduct,
useProductRecommendations,
} from "@/hooks/use-shopify-products";
import { ProductCard } from "./product-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Container } from "@/components/layout/Container";
import { Heading } from "@/components/Heading";
export type RecommendedProductsProps = {
product: ShopifyProduct | null;
tagline: string;
heading: string;
limit: number;
};
export function RecommendedProductsView({
product: selected,
tagline,
heading,
limit,
}: RecommendedProductsProps) {
const { product } = useProduct(selected?.handle ?? null);
const { recommendations } = useProductRecommendations(product?.id ?? null);
const items = (recommendations ?? []).slice(0, limit);
if (!selected) {
return (
<section className="bg-background py-20 md:py-28">
<Container>
<div className="mb-12 flex max-w-xl flex-col gap-3">
{tagline ? <Skeleton className="h-3 w-24" /> : null}
<Skeleton className="h-8 w-2/3" />
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{Array.from({ length: limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))}
</div>
</Container>
</section>
);
}
return (
<section className="bg-background py-20 md:py-28">
<Container>
<Heading
tagline={tagline}
title={heading}
align="left"
size="md"
className="mb-12"
maxWidth="max-w-xl"
/>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{items.length === 0
? Array.from({ length: limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>
</Container>
</section>
);
}
@@ -1,244 +0,0 @@
import { ComponentConfig } from '@reacteditor/core';
import { Search } from 'lucide-react';
import { SearchProductsView, type SearchProductsProps } from '@/components/commerce/search-products';
const searchProductsEditor: ComponentConfig<SearchProductsProps> = {
label: 'Search & filter',
icon: <Search size={16} />,
category: 'commerce',
defaultProps: {
heading: 'Shop',
subheading: 'Browse our full collection.',
columns: '4',
limit: 24,
showAvailability: 'yes',
showPriceRange: 'yes',
showProductType: 'yes',
productTypeOptions: [
{ label: 'T-Shirts' },
{ label: 'Pants' },
{ label: 'Outerwear' },
{ label: 'Accessories' },
{ label: 'Shoes' },
],
showVendor: 'yes',
vendorOptions: [
{ label: 'Maison' },
{ label: 'Atelier' },
{ label: 'Studio' },
],
showTags: 'yes',
tagOptions: [
{ label: 'New' },
{ label: 'Sale' },
{ label: 'Bestseller' },
{ label: 'Limited Edition' },
],
showColor: 'yes',
colorOptions: [
{ label: 'Black', color: '#000000' },
{ label: 'White', color: '#FFFFFF' },
{ label: 'Navy', color: '#1e3a5f' },
{ label: 'Red', color: '#c0392b' },
],
showStyle: 'no',
styleOptions: [],
showSize: 'yes',
sizeOptions: [
{ label: 'XS' },
{ label: 'S' },
{ label: 'M' },
{ label: 'L' },
{ label: 'XL' },
],
showMaterial: 'no',
materialOptions: [],
metafieldFilters: [],
defaultSort: 'BEST_SELLING',
},
fields: {
heading: { label: 'Heading', type: 'text', contentEditable: true },
subheading: { label: 'Subheading', type: 'textarea', contentEditable: true },
columns: {
label: 'Columns',
type: 'radio',
options: [
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
],
},
limit: { label: 'Products per page', type: 'number', min: 4, max: 48 },
defaultSort: {
label: 'Default sort',
type: 'select',
options: [
{ label: 'Best Selling', value: 'BEST_SELLING' },
{ label: 'Relevance', value: 'RELEVANCE' },
{ label: 'Newest', value: 'NEWEST' },
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
{ label: 'Alphabetical', value: 'TITLE_ASC' },
],
},
showAvailability: {
label: 'Availability filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
showPriceRange: {
label: 'Price range filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
showProductType: {
label: 'Product type filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
productTypeOptions: {
label: 'Product types',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Type',
arrayFields: {
label: { label: 'Type name', type: 'text' },
},
},
showVendor: {
label: 'Brand / vendor filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
vendorOptions: {
label: 'Brands',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Brand',
arrayFields: {
label: { label: 'Brand name', type: 'text' },
},
},
showTags: {
label: 'Tags filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
tagOptions: {
label: 'Tags',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Tag',
arrayFields: {
label: { label: 'Tag name', type: 'text' },
},
},
showColor: {
label: 'Color filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
colorOptions: {
label: 'Colors',
type: 'array',
defaultItemProps: { label: '', color: '#000000' },
getItemSummary: (it: any) => it?.label || 'Color',
arrayFields: {
label: { label: 'Color name', type: 'text' },
color: { label: 'Color', type: 'color' },
},
},
showStyle: {
label: 'Style filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
styleOptions: {
label: 'Styles',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Style',
arrayFields: {
label: { label: 'Style name', type: 'text' },
},
},
showSize: {
label: 'Size filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
sizeOptions: {
label: 'Sizes',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Size',
arrayFields: {
label: { label: 'Size name', type: 'text' },
},
},
showMaterial: {
label: 'Material filter',
type: 'radio',
options: [
{ label: 'Show', value: 'yes' },
{ label: 'Hide', value: 'no' },
],
},
materialOptions: {
label: 'Materials',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (it: any) => it?.label || 'Material',
arrayFields: {
label: { label: 'Material name', type: 'text' },
},
},
metafieldFilters: {
label: 'Metafield filters',
type: 'array',
defaultItemProps: { namespace: '', key: '', label: '', values: [{ label: '' }] },
getItemSummary: (it: any) => it?.label || it?.key || 'Metafield',
arrayFields: {
namespace: { label: 'Namespace', type: 'text' },
key: { label: 'Key', type: 'text' },
label: { label: 'Label', type: 'text' },
values: {
label: 'Values',
type: 'array',
defaultItemProps: { label: '' },
getItemSummary: (v: any) => v?.label || 'Value',
arrayFields: {
label: { label: 'Value', type: 'text' },
},
},
},
},
},
render: (props) => <SearchProductsView {...props} />,
};
export default searchProductsEditor;
-533
View File
@@ -1,533 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { useShopifySearch, type SearchFilters, type SortOption } from '@/hooks/use-shopify-search';
import { ProductCard } from './product-card';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
import { Container } from '@/components/layout/Container';
import { cn } from '@/lib/utils';
type FilterOption = { label: string };
type ColorOption = { label: string; color: string };
export type SearchProductsProps = {
heading: string;
subheading: string;
columns: '2' | '3' | '4';
limit: number;
showAvailability: 'yes' | 'no';
showPriceRange: 'yes' | 'no';
showProductType: 'yes' | 'no';
productTypeOptions: FilterOption[];
showVendor: 'yes' | 'no';
vendorOptions: FilterOption[];
showTags: 'yes' | 'no';
tagOptions: FilterOption[];
showColor: 'yes' | 'no';
colorOptions: ColorOption[];
showStyle: 'yes' | 'no';
styleOptions: FilterOption[];
showSize: 'yes' | 'no';
sizeOptions: FilterOption[];
showMaterial: 'yes' | 'no';
materialOptions: FilterOption[];
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
defaultSort: SortOption;
};
const SORT_OPTIONS: { label: string; value: SortOption }[] = [
{ label: 'Relevance', value: 'RELEVANCE' },
{ label: 'Best Selling', value: 'BEST_SELLING' },
{ label: 'Newest', value: 'NEWEST' },
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
{ label: 'Alphabetical', value: 'TITLE_ASC' },
];
const colClass: Record<SearchProductsProps['columns'], string> = {
'2': 'grid-cols-2',
'3': 'grid-cols-2 md:grid-cols-3',
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
};
// ─── Filter group (collapsible) ────────────────────────────────────────────────
function FilterGroup({ label, children, defaultOpen = true }: {
label: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-b border-border py-4">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
>
{label}
<ChevronDown
size={14}
className={cn('transition-transform', open ? 'rotate-180' : '')}
/>
</button>
{open && <div className="mt-3 space-y-2">{children}</div>}
</div>
);
}
function Checkbox({ checked, onChange, label }: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
checked ? 'border-foreground bg-foreground' : 'border-border',
)}
>
{checked && (
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{label}
</label>
);
}
// ─── Sidebar ────────────────────────────────────────────────────────────────────
type ActiveFilters = {
availability: boolean;
productTypes: string[];
vendors: string[];
tags: string[];
colors: string[];
styles: string[];
sizes: string[];
materials: string[];
minPrice: string;
maxPrice: string;
metafieldValues: Record<string, string[]>;
};
function Sidebar({
props,
active,
onChange,
}: {
props: SearchProductsProps;
active: ActiveFilters;
onChange: (patch: Partial<ActiveFilters>) => void;
}) {
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
const colors = (props.colorOptions ?? []) as ColorOption[];
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
const arr = active[key];
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
}
return (
<div className="w-full">
{props.showAvailability === 'yes' && (
<FilterGroup label="Availability">
<Checkbox
checked={active.availability}
onChange={(v) => onChange({ availability: v })}
label="In stock"
/>
</FilterGroup>
)}
{props.showPriceRange === 'yes' && (
<FilterGroup label="Price">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
placeholder="Min"
value={active.minPrice}
onChange={(e) => onChange({ minPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
min={0}
placeholder="Max"
value={active.maxPrice}
onChange={(e) => onChange({ maxPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
</FilterGroup>
)}
{props.showVendor === 'yes' && vendors.length > 0 && (
<FilterGroup label="Brand">
{vendors.map((v) => (
<Checkbox
key={v}
checked={active.vendors.includes(v)}
onChange={() => toggle('vendors', v)}
label={v}
/>
))}
</FilterGroup>
)}
{props.showColor === 'yes' && colors.length > 0 && (
<FilterGroup label="Color">
{colors.filter((c) => c.label).map((c) => (
<label
key={c.label}
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
>
<input
type="checkbox"
checked={active.colors.includes(c.label)}
onChange={() => toggle('colors', c.label)}
className="sr-only"
/>
<span
className={cn(
'flex h-5 w-5 shrink-0 rounded-full border-2',
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
)}
style={{ backgroundColor: c.color || undefined }}
/>
{c.label}
</label>
))}
</FilterGroup>
)}
{props.showStyle === 'yes' && styles.length > 0 && (
<FilterGroup label="Style">
{styles.map((s) => (
<Checkbox
key={s}
checked={active.styles.includes(s)}
onChange={() => toggle('styles', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showSize === 'yes' && sizes.length > 0 && (
<FilterGroup label="Size">
{sizes.map((s) => (
<Checkbox
key={s}
checked={active.sizes.includes(s)}
onChange={() => toggle('sizes', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showMaterial === 'yes' && materials.length > 0 && (
<FilterGroup label="Material">
{materials.map((m) => (
<Checkbox
key={m}
checked={active.materials.includes(m)}
onChange={() => toggle('materials', m)}
label={m}
/>
))}
</FilterGroup>
)}
{props.showProductType === 'yes' && productTypes.length > 0 && (
<FilterGroup label="Product type">
{productTypes.map((pt) => (
<Checkbox
key={pt}
checked={active.productTypes.includes(pt)}
onChange={() => toggle('productTypes', pt)}
label={pt}
/>
))}
</FilterGroup>
)}
{props.showTags === 'yes' && tags.length > 0 && (
<FilterGroup label="Tags">
{tags.map((t) => (
<Checkbox
key={t}
checked={active.tags.includes(t)}
onChange={() => toggle('tags', t)}
label={t}
/>
))}
</FilterGroup>
)}
{metafieldFilters.map((mf, i) => {
const mfKey = `${mf.namespace}.${mf.key}`;
const selected = active.metafieldValues[mfKey] ?? [];
return (
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
<Checkbox
key={val}
checked={selected.includes(val)}
onChange={(checked) => {
const next = checked
? [...selected, val]
: selected.filter((v) => v !== val);
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
}}
label={val}
/>
))}
</FilterGroup>
);
})}
</div>
);
}
// ─── Main component ──────────────────────────────────────────────────────────
export function SearchProductsView(props: SearchProductsProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const initialQ = searchParams.get('q') ?? '';
const [query, setQuery] = useState(initialQ);
const [inputValue, setInputValue] = useState(initialQ);
const [sort, setSort] = useState<SortOption>(props.defaultSort);
const [filtersOpen, setFiltersOpen] = useState(false);
const [active, setActive] = useState<ActiveFilters>({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
setActive((prev) => ({ ...prev, ...patch }));
}, []);
const clearAll = useCallback(() => {
setActive({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
}, []);
const filters: SearchFilters = {
q: query,
sort,
availability: active.availability || undefined,
productTypes: active.productTypes.length ? active.productTypes : undefined,
vendors: active.vendors.length ? active.vendors : undefined,
tags: active.tags.length ? active.tags : undefined,
colors: active.colors.length ? active.colors : undefined,
styles: active.styles.length ? active.styles : undefined,
sizes: active.sizes.length ? active.sizes : undefined,
materials: active.materials.length ? active.materials : undefined,
minPrice: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
maxPrice: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
metafields: (() => {
const mfs = Object.entries(active.metafieldValues).flatMap(([mfKey, vals]) => {
const [namespace, key] = mfKey.split('.');
return vals.map((value) => ({ namespace, key, value }));
});
return mfs.length ? mfs : undefined;
})(),
};
const { products, loading, error, hasNextPage, fetchMore } = useShopifySearch(filters, {
first: props.limit,
});
// Sync ?q= param when query changes
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) params.set('q', query); else params.delete('q');
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [query]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setQuery(inputValue.trim());
};
return (
<section className="bg-background py-12 md:py-16">
<Container>
{/* Page header */}
<div className="mb-10">
{props.heading && (
<h1 className="mb-2 font-heading text-4xl font-bold tracking-tight text-foreground md:text-5xl">
{props.heading}
</h1>
)}
{props.subheading && (
<p className="text-muted-foreground">{props.subheading}</p>
)}
{/* Search bar (mobile only desktop version lives in the product area) */}
<form onSubmit={handleSearch} className="mt-6 flex gap-2 md:hidden">
<input
type="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products…"
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
/>
<button
type="submit"
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
>
Search
</button>
</form>
</div>
{/* Search bar (desktop only — mobile lives in header above) */}
<form onSubmit={handleSearch} className="mb-4 hidden gap-2 md:flex">
<input
type="search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products…"
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
/>
<button
type="submit"
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
>
Search
</button>
</form>
{/* Filter + sort bar */}
<div className="mb-6 flex items-center justify-between">
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<SheetTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
>
<SlidersHorizontal size={14} />
Filters
</button>
</SheetTrigger>
<SheetContent side="left" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4">
<Sidebar props={props} active={active} onChange={patchActive} />
</div>
<SheetFooter className="flex-row gap-2 border-t border-border">
<Button variant="outline" className="flex-1" onClick={clearAll}>
Clear
</Button>
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
Search
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<div className="flex items-center gap-4">
<p className="hidden text-sm text-muted-foreground sm:block">
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
</p>
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
<SelectTrigger className="h-auto px-3 py-2 text-sm">
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{error && (
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
{error}
</p>
)}
{/* Grid */}
<div className={cn('grid gap-x-6 gap-y-10', colClass[props.columns])}>
{loading
? Array.from({ length: props.limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
{!loading && products.length === 0 && !error && (
<div className="mt-16 text-center text-sm text-muted-foreground">
No products found.{query ? ` Try a different search term.` : ''}
</div>
)}
{hasNextPage && !loading && (
<div className="mt-12 flex justify-center">
<button
type="button"
onClick={fetchMore}
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
>
Load more
</button>
</div>
)}
</Container>
</section>
);
}
-53
View File
@@ -1,53 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Megaphone } from "lucide-react";
import { CTA, type CTAProps } from "@/components/cta/cta";
const ctaEditor: ComponentConfig<CTAProps> = {
label: "Call to action",
icon: <Megaphone size={16} />,
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", type: "image" },
align: {
label: "Alignment",
type: "radio",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
],
},
},
render: (props) => <CTA {...props} />,
};
export default ctaEditor;
-81
View File
@@ -1,81 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Heading } from "@/components/Heading";
export type CTAProps = {
tagline: string;
heading: string;
subheading: string;
primaryCta: { label: string; href: string };
secondaryCta: { label: string; href: string };
imageUrl: string;
align: "left" | "center";
};
export function CTA({
tagline,
heading,
subheading,
primaryCta,
secondaryCta,
imageUrl,
align,
}: CTAProps) {
return (
<section className="relative overflow-hidden py-24 md:py-32">
<div className="absolute inset-0 -z-10">
{imageUrl ? (
<>
<img
src={imageUrl}
alt=""
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/45" />
</>
) : (
<div className="h-full w-full bg-foreground" />
)}
</div>
<div
className={cn(
"container mx-auto flex max-w-4xl flex-col px-6 text-white",
align === "center" ? "items-center text-center" : "items-start",
)}
>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align={align === "center" ? "center" : "left"}
size="lg"
tone="light"
subtitleClassName="max-w-xl"
/>
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{primaryCta?.label ? (
<Link
href={primaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-md bg-white px-6 py-3 text-sm font-medium tracking-wide text-black hover:opacity-90"
>
{primaryCta.label}
</Link>
) : null}
{secondaryCta?.label ? (
<Link
href={secondaryCta.href || "#"}
className="inline-flex items-center justify-center rounded-md border border-white px-6 py-3 text-sm font-medium tracking-wide text-white hover:bg-white/10"
>
{secondaryCta.label}
</Link>
) : null}
</div>
</div>
</section>
);
}
-54
View File
@@ -1,54 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { HelpCircle } from "lucide-react";
import { FAQ, type FAQProps } from "@/components/faq/faq";
const faqEditor: ComponentConfig<FAQProps> = {
label: "FAQ",
icon: <HelpCircle size={16} />,
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) => <FAQ {...props} />,
};
export default faqEditor;
-56
View File
@@ -1,56 +0,0 @@
import { useState } from "react";
import { Plus, Minus } from "lucide-react";
import { Heading } from "@/components/Heading";
export type FAQProps = {
tagline: string;
heading: string;
subheading: string;
items: Array<{ question: string; answer: string }>;
};
export function FAQ({ tagline, heading, subheading, items }: FAQProps) {
const [open, setOpen] = useState<number | null>(0);
return (
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-3xl px-6">
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="center"
size="lg"
className="mb-12"
/>
<div className="divide-y divide-border border-y border-border">
{items.map((item, i) => {
const isOpen = open === i;
return (
<div key={i}>
<button
onClick={() => setOpen(isOpen ? null : i)}
className="flex w-full items-center justify-between py-6 text-left"
>
<span className="text-base font-medium tracking-tight md:text-lg">
{item.question}
</span>
{isOpen ? (
<Minus size={18} strokeWidth={1.5} className="flex-shrink-0" />
) : (
<Plus size={18} strokeWidth={1.5} className="flex-shrink-0" />
)}
</button>
{isOpen ? (
<p className="pb-6 pr-8 text-sm leading-relaxed text-muted-foreground md:text-base">
{item.answer}
</p>
) : null}
</div>
);
})}
</div>
</div>
</section>
);
}
-56
View File
@@ -1,56 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Sparkles } from "lucide-react";
import { Features, type FeaturesProps } from "@/components/features/features";
const featuresEditor: ComponentConfig<FeaturesProps> = {
label: "Features",
icon: <Sparkles size={16} />,
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) => <Features {...props} />,
};
export default featuresEditor;
-49
View File
@@ -1,49 +0,0 @@
import { Typography } from "@/components/Typography";
import { Container } from "@/components/layout/Container";
import { Heading } from "@/components/Heading";
export type FeaturesProps = {
tagline: string;
heading: string;
subheading: string;
columns: "2" | "3" | "4";
items: Array<{ title: string; body: string }>;
};
const colClass: Record<FeaturesProps["columns"], string> = {
"2": "md:grid-cols-2",
"3": "md:grid-cols-3",
"4": "md:grid-cols-2 lg:grid-cols-4",
};
export function Features({ tagline, heading, subheading, columns, items }: FeaturesProps) {
return (
<section className="bg-background py-20 md:py-28">
<Container>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="center"
size="lg"
className="mx-auto mb-16"
maxWidth="max-w-2xl"
/>
<div className={`grid grid-cols-1 gap-x-10 gap-y-12 ${colClass[columns]}`}>
{items.map((item, i) => (
<div key={i} className="flex flex-col gap-3">
<Typography variant="caption">
{String(i + 1).padStart(2, "0")}
</Typography>
<Typography variant="h5">{item.title}</Typography>
<Typography variant="body2" className="text-muted-foreground">
{item.body}
</Typography>
</div>
))}
</div>
</Container>
</section>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutGrid } from "lucide-react";
import { Footer, type FooterProps } from "@/components/footer/footer";
const footerEditor: ComponentConfig<FooterProps> = {
label: "Footer",
icon: <LayoutGrid size={16} />,
category: "footer",
global: true,
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) => <Footer {...props} />,
};
export default footerEditor;
-130
View File
@@ -1,130 +0,0 @@
import { useState } from "react";
import Link from "next/link";
import { Typography } from "@/components/Typography";
import { Container } from "@/components/layout/Container";
export type FooterProps = {
brand: string;
tagline: string;
columns: Array<{
title: string;
links: Array<{ label: string; href: string }>;
}>;
social: Array<{ label: string; href: string }>;
showNewsletter: "yes" | "no";
newsletterHeading: string;
newsletterEndpoint: string;
copyright: string;
};
export function Footer({
brand,
tagline,
columns,
social,
showNewsletter,
newsletterHeading,
newsletterEndpoint,
copyright,
}: FooterProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (newsletterEndpoint) {
try {
await fetch(newsletterEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {}
}
setSubmitted(true);
};
return (
<footer className="border-t border-border bg-background">
<Container className="py-20 md:py-24">
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
<div className="md:col-span-4">
<Typography variant="h5" as="p">
{brand}
</Typography>
{tagline ? (
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
{tagline}
</Typography>
) : null}
{showNewsletter === "yes" ? (
<form onSubmit={submit} className="mt-8 max-w-sm">
<p className="text-sm font-medium">{newsletterHeading}</p>
{submitted ? (
<p className="mt-3 text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
>
Subscribe
</button>
</div>
)}
</form>
) : null}
</div>
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
{columns.map((col, i) => (
<div key={i}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{col.title}
</p>
<ul className="mt-4 space-y-2.5">
{col.links.map((l, j) => (
<li key={j}>
<Link
href={l.href}
className="text-sm text-foreground/80 hover:text-foreground"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
<p className="text-xs text-muted-foreground">{copyright}</p>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{social.map((s, i) => (
<a
key={i}
href={s.href}
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
>
{s.label}
</a>
))}
</div>
</div>
</Container>
</footer>
);
}
-82
View File
@@ -1,82 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutTemplate } from "lucide-react";
import { Hero, type HeroProps } from "@/components/hero/hero";
const heroEditor: ComponentConfig<HeroProps> = {
label: "Hero",
icon: <LayoutTemplate size={16} />,
category: "hero",
defaultProps: {
tagline: "Spring 2026",
heading: "Made for the way you move",
subheading:
"A considered wardrobe of essentials, cut from natural fibers and designed to last.",
buttons: [
{ label: "Shop the collection", href: "/collections", variant: "primary" },
{ label: "Our story", href: "/about", variant: "secondary" },
],
imageUrl:
"https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80",
align: "left",
height: "lg",
tone: "dark",
},
fields: {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "textarea", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
buttons: {
label: "Buttons",
type: "array",
arrayFields: {
label: { label: "Label", type: "text", contentEditable: true },
href: { label: "Link", type: "text" },
variant: {
label: "Variant",
type: "select",
options: [
{ label: "Primary (filled)", value: "primary" },
{ label: "Secondary (outline)", value: "secondary" },
{ label: "Outline", value: "outline" },
{ label: "Ghost", value: "ghost" },
],
},
},
defaultItemProps: {
label: "Button",
href: "/",
variant: "primary",
},
getItemSummary: (item) => item?.label || "Button",
},
imageUrl: { label: "Background image", type: "image" },
align: {
label: "Alignment",
type: "radio",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
],
},
height: {
label: "Height",
type: "select",
options: [
{ label: "Medium", value: "md" },
{ label: "Large", value: "lg" },
{ label: "Full", value: "full" },
],
},
tone: {
label: "Tone",
type: "radio",
options: [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
],
},
},
render: (props) => <Hero {...props} />,
};
export default heroEditor;
-141
View File
@@ -1,141 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Typography } from "@/components/Typography";
export type HeroButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type HeroButton = {
label: string;
href: string;
variant: HeroButtonVariant;
};
export type HeroProps = {
tagline: string;
heading: string;
subheading: string;
buttons: HeroButton[];
imageUrl: string;
align: "left" | "center";
height: "md" | "lg" | "full";
tone: "light" | "dark";
};
const heightClass: Record<HeroProps["height"], string> = {
md: "min-h-[60vh]",
lg: "min-h-[80vh]",
full: "min-h-screen",
};
function buttonClass(variant: HeroButtonVariant, isDark: boolean): string {
switch (variant) {
case "primary":
return cn(
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
isDark ? "bg-white text-black" : "bg-foreground text-background",
);
case "secondary":
case "outline":
return cn(
"inline-flex items-center justify-center rounded-md border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "border-white text-white" : "border-foreground text-foreground",
);
case "ghost":
return cn(
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "text-white" : "text-foreground",
);
}
}
export function Hero({
tagline,
heading,
subheading,
buttons,
imageUrl,
align,
height,
tone,
}: HeroProps) {
const isDark = tone === "dark";
const visibleButtons = (buttons ?? []).filter((b) => b?.label);
return (
<section
className={cn(
"relative flex w-full items-end overflow-hidden isolate",
heightClass[height],
)}
>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="absolute inset-0 z-0 h-full w-full object-cover"
/>
) : null}
<div
className="absolute inset-0 z-[1]"
style={{
background: isDark
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
}}
/>
<div
className={cn(
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
align === "center" ? "items-center text-center" : "items-start",
isDark ? "text-white" : "text-foreground",
)}
>
{tagline ? (
<Typography
variant="caption"
className={cn(
"mb-5",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{tagline}
</Typography>
) : null}
<Typography variant="h1" className="max-w-3xl">
{heading}
</Typography>
{subheading ? (
<Typography
variant="subtitle1"
className={cn(
"mt-6 max-w-xl",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{subheading}
</Typography>
) : null}
{visibleButtons.length > 0 ? (
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{visibleButtons.map((b, i) => (
<Link
key={`${b.href}-${b.label}-${i}`}
href={b.href || "#"}
className={buttonClass(b.variant, isDark)}
>
{b.label}
</Link>
))}
</div>
) : null}
</div>
</section>
);
}
-32
View File
@@ -1,32 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Megaphone } from "lucide-react";
import { Banner, type BannerProps } from "@/components/landing/banner";
const bannerEditor: ComponentConfig<BannerProps> = {
label: "Announcement bar",
icon: <Megaphone size={16} />,
category: "hero",
defaultProps: {
text: "Free shipping on orders over $150",
ctaLabel: "Shop new",
ctaHref: "/collections/new",
tone: "default",
},
fields: {
text: { label: "Text", type: "text", contentEditable: true },
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
ctaHref: { label: "CTA link", type: "text" },
tone: {
label: "Tone",
type: "select",
options: [
{ label: "Default (dark)", value: "default" },
{ label: "Inverse (light)", value: "inverse" },
{ label: "Muted", value: "muted" },
],
},
},
render: (props) => <Banner {...props} />,
};
export default bannerEditor;
-32
View File
@@ -1,32 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
export type BannerProps = {
text: string;
ctaLabel: string;
ctaHref: string;
tone: "default" | "inverse" | "muted";
};
export function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
const toneClass: Record<BannerProps["tone"], string> = {
default: "bg-foreground text-background",
inverse: "bg-background text-foreground border-y border-border",
muted: "bg-muted text-foreground border-y border-border",
};
return (
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
<span>{text}</span>
{ctaLabel ? (
<Link
href={ctaHref || "#"}
className="underline-offset-4 hover:underline"
>
{ctaLabel}
</Link>
) : null}
</div>
</div>
);
}
@@ -1,50 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Images } from "lucide-react";
import { ImageGallery, type ImageGalleryProps } from "@/components/landing/image-gallery";
const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
label: "Image gallery",
icon: <Images size={16} />,
category: "content",
defaultProps: {
tagline: "Lookbook",
heading: "Spring in the studio",
subheading: "",
layout: "editorial",
items: [
{ src: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1600&q=80", alt: "" },
{ src: "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=80", alt: "" },
{ src: "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1200&q=80", alt: "" },
{ src: "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80", alt: "" },
{ src: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80", alt: "" },
],
},
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: "select",
options: [
{ label: "Grid", value: "grid" },
{ label: "Masonry", value: "masonry" },
{ label: "Editorial (mosaic)", value: "editorial" },
],
},
items: {
label: "Images",
type: "array",
defaultItemProps: { src: "", alt: "" },
getItemSummary: (it) => it?.caption || "Image",
arrayFields: {
src: { label: "Image", type: "image" },
alt: { label: "Alt text", type: "text", contentEditable: true },
caption: { label: "Caption", type: "text", contentEditable: true },
},
},
},
render: (props) => <ImageGallery {...props} />,
};
export default imageGalleryEditor;
-87
View File
@@ -1,87 +0,0 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Heading } from "@/components/Heading";
export type ImageGalleryProps = {
tagline: string;
heading: string;
subheading: string;
layout: "grid" | "masonry" | "editorial";
items: Array<{ src: string; alt: string; caption?: string }>;
};
export function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
return (
<section className="bg-background py-20 md:py-28">
<Container>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="center"
size="lg"
className="mx-auto mb-12"
maxWidth="max-w-2xl"
/>
{layout === "masonry" ? (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{items.map((it, i) => (
<figure key={i} className="mb-4 break-inside-avoid">
<img
src={it.src}
alt={it.alt}
className="w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
) : layout === "editorial" ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{items.slice(0, 5).map((it, i) => (
<figure
key={i}
className={cn(
"overflow-hidden rounded-md bg-muted",
i === 0 && "md:col-span-7 md:row-span-2",
i === 1 && "md:col-span-5",
i === 2 && "md:col-span-5",
i === 3 && "md:col-span-6",
i === 4 && "md:col-span-6",
)}
>
<img
src={it.src}
alt={it.alt}
className="h-full w-full object-cover"
/>
</figure>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((it, i) => (
<figure key={i}>
<img
src={it.src}
alt={it.alt}
className="aspect-[4/5] w-full rounded-md object-cover"
/>
{it.caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground">
{it.caption}
</figcaption>
) : null}
</figure>
))}
</div>
)}
</Container>
</section>
);
}
@@ -1,80 +0,0 @@
import { ComponentConfig, Fields } from "@reacteditor/core";
import { Mail } from "lucide-react";
import { NewsletterCta, type NewsletterCtaProps } from "@/components/landing/newsletter-cta";
const baseFields: Fields<NewsletterCtaProps> = {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "text", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
buttonLabel: { label: "Button label", type: "text", contentEditable: true },
emailProvider: {
label: "Email provider",
type: "select",
options: [
{ label: "None (custom endpoint)", value: "none" },
{ label: "Mailchimp", value: "mailchimp" },
{ label: "Klaviyo", value: "klaviyo" },
],
},
imageUrl: { label: "Image", type: "image" },
layout: {
label: "Layout",
type: "radio",
options: [
{ label: "Split (image + form)", value: "split" },
{ label: "Stacked (centered)", value: "stacked" },
],
},
};
const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
label: "Newsletter",
icon: <Mail size={16} />,
category: "content",
defaultProps: {
tagline: "Stay in the loop",
heading: "Letters from the studio",
subheading:
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
buttonLabel: "Subscribe",
emailProvider: "none",
endpoint: "",
mailchimpApiKey: "",
mailchimpServerPrefix: "",
mailchimpAudienceId: "",
klaviyoCompanyId: "",
klaviyoListId: "",
imageUrl:
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
layout: "split",
},
fields: baseFields,
resolveFields: (data) => {
const provider = data.props.emailProvider;
if (provider === "mailchimp") {
return {
...baseFields,
mailchimpApiKey: { label: "Mailchimp API key", type: "text" },
mailchimpServerPrefix: {
label: "Server prefix (e.g. us21)",
type: "text",
},
mailchimpAudienceId: { label: "Audience ID", type: "text" },
};
}
if (provider === "klaviyo") {
return {
...baseFields,
klaviyoCompanyId: { label: "Company ID (public API key)", type: "text" },
klaviyoListId: { label: "List ID", type: "text" },
};
}
return {
...baseFields,
endpoint: { label: "Submit endpoint", type: "text" },
};
},
render: (props) => <NewsletterCta {...props} />,
};
export default newsletterCtaEditor;
-190
View File
@@ -1,190 +0,0 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Heading } from "@/components/Heading";
export type EmailProvider = "none" | "mailchimp" | "klaviyo";
export type NewsletterCtaProps = {
tagline: string;
heading: string;
subheading: string;
buttonLabel: string;
emailProvider: EmailProvider;
endpoint?: string;
mailchimpApiKey?: string;
mailchimpServerPrefix?: string;
mailchimpAudienceId?: string;
klaviyoCompanyId?: string;
klaviyoListId?: string;
imageUrl: string;
layout: "split" | "stacked";
};
export function NewsletterCta({
tagline,
heading,
subheading,
buttonLabel,
emailProvider,
endpoint,
mailchimpApiKey,
mailchimpServerPrefix,
mailchimpAudienceId,
klaviyoCompanyId,
klaviyoListId,
imageUrl,
layout,
}: NewsletterCtaProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setSubmitting(true);
try {
if (emailProvider === "mailchimp") {
if (mailchimpServerPrefix && mailchimpAudienceId && mailchimpApiKey) {
await fetch(
`https://${mailchimpServerPrefix}.api.mailchimp.com/3.0/lists/${mailchimpAudienceId}/members`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mailchimpApiKey}`,
},
body: JSON.stringify({
email_address: email,
status: "subscribed",
}),
},
);
}
} else if (emailProvider === "klaviyo") {
if (klaviyoCompanyId && klaviyoListId) {
await fetch(
`https://a.klaviyo.com/client/subscriptions/?company_id=${klaviyoCompanyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
revision: "2024-10-15",
},
body: JSON.stringify({
data: {
type: "subscription",
attributes: {
profile: {
data: {
type: "profile",
attributes: { email },
},
},
},
relationships: {
list: { data: { type: "list", id: klaviyoListId } },
},
},
}),
},
);
}
} else if (endpoint) {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
setSubmitted(true);
} catch {
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
const Form = (
<form
onSubmit={submit}
className="flex w-full max-w-md flex-col gap-3 sm:flex-row sm:items-center"
>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="h-11 flex-1"
/>
<Button type="submit" size="lg" disabled={submitting} className="h-11">
{submitting ? "Joining…" : buttonLabel}
</Button>
</form>
);
const isStacked = layout === "stacked";
return (
<section className="bg-background">
<Container className="py-20 md:py-28">
<div
className={cn(
"grid grid-cols-1 gap-10",
isStacked
? "mx-auto max-w-3xl items-center text-center"
: "items-center md:grid-cols-12 md:gap-16",
)}
>
{imageUrl ? (
<div className={cn(!isStacked && "md:col-span-7")}>
<div className="relative overflow-hidden rounded-xl bg-muted">
<img
src={imageUrl}
alt=""
className={cn(
"w-full object-cover transition-transform duration-700 hover:scale-105",
isStacked ? "aspect-[16/9]" : "aspect-[4/3]",
)}
/>
</div>
</div>
) : null}
<div
className={cn(
"flex w-full flex-col",
isStacked ? "items-center" : "items-start md:col-span-5",
)}
>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align={isStacked ? "center" : "left"}
size="lg"
subtitleClassName={isStacked ? "mx-auto max-w-xl" : "max-w-md"}
/>
<div
className={cn(
"mt-8 w-full",
isStacked && "flex justify-center",
)}
>
{submitted ? (
<p className="text-sm font-medium uppercase tracking-wide">
You're in. See you Monday at 5:30am.
</p>
) : (
Form
)}
</div>
</div>
</div>
</Container>
</section>
);
}
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type ContainerProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
};
export function Container({
as: Comp = "div",
className,
style,
...props
}: ContainerProps) {
return (
<Comp
className={cn("mx-auto w-full px-6", className)}
style={{ maxWidth: "var(--container-max-width, 80rem)", ...style }}
{...props}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
-44
View File
@@ -1,44 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Award } from "lucide-react";
import { Logos, type LogosProps } from "@/components/logos/logos";
const logosEditor: ComponentConfig<LogosProps> = {
label: "Press / Logos",
icon: <Award size={16} />,
category: "content",
defaultProps: {
tagline: "As seen in",
layout: "row",
items: [
{ src: "https://logo.clearbit.com/vogue.com", alt: "Vogue" },
{ src: "https://logo.clearbit.com/highsnobiety.com", alt: "Highsnobiety" },
{ src: "https://logo.clearbit.com/gq.com", alt: "GQ" },
{ src: "https://logo.clearbit.com/dezeen.com", alt: "Dezeen" },
{ src: "https://logo.clearbit.com/wallpaper.com", alt: "Wallpaper*" },
],
},
fields: {
tagline: { label: "Tagline", type: "text", contentEditable: true },
layout: {
label: "Layout",
type: "radio",
options: [
{ label: "Row", value: "row" },
{ label: "Marquee", value: "marquee" },
],
},
items: {
label: "Logos",
type: "array",
defaultItemProps: { src: "", alt: "" },
getItemSummary: (it) => it?.alt || "Logo",
arrayFields: {
src: { label: "Image", type: "image" },
alt: { label: "Alt text", type: "text", contentEditable: true },
},
},
},
render: (props) => <Logos {...props} />,
};
export default logosEditor;
-47
View File
@@ -1,47 +0,0 @@
import { Container } from "@/components/layout/Container";
import { Typography } from "@/components/Typography";
export type LogosProps = {
tagline: string;
items: Array<{ src: string; alt: string }>;
layout: "row" | "marquee";
};
export function Logos({ tagline, items, layout }: LogosProps) {
return (
<section className="border-y border-border bg-muted/40 py-12">
<Container>
{tagline ? (
<Typography variant="caption" className="mb-8 text-center">
{tagline}
</Typography>
) : null}
{layout === "marquee" ? (
<div className="overflow-hidden">
<div className="flex animate-[marquee_30s_linear_infinite] gap-16 [--gap:4rem]">
{[...items, ...items].map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
{items.map((it, i) => (
<img
key={i}
src={it.src}
alt={it.alt}
className="h-7 w-auto opacity-60 grayscale"
/>
))}
</div>
)}
</Container>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import React, { memo } from 'react';
import { cn } from '@/lib/utils';
interface AuroraTextProps {
children: React.ReactNode;
className?: string;
colors?: string[];
speed?: number;
}
export const AuroraText = memo(
({
children,
className,
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
speed = 1,
}: AuroraTextProps) => {
const animationDuration = `${10 / speed}s`;
const gradientStyle = {
backgroundImage: `linear-gradient(90deg, ${colors.join(', ')}, ${colors[0]})`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
animation: `aurora-flow ${animationDuration} ease-in-out infinite`,
} as React.CSSProperties;
return (
<span className={cn('relative inline-block', className)}>
<span className="sr-only">{children}</span>
<span className="relative" style={gradientStyle} aria-hidden="true">
{children}
</span>
</span>
);
}
);
AuroraText.displayName = 'AuroraText';
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
interface BlurFadeProps {
children: React.ReactNode;
className?: string;
duration?: number;
delay?: number;
inView?: boolean;
}
export function BlurFade({
children,
className,
duration = 0.4,
delay = 0,
inView = false,
}: BlurFadeProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(!inView);
useEffect(() => {
if (!inView) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: '-50px' }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [inView]);
return (
<div
ref={ref}
className={cn(
isVisible ? 'opacity-100 blur-none' : 'opacity-0 blur-sm',
className
)}
style={{
animation: isVisible
? `blur-fade ${duration}s ease-out ${delay}s forwards`
: 'none',
}}
>
{children}
</div>
);
}
@@ -1,74 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Menu as MenuIcon } from "lucide-react";
import { Navigation, type NavigationProps } from "@/components/navigation/navigation";
const navigationEditor: ComponentConfig<NavigationProps> = {
label: "Navigation",
icon: <MenuIcon size={16} />,
category: "navigation",
global: true,
defaultProps: {
brand: "Maison",
logo: "",
links: [
{ label: "Mens", href: "/collections/mens" },
{ label: "Womens", href: "/collections/womens" },
{ label: "Shop", href: "/search" },
{ label: "About", href: "/about" },
],
showSearch: "yes",
showCart: "yes",
sticky: "yes",
tone: "default",
},
fields: {
logo: { label: "Logo", type: "image" },
brand: { label: "Logo Alt", 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" },
},
},
showSearch: {
label: "Search icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showCart: {
label: "Cart icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
sticky: {
label: "Position",
type: "radio",
options: [
{ label: "Sticky", value: "yes" },
{ label: "Static", value: "no" },
],
},
tone: {
label: "Tone",
type: "select",
options: [
{ label: "Default", value: "default" },
{ label: "Muted", value: "muted" },
{ label: "Inverse (dark)", value: "inverse" },
],
},
},
render: (props) => <Navigation {...props} />,
};
export default navigationEditor;

Some files were not shown because too many files have changed in this diff Show More