Add React editor project
This commit is contained in:
@@ -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
@@ -6,3 +6,14 @@ dist
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
*.log
|
*.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.*
|
||||||
|
|||||||
@@ -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
@@ -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": "#"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /about. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/about" />;
|
||||||
|
}
|
||||||
+21
-169
@@ -1,189 +1,41 @@
|
|||||||
{
|
{
|
||||||
"root": {
|
"root": {
|
||||||
"props": {
|
"props": {
|
||||||
"title": "About — Pulse",
|
"title": "About — Shop",
|
||||||
"headerFont": "Archivo Black",
|
"description": "About Shop.",
|
||||||
"headerFontWeight": "400",
|
"ogImage": ""
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "nav-about",
|
"id": "header"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"links": [
|
"synced": true
|
||||||
{
|
|
||||||
"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": "cover",
|
"type": "content-section",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "hero-about",
|
"id": "about-intro",
|
||||||
"tagline": "The lab",
|
"eyebrow": "Our story",
|
||||||
"heading": "We don't make activewear.",
|
"heading": "About",
|
||||||
"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.",
|
"body": "Tell your store’s story here.\n\nBlank lines start a new paragraph. Everything on this page is editable at /editor?route=/about.",
|
||||||
"buttons": [
|
"imageUrl": "",
|
||||||
{
|
"imageAlt": ""
|
||||||
"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": "cover",
|
|
||||||
"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",
|
"type": "footer",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "footer-about",
|
"id": "footer"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
"synced": true
|
||||||
"columns": [
|
},
|
||||||
{
|
{
|
||||||
"title": "Shop",
|
"type": "store-assistant",
|
||||||
"links": [
|
"props": {
|
||||||
{
|
"id": "store-assistant"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
import PageRender from "@/components/page-render";
|
import PageRender from '@/components/page-render';
|
||||||
import page from "./page.json";
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/about' });
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <PageRender page={page} />;
|
return <PageRender page={page} />;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account/activate/[id]/[token]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account/activate/[id]/[token]" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Activate your account",
|
||||||
|
"description": "Finish setting up your account.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-activate",
|
||||||
|
"props": {
|
||||||
|
"id": "account-activate",
|
||||||
|
"flow": "activate",
|
||||||
|
"title": "Activate your account",
|
||||||
|
"description": "Choose a password to finish setting up your account.",
|
||||||
|
"submitLabel": "Activate account",
|
||||||
|
"successMessage": "",
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account/login. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account/login" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"description": "Sign in to your Shop account.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-login",
|
||||||
|
"props": {
|
||||||
|
"id": "account-login",
|
||||||
|
"flow": "login",
|
||||||
|
"title": "Sign in",
|
||||||
|
"description": "",
|
||||||
|
"submitLabel": "Sign in",
|
||||||
|
"successMessage": "",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"label": "Create an account",
|
||||||
|
"url": "/account/register"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Forgot your password?",
|
||||||
|
"url": "/account/recover"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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: '/account/login' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Order history",
|
||||||
|
"description": "Your recent orders.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-orders",
|
||||||
|
"props": {
|
||||||
|
"id": "account-orders",
|
||||||
|
"title": "Order history",
|
||||||
|
"signedOutMessage": "Sign in to see your orders.",
|
||||||
|
"emptyMessage": "You haven't placed any orders yet.",
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import { getSessionToken } from '@/services/shopify/session';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/account' });
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
// The order-history block re-reads the session through /api/account/orders,
|
||||||
|
// but bouncing signed-out visitors here avoids rendering the page at all.
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (!token) redirect('/account/login');
|
||||||
|
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account/recover. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account/recover" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Reset password",
|
||||||
|
"description": "Request a password reset link.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-recover",
|
||||||
|
"props": {
|
||||||
|
"id": "account-recover",
|
||||||
|
"flow": "recover",
|
||||||
|
"title": "Reset password",
|
||||||
|
"description": "Enter your email and we'll send you a link to set a new password.",
|
||||||
|
"submitLabel": "Send reset link",
|
||||||
|
"successMessage": "If that email has an account, a reset link is on its way.",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"label": "Back to sign in",
|
||||||
|
"url": "/account/login"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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: '/account/recover' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account/register. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account/register" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Create account",
|
||||||
|
"description": "Create a Shop account.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-register",
|
||||||
|
"props": {
|
||||||
|
"id": "account-register",
|
||||||
|
"flow": "register",
|
||||||
|
"title": "Create account",
|
||||||
|
"description": "",
|
||||||
|
"submitLabel": "Create account",
|
||||||
|
"successMessage": "",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"label": "Already have an account? Sign in",
|
||||||
|
"url": "/account/login"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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: '/account/register' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /account/reset/[id]/[token]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/account/reset/[id]/[token]" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Set a new password",
|
||||||
|
"description": "Choose a new password.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "account-reset",
|
||||||
|
"props": {
|
||||||
|
"id": "account-reset",
|
||||||
|
"flow": "reset",
|
||||||
|
"title": "Set a new password",
|
||||||
|
"description": "",
|
||||||
|
"submitLabel": "Save password",
|
||||||
|
"successMessage": "",
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
activateAccount,
|
||||||
|
toCustomerGid,
|
||||||
|
customerErrorMessage,
|
||||||
|
} from '@/services/shopify/customer';
|
||||||
|
import { setSessionToken } from '@/services/shopify/session';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { id, activationToken, password } = await req.json();
|
||||||
|
|
||||||
|
if (!id || !activationToken || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'This activation link is incomplete.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, errors } = await activateAccount(
|
||||||
|
toCustomerGid(id),
|
||||||
|
activationToken,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSessionToken(token.accessToken, token.expiresAt);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
||||||
|
import { setSessionToken } from '@/services/shopify/session';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Enter your email and password.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, errors } = await login(email, password);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||||
|
// the form can't be used to enumerate accounts.
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: errors.length
|
||||||
|
? 'Incorrect email or password.'
|
||||||
|
: customerErrorMessage(errors),
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSessionToken(token.accessToken, token.expiresAt);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { logout } from '@/services/shopify/customer';
|
||||||
|
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (token) await logout(token);
|
||||||
|
|
||||||
|
await clearSessionToken();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getSessionToken } from '@/services/shopify/session';
|
||||||
|
import { getCustomer } from '@/services/shopify/customer';
|
||||||
|
|
||||||
|
// Minimal session probe for the header menu — never returns the access token.
|
||||||
|
export async function GET() {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (!token) return Response.json({ customer: null });
|
||||||
|
|
||||||
|
const customer = await getCustomer(token, 0);
|
||||||
|
if (!customer) return Response.json({ customer: null });
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
customer: {
|
||||||
|
displayName: customer.displayName,
|
||||||
|
email: customer.email,
|
||||||
|
firstName: customer.firstName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getSessionToken } from '@/services/shopify/session';
|
||||||
|
import { getCustomer } from '@/services/shopify/customer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full customer record including orders, for the client-rendered order-history
|
||||||
|
* block. `/api/account/me` stays the lightweight session probe the header uses
|
||||||
|
* — it asks for zero orders — so the two don't fight over payload size.
|
||||||
|
*
|
||||||
|
* The access token never leaves the server: it is read from the session cookie
|
||||||
|
* here and only the resolved customer is returned.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (!token) return Response.json({ customer: null }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const parsed = Number(searchParams.get('orders'));
|
||||||
|
const orderCount = Number.isFinite(parsed)
|
||||||
|
? Math.min(Math.max(Math.trunc(parsed), 1), 50)
|
||||||
|
: 20;
|
||||||
|
|
||||||
|
const customer = await getCustomer(token, orderCount);
|
||||||
|
if (!customer) return Response.json({ customer: null }, { status: 401 });
|
||||||
|
|
||||||
|
return Response.json({ customer });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { recoverPassword } from '@/services/shopify/customer';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { email } = await req.json();
|
||||||
|
|
||||||
|
if (email) await recoverPassword(email);
|
||||||
|
|
||||||
|
// Always the same response, so the form can't reveal who has an account.
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
createCustomer,
|
||||||
|
login,
|
||||||
|
customerErrorMessage,
|
||||||
|
} from '@/services/shopify/customer';
|
||||||
|
import { setSessionToken } from '@/services/shopify/session';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { email, password, firstName, lastName } = await req.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Enter your email and password.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { errors } = await createCustomer({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the new customer straight in. Accounts needing email confirmation
|
||||||
|
// won't return a token yet, which is not an error.
|
||||||
|
const { token } = await login(email, password);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await setSessionToken(token.accessToken, token.expiresAt);
|
||||||
|
return Response.json({ ok: true, signedIn: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true, signedIn: false });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
resetPassword,
|
||||||
|
toCustomerGid,
|
||||||
|
customerErrorMessage,
|
||||||
|
} from '@/services/shopify/customer';
|
||||||
|
import { setSessionToken } from '@/services/shopify/session';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { id, resetToken, password } = await req.json();
|
||||||
|
|
||||||
|
if (!id || !resetToken || !password) {
|
||||||
|
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, errors } = await resetPassword(
|
||||||
|
toCustomerGid(id),
|
||||||
|
resetToken,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSessionToken(token.accessToken, token.expiresAt);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { findPageRoute } from '@/lib/pages';
|
||||||
|
|
||||||
|
// Touches the filesystem, so it must never be statically optimised.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a route key to its `page.json` on disk.
|
||||||
|
*
|
||||||
|
* The path is built from the registry in `lib/pages.ts`, never from the request
|
||||||
|
* body, so an unknown or crafted route key is rejected outright rather than
|
||||||
|
* escaping the `app/` directory. The realpath check is belt-and-braces for the
|
||||||
|
* same thing.
|
||||||
|
*/
|
||||||
|
function resolvePageFile(routeKey: string): string | null {
|
||||||
|
const route = findPageRoute(routeKey);
|
||||||
|
if (!route) return null;
|
||||||
|
|
||||||
|
const appDir = path.join(process.cwd(), 'app');
|
||||||
|
const file = path.join(appDir, route.dir, 'page.json');
|
||||||
|
|
||||||
|
return file.startsWith(appDir + path.sep) ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props of blocks marked `global: true` (header, footer) live in one file that
|
||||||
|
// every page.json references, so editing them once updates every route.
|
||||||
|
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
|
||||||
|
|
||||||
|
async function readJson(file: string): Promise<Record<string, any> | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const routeKey = new URL(request.url).searchParams.get('route') ?? '/';
|
||||||
|
const file = resolvePageFile(routeKey);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await readJson(file);
|
||||||
|
// A route with no page.json yet is a new page, not an error.
|
||||||
|
if (!page) return Response.json({ page: null });
|
||||||
|
|
||||||
|
const globals = await readJson(GLOBALS_FILE);
|
||||||
|
return Response.json({ page: { ...page, globals: globals ?? {} } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
let body: { route?: string; page?: unknown };
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Expected a JSON body.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeKey = body.route ?? '';
|
||||||
|
const file = resolvePageFile(routeKey);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.page || typeof body.page !== 'object') {
|
||||||
|
return Response.json({ error: 'Expected a page object.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals belong to the whole site, not this route, so they go to their own
|
||||||
|
// file and are stripped from the page before it is written.
|
||||||
|
const { globals, ...page } = body.page as Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(file, `${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
if (globals && typeof globals === 'object') {
|
||||||
|
await writeFile(
|
||||||
|
GLOBALS_FILE,
|
||||||
|
`${JSON.stringify(globals, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ file: path.relative(process.cwd(), file) });
|
||||||
|
} catch (err) {
|
||||||
|
// Read-only filesystems (most serverless hosts) land here. Say so plainly
|
||||||
|
// rather than reporting a save that did not happen.
|
||||||
|
console.error(`Failed to write ${file}:`, err);
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/collections/[handle]" />;
|
||||||
|
}
|
||||||
@@ -1,194 +1,38 @@
|
|||||||
{
|
{
|
||||||
"root": {
|
"root": {
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Collection — Pulse",
|
"title": "Collection",
|
||||||
"headerFont": "Archivo Black",
|
"description": "Shop the collection.",
|
||||||
"headerFontWeight": "400",
|
"ogImage": ""
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "nav-collection",
|
"id": "header"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"links": [
|
"synced": true
|
||||||
{
|
|
||||||
"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",
|
"type": "collection-detail",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "collection-detail",
|
"id": "collection-detail",
|
||||||
"collection": null,
|
"collection": null,
|
||||||
"showDescription": "yes",
|
"title": ""
|
||||||
"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",
|
"type": "footer",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "footer-collection",
|
"id": "footer"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
"synced": true
|
||||||
"columns": [
|
},
|
||||||
{
|
{
|
||||||
"title": "Shop",
|
"type": "store-assistant",
|
||||||
"links": [
|
"props": {
|
||||||
{
|
"id": "store-assistant"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import PageRender from "@/components/page-render";
|
import type { Metadata } from 'next';
|
||||||
import page from "./page.json";
|
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() {
|
export default function Page() {
|
||||||
return <PageRender page={page} />;
|
return <PageRender page={page} />;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /collections. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/collections" />;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/" />;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,149 +1,230 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme inline {
|
||||||
--font-family-heading: var(--font-header);
|
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
|
||||||
--font-family-header: var(--font-header);
|
--color-color-5: var(--color-5);
|
||||||
--font-family-body: var(--font-body);
|
--color-color-4: var(--color-4);
|
||||||
|
--color-color-3: var(--color-3);
|
||||||
--color-background: var(--background);
|
--color-color-2: var(--color-2);
|
||||||
--color-foreground: var(--foreground);
|
--color-color-1: var(--color-1);
|
||||||
--color-card: var(--card);
|
@keyframes rainbow {
|
||||||
--color-card-foreground: var(--card-foreground);
|
0% {
|
||||||
--color-popover: var(--popover);
|
background-position: 0%;
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
}
|
||||||
--color-primary: var(--primary);
|
100% {
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
background-position: 200%;
|
||||||
--color-secondary: var(--secondary);
|
}
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) * 0.5);
|
|
||||||
--radius-md: calc(var(--radius) * 0.75);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.5);
|
|
||||||
|
|
||||||
--animate-marquee: marquee var(--duration) infinite linear;
|
|
||||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
|
||||||
|
|
||||||
@keyframes marquee {
|
|
||||||
from { transform: translateX(0); }
|
|
||||||
to { transform: translateX(calc(-100% - var(--gap))); }
|
|
||||||
}
|
}
|
||||||
@keyframes marquee-vertical {
|
|
||||||
from { transform: translateY(0); }
|
|
||||||
to { transform: translateY(calc(-100% - var(--gap))); }
|
|
||||||
}
|
|
||||||
--font-heading: var(--font-sans);
|
|
||||||
--font-sans: 'Geist Variable', sans-serif;
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--radius-2xl: calc(var(--radius) * 2);
|
|
||||||
--radius-3xl: calc(var(--radius) * 3);
|
|
||||||
--radius-4xl: calc(var(--radius) * 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
--font-header: system-ui, -apple-system, sans-serif;
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--font-body: system-ui, -apple-system, sans-serif;
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--background: oklch(1 0 0);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--foreground: oklch(0.145 0 0);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: #fafafa;
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.87 0 0);
|
|
||||||
--chart-2: oklch(0.556 0 0);
|
|
||||||
--chart-3: oklch(0.439 0 0);
|
|
||||||
--chart-4: oklch(0.371 0 0);
|
|
||||||
--chart-5: oklch(0.269 0 0);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* { @apply outline-ring/50; }
|
|
||||||
html, body { background-color: var(--background); color: var(--foreground); }
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji";
|
|
||||||
margin: 0;
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
@apply font-sans;}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
--foreground: oklch(0.985 0 0);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--card: oklch(0.205 0 0);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--popover: oklch(0.205 0 0);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
+47
-9
@@ -1,16 +1,54 @@
|
|||||||
import type { ReactNode } from "react";
|
import React from 'react';
|
||||||
import "./globals.css";
|
import type { Metadata } from 'next';
|
||||||
import { Providers } from "./providers";
|
import './globals.css';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { site } from '@/config/site';
|
||||||
|
|
||||||
export const metadata = {
|
const geist = Geist({
|
||||||
title: "Shopify Storefront",
|
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 (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||||
<body>
|
{/* Header, footer and the store assistant are editable blocks now, so
|
||||||
<Providers>{children}</Providers>
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-229
@@ -1,249 +1,40 @@
|
|||||||
{
|
{
|
||||||
"root": {
|
"root": {
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Pulse — Made to Move",
|
"title": "Shop",
|
||||||
"headerFont": "Archivo Black",
|
"description": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||||
"headerFontWeight": "400",
|
"ogImage": ""
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "nav-home",
|
"id": "header"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"links": [
|
"synced": true
|
||||||
{
|
|
||||||
"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": "cover",
|
"type": "products",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "hero-home",
|
"id": "home-products",
|
||||||
"tagline": "Spring 2026",
|
"title": "Shopify Hydrogen Storefront",
|
||||||
"heading": "Made to move.",
|
"subtitle": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||||
"subheading": "Performance gear engineered for athletes who train like they mean it. Built light. Built honest. Built for the next mile.",
|
"collection": null,
|
||||||
"buttons": [
|
"limit": 12
|
||||||
{
|
|
||||||
"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": "cover",
|
|
||||||
"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": "cover",
|
|
||||||
"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": "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",
|
"type": "footer",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "footer-home",
|
"id": "footer"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
"synced": true
|
||||||
"columns": [
|
},
|
||||||
{
|
{
|
||||||
"title": "Shop",
|
"type": "store-assistant",
|
||||||
"links": [
|
"props": {
|
||||||
{
|
"id": "store-assistant"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
import PageRender from "@/components/page-render";
|
import PageRender from '@/components/page-render';
|
||||||
import page from "./page.json";
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/' });
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <PageRender page={page} />;
|
return <PageRender page={page} />;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/policies/[handle]" />;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /products/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/products/[handle]" />;
|
||||||
|
}
|
||||||
+26
-107
@@ -1,128 +1,47 @@
|
|||||||
{
|
{
|
||||||
"root": {
|
"root": {
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Pulse",
|
"title": "Product",
|
||||||
"headerFont": "Archivo Black",
|
"description": "Shop this product.",
|
||||||
"headerFontWeight": "400",
|
"ogImage": ""
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "nav-product",
|
"id": "header"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"links": [
|
"synced": true
|
||||||
{
|
},
|
||||||
"label": "Run",
|
{
|
||||||
"href": "/collections/run"
|
"type": "product-detail",
|
||||||
},
|
"props": {
|
||||||
{
|
"id": "product-detail",
|
||||||
"label": "Train",
|
"product": null,
|
||||||
"href": "/collections/train"
|
"addToCartLabel": "Add to Cart"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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",
|
"type": "product-recommendations",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "product-details",
|
"id": "product-recommendations",
|
||||||
"product": null
|
"title": "You May Also Like",
|
||||||
}
|
"product": null,
|
||||||
},
|
"limit": 4
|
||||||
{
|
|
||||||
"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",
|
"type": "footer",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "footer-product",
|
"id": "footer"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
"synced": true
|
||||||
"columns": [
|
},
|
||||||
{
|
{
|
||||||
"title": "Shop",
|
"type": "store-assistant",
|
||||||
"links": [
|
"props": {
|
||||||
{
|
"id": "store-assistant"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
import PageRender from "@/components/page-render";
|
import type { Metadata } from 'next';
|
||||||
import page from "./page.json";
|
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() {
|
export default function Page() {
|
||||||
return <PageRender page={page} />;
|
return <PageRender page={page} />;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
|
||||||
|
// Editor for /search. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/search" />;
|
||||||
|
}
|
||||||
+17
-141
@@ -1,161 +1,37 @@
|
|||||||
{
|
{
|
||||||
"root": {
|
"root": {
|
||||||
"props": {
|
"props": {
|
||||||
"title": "Shop — Pulse",
|
"title": "Search",
|
||||||
"headerFont": "Archivo Black",
|
"description": "Search the catalogue by product name, description or type.",
|
||||||
"headerFontWeight": "400",
|
"ogImage": ""
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "nav-search",
|
"id": "header"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"links": [
|
"synced": true
|
||||||
{
|
|
||||||
"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",
|
"type": "search-results",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "search-products",
|
"id": "search-results",
|
||||||
"heading": "All gear.",
|
"title": "Search"
|
||||||
"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",
|
"type": "footer",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "footer-search",
|
"id": "footer"
|
||||||
"brand": "PULSE",
|
},
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
"synced": true
|
||||||
"columns": [
|
},
|
||||||
{
|
{
|
||||||
"title": "Shop",
|
"type": "store-assistant",
|
||||||
"links": [
|
"props": {
|
||||||
{
|
"id": "store-assistant"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,16 @@
|
|||||||
import PageRender from "@/components/page-render";
|
import { Suspense } from 'react';
|
||||||
import page from "./page.json";
|
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() {
|
export default function Page() {
|
||||||
return <PageRender page={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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "base-nova",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default",
|
"registries": {
|
||||||
"menuAccent": "subtle",
|
"@magicui": "https://magicui.design/r/{name}"
|
||||||
"registries": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 h1–h6 (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;
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -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
@@ -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";
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { LayoutTemplate } from "lucide-react";
|
|
||||||
import { Cover, type CoverProps } from "@/components/cover/cover";
|
|
||||||
|
|
||||||
const coverEditor: ComponentConfig<CoverProps> = {
|
|
||||||
label: "Cover",
|
|
||||||
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) => <Cover {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default coverEditor;
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
|
|
||||||
export type CoverButtonVariant = "primary" | "secondary" | "outline" | "ghost";
|
|
||||||
|
|
||||||
export type CoverButton = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
variant: CoverButtonVariant;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CoverProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
buttons: CoverButton[];
|
|
||||||
imageUrl: string;
|
|
||||||
align: "left" | "center";
|
|
||||||
height: "md" | "lg" | "full";
|
|
||||||
tone: "light" | "dark";
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightClass: Record<CoverProps["height"], string> = {
|
|
||||||
md: "min-h-[60vh]",
|
|
||||||
lg: "min-h-[80vh]",
|
|
||||||
full: "min-h-screen",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buttonClass(variant: CoverButtonVariant, 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 Cover({
|
|
||||||
tagline,
|
|
||||||
heading,
|
|
||||||
subheading,
|
|
||||||
buttons,
|
|
||||||
imageUrl,
|
|
||||||
align,
|
|
||||||
height,
|
|
||||||
tone,
|
|
||||||
}: CoverProps) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Menu as MenuIcon } from "lucide-react";
|
|
||||||
import { Header, type HeaderProps } from "@/components/header/header";
|
|
||||||
|
|
||||||
const headerEditor: ComponentConfig<HeaderProps> = {
|
|
||||||
label: "Header",
|
|
||||||
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) => <Header {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default headerEditor;
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { Menu as MenuIcon, ShoppingBag, Search } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useShopifyCart } from "@/hooks/use-shopify-cart";
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type HeaderProps = {
|
|
||||||
brand: string;
|
|
||||||
logo?: string;
|
|
||||||
links: Array<{ label: string; href: string }>;
|
|
||||||
showSearch: "yes" | "no";
|
|
||||||
showCart: "yes" | "no";
|
|
||||||
sticky: "yes" | "no";
|
|
||||||
tone: "default" | "muted" | "inverse";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Header({
|
|
||||||
brand,
|
|
||||||
logo,
|
|
||||||
links,
|
|
||||||
showSearch,
|
|
||||||
showCart,
|
|
||||||
sticky,
|
|
||||||
tone,
|
|
||||||
}: HeaderProps) {
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
const cart = useShopifyCart();
|
|
||||||
const itemCount = cart?.itemCount ?? 0;
|
|
||||||
|
|
||||||
const toneClass: Record<HeaderProps["tone"], string> = {
|
|
||||||
default: "bg-background text-foreground border-b border-border",
|
|
||||||
muted: "bg-muted/40 text-foreground border-b border-border",
|
|
||||||
inverse: "bg-foreground text-background",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full",
|
|
||||||
sticky === "yes" && "sticky top-0 z-40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className={cn(
|
|
||||||
"w-full",
|
|
||||||
sticky === "yes" && "backdrop-blur",
|
|
||||||
toneClass[tone],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Container className="flex h-16 items-center justify-between md:h-20">
|
|
||||||
<Link href="/" className="inline-flex items-center">
|
|
||||||
{logo ? (
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt={brand || "Brand Logo"}
|
|
||||||
className="h-8 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h3" as="span">
|
|
||||||
{brand || "Brand Logo"}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 md:flex">
|
|
||||||
{links.map((l) => (
|
|
||||||
<Link
|
|
||||||
key={l.href + l.label}
|
|
||||||
href={l.href}
|
|
||||||
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{showSearch === "yes" && (
|
|
||||||
<Link
|
|
||||||
href="/search"
|
|
||||||
aria-label="Search"
|
|
||||||
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
|
|
||||||
>
|
|
||||||
<Search size={18} strokeWidth={1.5} />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{showCart === "yes" && (
|
|
||||||
<button
|
|
||||||
onClick={() => cart.openCart()}
|
|
||||||
aria-label="Cart"
|
|
||||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5"
|
|
||||||
>
|
|
||||||
<ShoppingBag size={18} strokeWidth={1.5} />
|
|
||||||
{itemCount > 0 && (
|
|
||||||
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-medium text-background">
|
|
||||||
{itemCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileOpen(true)}
|
|
||||||
aria-label="Menu"
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:hidden"
|
|
||||||
>
|
|
||||||
<MenuIcon size={20} strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
||||||
<SheetContent side="right" className="w-[88vw] max-w-sm">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle className="text-left">{brand}</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<nav className="mt-2 flex flex-col gap-1 px-4">
|
|
||||||
{links.map((l) => (
|
|
||||||
<Link
|
|
||||||
key={l.href + l.label}
|
|
||||||
href={l.href}
|
|
||||||
className="rounded-md px-3 py-3 text-base hover:bg-muted"
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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,80 +0,0 @@
|
|||||||
import { ComponentConfig, Fields } from "@reacteditor/core";
|
|
||||||
import { Mail } from "lucide-react";
|
|
||||||
import { NewsletterCta, type NewsletterCtaProps } from "@/components/newsletter-cta/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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
||||||
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
||||||
|
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
||||||
|
import { appConfig } from '@/editor.config';
|
||||||
|
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
|
||||||
|
|
||||||
|
// Plugin instances must keep a stable identity across renders, same as
|
||||||
|
// `appConfig`, so they are built once at module scope.
|
||||||
|
//
|
||||||
|
// The Tailwind CDN plugin only styles the editor's preview iframe; the public
|
||||||
|
// routes still get their utilities from the compiled `app/globals.css`.
|
||||||
|
const tailwindCdn = createTailwindCdnPlugin();
|
||||||
|
|
||||||
|
// Registers the `shopifyProduct` and `shopifyCollection` field types used by
|
||||||
|
// the commerce blocks. Credentials are the same public storefront pair the
|
||||||
|
// rendered components read — safe in the browser by definition.
|
||||||
|
const shopify = createShopifyPlugin({
|
||||||
|
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
|
||||||
|
publicAccessToken:
|
||||||
|
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
|
||||||
|
apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adds the outline panel — the tree of blocks on the page, for selecting and
|
||||||
|
// reordering without hunting through the preview.
|
||||||
|
const outline = outlinePlugin();
|
||||||
|
|
||||||
|
const plugins = [outline, tailwindCdn, shopify];
|
||||||
|
|
||||||
|
const EMPTY_PAGE: Data = { root: { props: { title: 'Untitled' } }, content: [] };
|
||||||
|
|
||||||
|
export interface PageEditorProps {
|
||||||
|
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
|
||||||
|
routeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The
|
||||||
|
* public routes mount `PageRender` against the same `appConfig`, so the two
|
||||||
|
* never drift.
|
||||||
|
*
|
||||||
|
* Being a child of the route it edits is what makes the preview real: the
|
||||||
|
* editor sits on the same dynamic segments as the public page, so a block
|
||||||
|
* calling `useParams()` inside the preview iframe sees the actual handle from
|
||||||
|
* `/products/warrior-club-hoodie/editor` — no stand-in data required.
|
||||||
|
*
|
||||||
|
* Data is read from and published back to the route's own `page.json` through
|
||||||
|
* `/api/pages`, which writes the file on disk.
|
||||||
|
*/
|
||||||
|
export default function PageEditor({ routeKey }: PageEditorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const [data, setData] = useState<Data | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The editor lives at `<public path>/editor`; drop that segment to recover
|
||||||
|
// the page's own URL for the route descriptor and the URL bar.
|
||||||
|
const publicPath = useMemo(
|
||||||
|
() => (pathname ?? '/editor').replace(/\/editor\/?$/, '') || '/',
|
||||||
|
[pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
const routeParams = useMemo(() => {
|
||||||
|
const entries: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, value] of Object.entries(params ?? {})) {
|
||||||
|
if (typeof value === 'string') entries[key] = value;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setData(null);
|
||||||
|
|
||||||
|
fetch(`/api/pages?route=${encodeURIComponent(routeKey)}`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!cancelled) setData(body.page ?? EMPTY_PAGE);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setData(EMPTY_PAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [routeKey]);
|
||||||
|
|
||||||
|
const handlePublish = useCallback(
|
||||||
|
async (published: Data) => {
|
||||||
|
setStatus('Saving…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/pages', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ route: routeKey, page: published }),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus(body.error ?? 'Could not save this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`Saved to ${body.file}`);
|
||||||
|
} catch {
|
||||||
|
setStatus('Could not reach the server.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[routeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the fetch: handing <Editor> a placeholder and then swapping it
|
||||||
|
// would seed the undo history with a page the author never wrote.
|
||||||
|
//
|
||||||
|
// Uses the local `components/ui/loader`, not core's export of the same name:
|
||||||
|
// core's is built on Chakra's Spinner and reads a context that only exists
|
||||||
|
// inside `<Editor>`, so it throws when used for a pre-Editor wait state.
|
||||||
|
// This one is provider-free SVG and renders anywhere.
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen items-center justify-center text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
aria-label={`Loading ${routeKey}`}
|
||||||
|
>
|
||||||
|
<Loader size={24} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
key={routeKey}
|
||||||
|
config={appConfig as any}
|
||||||
|
data={data}
|
||||||
|
plugins={plugins}
|
||||||
|
routes={ROUTE_KEYS}
|
||||||
|
route={{ key: routeKey, path: publicPath, params: routeParams }}
|
||||||
|
onRouteChange={(nextKey) => {
|
||||||
|
// The picker hands back a route key; ignore anything not registered.
|
||||||
|
if (findPageRoute(nextKey)) router.push(editorHref(nextKey));
|
||||||
|
}}
|
||||||
|
onPublish={handlePublish}
|
||||||
|
headerTitle={findPageRoute(routeKey)?.label ?? routeKey}
|
||||||
|
// The concrete URL being previewed. The editor's own URL bar shows the
|
||||||
|
// route key (the template being edited); this is the resolved path.
|
||||||
|
headerPath={publicPath}
|
||||||
|
renderHeaderActions={({ state }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{status ?? `${state.data.content.length} blocks`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { Render } from "@reacteditor/core/render";
|
import { Render } from '@reacteditor/core/render';
|
||||||
import { appConfig } from "@/editor.config";
|
import { appConfig } from '@/editor.config';
|
||||||
import globals from "@/app.globals.json";
|
import globals from '@/app.globals.json';
|
||||||
|
|
||||||
export type PageData = {
|
export type PageData = {
|
||||||
root?: unknown;
|
root?: unknown;
|
||||||
content?: unknown;
|
content?: unknown;
|
||||||
|
globals?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared renderer for a route. Drop a `page.json` next to a route's
|
* Shared renderer for a route. Drop a `page.json` next to a route's `page.tsx`,
|
||||||
* `page.tsx`, import it, and hand it here:
|
* import it, and hand it here:
|
||||||
*
|
*
|
||||||
* import page from "./page.json";
|
* import page from "./page.json";
|
||||||
* export default () => <PageRender page={page} />;
|
* export default () => <PageRender page={page} />;
|
||||||
|
*
|
||||||
|
* The same `appConfig` backs `PageEditor`, so what an editor sees is what the
|
||||||
|
* route ships.
|
||||||
|
*
|
||||||
|
* `app.globals.json` carries the props of blocks marked `global: true` — the
|
||||||
|
* header and footer. Every page.json references them via `"synced": true`, so
|
||||||
|
* editing the header once updates all thirteen routes.
|
||||||
*/
|
*/
|
||||||
export default function PageRender({ page }: { page: PageData }) {
|
export default function PageRender({ page }: { page: PageData }) {
|
||||||
const data = { root: page.root, content: page.content, globals };
|
const data = { root: page.root, content: page.content, globals };
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
|
||||||
|
export interface AccountFormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'text' | 'email' | 'password';
|
||||||
|
autoComplete?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountFormProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
fields: AccountFormField[];
|
||||||
|
submitLabel: string;
|
||||||
|
endpoint: string;
|
||||||
|
/** Merged into the request body alongside the field values. */
|
||||||
|
extraPayload?: Record<string, string>;
|
||||||
|
/** Where to go on success; omit to show `successMessage` instead. */
|
||||||
|
redirectTo?: string;
|
||||||
|
successMessage?: string;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountForm: React.FC<AccountFormProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fields,
|
||||||
|
submitLabel,
|
||||||
|
endpoint,
|
||||||
|
extraPayload,
|
||||||
|
redirectTo,
|
||||||
|
successMessage,
|
||||||
|
footer,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...values, ...extraPayload }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error ?? 'Something went wrong. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectTo) {
|
||||||
|
// refresh() so server components re-read the new session cookie.
|
||||||
|
router.push(redirectTo);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setDone(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not reach the server. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-sm">
|
||||||
|
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{done && successMessage ? (
|
||||||
|
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<label key={field.name} className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={field.type ?? 'text'}
|
||||||
|
name={field.name}
|
||||||
|
required={field.required ?? true}
|
||||||
|
autoComplete={field.autoComplete}
|
||||||
|
value={values[field.name] ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.name]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting} className="h-11">
|
||||||
|
{submitting && <Loader size={16} />}
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AccountForm;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RiUserLine } from '@remixicon/react';
|
||||||
|
|
||||||
|
interface SessionCustomer {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountMenu: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// The token lives in an httpOnly cookie, so the signed-in state has to come
|
||||||
|
// from the server rather than being read directly.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetch('/api/account/me')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setCustomer(data.customer ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCustomer(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setOpen(false);
|
||||||
|
await fetch('/api/account/logout', { method: 'POST' });
|
||||||
|
setCustomer(null);
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Signed out: straight to the sign-in page, no menu.
|
||||||
|
if (!customer) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Sign in"
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Link href="/account/login">
|
||||||
|
<RiUserLine className="size-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Account menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<RiUserLine className="size-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{customer.firstName || customer.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{customer.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Order history
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountMenu;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ComponentConfig } from '@reacteditor/core';
|
||||||
|
import { Receipt } from 'lucide-react';
|
||||||
|
import AccountOrders, {
|
||||||
|
type AccountOrdersProps,
|
||||||
|
} from '@/components/shopify/account-orders';
|
||||||
|
|
||||||
|
const accountOrdersEditor: ComponentConfig<AccountOrdersProps> = {
|
||||||
|
label: 'Order history',
|
||||||
|
icon: <Receipt size={16} />,
|
||||||
|
category: 'account',
|
||||||
|
defaultProps: {
|
||||||
|
title: 'Order history',
|
||||||
|
signedOutMessage: 'Sign in to see your orders.',
|
||||||
|
emptyMessage: "You haven't placed any orders yet.",
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||||
|
signedOutMessage: {
|
||||||
|
label: 'Signed-out message',
|
||||||
|
type: 'text',
|
||||||
|
contentEditable: true,
|
||||||
|
},
|
||||||
|
emptyMessage: {
|
||||||
|
label: 'Empty message',
|
||||||
|
type: 'text',
|
||||||
|
contentEditable: true,
|
||||||
|
},
|
||||||
|
limit: { label: 'Orders shown', type: 'number', min: 1, max: 50 },
|
||||||
|
},
|
||||||
|
render: (props) => <AccountOrders {...props} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default accountOrdersEditor;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import OrderHistory from '@/components/shopify/order-history';
|
||||||
|
import type { Customer } from '@/services/shopify/customer';
|
||||||
|
|
||||||
|
export interface AccountOrdersProps {
|
||||||
|
title?: string;
|
||||||
|
/** Shown while signed out — the route guard normally redirects first. */
|
||||||
|
signedOutMessage?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side wrapper so order history can live in a page.json alongside the
|
||||||
|
* other blocks. `/account/page.tsx` still guards the route server-side; this
|
||||||
|
* re-reads the session through `/api/account/orders` so the block works the
|
||||||
|
* same whether it is rendered by the page or previewed in the editor.
|
||||||
|
*/
|
||||||
|
const AccountOrders: React.FC<AccountOrdersProps> = ({
|
||||||
|
title = 'Order history',
|
||||||
|
signedOutMessage = 'Sign in to see your orders.',
|
||||||
|
emptyMessage = "You haven't placed any orders yet.",
|
||||||
|
limit = 20,
|
||||||
|
}) => {
|
||||||
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetch(`/api/account/orders?orders=${limit}`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setCustomer(data.customer ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCustomer(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [limit]);
|
||||||
|
|
||||||
|
const orderCount = customer?.orders?.edges.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{customer && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{customer.displayName} · {customer.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="h-20 bg-zinc-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !customer ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
|
||||||
|
) : orderCount === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||||
|
) : (
|
||||||
|
<OrderHistory customer={customer} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountOrders;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { ComponentConfig } from '@reacteditor/core';
|
||||||
|
import { KeyRound, LogIn, UserPlus } from 'lucide-react';
|
||||||
|
import AccountPanel, {
|
||||||
|
type AccountFlow,
|
||||||
|
type AccountPanelProps,
|
||||||
|
} from '@/components/shopify/account-panel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One component, five registered blocks. `flow` decides the field list and the
|
||||||
|
* API endpoint, so it lives in `defaultProps` and is deliberately absent from
|
||||||
|
* `fields` — it is behaviour, not content. Everything editable here is copy.
|
||||||
|
*/
|
||||||
|
const accountBlock = (
|
||||||
|
flow: AccountFlow,
|
||||||
|
label: string,
|
||||||
|
icon: React.ReactNode,
|
||||||
|
defaults: Omit<AccountPanelProps, 'flow'>
|
||||||
|
): ComponentConfig<AccountPanelProps> => ({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
category: 'account',
|
||||||
|
defaultProps: { flow, ...defaults },
|
||||||
|
fields: {
|
||||||
|
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||||
|
description: {
|
||||||
|
label: 'Description',
|
||||||
|
type: 'textarea',
|
||||||
|
contentEditable: true,
|
||||||
|
},
|
||||||
|
submitLabel: { label: 'Button label', type: 'text', contentEditable: true },
|
||||||
|
successMessage: {
|
||||||
|
label: 'Success message',
|
||||||
|
type: 'textarea',
|
||||||
|
contentEditable: true,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
label: 'Footer links',
|
||||||
|
type: 'array',
|
||||||
|
defaultItemProps: { label: 'Link', url: '/account/login' },
|
||||||
|
getItemSummary: (item) => item?.label || 'Link',
|
||||||
|
arrayFields: {
|
||||||
|
label: { label: 'Label', type: 'text', contentEditable: true },
|
||||||
|
url: { label: 'Link', type: 'text' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (props) => <AccountPanel {...props} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accountLoginEditor = accountBlock(
|
||||||
|
'login',
|
||||||
|
'Sign in form',
|
||||||
|
<LogIn size={16} />,
|
||||||
|
{
|
||||||
|
title: 'Sign in',
|
||||||
|
description: '',
|
||||||
|
submitLabel: 'Sign in',
|
||||||
|
successMessage: '',
|
||||||
|
links: [
|
||||||
|
{ label: 'Create an account', url: '/account/register' },
|
||||||
|
{ label: 'Forgot your password?', url: '/account/recover' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const accountRegisterEditor = accountBlock(
|
||||||
|
'register',
|
||||||
|
'Register form',
|
||||||
|
<UserPlus size={16} />,
|
||||||
|
{
|
||||||
|
title: 'Create account',
|
||||||
|
description: '',
|
||||||
|
submitLabel: 'Create account',
|
||||||
|
successMessage: '',
|
||||||
|
links: [{ label: 'Already have an account? Sign in', url: '/account/login' }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const accountRecoverEditor = accountBlock(
|
||||||
|
'recover',
|
||||||
|
'Password recovery form',
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
{
|
||||||
|
title: 'Reset password',
|
||||||
|
description:
|
||||||
|
"Enter your email and we'll send you a link to set a new password.",
|
||||||
|
submitLabel: 'Send reset link',
|
||||||
|
successMessage:
|
||||||
|
'If that email has an account, a reset link is on its way.',
|
||||||
|
links: [{ label: 'Back to sign in', url: '/account/login' }],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const accountResetEditor = accountBlock(
|
||||||
|
'reset',
|
||||||
|
'Set password form',
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
{
|
||||||
|
title: 'Set a new password',
|
||||||
|
description: '',
|
||||||
|
submitLabel: 'Save password',
|
||||||
|
successMessage: '',
|
||||||
|
links: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const accountActivateEditor = accountBlock(
|
||||||
|
'activate',
|
||||||
|
'Activate account form',
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
{
|
||||||
|
title: 'Activate your account',
|
||||||
|
description: 'Choose a password to finish setting up your account.',
|
||||||
|
submitLabel: 'Activate account',
|
||||||
|
successMessage: '',
|
||||||
|
links: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import AccountForm, {
|
||||||
|
AccountFormLink,
|
||||||
|
type AccountFormField,
|
||||||
|
} from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account routes are all the same form with a different field list and a
|
||||||
|
* different endpoint. Those two things are behaviour, not content, so they are
|
||||||
|
* fixed here per flow and are deliberately *not* exposed as editor fields —
|
||||||
|
* the editor only gets the wording (see `account-panel.editor.tsx`).
|
||||||
|
*/
|
||||||
|
export type AccountFlow =
|
||||||
|
| 'login'
|
||||||
|
| 'register'
|
||||||
|
| 'recover'
|
||||||
|
| 'reset'
|
||||||
|
| 'activate';
|
||||||
|
|
||||||
|
const EMAIL: AccountFormField = {
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
autoComplete: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLOWS: Record<
|
||||||
|
AccountFlow,
|
||||||
|
{
|
||||||
|
fields: AccountFormField[];
|
||||||
|
endpoint: string;
|
||||||
|
redirectTo?: string;
|
||||||
|
/** Reads Shopify's emailed `/{id}/{token}` link segments into the body. */
|
||||||
|
usesRouteToken?: 'resetToken' | 'activationToken';
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
login: {
|
||||||
|
fields: [
|
||||||
|
EMAIL,
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
endpoint: '/api/account/login',
|
||||||
|
redirectTo: '/account',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'firstName',
|
||||||
|
label: 'First name',
|
||||||
|
autoComplete: 'given-name',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastName',
|
||||||
|
label: 'Last name',
|
||||||
|
autoComplete: 'family-name',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
EMAIL,
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
endpoint: '/api/account/register',
|
||||||
|
redirectTo: '/account',
|
||||||
|
},
|
||||||
|
recover: {
|
||||||
|
fields: [EMAIL],
|
||||||
|
endpoint: '/api/account/recover',
|
||||||
|
},
|
||||||
|
reset: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'New password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
endpoint: '/api/account/reset',
|
||||||
|
redirectTo: '/account',
|
||||||
|
usesRouteToken: 'resetToken',
|
||||||
|
},
|
||||||
|
activate: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
endpoint: '/api/account/activate',
|
||||||
|
redirectTo: '/account',
|
||||||
|
usesRouteToken: 'activationToken',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AccountPanelLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountPanelProps {
|
||||||
|
flow?: AccountFlow;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
successMessage?: string;
|
||||||
|
/** Rendered under the form — "New here? Create an account", etc. */
|
||||||
|
links?: AccountPanelLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountPanel: React.FC<AccountPanelProps> = ({
|
||||||
|
flow = 'login',
|
||||||
|
title = 'Sign in',
|
||||||
|
description,
|
||||||
|
submitLabel = 'Sign in',
|
||||||
|
successMessage,
|
||||||
|
links = [],
|
||||||
|
}) => {
|
||||||
|
const params = useParams();
|
||||||
|
const config = FLOWS[flow] ?? FLOWS.login;
|
||||||
|
|
||||||
|
const extraPayload = config.usesRouteToken
|
||||||
|
? {
|
||||||
|
id: (params?.id as string) ?? '',
|
||||||
|
[config.usesRouteToken]: (params?.token as string) ?? '',
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
fields={config.fields}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
endpoint={config.endpoint}
|
||||||
|
extraPayload={extraPayload}
|
||||||
|
redirectTo={config.redirectTo}
|
||||||
|
successMessage={successMessage}
|
||||||
|
footer={
|
||||||
|
links.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{links.map((link) => (
|
||||||
|
<AccountFormLink key={link.url} href={link.url}>
|
||||||
|
{link.label}
|
||||||
|
</AccountFormLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountPanel;
|
||||||
+312
-201
@@ -1,11 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
import Image from 'next/image';
|
||||||
|
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { Loader } from '@/components/ui/loader';
|
import { Loader } from '@/components/ui/loader';
|
||||||
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetBody,
|
||||||
|
AnimatePresence,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import {
|
||||||
|
RiCloseLine,
|
||||||
|
RiImageLine,
|
||||||
|
RiSubtractLine,
|
||||||
|
RiAddLine,
|
||||||
|
} from '@remixicon/react';
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
@@ -13,25 +26,43 @@ import {
|
|||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
} from '@/components/ui/empty';
|
} from '@/components/ui/empty';
|
||||||
import {
|
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
|
|
||||||
const CartDrawer: React.FC = () => {
|
const CartDrawer: React.FC = () => {
|
||||||
const {
|
const isOpen = useCartStore((s) => s.isOpen);
|
||||||
isOpen,
|
const closeCart = useCartStore((s) => s.closeCart);
|
||||||
closeCart,
|
const loading = useCartStore((s) => s.loading);
|
||||||
items,
|
const cart = useCartStore((s) => s.cart);
|
||||||
itemCount,
|
const removeItem = useCartStore((s) => s.removeItem);
|
||||||
totalAmount,
|
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
|
||||||
checkoutUrl,
|
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
|
||||||
loading,
|
|
||||||
removeItem,
|
const [discountCode, setDiscountCode] = useState('');
|
||||||
updateItemQuantity,
|
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||||
} = useShopifyCart();
|
const [applyingDiscount, setApplyingDiscount] = useState(false);
|
||||||
|
// The store's `loading` flag is global, so track the specific line being
|
||||||
|
// changed to keep the other rows interactive.
|
||||||
|
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
|
||||||
|
if (pendingLineId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPendingLineId(lineId);
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Cart line update failed:', err);
|
||||||
|
} finally {
|
||||||
|
setPendingLineId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
|
||||||
|
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
|
||||||
|
const checkoutUrl = cart?.checkoutUrl ?? null;
|
||||||
|
const appliedDiscounts =
|
||||||
|
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
|
||||||
|
|
||||||
const handleCheckout = () => {
|
const handleCheckout = () => {
|
||||||
if (checkoutUrl) {
|
if (checkoutUrl) {
|
||||||
@@ -39,200 +70,280 @@ const CartDrawer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyDiscount = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const code = discountCode.trim();
|
||||||
|
if (!code || applyingDiscount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setApplyingDiscount(true);
|
||||||
|
setDiscountError(null);
|
||||||
|
const updatedCart = await applyDiscountCode(code);
|
||||||
|
|
||||||
|
// Shopify accepts unknown codes silently, flagging them as inapplicable.
|
||||||
|
const accepted = updatedCart.discountCodes?.some(
|
||||||
|
(discount) =>
|
||||||
|
discount.applicable &&
|
||||||
|
discount.code.toLowerCase() === code.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accepted) {
|
||||||
|
setDiscountCode('');
|
||||||
|
} else {
|
||||||
|
setDiscountError('That code is not valid for this cart.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDiscountError('Could not apply that code. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setApplyingDiscount(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getItemImage = (item: (typeof items)[0]) => {
|
const getItemImage = (item: (typeof items)[0]) => {
|
||||||
return item.merchandise.image?.url;
|
return item.merchandise.image?.url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedOptions = (item: (typeof items)[0]) => {
|
const getSelectedOptions = (item: (typeof items)[0]) => {
|
||||||
return item.merchandise.selectedOptions ?? [];
|
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
|
||||||
|
return (item.merchandise.selectedOptions ?? []).filter(
|
||||||
|
(option) => !isDefaultTitleSelection(option)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
|
<Sheet
|
||||||
<SheetContent
|
open={isOpen}
|
||||||
side="right"
|
onOpenChange={(open) => !open && closeCart()}
|
||||||
className="w-full max-w-md"
|
side="right"
|
||||||
showCloseButton={false}
|
>
|
||||||
>
|
<AnimatePresence>
|
||||||
{/* Header */}
|
{isOpen && (
|
||||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
|
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
||||||
<div className="flex items-center justify-between w-full">
|
{/* Header */}
|
||||||
<SheetTitle className="text-base">
|
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
|
||||||
Shopping Cart ({itemCount})
|
<div className="flex items-center justify-between w-full">
|
||||||
</SheetTitle>
|
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
|
||||||
<Button onClick={closeCart} variant="ghost" size="icon-sm">
|
Cart
|
||||||
<X size={20} />
|
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
|
||||||
</Button>
|
{itemCount}
|
||||||
</div>
|
</span>
|
||||||
</SheetHeader>
|
</SheetTitle>
|
||||||
|
|
||||||
{/* 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
|
<Button
|
||||||
onClick={closeCart}
|
onClick={closeCart}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Close cart"
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Cart Items */}
|
||||||
|
<SheetBody className="px-5">
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader size={20} />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Add some products to get started!
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button onClick={closeCart} className="w-full">
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const image = getItemImage(item);
|
||||||
|
const selectedOptions = getSelectedOptions(item);
|
||||||
|
const isPending = pendingLineId === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="flex items-start gap-x-3">
|
||||||
|
{/* Product Image */}
|
||||||
|
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
|
||||||
|
{image ? (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={item.merchandise.product.title}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-zinc-400">
|
||||||
|
<RiImageLine size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-x-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground line-clamp-2">
|
||||||
|
{item.merchandise.product.title}
|
||||||
|
</h4>
|
||||||
|
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
|
||||||
|
$
|
||||||
|
{parseFloat(
|
||||||
|
item.cost?.totalAmount?.amount ??
|
||||||
|
item.merchandise.price.amount
|
||||||
|
).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedOptions.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{selectedOptions
|
||||||
|
.map((option) => option.value)
|
||||||
|
.join(' / ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quantity Controls */}
|
||||||
|
<div className="flex items-center gap-x-2 mt-2">
|
||||||
|
<div className="inline-flex items-center rounded-full bg-secondary">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
runLineAction(item.id, () =>
|
||||||
|
updateItemQuantity(
|
||||||
|
item.id,
|
||||||
|
item.quantity - 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={item.quantity <= 1 || isPending}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
className="size-7 rounded-full"
|
||||||
|
>
|
||||||
|
<RiSubtractLine size={14} />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
|
||||||
|
{isPending ? (
|
||||||
|
<Loader size={12} className="mx-auto" />
|
||||||
|
) : (
|
||||||
|
item.quantity
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
runLineAction(item.id, () =>
|
||||||
|
updateItemQuantity(
|
||||||
|
item.id,
|
||||||
|
item.quantity + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isPending}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
className="size-7 rounded-full"
|
||||||
|
>
|
||||||
|
<RiAddLine size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
runLineAction(item.id, () => removeItem(item.id))
|
||||||
|
}
|
||||||
|
disabled={isPending}
|
||||||
|
variant="link"
|
||||||
|
aria-label={`Remove ${item.merchandise.product.title}`}
|
||||||
|
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetBody>
|
||||||
|
|
||||||
|
{/* Footer — discount, total, checkout */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="px-5 py-5 space-y-4">
|
||||||
|
{/* Discount Code */}
|
||||||
|
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={discountCode}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDiscountCode(event.target.value);
|
||||||
|
setDiscountError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Discount code"
|
||||||
|
aria-label="Discount code"
|
||||||
|
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!discountCode.trim() || applyingDiscount}
|
||||||
|
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
|
||||||
|
>
|
||||||
|
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{discountError && (
|
||||||
|
<p className="text-xs text-destructive">{discountError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appliedDiscounts.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Estimated total */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span className="text-base text-foreground">
|
||||||
|
Estimated total
|
||||||
|
</span>
|
||||||
|
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
|
||||||
|
${totalAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Taxes and shipping calculated at checkout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleCheckout}
|
||||||
|
disabled={!checkoutUrl || pendingLineId !== null}
|
||||||
|
className="h-12 w-full"
|
||||||
|
>
|
||||||
|
Go to Checkout
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={closeCart}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full font-normal text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Continue Shopping
|
Continue Shopping
|
||||||
</Button>
|
</Button>
|
||||||
</EmptyContent>
|
</div>
|
||||||
</Empty>
|
)}
|
||||||
) : (
|
</SheetContent>
|
||||||
<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>
|
</AnimatePresence>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import Image from 'next/image';
|
||||||
import { Typography } from '@/components/Typography';
|
|
||||||
|
|
||||||
interface CollectionImage {
|
interface CollectionImage {
|
||||||
url: string;
|
url: string;
|
||||||
altText?: string;
|
altText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Collection {
|
interface Collection {
|
||||||
@@ -13,7 +12,7 @@ interface Collection {
|
|||||||
title: string;
|
title: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
image?: CollectionImage;
|
image?: CollectionImage | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollectionCardProps {
|
interface CollectionCardProps {
|
||||||
@@ -22,47 +21,35 @@ interface CollectionCardProps {
|
|||||||
|
|
||||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||||
return (
|
return (
|
||||||
<Link href={`/collections/${collection.handle}`} className="block group">
|
<Link
|
||||||
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
href={`/collections/${collection.handle}`}
|
||||||
{/* Collection Image */}
|
className="group block h-full"
|
||||||
<div className="aspect-video overflow-hidden bg-muted">
|
>
|
||||||
{collection.image ? (
|
{/* Collection Image */}
|
||||||
<img
|
<div className="relative aspect-square overflow-hidden">
|
||||||
src={collection.image.url}
|
{collection.image ? (
|
||||||
alt={collection.image.altText || collection.title}
|
<Image
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
src={collection.image.url}
|
||||||
/>
|
alt={collection.image.altText || collection.title}
|
||||||
) : (
|
fill
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
sizes="(min-width: 1024px) 25vw, 50vw"
|
||||||
<i className="ri-folder-line text-6xl"></i>
|
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
||||||
</div>
|
/>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||||
|
<i className="ri-folder-line text-8xl"></i>
|
||||||
{/* 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>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Info */}
|
||||||
|
<div className="flex flex-col flex-1 py-2.5">
|
||||||
|
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||||
|
{collection.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CollectionCard;
|
export default CollectionCard;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ComponentConfig } from '@reacteditor/core';
|
||||||
|
import type { ShopifyCollection } from '@reacteditor/plugin-shopify';
|
||||||
|
import { Boxes } from 'lucide-react';
|
||||||
|
import CollectionDetail from '@/components/shopify/collection-detail';
|
||||||
|
|
||||||
|
export type CollectionDetailBlockProps = {
|
||||||
|
collection?: ShopifyCollection | null;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used two ways: dropped on `/collections/[handle]` it follows the route, so
|
||||||
|
* `collection` stays empty and the picker only drives the editor preview;
|
||||||
|
* dropped on any other page it pins to whichever collection is picked.
|
||||||
|
*/
|
||||||
|
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
|
||||||
|
label: 'Collection page',
|
||||||
|
icon: <Boxes size={16} />,
|
||||||
|
category: 'commerce',
|
||||||
|
defaultProps: {
|
||||||
|
collection: null,
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
collection: {
|
||||||
|
label: 'Collection',
|
||||||
|
type: 'shopifyCollection',
|
||||||
|
} as any,
|
||||||
|
title: {
|
||||||
|
label: 'Title override',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: "Leave empty to use the collection's own title",
|
||||||
|
contentEditable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: ({ collection, title }) => (
|
||||||
|
<CollectionDetail handle={collection?.handle} title={title} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default collectionDetailEditor;
|
||||||
@@ -1,94 +1,229 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
import { useParams } from 'next/navigation';
|
||||||
import ProductCard from './product-card';
|
import ProductCard from './product-card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import ProductFilters, { type ProductFilterFacet } from './product-filters';
|
||||||
|
import ProductToolbar from './product-toolbar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
import {
|
||||||
|
getCollectionProductsPage,
|
||||||
|
type CollectionSortKey,
|
||||||
|
} from '@/hooks/use-shopify-collections';
|
||||||
|
import type { Product } from '@/hooks/use-shopify-products';
|
||||||
|
|
||||||
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
|
const PAGE_SIZE = 24;
|
||||||
const handle = handleProp ?? '';
|
|
||||||
|
|
||||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
const GRID_CLASSES =
|
||||||
|
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
|
||||||
|
|
||||||
// Format title from handle
|
interface SortOption {
|
||||||
|
label: string;
|
||||||
|
sortKey: CollectionSortKey;
|
||||||
|
reverse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS: SortOption[] = [
|
||||||
|
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
|
||||||
|
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
|
||||||
|
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
|
||||||
|
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
|
||||||
|
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CollectionDetailProps {
|
||||||
|
/**
|
||||||
|
* Pins the block to one collection. Left empty, it reads the `[handle]`
|
||||||
|
* segment instead, which is what the `/collections/[handle]` template does.
|
||||||
|
*/
|
||||||
|
handle?: string;
|
||||||
|
/** Overrides the collection's own title. Empty falls back to Shopify's. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
||||||
|
handle: handleProp,
|
||||||
|
title: titleProp,
|
||||||
|
}) => {
|
||||||
|
const params = useParams();
|
||||||
|
const handle = handleProp || (params?.handle as string);
|
||||||
|
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
|
||||||
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [hasNextPage, setHasNextPage] = useState(false);
|
||||||
|
|
||||||
|
const [sortIndex, setSortIndex] = useState(0);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const sort = SORT_OPTIONS[sortIndex];
|
||||||
|
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||||
|
|
||||||
|
// Fall back to the handle until the collection's real title arrives.
|
||||||
const formattedTitle = handle
|
const formattedTitle = handle
|
||||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
: 'Collection';
|
: 'Collection';
|
||||||
|
|
||||||
if (loading || !handle) {
|
useEffect(() => {
|
||||||
return (
|
// Reached when the editor was opened on the literal `[handle]` pattern
|
||||||
<div className="pt-4 pb-16">
|
// rather than a real collection URL. Drop the skeleton and say so rather
|
||||||
<div className="container mx-auto px-4">
|
// than spinning forever.
|
||||||
<div className="mb-16 flex justify-center">
|
if (!handle || handle === '[handle]') {
|
||||||
<Skeleton className="h-12 w-64" />
|
setLoading(false);
|
||||||
</div>
|
setError(
|
||||||
|
'Open a collection page and add /editor to preview it, or pick a collection above.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
const run = async () => {
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
try {
|
||||||
<div key={index} className="flex flex-col gap-3">
|
setLoading(true);
|
||||||
<Skeleton className="aspect-square w-full" />
|
setError(null);
|
||||||
<Skeleton className="h-5 w-3/4" />
|
|
||||||
<Skeleton className="h-4 w-1/3" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
const page = await getCollectionProductsPage(handle, {
|
||||||
return (
|
first: PAGE_SIZE,
|
||||||
<div className="pt-4 pb-16">
|
sortKey: sort.sortKey,
|
||||||
<div className="container mx-auto px-4">
|
reverse: sort.reverse,
|
||||||
<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">
|
filterInputs: activeFilters,
|
||||||
<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 || [];
|
if (cancelled) return;
|
||||||
const title = collection?.title || formattedTitle;
|
|
||||||
|
if (!page.collection) {
|
||||||
|
setError('Collection not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(page.collection.title);
|
||||||
|
setProducts(page.products);
|
||||||
|
setCursor(page.endCursor);
|
||||||
|
setHasNextPage(page.hasNextPage);
|
||||||
|
// Keep the facet list stable while a selection is active, so options
|
||||||
|
// don't disappear out from under the panel.
|
||||||
|
if (activeFilters.length === 0) setFilters(page.filters);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error('Error fetching collection products:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load collection');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [handle, sort.sortKey, sort.reverse, activeKey]);
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (loadingMore || !hasNextPage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingMore(true);
|
||||||
|
const page = await getCollectionProductsPage(handle, {
|
||||||
|
first: PAGE_SIZE,
|
||||||
|
after: cursor,
|
||||||
|
sortKey: sort.sortKey,
|
||||||
|
reverse: sort.reverse,
|
||||||
|
filterInputs: activeFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
setProducts((prev) => {
|
||||||
|
const seen = new Set(prev.map((p) => p.id));
|
||||||
|
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
|
||||||
|
});
|
||||||
|
setCursor(page.endCursor);
|
||||||
|
setHasNextPage(page.hasNextPage);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load more products:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 pb-16">
|
<section className="bg-background py-10">
|
||||||
<div className="container mx-auto px-4">
|
<div className="max-w-screen-2xl mx-auto px-8">
|
||||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||||
{title}
|
{titleProp || title || formattedTitle}
|
||||||
</h2>
|
</h1>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
<div className="mt-6">
|
||||||
<div className="text-center py-12">
|
<ProductToolbar
|
||||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
totalCount={products.length > 0 ? products.length : null}
|
||||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
sortOptions={SORT_OPTIONS}
|
||||||
No Products in Collection
|
sortIndex={sortIndex}
|
||||||
</h3>
|
onSortChange={setSortIndex}
|
||||||
<p className="text-muted-foreground">
|
activeFilterCount={activeFilters.length}
|
||||||
This collection doesn't have any products yet.
|
/>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{loading ? (
|
||||||
|
<div className={GRID_CLASSES}>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<div key={index} className="animate-pulse">
|
||||||
|
<div className="aspect-square bg-zinc-100"></div>
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
<div className="h-4 w-4/5 bg-zinc-200"></div>
|
||||||
|
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : error ? (
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
) : products.length === 0 ? (
|
||||||
{products.map((product) => (
|
<p className="text-sm text-muted-foreground">
|
||||||
<ProductCard key={product.id} product={product} />
|
{activeFilters.length > 0
|
||||||
))}
|
? 'No products matched your filters.'
|
||||||
</div>
|
: "This collection doesn't have any products yet."}
|
||||||
)}
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={GRID_CLASSES}>
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="mt-16 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="font-normal text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{loadingMore && <Loader size={16} />}
|
||||||
|
{loadingMore ? 'Loading' : 'Load more'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<ProductFilters
|
||||||
|
open={filtersOpen}
|
||||||
|
onOpenChange={setFiltersOpen}
|
||||||
|
filters={filters}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onActiveFiltersChange={setActiveFilters}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
|
||||||
import { CollectionGrid, type CollectionGridProps } from "@/components/shopify/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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
|
||||||
import { CollectionView, type CollectionProps } from "@/components/shopify/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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user