Compare commits
12
Commits
63ecc5e284
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6104838684 | ||
|
|
f71d73d7c0 | ||
|
|
b2d2f65071 | ||
|
|
69ca137c7d | ||
|
|
3bc61066f0 | ||
|
|
9ed1cc60de | ||
|
|
ebb07c40fc | ||
|
|
bd7e6810b7 | ||
|
|
bd68e1eafb | ||
|
|
0fb6cef226 | ||
|
|
0a7ea0bf6b | ||
|
|
f11a764426 |
@@ -1,12 +0,0 @@
|
|||||||
# 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=
|
|
||||||
+7
-4
@@ -1,9 +1,12 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
|
||||||
# Yarn quarantines npm releases younger than `npmMinimalAgeGate` (24h by
|
npmMinimalAgeGate: 0
|
||||||
# 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:
|
npmPreapprovedPackages:
|
||||||
- "@reacteditor/*"
|
- "@reacteditor/*"
|
||||||
- "@shopify/hydrogen"
|
- "@shopify/hydrogen"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# React Editor Demo (Shopify)
|
# Shopify Hydrogen
|
||||||
|
|
||||||
Standalone Vite SPA wiring up the Shopify-aware React Editor via `<App />` from `@reacteditor/core`.
|
|
||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /about. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/about" />;
|
return <PageEditor pagePath="app/about/page.json" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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]" />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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" />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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" />;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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" />;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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" />;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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]" />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,8 @@ import {
|
|||||||
getCollectionProductsPage,
|
getCollectionProductsPage,
|
||||||
isDefaultTitleOption,
|
isDefaultTitleOption,
|
||||||
isDefaultTitleSelection,
|
isDefaultTitleSelection,
|
||||||
} from '@/services/shopify/catalog';
|
} from '@/services/shopify/shop';
|
||||||
|
|
||||||
// Streaming needs the Node runtime here because the Storefront helpers run
|
|
||||||
// server-side on each tool call.
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
||||||
@@ -36,7 +34,6 @@ Guidelines:
|
|||||||
- You cannot place orders, change carts, process payments, or look up customer
|
- 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.`;
|
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: {
|
const summariseProduct = (product: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -88,12 +85,9 @@ export async function POST(req: Request) {
|
|||||||
const openrouter = createOpenRouter({ apiKey });
|
const openrouter = createOpenRouter({ apiKey });
|
||||||
|
|
||||||
const result = streamText({
|
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' } }),
|
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
|
||||||
system: SYSTEM_PROMPT,
|
system: SYSTEM_PROMPT,
|
||||||
messages: await convertToModelMessages(messages),
|
messages: await convertToModelMessages(messages),
|
||||||
// Let the model call a tool, read the result, then answer.
|
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
tools: {
|
tools: {
|
||||||
searchCatalogue: tool({
|
searchCatalogue: tool({
|
||||||
@@ -201,7 +195,5 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// sendReasoning forwards reasoning parts to the client; without it the
|
|
||||||
// stream carries text and tool calls only.
|
|
||||||
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/collections/[handle]" />;
|
return (
|
||||||
|
<PageEditor
|
||||||
|
pagePath="app/collections/[handle]/page.json"
|
||||||
|
page={pageData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import PageRender from '@/components/page-render';
|
import PageRender from '@/components/page-render';
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
import { getCollectionProductsPage } from '@/services/shopify/catalog';
|
import { getCollectionProductsPage } from '@/services/shopify/shop';
|
||||||
import { truncate } from '@/lib/utils';
|
import { truncate } from '@/lib/utils';
|
||||||
import page from './page.json';
|
import page from './page.json';
|
||||||
|
|
||||||
@@ -12,14 +12,11 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { handle } = await params;
|
const { handle } = await params;
|
||||||
|
|
||||||
// No collection-only query exists, so ask for the smallest page of products
|
// Shopify exposes collection metadata through the products connection.
|
||||||
// and use just the collection node off it.
|
|
||||||
const { collection } = await getCollectionProductsPage(handle, {
|
const { collection } = await getCollectionProductsPage(handle, {
|
||||||
first: 1,
|
first: 1,
|
||||||
}).catch(() => ({ collection: null }));
|
}).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);
|
if (!collection) return pageMetadata(page);
|
||||||
|
|
||||||
return pageMetadata(page, {
|
return pageMetadata(page, {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /collections. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/collections" />;
|
return <PageEditor pagePath="app/collections/page.json" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/" />;
|
return <PageEditor pagePath="app/page.json" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-27
@@ -3,53 +3,41 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Refined neutral modern palette */
|
|
||||||
--color-background: hsl(0 0% 100%);
|
--color-background: hsl(0 0% 100%);
|
||||||
--color-foreground: hsl(240 10% 3.9%);
|
--color-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
/* Card */
|
|
||||||
--color-card: hsl(0 0% 100%);
|
--color-card: hsl(0 0% 100%);
|
||||||
--color-card-foreground: hsl(240 10% 3.9%);
|
--color-card-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
/* Popover */
|
|
||||||
--color-popover: hsl(0 0% 100%);
|
--color-popover: hsl(0 0% 100%);
|
||||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
/* Primary - Deep neutral slate for professional look */
|
|
||||||
--color-primary: hsl(240 5.9% 10%);
|
--color-primary: hsl(240 5.9% 10%);
|
||||||
--color-primary-foreground: hsl(0 0% 98%);
|
--color-primary-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
/* Secondary */
|
|
||||||
--color-secondary: hsl(240 4.8% 95.9%);
|
--color-secondary: hsl(240 4.8% 95.9%);
|
||||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
/* Muted */
|
|
||||||
--color-muted: hsl(240 4.8% 95.9%);
|
--color-muted: hsl(240 4.8% 95.9%);
|
||||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
|
||||||
/* Accent - Subtle warm gray */
|
|
||||||
--color-accent: hsl(30 6.7% 95%);
|
--color-accent: hsl(30 6.7% 95%);
|
||||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
/* Destructive */
|
|
||||||
--color-destructive: hsl(0 84.2% 60.2%);
|
--color-destructive: hsl(0 84.2% 60.2%);
|
||||||
--color-destructive-foreground: hsl(0 0% 98%);
|
--color-destructive-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
/* Shop (accelerated checkout) purple */
|
|
||||||
--color-shop: #5a31f4;
|
--color-shop: #5a31f4;
|
||||||
|
|
||||||
/* Border */
|
|
||||||
--color-border: hsl(240 5.9% 90%);
|
--color-border: hsl(240 5.9% 90%);
|
||||||
--color-input: hsl(240 5.9% 90%);
|
--color-input: hsl(240 5.9% 90%);
|
||||||
--color-ring: hsl(240 5.9% 10%);
|
--color-ring: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
/* Radius - Modern larger corners */
|
|
||||||
--radius-sm: 0.5rem;
|
--radius-sm: 0.5rem;
|
||||||
--radius-md: 0.75rem;
|
--radius-md: 0.75rem;
|
||||||
--radius-lg: 1rem;
|
--radius-lg: 1rem;
|
||||||
--radius-xl: 1.25rem;
|
--radius-xl: 1.25rem;
|
||||||
|
|
||||||
/* Typography — Geist Sans / Geist Mono */
|
|
||||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||||
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
@@ -57,10 +45,7 @@
|
|||||||
--font-poppins: 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 otherwise defaults unqualified borders to currentColor. */
|
||||||
/* 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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
@@ -75,7 +60,6 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modern card design for products and collections */
|
|
||||||
.card-modern {
|
.card-modern {
|
||||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -87,7 +71,6 @@ body {
|
|||||||
border-color: hsl(240 5.9% 85%);
|
border-color: hsl(240 5.9% 85%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
.font-heading {
|
.font-heading {
|
||||||
@@ -95,7 +78,6 @@ h2,
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
@@ -105,7 +87,6 @@ h2,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
|
|
||||||
.option-unavailable {
|
.option-unavailable {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to top right,
|
to top right,
|
||||||
@@ -116,7 +97,6 @@ h2,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Product description (HTML returned by the Storefront API) */
|
|
||||||
.product-description p {
|
.product-description p {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -125,7 +105,6 @@ h2,
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shop policy body (HTML returned by the Storefront API) */
|
|
||||||
.policy-body p,
|
.policy-body p,
|
||||||
.policy-body ul,
|
.policy-body ul,
|
||||||
.policy-body ol {
|
.policy-body ol {
|
||||||
@@ -164,7 +143,6 @@ h2,
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced button styles */
|
|
||||||
button,
|
button,
|
||||||
.btn-modern {
|
.btn-modern {
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -177,12 +155,10 @@ button,
|
|||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero section styles */
|
|
||||||
.hero-bg {
|
.hero-bg {
|
||||||
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Product gallery improvements */
|
|
||||||
.product-gallery-main {
|
.product-gallery-main {
|
||||||
transition: box-shadow 0.3s ease;
|
transition: box-shadow 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -191,7 +167,6 @@ button,
|
|||||||
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clean price display */
|
|
||||||
.price-display {
|
.price-display {
|
||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
}
|
}
|
||||||
@@ -227,4 +202,4 @@ button,
|
|||||||
--color-3: oklch(69.6% 0.165 251);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--color-4: oklch(80.2% 0.134 225);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--color-5: oklch(90.7% 0.231 133);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ const geistMono = Geist_Mono({
|
|||||||
variable: '--font-geist-mono',
|
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 = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(site.url),
|
metadataBase: new URL(site.url),
|
||||||
title: {
|
title: {
|
||||||
@@ -44,9 +42,6 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
<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">
|
<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>
|
<div className="flex-1">{children}</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/policies/[handle]" />;
|
return (
|
||||||
|
<PageEditor pagePath="app/policies/[handle]/page.json" page={pageData} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /products/[handle]. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/products/[handle]" />;
|
return (
|
||||||
|
<PageEditor pagePath="app/products/[handle]/page.json" page={pageData} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import PageRender from '@/components/page-render';
|
import PageRender from '@/components/page-render';
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
import { getProduct } from '@/services/shopify/catalog';
|
import { getProduct } from '@/services/shopify/shop';
|
||||||
import { truncate } from '@/lib/utils';
|
import { truncate } from '@/lib/utils';
|
||||||
import page from './page.json';
|
import page from './page.json';
|
||||||
|
|
||||||
@@ -12,8 +12,6 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { handle } = await params;
|
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);
|
const product = await getProduct(handle).catch(() => null);
|
||||||
if (!product) return pageMetadata(page);
|
if (!product) return pageMetadata(page);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /search. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/search" />;
|
return <PageEditor pagePath="app/search/page.json" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"type": "search-results",
|
"type": "search-results",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "search-results",
|
"id": "search-results",
|
||||||
"title": "Search"
|
"title": "Search",
|
||||||
|
"placeholder": "Search products"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import PageRender from '@/components/page-render';
|
import PageRender from '@/components/page-render';
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
// Alias of /collections — same page.json, so editing that route updates both.
|
|
||||||
import page from '../../collections/page.json';
|
import page from '../../collections/page.json';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
...pageMetadata(page),
|
...pageMetadata(page),
|
||||||
// Point crawlers at the canonical listing.
|
|
||||||
alternates: { canonical: '/collections' },
|
alternates: { canonical: '/collections' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ import {
|
|||||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentData =
|
export type AttachmentData =
|
||||||
| (FileUIPart & { id: string })
|
| (FileUIPart & { id: string })
|
||||||
| (SourceDocumentUIPart & { id: string });
|
| (SourceDocumentUIPart & { id: string });
|
||||||
@@ -47,10 +43,6 @@ const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
|||||||
video: VideoIcon,
|
video: VideoIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Utility Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const getMediaCategory = (
|
export const getMediaCategory = (
|
||||||
data: AttachmentData
|
data: AttachmentData
|
||||||
): AttachmentMediaCategory => {
|
): AttachmentMediaCategory => {
|
||||||
@@ -108,10 +100,6 @@ const renderAttachmentImage = (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Contexts
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface AttachmentsContextValue {
|
interface AttachmentsContextValue {
|
||||||
variant: AttachmentVariant;
|
variant: AttachmentVariant;
|
||||||
}
|
}
|
||||||
@@ -127,10 +115,6 @@ interface AttachmentContextValue {
|
|||||||
|
|
||||||
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Hooks
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const useAttachmentsContext = () =>
|
export const useAttachmentsContext = () =>
|
||||||
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||||
|
|
||||||
@@ -142,10 +126,6 @@ export const useAttachmentContext = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Attachments - Container
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
variant?: AttachmentVariant;
|
variant?: AttachmentVariant;
|
||||||
};
|
};
|
||||||
@@ -175,10 +155,6 @@ export const Attachments = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Attachment - Item
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: AttachmentData;
|
data: AttachmentData;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
@@ -225,10 +201,6 @@ export const Attachment = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AttachmentPreview - Media preview
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
fallbackIcon?: ReactNode;
|
fallbackIcon?: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -275,10 +247,6 @@ export const AttachmentPreview = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AttachmentInfo - Name and type display
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
showMediaType?: boolean;
|
showMediaType?: boolean;
|
||||||
};
|
};
|
||||||
@@ -307,10 +275,6 @@ export const AttachmentInfo = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AttachmentRemove - Remove button
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
@@ -365,10 +329,6 @@ export const AttachmentRemove = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AttachmentHoverCard - Hover preview
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||||
|
|
||||||
export const AttachmentHoverCard = ({
|
export const AttachmentHoverCard = ({
|
||||||
@@ -403,10 +363,6 @@ export const AttachmentHoverCardContent = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AttachmentEmpty - Empty state
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const AttachmentEmpty = ({
|
export const AttachmentEmpty = ({
|
||||||
|
|||||||
@@ -24,16 +24,12 @@ import remarkRehype from "remark-rehype";
|
|||||||
import remend from "remend";
|
import remend from "remend";
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
|
|
||||||
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
|
// Sanitize model output and reject unsafe URL protocols.
|
||||||
// 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 = {
|
const schema = {
|
||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
// GFM task lists render as disabled checkboxes. The default schema allows
|
// Preserve checked state for sanitized GFM task lists.
|
||||||
// `type` and `disabled` but not `checked`, so every box would read unticked.
|
|
||||||
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
|
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -44,22 +40,15 @@ const processor = unified()
|
|||||||
.use(remarkRehype)
|
.use(remarkRehype)
|
||||||
.use(rehypeSanitize, schema);
|
.use(rehypeSanitize, schema);
|
||||||
|
|
||||||
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
|
// Keep incomplete links as text while streaming.
|
||||||
// default instead emits a `streamdown:incomplete-link` placeholder href, which
|
|
||||||
// the sanitizer would strip anyway.
|
|
||||||
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
|
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
|
||||||
|
|
||||||
// remend only ever runs on text that is still arriving. On finished text it can
|
// Repair only streaming text; remend can corrupt settled italicized prices.
|
||||||
// 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) =>
|
const repair = (markdown: string, isStreaming: boolean) =>
|
||||||
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
|
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
|
||||||
|
|
||||||
export interface LinkSafetyConfig {
|
export interface LinkSafetyConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Return true for links that may open without a confirmation step. */
|
|
||||||
onLinkCheck?: (url: string) => boolean;
|
onLinkCheck?: (url: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +60,6 @@ type AnchorProps = ComponentProps<"a"> & {
|
|||||||
const LINK_CLASS =
|
const LINK_CLASS =
|
||||||
"font-medium underline underline-offset-4 hover:text-foreground";
|
"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 isRouteHref = (href: string) => /^[/#?]/.test(href);
|
||||||
|
|
||||||
const MarkdownLink = ({
|
const MarkdownLink = ({
|
||||||
@@ -82,8 +68,6 @@ const MarkdownLink = ({
|
|||||||
onUntrusted,
|
onUntrusted,
|
||||||
...props
|
...props
|
||||||
}: AnchorProps) => {
|
}: AnchorProps) => {
|
||||||
// Runs on click rather than on render so it can read `window.location`,
|
|
||||||
// which is unavailable during the server pass.
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
|
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
|
||||||
@@ -95,8 +79,6 @@ const MarkdownLink = ({
|
|||||||
[href, linkSafety, onUntrusted]
|
[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)) {
|
if (href && isRouteHref(href)) {
|
||||||
return <Link className={LINK_CLASS} href={href} {...props} />;
|
return <Link className={LINK_CLASS} href={href} {...props} />;
|
||||||
}
|
}
|
||||||
@@ -113,7 +95,6 @@ const MarkdownLink = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
|
|
||||||
const buildComponents = (
|
const buildComponents = (
|
||||||
linkSafety: LinkSafetyConfig | undefined,
|
linkSafety: LinkSafetyConfig | undefined,
|
||||||
onUntrusted: (url: string) => void
|
onUntrusted: (url: string) => void
|
||||||
@@ -128,8 +109,6 @@ const buildComponents = (
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
code: ({ className, ...props }: ComponentProps<"code">) => {
|
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 =
|
const isBlock =
|
||||||
typeof className === "string" && className.includes("language-");
|
typeof className === "string" && className.includes("language-");
|
||||||
|
|
||||||
@@ -167,8 +146,6 @@ const buildComponents = (
|
|||||||
hr: (props: ComponentProps<"hr">) => (
|
hr: (props: ComponentProps<"hr">) => (
|
||||||
<hr className="my-4 border-border" {...props} />
|
<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: (props: ComponentProps<"input">) => (
|
||||||
<input className="mr-1.5 align-middle" readOnly {...props} />
|
<input className="mr-1.5 align-middle" readOnly {...props} />
|
||||||
),
|
),
|
||||||
@@ -213,7 +190,6 @@ const buildComponents = (
|
|||||||
|
|
||||||
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||||
children: string;
|
children: string;
|
||||||
/** True while tokens are still arriving; enables incomplete-syntax repair. */
|
|
||||||
isAnimating?: boolean;
|
isAnimating?: boolean;
|
||||||
linkSafety?: LinkSafetyConfig;
|
linkSafety?: LinkSafetyConfig;
|
||||||
};
|
};
|
||||||
@@ -231,8 +207,6 @@ export const Markdown = memo(
|
|||||||
const handleUntrusted = useCallback((url: string) => setPending(url), []);
|
const handleUntrusted = useCallback((url: string) => setPending(url), []);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
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 source = repair(children ?? "", isAnimating === true);
|
||||||
const tree = processor.runSync(processor.parse(source)) as Nodes;
|
const tree = processor.runSync(processor.parse(source)) as Nodes;
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ export const MessageBranchContent = ({
|
|||||||
[children]
|
[children]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use useEffect to update branches when they change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (branches.length !== childrenArray.length) {
|
if (branches.length !== childrenArray.length) {
|
||||||
setBranches(childrenArray);
|
setBranches(childrenArray);
|
||||||
@@ -231,7 +230,6 @@ export const MessageBranchSelector = ({
|
|||||||
}: MessageBranchSelectorProps) => {
|
}: MessageBranchSelectorProps) => {
|
||||||
const { totalBranches } = useMessageBranch();
|
const { totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
// Don't render if there's only one branch
|
|
||||||
if (totalBranches <= 1) {
|
if (totalBranches <= 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -317,7 +315,6 @@ export const MessageBranchPage = ({
|
|||||||
|
|
||||||
export type MessageResponseProps = ComponentProps<typeof Markdown>;
|
export type MessageResponseProps = ComponentProps<typeof Markdown>;
|
||||||
|
|
||||||
// Markdown already memoises on children/isAnimating.
|
|
||||||
export const MessageResponse = Markdown;
|
export const MessageResponse = Markdown;
|
||||||
|
|
||||||
export type MessageToolbarProps = ComponentProps<"div">;
|
export type MessageToolbarProps = ComponentProps<"div">;
|
||||||
|
|||||||
@@ -74,15 +74,10 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
|
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
// FileReader uses callback-based API, wrapping in Promise is necessary
|
|
||||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
|||||||
|
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
|
|
||||||
// Video element uses callback-based API, wrapping in Promise is necessary
|
|
||||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
||||||
@@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.drawImage(video, 0, 0, width, height);
|
context.drawImage(video, 0, 0, width, height);
|
||||||
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
|
|
||||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||||
const blob = await new Promise<Blob | null>((resolve) => {
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
canvas.toBlob(resolve, "image/png");
|
canvas.toBlob(resolve, "image/png");
|
||||||
@@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Provider Context & Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface AttachmentsContext {
|
export interface AttachmentsContext {
|
||||||
files: (FileUIPart & { id: string })[];
|
files: (FileUIPart & { id: string })[];
|
||||||
add: (files: File[] | FileList) => void;
|
add: (files: File[] | FileList) => void;
|
||||||
@@ -196,7 +185,6 @@ export interface TextInputContext {
|
|||||||
export interface PromptInputControllerProps {
|
export interface PromptInputControllerProps {
|
||||||
textInput: TextInputContext;
|
textInput: TextInputContext;
|
||||||
attachments: AttachmentsContext;
|
attachments: AttachmentsContext;
|
||||||
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
|
||||||
__registerFileInput: (
|
__registerFileInput: (
|
||||||
ref: RefObject<HTMLInputElement | null>,
|
ref: RefObject<HTMLInputElement | null>,
|
||||||
open: () => void
|
open: () => void
|
||||||
@@ -220,7 +208,6 @@ export const usePromptInputController = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
|
||||||
const useOptionalPromptInputController = () =>
|
const useOptionalPromptInputController = () =>
|
||||||
useContext(PromptInputController);
|
useContext(PromptInputController);
|
||||||
|
|
||||||
@@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
|||||||
initialInput?: string;
|
initialInput?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/** Lifts prompt state when shared control is needed. */
|
||||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
|
||||||
* If you don't use it, PromptInput stays fully self-managed.
|
|
||||||
*/
|
|
||||||
export const PromptInputProvider = ({
|
export const PromptInputProvider = ({
|
||||||
initialInput: initialTextInput = "",
|
initialInput: initialTextInput = "",
|
||||||
children,
|
children,
|
||||||
}: PromptInputProviderProps) => {
|
}: PromptInputProviderProps) => {
|
||||||
// ----- textInput state
|
|
||||||
const [textInput, setTextInput] = useState(initialTextInput);
|
const [textInput, setTextInput] = useState(initialTextInput);
|
||||||
const clearInput = useCallback(() => setTextInput(""), []);
|
const clearInput = useCallback(() => setTextInput(""), []);
|
||||||
|
|
||||||
// ----- attachments state (global when wrapped)
|
|
||||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||||
(FileUIPart & { id: string })[]
|
(FileUIPart & { id: string })[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -300,14 +282,13 @@ export const PromptInputProvider = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
// Keep the latest files available to the unmount cleanup.
|
||||||
const attachmentsRef = useRef(attachmentFiles);
|
const attachmentsRef = useRef(attachmentFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
attachmentsRef.current = attachmentFiles;
|
attachmentsRef.current = attachmentFiles;
|
||||||
}, [attachmentFiles]);
|
}, [attachmentFiles]);
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
for (const f of attachmentsRef.current) {
|
for (const f of attachmentsRef.current) {
|
||||||
@@ -365,14 +346,9 @@ export const PromptInputProvider = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Component Context & Hooks
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
||||||
|
|
||||||
export const usePromptInputAttachments = () => {
|
export const usePromptInputAttachments = () => {
|
||||||
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
|
|
||||||
const provider = useOptionalProviderAttachments();
|
const provider = useOptionalProviderAttachments();
|
||||||
const local = useContext(LocalAttachmentsContext);
|
const local = useContext(LocalAttachmentsContext);
|
||||||
const context = local ?? provider;
|
const context = local ?? provider;
|
||||||
@@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Referenced Sources (Local to PromptInput)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ReferencedSourcesContext {
|
export interface ReferencedSourcesContext {
|
||||||
sources: (SourceDocumentUIPart & { id: string })[];
|
sources: (SourceDocumentUIPart & { id: string })[];
|
||||||
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
|
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
|
||||||
@@ -490,16 +462,11 @@ export type PromptInputProps = Omit<
|
|||||||
HTMLAttributes<HTMLFormElement>,
|
HTMLAttributes<HTMLFormElement>,
|
||||||
"onSubmit" | "onError"
|
"onSubmit" | "onError"
|
||||||
> & {
|
> & {
|
||||||
// e.g., "image/*" or leave undefined for any
|
|
||||||
accept?: string;
|
accept?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
// When true, accepts drops anywhere on document. Default false (opt-in).
|
|
||||||
globalDrop?: boolean;
|
globalDrop?: boolean;
|
||||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
|
||||||
syncHiddenInput?: boolean;
|
syncHiddenInput?: boolean;
|
||||||
// Minimal constraints
|
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
// bytes
|
|
||||||
maxFileSize?: number;
|
maxFileSize?: number;
|
||||||
onError?: (err: {
|
onError?: (err: {
|
||||||
code: "max_files" | "max_file_size" | "accept";
|
code: "max_files" | "max_file_size" | "accept";
|
||||||
@@ -524,24 +491,19 @@ export const PromptInput = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PromptInputProps) => {
|
}: PromptInputProps) => {
|
||||||
// Try to use a provider controller if present
|
|
||||||
const controller = useOptionalPromptInputController();
|
const controller = useOptionalPromptInputController();
|
||||||
const usingProvider = !!controller;
|
const usingProvider = !!controller;
|
||||||
|
|
||||||
// Refs
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
// ----- Local attachments (only used when no provider)
|
|
||||||
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
||||||
const files = usingProvider ? controller.attachments.files : items;
|
const files = usingProvider ? controller.attachments.files : items;
|
||||||
|
|
||||||
// ----- Local referenced sources (always local to PromptInput)
|
|
||||||
const [referencedSources, setReferencedSources] = useState<
|
const [referencedSources, setReferencedSources] = useState<
|
||||||
(SourceDocumentUIPart & { id: string })[]
|
(SourceDocumentUIPart & { id: string })[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
|
||||||
const filesRef = useRef(files);
|
const filesRef = useRef(files);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -565,7 +527,6 @@ export const PromptInput = ({
|
|||||||
|
|
||||||
return patterns.some((pattern) => {
|
return patterns.some((pattern) => {
|
||||||
if (pattern.endsWith("/*")) {
|
if (pattern.endsWith("/*")) {
|
||||||
// e.g: image/* -> image/
|
|
||||||
const prefix = pattern.slice(0, -1);
|
const prefix = pattern.slice(0, -1);
|
||||||
return f.type.startsWith(prefix);
|
return f.type.startsWith(prefix);
|
||||||
}
|
}
|
||||||
@@ -638,7 +599,6 @@ export const PromptInput = ({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrapper that validates files before calling provider's add
|
|
||||||
const addWithProviderValidation = useCallback(
|
const addWithProviderValidation = useCallback(
|
||||||
(fileList: File[] | FileList) => {
|
(fileList: File[] | FileList) => {
|
||||||
const incoming = [...fileList];
|
const incoming = [...fileList];
|
||||||
@@ -713,7 +673,6 @@ export const PromptInput = ({
|
|||||||
clearReferencedSources();
|
clearReferencedSources();
|
||||||
}, [clearAttachments, clearReferencedSources]);
|
}, [clearAttachments, clearReferencedSources]);
|
||||||
|
|
||||||
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!usingProvider) {
|
if (!usingProvider) {
|
||||||
return;
|
return;
|
||||||
@@ -721,22 +680,19 @@ export const PromptInput = ({
|
|||||||
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
||||||
}, [usingProvider, controller]);
|
}, [usingProvider, controller]);
|
||||||
|
|
||||||
// Note: File input cannot be programmatically set for security reasons
|
// Browsers forbid programmatically populating file inputs.
|
||||||
// The syncHiddenInput prop is no longer functional
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
||||||
inputRef.current.value = "";
|
inputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}, [files, syncHiddenInput]);
|
}, [files, syncHiddenInput]);
|
||||||
|
|
||||||
// Attach drop handlers on nearest form and document (opt-in)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const form = formRef.current;
|
const form = formRef.current;
|
||||||
if (!form) {
|
if (!form) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (globalDrop) {
|
if (globalDrop) {
|
||||||
// when global drop is on, let the document-level handler own drops
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +761,6 @@ export const PromptInput = ({
|
|||||||
if (event.currentTarget.files) {
|
if (event.currentTarget.files) {
|
||||||
add(event.currentTarget.files);
|
add(event.currentTarget.files);
|
||||||
}
|
}
|
||||||
// Reset input value to allow selecting files that were previously removed
|
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
},
|
},
|
||||||
[add]
|
[add]
|
||||||
@@ -853,19 +808,16 @@ export const PromptInput = ({
|
|||||||
return (formData.get("message") as string) || "";
|
return (formData.get("message") as string) || "";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Reset form immediately after capturing text to avoid race condition
|
// Reset before async conversion so new input is not cleared afterward.
|
||||||
// where user input during async blob conversion would be lost
|
|
||||||
if (!usingProvider) {
|
if (!usingProvider) {
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert blob URLs to data URLs asynchronously
|
|
||||||
const convertedFiles: FileUIPart[] = await Promise.all(
|
const convertedFiles: FileUIPart[] = await Promise.all(
|
||||||
files.map(async ({ id: _id, ...item }) => {
|
files.map(async ({ id: _id, ...item }) => {
|
||||||
if (item.url?.startsWith("blob:")) {
|
if (item.url?.startsWith("blob:")) {
|
||||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||||
// If conversion failed, keep the original blob URL
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
url: dataUrl ?? item.url,
|
url: dataUrl ?? item.url,
|
||||||
@@ -877,7 +829,6 @@ export const PromptInput = ({
|
|||||||
|
|
||||||
const result = onSubmit({ files: convertedFiles, text }, event);
|
const result = onSubmit({ files: convertedFiles, text }, event);
|
||||||
|
|
||||||
// Handle both sync and async onSubmit
|
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
try {
|
try {
|
||||||
await result;
|
await result;
|
||||||
@@ -886,23 +837,21 @@ export const PromptInput = ({
|
|||||||
controller.textInput.clear();
|
controller.textInput.clear();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Don't clear on error - user may want to retry
|
// Preserve failed submissions for retry.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Sync function completed without throwing, clear inputs
|
|
||||||
clear();
|
clear();
|
||||||
if (usingProvider) {
|
if (usingProvider) {
|
||||||
controller.textInput.clear();
|
controller.textInput.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Don't clear on error - user may want to retry
|
// Preserve failed submissions for retry.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[usingProvider, controller, files, onSubmit, clear]
|
[usingProvider, controller, files, onSubmit, clear]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render with or without local provider
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -932,7 +881,6 @@ export const PromptInput = ({
|
|||||||
</LocalReferencedSourcesContext.Provider>
|
</LocalReferencedSourcesContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always provide LocalAttachmentsContext so children get validated add function
|
|
||||||
return (
|
return (
|
||||||
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
|
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
|
||||||
{withReferencedSources}
|
{withReferencedSources}
|
||||||
@@ -966,10 +914,8 @@ export const PromptInputTextarea = ({
|
|||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
// Call the external onKeyDown handler first
|
|
||||||
onKeyDown?.(e);
|
onKeyDown?.(e);
|
||||||
|
|
||||||
// If the external handler prevented default, don't run internal logic
|
|
||||||
if (e.defaultPrevented) {
|
if (e.defaultPrevented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -983,7 +929,6 @@ export const PromptInputTextarea = ({
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Check if the submit button is disabled before submitting
|
|
||||||
const { form } = e.currentTarget;
|
const { form } = e.currentTarget;
|
||||||
const submitButton = form?.querySelector(
|
const submitButton = form?.querySelector(
|
||||||
'button[type="submit"]'
|
'button[type="submit"]'
|
||||||
@@ -995,7 +940,6 @@ export const PromptInputTextarea = ({
|
|||||||
form?.requestSubmit();
|
form?.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove last attachment when Backspace is pressed and textarea is empty
|
|
||||||
if (
|
if (
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
e.currentTarget.value === "" &&
|
e.currentTarget.value === "" &&
|
||||||
@@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({
|
|||||||
<DropdownMenuItem className={cn(className)} {...props} />
|
<DropdownMenuItem className={cn(className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: Actions that perform side-effects (like opening a file dialog)
|
|
||||||
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
|
||||||
|
|
||||||
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
||||||
status?: ChatStatus;
|
status?: ChatStatus;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
@@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: PromptInputTabLabelProps) => (
|
}: PromptInputTabLabelProps) => (
|
||||||
// Content provided via children in props
|
|
||||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
||||||
<h3
|
<h3
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export const Reasoning = memo(
|
|||||||
...props
|
...props
|
||||||
}: ReasoningProps) => {
|
}: ReasoningProps) => {
|
||||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
|
||||||
const isExplicitlyClosed = defaultOpen === false;
|
const isExplicitlyClosed = defaultOpen === false;
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||||
@@ -80,7 +79,6 @@ export const Reasoning = memo(
|
|||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Track when streaming starts and compute duration
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
hasEverStreamedRef.current = true;
|
hasEverStreamedRef.current = true;
|
||||||
@@ -93,14 +91,12 @@ export const Reasoning = memo(
|
|||||||
}
|
}
|
||||||
}, [isStreaming, setDuration]);
|
}, [isStreaming, setDuration]);
|
||||||
|
|
||||||
// Auto-open when streaming starts (unless explicitly closed)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||||
|
|
||||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
hasEverStreamedRef.current &&
|
hasEverStreamedRef.current &&
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { memo, useMemo } from "react";
|
|||||||
|
|
||||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||||
|
|
||||||
// Cache motion components at module level to avoid creating during render
|
|
||||||
const motionComponentCache = new Map<
|
const motionComponentCache = new Map<
|
||||||
keyof JSX.IntrinsicElements,
|
keyof JSX.IntrinsicElements,
|
||||||
React.ComponentType<MotionHTMLProps>
|
React.ComponentType<MotionHTMLProps>
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
|
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
/** Wordmark text, and the image's alt text. */
|
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
/** Where the logo links to. Pass null to render it unwrapped. */
|
|
||||||
href?: string | null;
|
href?: string | null;
|
||||||
/** Sizing for the image — height only, so the aspect ratio is preserved. */
|
|
||||||
imageClassName?: string;
|
imageClassName?: string;
|
||||||
/** Sizing and weight for the wordmark fallback. */
|
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-137
@@ -1,164 +1,55 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
||||||
import { Loader } from '@/components/ui/loader';
|
|
||||||
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
||||||
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
||||||
import { appConfig } from '@/editor.config';
|
import { appConfig } from '@/editor.config';
|
||||||
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
|
import { publishPage } from '@/lib/publish-page';
|
||||||
|
import globals from '@/app.globals.json';
|
||||||
|
import {
|
||||||
|
SHOPIFY_API_VERSION,
|
||||||
|
SHOPIFY_PUBLIC_ACCESS_TOKEN,
|
||||||
|
SHOPIFY_STORE_DOMAIN,
|
||||||
|
} from '@/services/shopify/config';
|
||||||
|
|
||||||
// Plugin instances must keep a stable identity across renders, same as
|
const plugins = [
|
||||||
// `appConfig`, so they are built once at module scope.
|
outlinePlugin(),
|
||||||
//
|
createTailwindCdnPlugin(),
|
||||||
// The Tailwind CDN plugin only styles the editor's preview iframe; the public
|
createShopifyPlugin({
|
||||||
// routes still get their utilities from the compiled `app/globals.css`.
|
// Same resolved config as the storefront, including the mock.shop fallback.
|
||||||
const tailwindCdn = createTailwindCdnPlugin();
|
storeDomain: SHOPIFY_STORE_DOMAIN,
|
||||||
|
publicAccessToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
|
||||||
// Registers the `shopifyProduct` and `shopifyCollection` field types used by
|
apiVersion: SHOPIFY_API_VERSION,
|
||||||
// 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 {
|
export interface PageEditorProps {
|
||||||
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
|
pagePath: string;
|
||||||
routeKey: string;
|
page: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function PageEditor({ pagePath, page }: PageEditorProps) {
|
||||||
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The
|
const data = useMemo(
|
||||||
* public routes mount `PageRender` against the same `appConfig`, so the two
|
() => ({ ...page, globals }) as unknown as Data,
|
||||||
* never drift.
|
[page]
|
||||||
*
|
|
||||||
* 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(
|
const handlePublish = useCallback(
|
||||||
async (published: Data) => {
|
async (published: Data) => {
|
||||||
setStatus('Saving…');
|
const result = await publishPage(pagePath, published);
|
||||||
|
if (result.error) throw new Error(result.error);
|
||||||
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]
|
[pagePath]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<Editor
|
<Editor
|
||||||
key={routeKey}
|
theme="light"
|
||||||
config={appConfig as any}
|
config={appConfig as any}
|
||||||
data={data}
|
data={data}
|
||||||
plugins={plugins}
|
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}
|
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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,6 @@ export type PageData = {
|
|||||||
globals?: 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:
|
|
||||||
*
|
|
||||||
* 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 }) {
|
export default function PageRender({ page }: { page: PageData }) {
|
||||||
const data = { root: page.root, content: page.content, globals };
|
const data = { root: page.root, content: page.content, globals };
|
||||||
return <Render config={appConfig as any} data={data as any} />;
|
return <Render config={appConfig as any} data={data as any} />;
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
} from '@/components/ui/empty';
|
} from '@/components/ui/empty';
|
||||||
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
|
import { isDefaultTitleSelection } from '@/services/shopify/shop';
|
||||||
|
|
||||||
const CartDrawer: React.FC = () => {
|
const CartDrawer: React.FC = () => {
|
||||||
const isOpen = useCartStore((s) => s.isOpen);
|
const isOpen = useCartStore((s) => s.isOpen);
|
||||||
@@ -40,8 +40,7 @@ const CartDrawer: React.FC = () => {
|
|||||||
const [discountCode, setDiscountCode] = useState('');
|
const [discountCode, setDiscountCode] = useState('');
|
||||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||||
const [applyingDiscount, setApplyingDiscount] = useState(false);
|
const [applyingDiscount, setApplyingDiscount] = useState(false);
|
||||||
// The store's `loading` flag is global, so track the specific line being
|
// Track row-level work separately from the cart's global loading state.
|
||||||
// changed to keep the other rows interactive.
|
|
||||||
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
|
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
|
||||||
|
|
||||||
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
|
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
|
||||||
@@ -80,7 +79,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
setDiscountError(null);
|
setDiscountError(null);
|
||||||
const updatedCart = await applyDiscountCode(code);
|
const updatedCart = await applyDiscountCode(code);
|
||||||
|
|
||||||
// Shopify accepts unknown codes silently, flagging them as inapplicable.
|
|
||||||
const accepted = updatedCart.discountCodes?.some(
|
const accepted = updatedCart.discountCodes?.some(
|
||||||
(discount) =>
|
(discount) =>
|
||||||
discount.applicable &&
|
discount.applicable &&
|
||||||
@@ -104,7 +102,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedOptions = (item: (typeof items)[0]) => {
|
const getSelectedOptions = (item: (typeof items)[0]) => {
|
||||||
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
|
|
||||||
return (item.merchandise.selectedOptions ?? []).filter(
|
return (item.merchandise.selectedOptions ?? []).filter(
|
||||||
(option) => !isDefaultTitleSelection(option)
|
(option) => !isDefaultTitleSelection(option)
|
||||||
);
|
);
|
||||||
@@ -119,7 +116,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
||||||
{/* Header */}
|
|
||||||
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
|
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
|
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
|
||||||
@@ -140,7 +136,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{/* Cart Items */}
|
|
||||||
<SheetBody className="px-5">
|
<SheetBody className="px-5">
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -169,7 +164,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-start gap-x-3">
|
<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">
|
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
|
||||||
{image ? (
|
{image ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -186,7 +180,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Details */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-x-3">
|
<div className="flex items-start justify-between gap-x-3">
|
||||||
<h4 className="text-sm font-medium text-foreground line-clamp-2">
|
<h4 className="text-sm font-medium text-foreground line-clamp-2">
|
||||||
@@ -209,7 +202,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quantity Controls */}
|
|
||||||
<div className="flex items-center gap-x-2 mt-2">
|
<div className="flex items-center gap-x-2 mt-2">
|
||||||
<div className="inline-flex items-center rounded-full bg-secondary">
|
<div className="inline-flex items-center rounded-full bg-secondary">
|
||||||
<Button
|
<Button
|
||||||
@@ -274,10 +266,8 @@ const CartDrawer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
|
|
||||||
{/* Footer — discount, total, checkout */}
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="px-5 py-5 space-y-4">
|
<div className="px-5 py-5 space-y-4">
|
||||||
{/* Discount Code */}
|
|
||||||
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
|
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -309,7 +299,6 @@ const CartDrawer: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Estimated total */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="text-base text-foreground">
|
<span className="text-base text-foreground">
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
|||||||
href={`/collections/${collection.handle}`}
|
href={`/collections/${collection.handle}`}
|
||||||
className="group block h-full"
|
className="group block h-full"
|
||||||
>
|
>
|
||||||
{/* Collection Image */}
|
|
||||||
<div className="relative aspect-square overflow-hidden">
|
<div className="relative aspect-square overflow-hidden">
|
||||||
{collection.image ? (
|
{collection.image ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -42,7 +41,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collection Info */}
|
|
||||||
<div className="flex flex-col flex-1 py-2.5">
|
<div className="flex flex-col flex-1 py-2.5">
|
||||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||||
{collection.title}
|
{collection.title}
|
||||||
|
|||||||
-5
@@ -8,11 +8,6 @@ export type CollectionDetailBlockProps = {
|
|||||||
title?: string;
|
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> = {
|
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
|
||||||
label: 'Collection page',
|
label: 'Collection page',
|
||||||
icon: <Boxes size={16} />,
|
icon: <Boxes size={16} />,
|
||||||
@@ -33,12 +33,7 @@ const SORT_OPTIONS: SortOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface CollectionDetailProps {
|
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;
|
handle?: string;
|
||||||
/** Overrides the collection's own title. Empty falls back to Shopify's. */
|
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +60,12 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
|||||||
const sort = SORT_OPTIONS[sortIndex];
|
const sort = SORT_OPTIONS[sortIndex];
|
||||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||||
|
|
||||||
// Fall back to the handle until the collection's real title arrives.
|
|
||||||
const formattedTitle = handle
|
const formattedTitle = handle
|
||||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
: 'Collection';
|
: 'Collection';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reached when the editor was opened on the literal `[handle]` pattern
|
// The literal editor route pattern has no collection to load.
|
||||||
// rather than a real collection URL. Drop the skeleton and say so rather
|
|
||||||
// than spinning forever.
|
|
||||||
if (!handle || handle === '[handle]') {
|
if (!handle || handle === '[handle]') {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
@@ -106,8 +98,7 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
|||||||
setProducts(page.products);
|
setProducts(page.products);
|
||||||
setCursor(page.endCursor);
|
setCursor(page.endCursor);
|
||||||
setHasNextPage(page.hasNextPage);
|
setHasNextPage(page.hasNextPage);
|
||||||
// Keep the facet list stable while a selection is active, so options
|
// Do not remove active facet choices as result counts change.
|
||||||
// don't disappear out from under the panel.
|
|
||||||
if (activeFilters.length === 0) setFilters(page.filters);
|
if (activeFilters.length === 0) setFilters(page.filters);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -169,7 +160,10 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{loading ? (
|
{/* A missing or failed collection keeps the skeletons up rather than
|
||||||
|
showing an empty state, so the page stays presentable while a
|
||||||
|
handle is being chosen. */}
|
||||||
|
{loading || error ? (
|
||||||
<div className={GRID_CLASSES}>
|
<div className={GRID_CLASSES}>
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
<div key={index} className="animate-pulse">
|
<div key={index} className="animate-pulse">
|
||||||
@@ -181,8 +175,6 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
|
||||||
) : products.length === 0 ? (
|
) : products.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{activeFilters.length > 0
|
{activeFilters.length > 0
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import { useCollections } from '@/hooks/use-shopify-collections';
|
import { useCollections } from '@/hooks/use-shopify-collections';
|
||||||
import CollectionCard from './collection-card';
|
import CollectionCard from './collection-card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
|
||||||
interface CollectionsProps {
|
interface CollectionsProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -32,7 +33,15 @@ const Collections: React.FC<CollectionsProps> = ({
|
|||||||
title = 'Our Collections',
|
title = 'Our Collections',
|
||||||
subtitle = 'Discover our carefully crafted worlds',
|
subtitle = 'Discover our carefully crafted worlds',
|
||||||
}) => {
|
}) => {
|
||||||
const { collections, loading, error, refetch } = useCollections(12);
|
const {
|
||||||
|
collections,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
hasNextPage,
|
||||||
|
loadMore,
|
||||||
|
refetch,
|
||||||
|
} = useCollections(12);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +101,21 @@ const Collections: React.FC<CollectionsProps> = ({
|
|||||||
<CollectionCard key={collection.id} collection={collection} />
|
<CollectionCard key={collection.id} collection={collection} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="mt-16 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="font-normal text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{loadingMore && <Loader size={16} />}
|
||||||
|
{loadingMore ? 'Loading' : 'See more'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,21 +6,11 @@ import Image from 'next/image';
|
|||||||
export interface ContentSectionProps {
|
export interface ContentSectionProps {
|
||||||
eyebrow?: React.ReactNode;
|
eyebrow?: React.ReactNode;
|
||||||
heading?: React.ReactNode;
|
heading?: React.ReactNode;
|
||||||
/**
|
|
||||||
* Plain text when it comes from a `page.json`; while the field is being
|
|
||||||
* edited inline the editor hands over a ReactNode instead, so this must not
|
|
||||||
* assume a string.
|
|
||||||
*/
|
|
||||||
body?: React.ReactNode;
|
body?: React.ReactNode;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic prose section for the non-commerce routes (about, landing copy).
|
|
||||||
* Typography matches the storefront's other sections — the editor supplies the
|
|
||||||
* words and the image, never the layout.
|
|
||||||
*/
|
|
||||||
const ContentSection: React.FC<ContentSectionProps> = ({
|
const ContentSection: React.FC<ContentSectionProps> = ({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
heading,
|
heading,
|
||||||
@@ -48,8 +38,6 @@ const ContentSection: React.FC<ContentSectionProps> = ({
|
|||||||
<div className="relative mt-10 aspect-[3/2] overflow-hidden">
|
<div className="relative mt-10 aspect-[3/2] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
// `heading` is a node while it's edited inline, and alt text
|
|
||||||
// has to be a string — fall back to empty rather than stringify.
|
|
||||||
alt={imageAlt || (typeof heading === 'string' ? heading : '')}
|
alt={imageAlt || (typeof heading === 'string' ? heading : '')}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 42rem, 100vw"
|
sizes="(min-width: 768px) 42rem, 100vw"
|
||||||
@@ -61,14 +49,11 @@ const ContentSection: React.FC<ContentSectionProps> = ({
|
|||||||
{body && (
|
{body && (
|
||||||
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
|
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
|
||||||
{typeof body === 'string'
|
{typeof body === 'string'
|
||||||
? // Authored as plain paragraphs; blank lines separate them.
|
? body
|
||||||
body
|
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.filter((paragraph) => paragraph.trim())
|
.filter((paragraph) => paragraph.trim())
|
||||||
.map((paragraph, index) => <p key={index}>{paragraph}</p>)
|
.map((paragraph, index) => <p key={index}>{paragraph}</p>)
|
||||||
: // Mid-edit: render the editor's node as-is so inline editing
|
: body}
|
||||||
// keeps working. Paragraph splitting resumes once saved.
|
|
||||||
body}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
return (
|
return (
|
||||||
<footer className="bg-background">
|
<footer className="bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
||||||
{/* Left-aligned: the bottom-right corner belongs to the floating
|
|
||||||
assistant launcher. Links and socials sit on separate rows so the
|
|
||||||
icons aren't crammed onto the end of the link list. */}
|
|
||||||
<div className="flex flex-col items-center gap-y-5 sm:items-start">
|
<div className="flex flex-col items-center gap-y-5 sm:items-start">
|
||||||
<Logo
|
<Logo
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -57,9 +54,9 @@ const Footer: React.FC<FooterProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
|
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
|
||||||
{links.map((link) => (
|
{links.map((link, index) => (
|
||||||
<a
|
<a
|
||||||
key={link.label}
|
key={`${link.url}-${index}`}
|
||||||
href={link.url}
|
href={link.url}
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,16 +11,10 @@ export type HeaderBlockProps = {
|
|||||||
links?: Array<{ label: string; url: string }>;
|
links?: Array<{ label: string; url: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Content only: the store name, the logo, the announcement copy and the nav
|
|
||||||
* labels. Stickiness, backdrop blur, spacing and the cart/search/account
|
|
||||||
* affordances are the component's business, not the editor's.
|
|
||||||
*/
|
|
||||||
const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
||||||
label: 'Header',
|
label: 'Header',
|
||||||
icon: <Menu size={16} />,
|
icon: <Menu size={16} />,
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
// Shared across pages: edit it once and every page.json picks it up.
|
|
||||||
global: true,
|
global: true,
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
storeName: 'Shop',
|
storeName: 'Shop',
|
||||||
@@ -47,9 +41,7 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
|||||||
announcement: {
|
announcement: {
|
||||||
label: 'Announcement',
|
label: 'Announcement',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
// Deliberately not `contentEditable`: an inline-edited field arrives as a
|
// Keep this a string so blank copy can hide the announcement.
|
||||||
// ReactNode, which stays truthy once emptied, so the bar would linger as
|
|
||||||
// an empty band. Keeping it a plain string makes "blank hides it" work.
|
|
||||||
placeholder: 'Leave empty to hide the bar',
|
placeholder: 'Leave empty to hide the bar',
|
||||||
},
|
},
|
||||||
announcementUrl: { label: 'Announcement link', type: 'text' },
|
announcementUrl: { label: 'Announcement link', type: 'text' },
|
||||||
@@ -76,9 +68,6 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
|||||||
storeName={storeName}
|
storeName={storeName}
|
||||||
logoUrl={logoUrl}
|
logoUrl={logoUrl}
|
||||||
links={links}
|
links={links}
|
||||||
// Header decides visibility: blank copy hides the bar, and the toggle
|
|
||||||
// hides it regardless — needed because an inline-edited field is a node,
|
|
||||||
// which can't be inspected for emptiness.
|
|
||||||
showAnnouncement={showAnnouncement}
|
showAnnouncement={showAnnouncement}
|
||||||
announcement={announcement}
|
announcement={announcement}
|
||||||
announcementUrl={announcementUrl}
|
announcementUrl={announcementUrl}
|
||||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
|||||||
import { useCartStore } from '@/hooks/use-shopify-cart';
|
import { useCartStore } from '@/hooks/use-shopify-cart';
|
||||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||||
import SearchDialog from '@/components/shopify/search-dialog';
|
import SearchDialog from '@/components/shopify/search-dialog';
|
||||||
import AccountMenu from '@/components/shopify/account-menu';
|
|
||||||
import ShopMenu from '@/components/shopify/shop-menu';
|
import ShopMenu from '@/components/shopify/shop-menu';
|
||||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -44,10 +43,8 @@ interface HeaderProps {
|
|||||||
storeName?: string;
|
storeName?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
links?: NavLink[];
|
links?: NavLink[];
|
||||||
/** Thin bar above the nav. Blank copy hides it. */
|
|
||||||
announcement?: React.ReactNode;
|
announcement?: React.ReactNode;
|
||||||
announcementUrl?: string;
|
announcementUrl?: string;
|
||||||
/** Hides the bar outright, whatever the copy says. */
|
|
||||||
showAnnouncement?: 'yes' | 'no';
|
showAnnouncement?: 'yes' | 'no';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,9 +61,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Whitespace-only copy counts as empty. While the field is edited inline the
|
// Inline editing supplies a node, so only string values can be blank-checked.
|
||||||
// editor supplies a node rather than a string — it can't be inspected for
|
|
||||||
// emptiness, which is what the explicit toggle is for.
|
|
||||||
const hasAnnouncementText =
|
const hasAnnouncementText =
|
||||||
typeof announcement === 'string'
|
typeof announcement === 'string'
|
||||||
? announcement.trim() !== ''
|
? announcement.trim() !== ''
|
||||||
@@ -76,7 +71,6 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Sits above the sticky nav, so it scrolls away on its own. */}
|
|
||||||
{showAnnouncementBar && (
|
{showAnnouncementBar && (
|
||||||
<div className="bg-muted text-foreground">
|
<div className="bg-muted text-foreground">
|
||||||
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
|
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
|
||||||
@@ -97,10 +91,8 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
|
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8">
|
<div className="max-w-screen-2xl mx-auto px-8">
|
||||||
<div className="flex justify-between items-center h-14">
|
<div className="flex justify-between items-center h-14">
|
||||||
{/* Logo */}
|
|
||||||
<Logo src={logoUrl} storeName={storeName} />
|
<Logo src={logoUrl} storeName={storeName} />
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
||||||
<ShopMenu />
|
<ShopMenu />
|
||||||
{links.map((link, index) => (
|
{links.map((link, index) => (
|
||||||
@@ -114,13 +106,10 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SearchDialog />
|
<SearchDialog />
|
||||||
<AccountMenu />
|
|
||||||
<CartIcon />
|
<CartIcon />
|
||||||
|
|
||||||
{/* Mobile hamburger */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -138,7 +127,6 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="md:hidden bg-background">
|
<div className="md:hidden bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
|
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { RiImageLine } from '@remixicon/react';
|
|
||||||
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
interface OrderHistoryProps {
|
|
||||||
customer: Customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMoney = (amount: string, currencyCode: string) => {
|
|
||||||
const value = parseFloat(amount);
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currencyCode,
|
|
||||||
}).format(value);
|
|
||||||
} catch {
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
|
|
||||||
if (!label) return null;
|
|
||||||
return (
|
|
||||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
{label.replace(/_/g, ' ').toLowerCase()}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
|
|
||||||
const orders: CustomerOrder[] =
|
|
||||||
customer.orders?.edges.map((edge) => edge.node) ?? [];
|
|
||||||
|
|
||||||
// The Storefront API has no standalone order-by-id query for customers, so
|
|
||||||
// the detail is rendered from the order already loaded in this list.
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
|
||||||
orders[0]?.id ?? null
|
|
||||||
);
|
|
||||||
const selected = orders.find((order) => order.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
if (orders.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You haven't placed any orders yet.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
|
|
||||||
{/* List */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{orders.map((order) => {
|
|
||||||
const isSelected = order.id === selectedId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={order.id}>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedId(order.id)}
|
|
||||||
aria-current={isSelected}
|
|
||||||
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
|
|
||||||
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block text-sm font-medium text-foreground">
|
|
||||||
Order #{order.orderNumber}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-muted-foreground">
|
|
||||||
{formatDate(order.processedAt)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
|
|
||||||
{formatMoney(
|
|
||||||
order.currentTotalPrice.amount,
|
|
||||||
order.currentTotalPrice.currencyCode
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail — same screen, no navigation */}
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
{selected && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
|
||||||
<h2 className="text-lg text-foreground">
|
|
||||||
Order #{selected.orderNumber}
|
|
||||||
</h2>
|
|
||||||
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
|
|
||||||
{formatMoney(
|
|
||||||
selected.currentTotalPrice.amount,
|
|
||||||
selected.currentTotalPrice.currencyCode
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Placed {formatDate(selected.processedAt)}
|
|
||||||
</span>
|
|
||||||
<StatusPill label={selected.financialStatus} />
|
|
||||||
<StatusPill label={selected.fulfillmentStatus} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-6 flex flex-col gap-4">
|
|
||||||
{selected.lineItems.edges.map(({ node }, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-x-3">
|
|
||||||
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
|
|
||||||
{node.variant?.image ? (
|
|
||||||
<Image
|
|
||||||
src={node.variant.image.url}
|
|
||||||
alt={node.variant.image.altText || node.title}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
|
||||||
<RiImageLine className="size-5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{node.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Qty {node.quantity}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{selected.statusUrl && (
|
|
||||||
<a
|
|
||||||
href={selected.statusUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
||||||
>
|
|
||||||
View order status
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrderHistory;
|
|
||||||
@@ -18,7 +18,6 @@ const policyBodyEditor: ComponentConfig<PolicyBodyProps> = {
|
|||||||
handle: {
|
handle: {
|
||||||
label: 'Policy',
|
label: 'Policy',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
// Empty follows the `[handle]` route segment on /policies/[handle].
|
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Follow the page URL', value: '' },
|
{ label: 'Follow the page URL', value: '' },
|
||||||
...POLICY_HANDLES.map((handle) => ({
|
...POLICY_HANDLES.map((handle) => ({
|
||||||
@@ -5,20 +5,11 @@ import { useParams } from 'next/navigation';
|
|||||||
import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies';
|
import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies';
|
||||||
|
|
||||||
export interface PolicyBodyProps {
|
export interface PolicyBodyProps {
|
||||||
/**
|
|
||||||
* Pins the block to one policy. Left empty, it reads the `[handle]` segment,
|
|
||||||
* which is what the `/policies/[handle]` template does.
|
|
||||||
*/
|
|
||||||
handle?: string;
|
handle?: string;
|
||||||
/** Overrides the policy's own title. Empty falls back to Shopify's. */
|
|
||||||
title?: string;
|
title?: string;
|
||||||
notFoundMessage?: string;
|
notFoundMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Policy copy is authored in the Shopify admin, not here — the editor only
|
|
||||||
* chooses which policy to show and can override the heading.
|
|
||||||
*/
|
|
||||||
const PolicyBody: React.FC<PolicyBodyProps> = ({
|
const PolicyBody: React.FC<PolicyBodyProps> = ({
|
||||||
handle: handleProp,
|
handle: handleProp,
|
||||||
title: titleProp,
|
title: titleProp,
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
|||||||
href={`/products/${product.handle}`}
|
href={`/products/${product.handle}`}
|
||||||
className="group block h-full"
|
className="group block h-full"
|
||||||
>
|
>
|
||||||
{/* Product Image */}
|
|
||||||
<div className="relative aspect-square overflow-hidden">
|
<div className="relative aspect-square overflow-hidden">
|
||||||
{firstImage ? (
|
{firstImage ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -95,7 +94,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Info */}
|
|
||||||
<div className="flex flex-col flex-1 py-2.5">
|
<div className="flex flex-col flex-1 py-2.5">
|
||||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||||
{truncate(product.title, 65)}
|
{truncate(product.title, 65)}
|
||||||
|
|||||||
-5
@@ -8,11 +8,6 @@ export type ProductDetailBlockProps = {
|
|||||||
addToCartLabel?: string;
|
addToCartLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* On `/products/[handle]` leave `product` empty so the block follows the route;
|
|
||||||
* the picker then only chooses what the editor previews. Pinning a product
|
|
||||||
* turns it into a featured-product block usable on any page.
|
|
||||||
*/
|
|
||||||
const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = {
|
const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = {
|
||||||
label: 'Product page',
|
label: 'Product page',
|
||||||
icon: <Tag size={16} />,
|
icon: <Tag size={16} />,
|
||||||
@@ -6,14 +6,6 @@ import { useProduct, type Product } from '@/hooks/use-shopify-products';
|
|||||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||||
import ProductDetailGallery from './product-detail-gallery';
|
import ProductDetailGallery from './product-detail-gallery';
|
||||||
import ProductDetailInfo from './product-detail-info';
|
import ProductDetailInfo from './product-detail-info';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyContent,
|
|
||||||
} from '@/components/ui/empty';
|
|
||||||
|
|
||||||
interface ProductVariant {
|
interface ProductVariant {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,7 +52,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
const [addingToCart, setAddingToCart] = useState(false);
|
const [addingToCart, setAddingToCart] = useState(false);
|
||||||
const [buyingNow, setBuyingNow] = useState(false);
|
const [buyingNow, setBuyingNow] = useState(false);
|
||||||
|
|
||||||
// Initialize variant when product loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product) {
|
if (product) {
|
||||||
const firstVariant = product.variants.edges[0]?.node;
|
const firstVariant = product.variants.edges[0]?.node;
|
||||||
@@ -78,9 +69,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
}
|
}
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
// A value is available if some in-stock variant carries it alongside the
|
// Unselected options act as wildcards when checking variant availability.
|
||||||
// other currently-selected options. Options the shopper hasn't chosen yet
|
|
||||||
// act as wildcards, so nothing is struck through before a full selection.
|
|
||||||
const isOptionValueAvailable = (optionName: string, value: string) => {
|
const isOptionValueAvailable = (optionName: string, value: string) => {
|
||||||
const variants = product?.variants.edges ?? [];
|
const variants = product?.variants.edges ?? [];
|
||||||
if (variants.length === 0) return true;
|
if (variants.length === 0) return true;
|
||||||
@@ -100,7 +89,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
const newOptions = { ...selectedOptions, [optionName]: value };
|
const newOptions = { ...selectedOptions, [optionName]: value };
|
||||||
setSelectedOptions(newOptions);
|
setSelectedOptions(newOptions);
|
||||||
|
|
||||||
// Find matching variant
|
|
||||||
const matchingVariant = product?.variants.edges.find(({ node }) => {
|
const matchingVariant = product?.variants.edges.find(({ node }) => {
|
||||||
return node.selectedOptions.every(
|
return node.selectedOptions.every(
|
||||||
(option) => newOptions[option.name] === option.value
|
(option) => newOptions[option.name] === option.value
|
||||||
@@ -126,8 +114,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adds the item, then sends the shopper straight to the Shopify checkout
|
|
||||||
// (where Shop Pay is offered) rather than opening the cart drawer.
|
|
||||||
const handleBuyNow = async () => {
|
const handleBuyNow = async () => {
|
||||||
if (!selectedVariant || !product) return;
|
if (!selectedVariant || !product) return;
|
||||||
|
|
||||||
@@ -148,11 +134,12 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
// A missing or failed product keeps the skeletons up rather than showing an
|
||||||
|
// empty state, so the page stays presentable while a handle is being chosen.
|
||||||
|
if (loading || error || !product) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
|
||||||
{/* Image Gallery Skeleton */}
|
|
||||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -162,7 +149,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Info Skeleton */}
|
|
||||||
<div className="lg:col-span-2 animate-pulse">
|
<div className="lg:col-span-2 animate-pulse">
|
||||||
<div className="h-8 bg-zinc-100 w-2/3"></div>
|
<div className="h-8 bg-zinc-100 w-2/3"></div>
|
||||||
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
|
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
|
||||||
@@ -176,26 +162,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !product) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<Empty className="min-h-[400px]">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>Product Not Found</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
{error || 'The requested product could not be found.'}
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
<EmptyContent>
|
|
||||||
<Button onClick={() => window.history.back()} variant="outline">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</EmptyContent>
|
|
||||||
</Empty>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background">
|
<div className="bg-background">
|
||||||
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
|
|
||||||
const close = useCallback(() => setZoomedIndex(null), []);
|
const close = useCallback(() => setZoomedIndex(null), []);
|
||||||
|
|
||||||
// The slide whose left edge sits closest to the scroller's left edge is the
|
|
||||||
// one in view. Measuring rects keeps this correct whatever the gap or width.
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
@@ -47,8 +45,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
setActiveIndex(nearest);
|
setActiveIndex(nearest);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Touch already scrolls natively; this adds click-and-drag for pointers that
|
// Add pointer dragging without disrupting native touch scrolling.
|
||||||
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
|
|
||||||
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
|
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
|
||||||
|
|
||||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -74,7 +71,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
if (!drag.current || !scroller) return;
|
if (!drag.current || !scroller) return;
|
||||||
|
|
||||||
drag.current = null;
|
drag.current = null;
|
||||||
// Restoring snap lets the browser settle on the nearest slide.
|
|
||||||
scroller.style.scrollSnapType = '';
|
scroller.style.scrollSnapType = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +84,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
|
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close on Escape, and keep the page behind the overlay from scrolling.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isZoomed) return;
|
if (!isZoomed) return;
|
||||||
|
|
||||||
@@ -119,7 +114,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Swipeable carousel on mobile, grid from sm up */}
|
|
||||||
<div
|
<div
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
@@ -152,7 +146,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel pagination — the grid needs no dots, so mobile only */}
|
|
||||||
{!isSingle && (
|
{!isSingle && (
|
||||||
<div className="flex justify-center gap-2 pt-4 sm:hidden">
|
<div className="flex justify-center gap-2 pt-4 sm:hidden">
|
||||||
{images.map((_, index) => (
|
{images.map((_, index) => (
|
||||||
@@ -182,15 +175,10 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
src={zoomedImage.url}
|
src={zoomedImage.url}
|
||||||
alt={zoomedImage.altText || 'Product image'}
|
alt={zoomedImage.altText || 'Product image'}
|
||||||
// Shopify gives us the intrinsic size; the square fallback only
|
|
||||||
// reserves space until CSS scales it down to fit the overlay.
|
|
||||||
width={zoomedImage.width || 1600}
|
width={zoomedImage.width || 1600}
|
||||||
height={zoomedImage.height || 1600}
|
height={zoomedImage.height || 1600}
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
// w/h-auto keeps the box at the image's own ratio; without it the
|
|
||||||
// width+height attributes make both axes definite and the element
|
|
||||||
// stretches to the overlay, swallowing backdrop clicks that close it.
|
|
||||||
className="max-h-full max-w-full w-auto h-auto object-contain"
|
className="max-h-full max-w-full w-auto h-auto object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RiSubtractLine, RiAddLine } from '@remixicon/react';
|
|||||||
import ShopPayButton from '@/components/shopify/shop-pay-button';
|
import ShopPayButton from '@/components/shopify/shop-pay-button';
|
||||||
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
|
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
|
||||||
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
|
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
|
||||||
import { isDefaultTitleOption } from '@/services/shopify/catalog';
|
import { isDefaultTitleOption } from '@/services/shopify/shop';
|
||||||
|
|
||||||
interface ProductPrice {
|
interface ProductPrice {
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -52,15 +52,12 @@ interface ProductDetailInfoProps {
|
|||||||
handleAddToCart: () => void;
|
handleAddToCart: () => void;
|
||||||
handleBuyNow?: () => void;
|
handleBuyNow?: () => void;
|
||||||
onOptionChange: (optionName: string, value: string) => void;
|
onOptionChange: (optionName: string, value: string) => void;
|
||||||
/** Whether an option value still has an in-stock variant behind it. */
|
|
||||||
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
|
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
buyingNow?: boolean;
|
buyingNow?: boolean;
|
||||||
addToCartLabel?: string;
|
addToCartLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A swatch comes from the option value's own swatch (colour or image) or the
|
|
||||||
// colour its name implies (see config/swatches) — never a variant photo.
|
|
||||||
const swatchStyle = (
|
const swatchStyle = (
|
||||||
value: ProductOptionValue
|
value: ProductOptionValue
|
||||||
): { background?: string; image?: string } => {
|
): { background?: string; image?: string } => {
|
||||||
@@ -103,13 +100,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
const isSwatchOption = (option: ProductOption) =>
|
const isSwatchOption = (option: ProductOption) =>
|
||||||
isSwatchOptionName(option.name);
|
isSwatchOptionName(option.name);
|
||||||
|
|
||||||
// Some products return Size before Color; show the swatches first either way.
|
// Show swatches first and omit Shopify's synthetic single-SKU option.
|
||||||
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
|
|
||||||
const orderedOptions = [...(product.options ?? [])]
|
const orderedOptions = [...(product.options ?? [])]
|
||||||
.filter((option) => !isDefaultTitleOption(option))
|
.filter((option) => !isDefaultTitleOption(option))
|
||||||
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
|
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
|
||||||
|
|
||||||
// `optionValues` carries the swatch data; fall back to plain `values`.
|
|
||||||
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
|
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
|
||||||
option.optionValues?.length
|
option.optionValues?.length
|
||||||
? option.optionValues
|
? option.optionValues
|
||||||
@@ -131,7 +126,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Options — colour swatches lead, whatever order the API returns */}
|
|
||||||
{orderedOptions.map((option) => {
|
{orderedOptions.map((option) => {
|
||||||
const isSwatch = isSwatchOption(option);
|
const isSwatch = isSwatchOption(option);
|
||||||
const selected = selectedOptions[option.name];
|
const selected = selectedOptions[option.name];
|
||||||
@@ -172,12 +166,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||||
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
|
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
// With no colour and no image the circle would be fully
|
|
||||||
// transparent, leaving just a hairline ring that
|
|
||||||
// antialiases unevenly and reads as a speckled border.
|
|
||||||
// A neutral fill makes the initial-letter fallback look
|
|
||||||
// deliberate. Set inline so it beats the ghost variant's
|
|
||||||
// hover background.
|
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
background ??
|
background ??
|
||||||
(image ? undefined : 'var(--color-muted)'),
|
(image ? undefined : 'var(--color-muted)'),
|
||||||
@@ -215,7 +203,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Quantity + Add to Cart */}
|
|
||||||
<div className="mt-8 flex items-stretch gap-3">
|
<div className="mt-8 flex items-stretch gap-3">
|
||||||
<div className="flex items-center rounded-md border border-border h-11">
|
<div className="flex items-center rounded-md border border-border h-11">
|
||||||
<Button
|
<Button
|
||||||
@@ -252,7 +239,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
|
|
||||||
<ShopPayButton
|
<ShopPayButton
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
variants={
|
variants={
|
||||||
@@ -263,7 +249,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|||||||
onFallbackClick={handleBuyNow}
|
onFallbackClick={handleBuyNow}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{(product.descriptionHtml || product.description) && (
|
{(product.descriptionHtml || product.description) && (
|
||||||
<div className="mt-10 text-sm leading-6 text-foreground product-description">
|
<div className="mt-10 text-sm leading-6 text-foreground product-description">
|
||||||
{product.descriptionHtml ? (
|
{product.descriptionHtml ? (
|
||||||
|
|||||||
@@ -12,13 +12,10 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
||||||
import { swatchColorForName } from '@/config/swatches';
|
import { swatchColorForName } from '@/config/swatches';
|
||||||
// Shape of a Storefront facet — identical for `search.productFilters` and
|
|
||||||
// `collection.products.filters`, so both pages share this panel.
|
|
||||||
export interface ProductFilterValue {
|
export interface ProductFilterValue {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
count: number;
|
||||||
/** JSON string accepted back as a `ProductFilter` input. */
|
|
||||||
input: string;
|
input: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ interface ProductFiltersProps {
|
|||||||
onActiveFiltersChange: (filters: string[]) => void;
|
onActiveFiltersChange: (filters: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colour facets render as swatches; everything else as a labelled list.
|
|
||||||
const isColorFilter = (filter: ProductFilterFacet) =>
|
const isColorFilter = (filter: ProductFilterFacet) =>
|
||||||
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
||||||
|
|
||||||
@@ -55,7 +51,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
|
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
|
||||||
|
|
||||||
const activeSet = new Set(activeFilters);
|
const activeSet = new Set(activeFilters);
|
||||||
// Price is rebuilt from the inputs rather than toggled, so track it apart.
|
|
||||||
const activePriceInput = activeFilters.find((input) =>
|
const activePriceInput = activeFilters.find((input) =>
|
||||||
input.includes('"price"')
|
input.includes('"price"')
|
||||||
);
|
);
|
||||||
@@ -93,7 +88,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
onActiveFiltersChange([]);
|
onActiveFiltersChange([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Labels for the chips at the top of the panel.
|
|
||||||
const activeChips = filters
|
const activeChips = filters
|
||||||
.flatMap((filter) => filter.values)
|
.flatMap((filter) => filter.values)
|
||||||
.filter((value) => activeSet.has(value.input));
|
.filter((value) => activeSet.has(value.input));
|
||||||
@@ -118,7 +112,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<SheetBody className="px-5">
|
<SheetBody className="px-5">
|
||||||
{/* Active selections */}
|
|
||||||
{(activeChips.length > 0 || activePriceInput) && (
|
{(activeChips.length > 0 || activePriceInput) && (
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||||
{activeChips.map((value) => (
|
{activeChips.map((value) => (
|
||||||
@@ -147,7 +140,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
{priceFilter && (
|
{priceFilter && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="mb-3 text-base text-foreground">Price</h3>
|
<h3 className="mb-3 text-base text-foreground">Price</h3>
|
||||||
@@ -183,7 +175,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Facets */}
|
|
||||||
{listFilters.map((filter) => (
|
{listFilters.map((filter) => (
|
||||||
<div key={filter.id} className="mb-8">
|
<div key={filter.id} className="mb-8">
|
||||||
<h3 className="mb-3 text-base text-foreground">
|
<h3 className="mb-3 text-base text-foreground">
|
||||||
@@ -209,9 +200,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
|||||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
// A colourless swatch would otherwise be a fully
|
|
||||||
// transparent circle behind a hairline ring,
|
|
||||||
// which antialiases into a speckled border.
|
|
||||||
backgroundColor: color ?? 'var(--color-muted)',
|
backgroundColor: color ?? 'var(--color-muted)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
-1
@@ -23,7 +23,6 @@ const productRecommendationsEditor: ComponentConfig<ProductRecommendationsBlockP
|
|||||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||||
product: {
|
product: {
|
||||||
label: 'Seed product',
|
label: 'Seed product',
|
||||||
// Empty follows the `[handle]` route segment; Shopify picks the rest.
|
|
||||||
type: 'shopifyProduct',
|
type: 'shopifyProduct',
|
||||||
} as any,
|
} as any,
|
||||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
|
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
|
||||||
@@ -10,10 +10,6 @@ import ProductCard from './product-card';
|
|||||||
|
|
||||||
interface ProductRecommendationsProps {
|
interface ProductRecommendationsProps {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
/**
|
|
||||||
* Seeds recommendations from a specific product. Left empty, it reads the
|
|
||||||
* `[handle]` segment, which is what the `/products/[handle]` template does.
|
|
||||||
*/
|
|
||||||
handle?: string;
|
handle?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ interface ProductToolbarProps {
|
|||||||
activeFilterCount?: number;
|
activeFilterCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters trigger on the left, item count and sort menu on the right — shared
|
|
||||||
// by the search results and collection pages.
|
|
||||||
const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
||||||
totalCount,
|
totalCount,
|
||||||
onOpenFilters,
|
onOpenFilters,
|
||||||
@@ -69,7 +67,6 @@ const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
|||||||
|
|
||||||
{sortOpen && (
|
{sortOpen && (
|
||||||
<>
|
<>
|
||||||
{/* Click-away layer sits under the menu, above the page. */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40"
|
className="fixed inset-0 z-40"
|
||||||
onClick={() => setSortOpen(false)}
|
onClick={() => setSortOpen(false)}
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ export type ProductsBlockProps = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Column counts, gutters and the card design are fixed in `products.tsx`. The
|
|
||||||
* editor picks *which* products appear and what the section says about them.
|
|
||||||
*/
|
|
||||||
const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
||||||
label: 'Product grid',
|
label: 'Product grid',
|
||||||
icon: <LayoutGrid size={16} />,
|
icon: <LayoutGrid size={16} />,
|
||||||
@@ -30,8 +26,6 @@ const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
|||||||
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
|
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
|
||||||
collection: {
|
collection: {
|
||||||
label: 'Collection',
|
label: 'Collection',
|
||||||
// Registered by createShopifyPlugin — a live search against the store.
|
|
||||||
// Leave empty to show the newest products across the whole catalogue.
|
|
||||||
type: 'shopifyCollection',
|
type: 'shopifyCollection',
|
||||||
} as any,
|
} as any,
|
||||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 48 },
|
limit: { label: 'Products shown', type: 'number', min: 2, max: 48 },
|
||||||
@@ -52,10 +52,6 @@ interface ProductsProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
showLoadMore?: boolean;
|
showLoadMore?: boolean;
|
||||||
/**
|
|
||||||
* Narrows the grid to one collection. Empty shows the newest products across
|
|
||||||
* the whole catalogue, which is what the home page does.
|
|
||||||
*/
|
|
||||||
collectionHandle?: string;
|
collectionHandle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +69,6 @@ const Products: React.FC<ProductsProps> = ({
|
|||||||
const [hasMoreProducts, setHasMoreProducts] = useState(true);
|
const [hasMoreProducts, setHasMoreProducts] = useState(true);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
|
||||||
// Paging is cursor-based: without `after`, Shopify returns the same first
|
|
||||||
// page every time and "load more" appends nothing.
|
|
||||||
const fetchProducts = async (loadMore = false) => {
|
const fetchProducts = async (loadMore = false) => {
|
||||||
try {
|
try {
|
||||||
if (loadMore) {
|
if (loadMore) {
|
||||||
@@ -84,8 +78,6 @@ const Products: React.FC<ProductsProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pinned collection uses the collection query so its own ordering
|
|
||||||
// applies; otherwise fall back to newest-first across the catalogue.
|
|
||||||
const page = collectionHandle
|
const page = collectionHandle
|
||||||
? await getCollectionProductsPage(collectionHandle, {
|
? await getCollectionProductsPage(collectionHandle, {
|
||||||
first: limit,
|
first: limit,
|
||||||
|
|||||||
+3
-4
@@ -4,21 +4,20 @@ import SearchResults from '@/components/shopify/search-results';
|
|||||||
|
|
||||||
export type SearchResultsBlockProps = {
|
export type SearchResultsBlockProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The result set comes from the `?q=` param and the shopper's own filter and
|
|
||||||
* sort choices, so the heading is the only thing left for the editor to own.
|
|
||||||
*/
|
|
||||||
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
||||||
label: 'Search results',
|
label: 'Search results',
|
||||||
icon: <Search size={16} />,
|
icon: <Search size={16} />,
|
||||||
category: 'commerce',
|
category: 'commerce',
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
|
placeholder: 'Search products',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||||
|
placeholder: { label: 'Search placeholder', type: 'text' },
|
||||||
},
|
},
|
||||||
render: (props) => <SearchResults {...props} />,
|
render: (props) => <SearchResults {...props} />,
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import ProductCard from './product-card';
|
import ProductCard from './product-card';
|
||||||
import ProductFilters from './product-filters';
|
import ProductFilters from './product-filters';
|
||||||
import ProductToolbar from './product-toolbar';
|
import ProductToolbar from './product-toolbar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Loader } from '@/components/ui/loader';
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
|
||||||
import {
|
import {
|
||||||
searchProducts,
|
searchProducts,
|
||||||
type SearchFilter,
|
type SearchFilter,
|
||||||
@@ -30,11 +32,17 @@ const SORT_OPTIONS: SortOption[] = [
|
|||||||
|
|
||||||
interface SearchResultsProps {
|
interface SearchResultsProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
const SearchResults: React.FC<SearchResultsProps> = ({
|
||||||
|
title = 'Search',
|
||||||
|
placeholder = 'Search products',
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const query = searchParams.get('q') ?? '';
|
const query = searchParams.get('q') ?? '';
|
||||||
|
const [term, setTerm] = useState(query);
|
||||||
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
||||||
@@ -50,9 +58,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
const sort = SORT_OPTIONS[sortIndex];
|
const sort = SORT_OPTIONS[sortIndex];
|
||||||
// Serialised so the effect re-runs when the selection changes, not the array.
|
// Stabilize the effect dependency by filter contents.
|
||||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||||
|
|
||||||
|
// Keep the field in step with the URL, which also changes from the header
|
||||||
|
// search dialog and from back/forward navigation.
|
||||||
|
useEffect(() => {
|
||||||
|
setTerm(query);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const submitSearch = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = term.trim();
|
||||||
|
if (next === query) return;
|
||||||
|
router.push(next ? `/search?q=${encodeURIComponent(next)}` : '/search');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setTerm('');
|
||||||
|
if (query) router.push('/search');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -62,8 +88,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const result = await searchProducts({
|
const result = await searchProducts({
|
||||||
// An empty term still returns the catalogue, which is what an
|
|
||||||
// unqualified /search visit should show.
|
|
||||||
query,
|
query,
|
||||||
first: PAGE_SIZE,
|
first: PAGE_SIZE,
|
||||||
sortKey: sort.sortKey,
|
sortKey: sort.sortKey,
|
||||||
@@ -77,8 +101,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
setTotalCount(result.totalCount);
|
setTotalCount(result.totalCount);
|
||||||
setCursor(result.endCursor);
|
setCursor(result.endCursor);
|
||||||
setHasNextPage(result.hasNextPage);
|
setHasNextPage(result.hasNextPage);
|
||||||
// Facet counts change with the result set, but keep the panel stable
|
// Do not remove active facet choices as result counts change.
|
||||||
// while filters are applied so options don't vanish mid-selection.
|
|
||||||
if (activeFilters.length === 0) setFilters(result.filters);
|
if (activeFilters.length === 0) setFilters(result.filters);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -129,7 +152,34 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-6">
|
<form onSubmit={submitSearch} className="mt-6 max-w-xl">
|
||||||
|
<div className="relative">
|
||||||
|
<RiSearchLine
|
||||||
|
size={18}
|
||||||
|
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={term}
|
||||||
|
onChange={(event) => setTerm(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label="Search products"
|
||||||
|
className="h-11 rounded-none border-0 border-b border-border bg-transparent pl-7 pr-8 shadow-none focus-visible:border-foreground focus-visible:ring-0 dark:bg-transparent"
|
||||||
|
/>
|
||||||
|
{term && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RiCloseLine size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
<ProductToolbar
|
<ProductToolbar
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onOpenFilters={() => setFiltersOpen(true)}
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
@@ -140,7 +190,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
interface ShopMenuProps {
|
interface ShopMenuProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Renders inline inside the mobile menu instead of as a floating panel. */
|
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
/** Fires after a collection is picked, so the mobile menu can close itself. */
|
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { collections, loading, error, load } = useCollectionsOnDemand();
|
const { collections, loading, error, load } = useCollectionsOnDemand();
|
||||||
|
|
||||||
// The collection list is only worth fetching once someone opens the menu.
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const next = !open;
|
const next = !open;
|
||||||
setOpen(next);
|
setOpen(next);
|
||||||
@@ -138,7 +135,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Catches the click that dismisses the panel. */}
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
|
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ interface ShopPayButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Used when no permalink can be built (missing domain or unusable IDs). */
|
|
||||||
onFallbackClick?: () => void;
|
onFallbackClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
|
// Cart permalinks require numeric IDs rather than Storefront GIDs.
|
||||||
function toNumericVariantId(id: string): string | null {
|
function toNumericVariantId(id: string): string | null {
|
||||||
const trimmed = id.trim();
|
const trimmed = id.trim();
|
||||||
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
||||||
@@ -38,8 +37,6 @@ function toStoreUrl(domain?: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
|
|
||||||
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
|
|
||||||
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
|
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
|
||||||
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
||||||
if (!storeUrl || variants.length === 0) return null;
|
if (!storeUrl || variants.length === 0) return null;
|
||||||
@@ -72,8 +69,6 @@ const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// An anchor keeps the checkout URL visible, openable in a new tab, and
|
|
||||||
// navigable without JS; the button only stands in when there's no URL.
|
|
||||||
if (shopPayUrl && !disabled) {
|
if (shopPayUrl && !disabled) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
|
|
||||||
// data, so the button needs no additional text beyond a screen-reader label.
|
|
||||||
const ShopPayLogo: React.FC<{ className?: string }> = ({
|
const ShopPayLogo: React.FC<{ className?: string }> = ({
|
||||||
className = 'h-auto w-[98px]',
|
className = 'h-auto w-[98px]',
|
||||||
}) => (
|
}) => (
|
||||||
|
|||||||
-5
@@ -4,11 +4,6 @@ import StoreAssistant from '@/components/shopify/store-assistant';
|
|||||||
|
|
||||||
export type StoreAssistantBlockProps = Record<string, never>;
|
export type StoreAssistantBlockProps = Record<string, never>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The floating AI shopping assistant. It has no editable copy — its prompts and
|
|
||||||
* suggestions come from `/api/chat` — so it is registered purely so a page can
|
|
||||||
* choose whether to carry it.
|
|
||||||
*/
|
|
||||||
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
|
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
|
||||||
label: 'Store assistant',
|
label: 'Store assistant',
|
||||||
icon: <MessageCircle size={16} />,
|
icon: <MessageCircle size={16} />,
|
||||||
@@ -64,8 +64,6 @@ import {
|
|||||||
RiShoppingBag3Line,
|
RiShoppingBag3Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
|
|
||||||
// Feature flag. Written as a static member expression so Next inlines it at
|
|
||||||
// build time; the assistant is off unless the env var is explicitly "1".
|
|
||||||
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
|
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
|
||||||
|
|
||||||
const SUGGESTIONS = [
|
const SUGGESTIONS = [
|
||||||
@@ -101,8 +99,6 @@ const toolName = (type: string) =>
|
|||||||
const plural = (count: number, noun: string) =>
|
const plural = (count: number, noun: string) =>
|
||||||
`${count} ${noun}${count === 1 ? '' : 's'}`;
|
`${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
// Turns a finished tool result into the one-line summary plus any products
|
|
||||||
// worth previewing.
|
|
||||||
const summariseTool = (
|
const summariseTool = (
|
||||||
name: string,
|
name: string,
|
||||||
output: Record<string, unknown> | undefined
|
output: Record<string, unknown> | undefined
|
||||||
@@ -166,7 +162,6 @@ const summariseTool = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
|
|
||||||
const isInternalLink = (url: string) => {
|
const isInternalLink = (url: string) => {
|
||||||
if (url.startsWith('/')) return true;
|
if (url.startsWith('/')) return true;
|
||||||
try {
|
try {
|
||||||
@@ -229,7 +224,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
|||||||
<AttachmentHoverCard key={attachment.id}>
|
<AttachmentHoverCard key={attachment.id}>
|
||||||
<AttachmentHoverCardTrigger asChild>
|
<AttachmentHoverCardTrigger asChild>
|
||||||
<Attachment data={attachment} onRemove={handleRemove}>
|
<Attachment data={attachment} onRemove={handleRemove}>
|
||||||
{/* Thumbnail swaps to the remove button on hover. */}
|
|
||||||
<div className="group relative size-5 shrink-0">
|
<div className="group relative size-5 shrink-0">
|
||||||
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
|
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
|
||||||
<AttachmentPreview />
|
<AttachmentPreview />
|
||||||
@@ -270,7 +264,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
|||||||
|
|
||||||
AttachmentItem.displayName = 'AttachmentItem';
|
AttachmentItem.displayName = 'AttachmentItem';
|
||||||
|
|
||||||
// Pending uploads, shown inline above the textarea.
|
|
||||||
const PromptInputAttachmentsDisplay = () => {
|
const PromptInputAttachmentsDisplay = () => {
|
||||||
const attachments = usePromptInputAttachments();
|
const attachments = usePromptInputAttachments();
|
||||||
|
|
||||||
@@ -295,15 +288,12 @@ const PromptInputAttachmentsDisplay = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StoreAssistant: React.FC = () => {
|
const StoreAssistant: React.FC = () => {
|
||||||
// Returns before any hooks run — safe because the flag is a build-time
|
|
||||||
// constant and cannot change between renders.
|
|
||||||
if (!AI_ENABLED) return null;
|
if (!AI_ENABLED) return null;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
// Drives the launcher's slide-in on first paint.
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
const { messages, sendMessage, status, error } = useChat({
|
const { messages, sendMessage, status, error } = useChat({
|
||||||
@@ -323,7 +313,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attachments arrive on the submitted message, so images go out with it.
|
|
||||||
const handleSubmit = (message: PromptInputMessage) => {
|
const handleSubmit = (message: PromptInputMessage) => {
|
||||||
const text = (message.text ?? input).trim();
|
const text = (message.text ?? input).trim();
|
||||||
const files = message.files ?? [];
|
const files = message.files ?? [];
|
||||||
@@ -335,7 +324,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Popover panel, anchored above the launcher */}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
|
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
|
||||||
@@ -365,8 +353,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
title="Ask about the store"
|
title="Ask about the store"
|
||||||
description="Find products, compare options, browse collections."
|
description="Find products, compare options, browse collections."
|
||||||
>
|
>
|
||||||
{/* w-full + wrap so chips stack in the narrow popover rather
|
|
||||||
than scrolling off the edge. */}
|
|
||||||
<Suggestions className="mt-3 w-full flex-wrap justify-center">
|
<Suggestions className="mt-3 w-full flex-wrap justify-center">
|
||||||
{SUGGESTIONS.map((suggestion) => (
|
{SUGGESTIONS.map((suggestion) => (
|
||||||
<Suggestion
|
<Suggestion
|
||||||
@@ -393,8 +379,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MessageResponse
|
<MessageResponse
|
||||||
key={index}
|
key={index}
|
||||||
// Only repair half-written markdown while it streams;
|
|
||||||
// settled text is rendered exactly as sent.
|
|
||||||
isAnimating={
|
isAnimating={
|
||||||
status === 'streaming' && part.state === 'streaming'
|
status === 'streaming' && part.state === 'streaming'
|
||||||
}
|
}
|
||||||
@@ -432,8 +416,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
};
|
};
|
||||||
const name = toolName(part.type);
|
const name = toolName(part.type);
|
||||||
|
|
||||||
// Shimmer while the call is in flight; a quiet summary
|
|
||||||
// line once it returns.
|
|
||||||
if (
|
if (
|
||||||
toolPart.state === 'input-streaming' ||
|
toolPart.state === 'input-streaming' ||
|
||||||
toolPart.state === 'input-available'
|
toolPart.state === 'input-available'
|
||||||
@@ -472,7 +454,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Images the shopper attached, shown under their message. */}
|
|
||||||
{fileParts.length > 0 && (
|
{fileParts.length > 0 && (
|
||||||
<Attachments variant="grid" className="ml-0 justify-start">
|
<Attachments variant="grid" className="ml-0 justify-start">
|
||||||
{fileParts.map((part, index) => (
|
{fileParts.map((part, index) => (
|
||||||
@@ -544,9 +525,6 @@ const StoreAssistant: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Launcher */}
|
|
||||||
{/* Rainbow treatment only while the assistant is open; otherwise the
|
|
||||||
launcher matches the rest of the site's buttons. */}
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<RainbowButton
|
<RainbowButton
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ function CommandDialog({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
// Forwarded to the inner Command so callers can drive filtering themselves
|
|
||||||
// (e.g. results already filtered server-side).
|
|
||||||
shouldFilter,
|
shouldFilter,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
@@ -56,7 +54,6 @@ function CommandDialog({
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden p-0",
|
"overflow-hidden p-0",
|
||||||
// No fade/zoom on the command palette — it should appear instantly.
|
|
||||||
"transition-none animate-none duration-0",
|
"transition-none animate-none duration-0",
|
||||||
"data-[state=closed]:animate-none data-[state=open]:animate-none",
|
"data-[state=closed]:animate-none data-[state=open]:animate-none",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -17,16 +17,13 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
|
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
|
||||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
// Variants based on alignment.
|
|
||||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
// Focus state.
|
|
||||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
|
||||||
|
|
||||||
// Error state.
|
|
||||||
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Utility function to combine classNames
|
|
||||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item variants helper
|
|
||||||
function getItemVariants(
|
function getItemVariants(
|
||||||
variant: 'default' | 'outline' | 'muted',
|
variant: 'default' | 'outline' | 'muted',
|
||||||
size: 'default' | 'sm'
|
size: 'default' | 'sm'
|
||||||
@@ -27,7 +25,6 @@ function getItemVariants(
|
|||||||
return cn(baseStyles, variantStyles[variant], sizeStyles[size]);
|
return cn(baseStyles, variantStyles[variant], sizeStyles[size]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item media variants helper
|
|
||||||
function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string {
|
function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string {
|
||||||
const baseStyles =
|
const baseStyles =
|
||||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5';
|
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Utility function to combine classNames
|
|
||||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ const RemixIcon: React.FC<RemixIconProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
||||||
// Normalise: accept "RiTruckLine", "ri-truck-line", or "riTruckLine"
|
// Accept component, kebab-case, or camelCase icon names.
|
||||||
const normalised = name
|
const normalised = name
|
||||||
// ri-truck-line → RiTruckLine
|
|
||||||
.replace(/^ri-/, 'Ri')
|
.replace(/^ri-/, 'Ri')
|
||||||
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
||||||
// ensure first char is uppercase
|
|
||||||
.replace(/^./, (c) => c.toUpperCase());
|
.replace(/^./, (c) => c.toUpperCase());
|
||||||
|
|
||||||
const IconComponent = (
|
const IconComponent = (
|
||||||
|
|||||||
+1
-14
@@ -1,14 +1,5 @@
|
|||||||
import { RootConfig } from '@reacteditor/core';
|
import { RootConfig } from '@reacteditor/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Root props are the page's *content*, not its design. Colours, fonts, radius
|
|
||||||
* and spacing all live in `app/globals.css` and in the components themselves —
|
|
||||||
* the editor only owns the words and the imagery. See `editor.config.tsx` for
|
|
||||||
* the same rule applied to every block.
|
|
||||||
*
|
|
||||||
* The field names below are the ones `pageMetadata()` reads, so editing a page
|
|
||||||
* in the editor is what changes its `<head>` tags.
|
|
||||||
*/
|
|
||||||
export type RootProps = {
|
export type RootProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -16,10 +7,7 @@ export type RootProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Root: RootConfig<{ props: RootProps }> = {
|
export const Root: RootConfig<{ props: RootProps }> = {
|
||||||
// Root props default to being shared across pages. These are per-page SEO
|
// SEO fields belong to each page rather than the global block store.
|
||||||
// tags — `pageMetadata()` reads them straight off each `page.json` — so
|
|
||||||
// opting out keeps every route's title and description its own. The header
|
|
||||||
// and footer blocks are the things that stay global.
|
|
||||||
global: false,
|
global: false,
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
title: 'Shop',
|
title: 'Shop',
|
||||||
@@ -31,7 +19,6 @@ export const Root: RootConfig<{ props: RootProps }> = {
|
|||||||
description: { label: 'Meta description', type: 'textarea' },
|
description: { label: 'Meta description', type: 'textarea' },
|
||||||
ogImage: { label: 'Social share image', type: 'image' },
|
ogImage: { label: 'Social share image', type: 'image' },
|
||||||
},
|
},
|
||||||
// No wrapper: the storefront's own layout and stylesheet own the chrome.
|
|
||||||
render: ({ children }) => <>{children}</>,
|
render: ({ children }) => <>{children}</>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
/**
|
|
||||||
* Store-wide constants that aren't page content.
|
|
||||||
*
|
|
||||||
* Nav links, the logo and the footer used to live here; they're editor fields
|
|
||||||
* on the header/footer blocks now, so a merchant changes them in the editor
|
|
||||||
* rather than in code. What's left is the metadata fallback for routes that
|
|
||||||
* haven't set their own.
|
|
||||||
*/
|
|
||||||
export const site = {
|
export const site = {
|
||||||
storeName: 'Shop',
|
storeName: 'Shop',
|
||||||
description:
|
description:
|
||||||
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
|
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
|
||||||
// Absolute origin behind `metadataBase`, so Open Graph images resolve to full
|
|
||||||
// URLs. Vercel injects VERCEL_PROJECT_PRODUCTION_URL on deployed builds.
|
|
||||||
url:
|
url:
|
||||||
process.env.NEXT_PUBLIC_SITE_URL ??
|
process.env.NEXT_PUBLIC_SITE_URL ??
|
||||||
(process.env.VERCEL_PROJECT_PRODUCTION_URL
|
(process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
// Swatch configuration for product option pickers.
|
|
||||||
//
|
|
||||||
// Shopify exposes a `swatch` on each option value, but plenty of products ship
|
|
||||||
// without one configured. These settings decide which options render as colour
|
|
||||||
// circles and what colour to use when the API doesn't supply one.
|
|
||||||
|
|
||||||
// Option names (case-insensitive) rendered as colour circles instead of pills.
|
|
||||||
export const SWATCH_OPTION_NAMES = ['color', 'colour'];
|
export const SWATCH_OPTION_NAMES = ['color', 'colour'];
|
||||||
|
|
||||||
// Fallback colours, keyed by lowercased option value name. The first group
|
|
||||||
// mirrors hexes the store defines on other products, so a product missing its
|
|
||||||
// swatches still matches the ones that have them. Add your own names here.
|
|
||||||
export const SWATCH_COLORS: Record<string, string> = {
|
export const SWATCH_COLORS: Record<string, string> = {
|
||||||
clay: '#5a4b3c',
|
clay: '#5a4b3c',
|
||||||
green: '#81a69b',
|
green: '#81a69b',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user