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
|
||||
*.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": {
|
||||
"props": {
|
||||
"title": "About — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "About — Shop",
|
||||
"description": "About Shop.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-about",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"type": "content-section",
|
||||
"props": {
|
||||
"id": "hero-about",
|
||||
"tagline": "The lab",
|
||||
"heading": "We don't make activewear.",
|
||||
"subheading": "Pulse started in a converted warehouse with three coaches, two athletes, and a whiteboard full of things they wished worked better. Eight years later, the whiteboard is still there. So is the standard.",
|
||||
"buttons": [
|
||||
{
|
||||
"label": "Shop the kit",
|
||||
"href": "/search",
|
||||
"variant": "primary"
|
||||
}
|
||||
],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/hero-about.png",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "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"
|
||||
"id": "about-intro",
|
||||
"eyebrow": "Our story",
|
||||
"heading": "About",
|
||||
"body": "Tell your store’s story here.\n\nBlank lines start a new paragraph. Everything on this page is editable at /editor?route=/about.",
|
||||
"imageUrl": "",
|
||||
"imageAlt": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-about",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "About",
|
||||
"links": [
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "The lab",
|
||||
"href": "/lab"
|
||||
},
|
||||
{
|
||||
"label": "Athletes",
|
||||
"href": "/athletes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "YouTube",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
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: '/about' });
|
||||
|
||||
export default function 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": {
|
||||
"props": {
|
||||
"title": "Collection — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Collection",
|
||||
"description": "Shop the collection.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-collection",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "collection",
|
||||
"type": "collection-detail",
|
||||
"props": {
|
||||
"id": "collection-detail",
|
||||
"collection": null,
|
||||
"showDescription": "yes",
|
||||
"showCoverImage": "yes",
|
||||
"customCoverImage": "",
|
||||
"columns": "4",
|
||||
"limit": 24,
|
||||
"defaultSort": "BEST_SELLING",
|
||||
"showAvailability": "yes",
|
||||
"showPriceRange": "yes",
|
||||
"showProductType": "yes",
|
||||
"productTypeOptions": [
|
||||
{
|
||||
"label": "Tops"
|
||||
},
|
||||
{
|
||||
"label": "Shorts"
|
||||
},
|
||||
{
|
||||
"label": "Tights"
|
||||
},
|
||||
{
|
||||
"label": "Outerwear"
|
||||
},
|
||||
{
|
||||
"label": "Shoes"
|
||||
},
|
||||
{
|
||||
"label": "Accessories"
|
||||
}
|
||||
],
|
||||
"showVendor": "no",
|
||||
"vendorOptions": [],
|
||||
"showTags": "no",
|
||||
"tagOptions": [],
|
||||
"showColor": "yes",
|
||||
"colorOptions": [
|
||||
{
|
||||
"label": "Black",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"label": "White",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"label": "Grey",
|
||||
"color": "#707072"
|
||||
},
|
||||
{
|
||||
"label": "Volt",
|
||||
"color": "#d6ff3f"
|
||||
},
|
||||
{
|
||||
"label": "Sodium",
|
||||
"color": "#fa5400"
|
||||
}
|
||||
],
|
||||
"showStyle": "no",
|
||||
"styleOptions": [],
|
||||
"showSize": "yes",
|
||||
"sizeOptions": [
|
||||
{
|
||||
"label": "XS"
|
||||
},
|
||||
{
|
||||
"label": "S"
|
||||
},
|
||||
{
|
||||
"label": "M"
|
||||
},
|
||||
{
|
||||
"label": "L"
|
||||
},
|
||||
{
|
||||
"label": "XL"
|
||||
},
|
||||
{
|
||||
"label": "XXL"
|
||||
}
|
||||
],
|
||||
"showMaterial": "no",
|
||||
"materialOptions": [],
|
||||
"metafieldFilters": []
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-collection",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import type { Metadata } from 'next';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getCollectionProductsPage } from '@/services/shopify/catalog';
|
||||
import { truncate } from '@/lib/utils';
|
||||
import page from './page.json';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
|
||||
// No collection-only query exists, so ask for the smallest page of products
|
||||
// and use just the collection node off it.
|
||||
const { collection } = await getCollectionProductsPage(handle, {
|
||||
first: 1,
|
||||
}).catch(() => ({ collection: null }));
|
||||
|
||||
// The blocks fetch client-side and render their own empty state, so a failed
|
||||
// lookup falls back to the page.json tags rather than a 500.
|
||||
if (!collection) return pageMetadata(page);
|
||||
|
||||
return pageMetadata(page, {
|
||||
title: collection.title,
|
||||
description: collection.description
|
||||
? truncate(collection.description, 160)
|
||||
: `Shop the ${collection.title} collection.`,
|
||||
image: collection.image?.url,
|
||||
path: `/collections/${collection.handle}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -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 "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
/* Refined neutral modern palette */
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Card */
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Popover */
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Primary - Deep neutral slate for professional look */
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Secondary */
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Muted */
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
|
||||
/* Accent - Subtle warm gray */
|
||||
--color-accent: hsl(30 6.7% 95%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Destructive */
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Shop (accelerated checkout) purple */
|
||||
--color-shop: #5a31f4;
|
||||
|
||||
/* Border */
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5.9% 10%);
|
||||
|
||||
/* Radius - Modern larger corners */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
|
||||
/* Typography — Geist Sans / Geist Mono */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles for light, modern Shopify storefront */
|
||||
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
|
||||
components (e.g. Button's outline variant) rely on this base layer to pick up
|
||||
the theme's border colour instead of the text colour. */
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings:
|
||||
'kern' 1,
|
||||
'tnum' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Modern card design for products and collections */
|
||||
.card-modern {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-modern:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
|
||||
border-color: hsl(240 5.9% 85%);
|
||||
}
|
||||
|
||||
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
||||
h1,
|
||||
h2,
|
||||
.font-heading {
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
|
||||
.option-unavailable {
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
transparent calc(50% - 0.5px),
|
||||
currentColor calc(50% - 0.5px),
|
||||
currentColor calc(50% + 0.5px),
|
||||
transparent calc(50% + 0.5px)
|
||||
);
|
||||
}
|
||||
|
||||
/* Product description (HTML returned by the Storefront API) */
|
||||
.product-description p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.product-description > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Shop policy body (HTML returned by the Storefront API) */
|
||||
.policy-body p,
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
padding-left: 1.25rem;
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.policy-body li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-body h2,
|
||||
.policy-body h3,
|
||||
.policy-body h4 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.policy-body strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Enhanced button styles */
|
||||
button,
|
||||
.btn-modern {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-modern:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Hero section styles */
|
||||
.hero-bg {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
||||
}
|
||||
|
||||
/* Product gallery improvements */
|
||||
.product-gallery-main {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.product-gallery-main:hover {
|
||||
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
||||
}
|
||||
|
||||
/* Clean price display */
|
||||
.price-display {
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-family-heading: var(--font-header);
|
||||
--font-family-header: var(--font-header);
|
||||
--font-family-body: var(--font-body);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.5);
|
||||
--radius-md: calc(var(--radius) * 0.75);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.5);
|
||||
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100% - var(--gap))); }
|
||||
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
|
||||
--color-color-5: var(--color-5);
|
||||
--color-color-4: var(--color-4);
|
||||
--color-color-3: var(--color-3);
|
||||
--color-color-2: var(--color-2);
|
||||
--color-color-1: var(--color-1);
|
||||
@keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200%;
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(-100% - var(--gap))); }
|
||||
}
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-2xl: calc(var(--radius) * 2);
|
||||
--radius-3xl: calc(var(--radius) * 3);
|
||||
--radius-4xl: calc(var(--radius) * 4)
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-header: system-ui, -apple-system, sans-serif;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply outline-ring/50; }
|
||||
html, body { background-color: var(--background); color: var(--foreground); }
|
||||
body {
|
||||
font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji";
|
||||
margin: 0;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;}
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
+47
-9
@@ -1,16 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { site } from '@/config/site';
|
||||
|
||||
export const metadata = {
|
||||
title: "Shopify Storefront",
|
||||
const geist = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
// Per-page tags come from each page.json's root props via pageMetadata(); this
|
||||
// is the fallback for routes that don't set their own.
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(site.url),
|
||||
title: {
|
||||
default: site.storeName,
|
||||
template: `%s — ${site.storeName}`,
|
||||
},
|
||||
description: site.description,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: site.storeName,
|
||||
title: site.storeName,
|
||||
description: site.description,
|
||||
url: '/',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: site.storeName,
|
||||
description: site.description,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||
{/* Header, footer and the store assistant are editable blocks now, so
|
||||
each page.json carries its own chrome rather than the layout doing
|
||||
it. Keeps what the editor shows identical to what the route ships. */}
|
||||
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
"props": {
|
||||
"title": "Pulse — Made to Move",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Shop",
|
||||
"description": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-home",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"type": "products",
|
||||
"props": {
|
||||
"id": "hero-home",
|
||||
"tagline": "Spring 2026",
|
||||
"heading": "Made to move.",
|
||||
"subheading": "Performance gear engineered for athletes who train like they mean it. Built light. Built honest. Built for the next mile.",
|
||||
"buttons": [
|
||||
{
|
||||
"label": "Shop the kit",
|
||||
"href": "/search",
|
||||
"variant": "primary"
|
||||
},
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about",
|
||||
"variant": "secondary"
|
||||
}
|
||||
],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/athletic-assortment.jpeg",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "products-carousel",
|
||||
"props": {
|
||||
"id": "carousel-home",
|
||||
"tagline": "Just dropped",
|
||||
"heading": "New arrivals",
|
||||
"subheading": "Fresh kit for the season ahead.",
|
||||
"limit": 12,
|
||||
"slidesPerView": "4",
|
||||
"ctaLabel": "Shop new",
|
||||
"ctaHref": "/collections/new"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "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"
|
||||
"id": "home-products",
|
||||
"title": "Shopify Hydrogen Storefront",
|
||||
"subtitle": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||
"collection": null,
|
||||
"limit": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-home",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "About",
|
||||
"links": [
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "The lab",
|
||||
"href": "/lab"
|
||||
},
|
||||
{
|
||||
"label": "Athletes",
|
||||
"href": "/athletes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Legal",
|
||||
"links": [
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "YouTube",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
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: '/' });
|
||||
|
||||
export default function 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": {
|
||||
"props": {
|
||||
"title": "Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Product",
|
||||
"description": "Shop this product.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-product",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "product-detail",
|
||||
"props": {
|
||||
"id": "product-detail",
|
||||
"product": null,
|
||||
"addToCartLabel": "Add to Cart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "product-details",
|
||||
"type": "product-recommendations",
|
||||
"props": {
|
||||
"id": "product-details",
|
||||
"product": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "products-carousel",
|
||||
"props": {
|
||||
"id": "carousel-related",
|
||||
"tagline": "Pairs well with",
|
||||
"heading": "Complete the kit",
|
||||
"limit": 8,
|
||||
"slidesPerView": "4",
|
||||
"ctaLabel": "Shop all",
|
||||
"ctaHref": "/collections"
|
||||
"id": "product-recommendations",
|
||||
"title": "You May Also Like",
|
||||
"product": null,
|
||||
"limit": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-product",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import type { Metadata } from 'next';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getProduct } from '@/services/shopify/catalog';
|
||||
import { truncate } from '@/lib/utils';
|
||||
import page from './page.json';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
|
||||
// The blocks fetch client-side and render their own empty state, so a failed
|
||||
// lookup falls back to the page.json tags rather than a 500.
|
||||
const product = await getProduct(handle).catch(() => null);
|
||||
if (!product) return pageMetadata(page);
|
||||
|
||||
return pageMetadata(page, {
|
||||
title: product.title,
|
||||
description: product.description
|
||||
? truncate(product.description, 160)
|
||||
: undefined,
|
||||
image: product.images.edges[0]?.node.url,
|
||||
path: `/products/${product.handle}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -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": {
|
||||
"props": {
|
||||
"title": "Shop — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Search",
|
||||
"description": "Search the catalogue by product name, description or type.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-search",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "search-products",
|
||||
"type": "search-results",
|
||||
"props": {
|
||||
"id": "search-products",
|
||||
"heading": "All gear.",
|
||||
"subheading": "Filter by sport, fabric, fit, color. Or sort by what's been beaten the hardest.",
|
||||
"columns": "4",
|
||||
"limit": 24,
|
||||
"showAvailability": "yes",
|
||||
"showPriceRange": "yes",
|
||||
"showProductType": "yes",
|
||||
"showVendor": "no",
|
||||
"showTags": "yes",
|
||||
"metafieldFilters": [],
|
||||
"defaultSort": "BEST_SELLING",
|
||||
"productTypeOptions": [
|
||||
{
|
||||
"label": "Tops"
|
||||
},
|
||||
{
|
||||
"label": "Shorts"
|
||||
},
|
||||
{
|
||||
"label": "Tights"
|
||||
},
|
||||
{
|
||||
"label": "Outerwear"
|
||||
},
|
||||
{
|
||||
"label": "Shoes"
|
||||
},
|
||||
{
|
||||
"label": "Accessories"
|
||||
}
|
||||
],
|
||||
"vendorOptions": [],
|
||||
"tagOptions": [
|
||||
{
|
||||
"label": "New"
|
||||
},
|
||||
{
|
||||
"label": "Race day"
|
||||
},
|
||||
{
|
||||
"label": "Field tested"
|
||||
},
|
||||
{
|
||||
"label": "Sale"
|
||||
}
|
||||
]
|
||||
"id": "search-results",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-search",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+13
-3
@@ -1,6 +1,16 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import { Suspense } from 'react';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/search' });
|
||||
|
||||
export default function Page() {
|
||||
return <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",
|
||||
"style": "base-nova",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
@@ -11,7 +11,6 @@
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -19,7 +18,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { appConfig } from "@/editor.config";
|
||||
import globals from "@/app.globals.json";
|
||||
import { Render } from '@reacteditor/core/render';
|
||||
import { appConfig } from '@/editor.config';
|
||||
import globals from '@/app.globals.json';
|
||||
|
||||
export type PageData = {
|
||||
root?: unknown;
|
||||
content?: unknown;
|
||||
globals?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared renderer for a route. Drop a `page.json` next to a route's
|
||||
* `page.tsx`, import it, and hand it here:
|
||||
* Shared renderer for a route. Drop a `page.json` next to a route's `page.tsx`,
|
||||
* import it, and hand it here:
|
||||
*
|
||||
* import page from "./page.json";
|
||||
* 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 }) {
|
||||
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';
|
||||
|
||||
import React from 'react';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
AnimatePresence,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiImageLine,
|
||||
RiSubtractLine,
|
||||
RiAddLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
@@ -13,25 +26,43 @@ import {
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
|
||||
|
||||
const CartDrawer: React.FC = () => {
|
||||
const {
|
||||
isOpen,
|
||||
closeCart,
|
||||
items,
|
||||
itemCount,
|
||||
totalAmount,
|
||||
checkoutUrl,
|
||||
loading,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
} = useShopifyCart();
|
||||
const isOpen = useCartStore((s) => s.isOpen);
|
||||
const closeCart = useCartStore((s) => s.closeCart);
|
||||
const loading = useCartStore((s) => s.loading);
|
||||
const cart = useCartStore((s) => s.cart);
|
||||
const removeItem = useCartStore((s) => s.removeItem);
|
||||
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
|
||||
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
|
||||
|
||||
const [discountCode, setDiscountCode] = useState('');
|
||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||
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 = () => {
|
||||
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]) => {
|
||||
return item.merchandise.image?.url;
|
||||
};
|
||||
|
||||
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 (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base">
|
||||
Shopping Cart ({itemCount})
|
||||
</SheetTitle>
|
||||
<Button onClick={closeCart} variant="ghost" size="icon-sm">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty className="border-0">
|
||||
<EmptyContent>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Add some products to get started!
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Sheet
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && closeCart()}
|
||||
side="right"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
||||
{/* Header */}
|
||||
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
|
||||
Cart
|
||||
<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">
|
||||
{itemCount}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const image = getItemImage(item);
|
||||
const selectedOptions = getSelectedOptions(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start space-x-4 pb-6 border-b border-border last:border-b-0"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="w-20 h-20 bg-muted rounded-lg overflow-hidden flex-shrink-0">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={item.merchandise.product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-foreground mb-1 line-clamp-2">
|
||||
{item.merchandise.product.title}
|
||||
</h4>
|
||||
|
||||
{/* Variant Info */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{selectedOptions.map((option, index) => (
|
||||
<span key={option.name}>
|
||||
{option.value}
|
||||
{index < selectedOptions.length - 1
|
||||
? ' / '
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center mt-3">
|
||||
<div className="flex items-center border border-border rounded-lg">
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity - 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={item.quantity <= 1 || loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity + 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => removeItem(item.id)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-auto w-auto p-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Checkout Section */}
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-border p-6">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-base font-semibold">Subtotal</span>
|
||||
<span className="text-lg font-bold">
|
||||
${totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
Shipping and taxes calculated at checkout
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={loading || !checkoutUrl}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<Loader size={16} />
|
||||
<span>Processing...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Checkout'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onClick={closeCart} variant="link" className="w-full">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
)}
|
||||
</SheetContent>
|
||||
</AnimatePresence>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Typography } from '@/components/Typography';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface CollectionImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
altText?: string | null;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
@@ -13,7 +12,7 @@ interface Collection {
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
image?: CollectionImage;
|
||||
image?: CollectionImage | null;
|
||||
}
|
||||
|
||||
interface CollectionCardProps {
|
||||
@@ -22,45 +21,33 @@ interface CollectionCardProps {
|
||||
|
||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
return (
|
||||
<Link href={`/collections/${collection.handle}`} className="block group">
|
||||
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
||||
{/* Collection Image */}
|
||||
<div className="aspect-video overflow-hidden bg-muted">
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<i className="ri-folder-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<CardContent className="p-6">
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="mb-3 font-semibold tracking-tight text-foreground transition-colors group-hover:text-muted-foreground"
|
||||
>
|
||||
{collection.title}
|
||||
</Typography>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-muted-foreground">
|
||||
{collection.description.substring(0, 100)}
|
||||
{collection.description.length > 100 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-foreground font-semibold group-hover:text-muted-foreground transition-colors flex items-center">
|
||||
<span>View Collection</span>
|
||||
<i className="ri-arrow-right-s-line ml-2"></i>
|
||||
<Link
|
||||
href={`/collections/${collection.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Collection Image */}
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
{collection.image ? (
|
||||
<Image
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, 50vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-folder-line text-8xl"></i>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
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 handle = handleProp ?? '';
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
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
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
: 'Collection';
|
||||
|
||||
if (loading || !handle) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 flex justify-center">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
</div>
|
||||
useEffect(() => {
|
||||
// Reached when the editor was opened on the literal `[handle]` pattern
|
||||
// rather than a real collection URL. Drop the skeleton and say so rather
|
||||
// than spinning forever.
|
||||
if (!handle || handle === '[handle]') {
|
||||
setLoading(false);
|
||||
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">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="flex flex-col gap-3">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load collection</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const page = await getCollectionProductsPage(handle, {
|
||||
first: PAGE_SIZE,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
const products = collection?.products || [];
|
||||
const title = collection?.title || formattedTitle;
|
||||
if (cancelled) return;
|
||||
|
||||
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 (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
<section className="bg-background py-10">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{titleProp || title || formattedTitle}
|
||||
</h1>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Products in Collection
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This collection doesn't have any products yet.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ProductToolbar
|
||||
totalCount={products.length > 0 ? products.length : null}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
sortIndex={sortIndex}
|
||||
onSortChange={setSortIndex}
|
||||
activeFilterCount={activeFilters.length}
|
||||
/>
|
||||
</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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeFilters.length > 0
|
||||
? 'No products matched your filters.'
|
||||
: "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>
|
||||
|
||||
<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