Load page.json by import, publish via fs, drop cookie auth

The editor no longer round-trips through /api/pages. Each route's
editor/page.tsx imports its own `../page.json` and hands it to
PageEditor as a prop, so the editor opens with the page already in
hand — no fetch, no loading state, no undo history seeded from a
placeholder. Globals still come from app.globals.json.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STGDvL4X7FhHHnxdRE2ayo
This commit is contained in:
Rami Bitar
2026-08-09 16:36:18 -04:00
co-authored by Claude Opus 5
parent 63ecc5e284
commit f11a764426
51 changed files with 129 additions and 2080 deletions
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /about. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/about" />;
return <PageEditor routeKey="/about" 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} />;
}
-7
View File
@@ -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" />;
}
-7
View File
@@ -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" />;
}
-52
View File
@@ -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"
}
}
]
}
-9
View File
@@ -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} />;
}
-41
View File
@@ -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"
}
}
]
}
-16
View File
@@ -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} />;
}
-7
View File
@@ -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" />;
}
-48
View File
@@ -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"
}
}
]
}
-9
View File
@@ -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} />;
}
-7
View File
@@ -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" />;
}
-48
View File
@@ -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"
}
}
]
}
-9
View File
@@ -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]" />;
}
-43
View File
@@ -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"
}
}
]
}
-9
View File
@@ -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} />;
}
-30
View File
@@ -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 });
}
-31
View File
@@ -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 });
}
-10
View File
@@ -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 });
}
-19
View File
@@ -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,
},
});
}
-26
View File
@@ -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 });
}
-10
View File
@@ -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 });
}
-39
View File
@@ -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 });
}
-27
View File
@@ -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 });
}
-103
View File
@@ -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 }
);
}
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections/[handle]" />;
return <PageEditor routeKey="/collections/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/collections" />;
return <PageEditor routeKey="/collections" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/" />;
return <PageEditor routeKey="/" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/policies/[handle]" />;
return <PageEditor routeKey="/policies/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /products/[handle]. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/products/[handle]" />;
return <PageEditor routeKey="/products/[handle]" page={pageData} />;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /search. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() {
return <PageEditor routeKey="/search" />;
return <PageEditor routeKey="/search" page={pageData} />;
}
+25 -57
View File
@@ -1,13 +1,14 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
import { Loader } from '@/components/ui/loader';
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
import { appConfig } from '@/editor.config';
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
import { publishPage } from '@/lib/publish-page';
import globals from '@/app.globals.json';
// Plugin instances must keep a stable identity across renders, same as
// `appConfig`, so they are built once at module scope.
@@ -32,11 +33,15 @@ const outline = outlinePlugin();
const plugins = [outline, tailwindCdn, shopify];
const EMPTY_PAGE: Data = { root: { props: { title: 'Untitled' } }, content: [] };
export interface PageEditorProps {
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
routeKey: string;
/**
* The route's own `page.json`, imported by the `editor/page.tsx` that mounts
* this — `import pageData from '../page.json'`. Bundled at build time, so the
* editor opens with the page already in hand and no request to wait on.
*/
page: Record<string, unknown>;
}
/**
@@ -49,17 +54,25 @@ export interface PageEditorProps {
* 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.
* Data comes in as a prop — the route's own `page.json`, imported by the
* `editor/page.tsx` above it — and publishing writes that same file back to
* disk through the `publishPage` server action.
*/
export default function PageEditor({ routeKey }: PageEditorProps) {
export default function PageEditor({ routeKey, page }: PageEditorProps) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const [data, setData] = useState<Data | null>(null);
const [status, setStatus] = useState<string | null>(null);
// `app.globals.json` holds the props of blocks marked `global: true` (header,
// footer), which every page.json references via `"synced": true`. The editor
// needs them alongside the page so those blocks render their real content.
const data = useMemo(
() => ({ ...page, globals }) as unknown as Data,
[page]
);
// 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(
@@ -75,42 +88,16 @@ export default function PageEditor({ routeKey }: PageEditorProps) {
return entries;
}, [params]);
useEffect(() => {
let cancelled = false;
setData(null);
fetch(`/api/pages?route=${encodeURIComponent(routeKey)}`)
.then((response) => response.json())
.then((body) => {
if (!cancelled) setData(body.page ?? EMPTY_PAGE);
})
.catch(() => {
if (!cancelled) setData(EMPTY_PAGE);
});
return () => {
cancelled = true;
};
}, [routeKey]);
const handlePublish = useCallback(
async (published: Data) => {
setStatus('Saving…');
try {
const response = await fetch('/api/pages', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ route: routeKey, page: published }),
});
const body = await response.json().catch(() => ({}));
const result = await publishPage(routeKey, published);
if (!response.ok) {
setStatus(body.error ?? 'Could not save this page.');
return;
}
setStatus(`Saved to ${body.file}`);
setStatus(
result.error ? result.error : `Saved to ${result.file}`
);
} catch {
setStatus('Could not reach the server.');
}
@@ -118,25 +105,6 @@ export default function PageEditor({ routeKey }: PageEditorProps) {
[routeKey]
);
// Wait for the fetch: handing <Editor> a placeholder and then swapping it
// would seed the undo history with a page the author never wrote.
//
// Uses the local `components/ui/loader`, not core's export of the same name:
// core's is built on Chakra's Spinner and reads a context that only exists
// inside `<Editor>`, so it throws when used for a pre-Editor wait state.
// This one is provider-free SVG and renders anywhere.
if (!data) {
return (
<div
className="flex h-screen items-center justify-center text-muted-foreground"
role="status"
aria-label={`Loading ${routeKey}`}
>
<Loader size={24} />
</div>
);
}
return (
<Editor
key={routeKey}
-142
View File
@@ -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;
-112
View File
@@ -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;
-83
View File
@@ -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;
-118
View File
@@ -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: [],
}
);
-170
View File
@@ -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;
-2
View File
@@ -5,7 +5,6 @@ import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
@@ -117,7 +116,6 @@ const Header: React.FC<HeaderProps> = ({
{/* Actions */}
<div className="flex items-center">
<SearchDialog />
<AccountMenu />
<CartIcon />
{/* Mobile hamburger */}
-169
View File
@@ -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&apos;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;
+1 -9
View File
@@ -11,8 +11,6 @@ import type { SearchResultsBlockProps } from '@/components/shopify/search-result
import type { StoreAssistantBlockProps } from '@/components/shopify/store-assistant.editor';
import type { ContentSectionProps } from '@/components/shopify/content-section';
import type { PolicyBodyProps } from '@/components/shopify/policy-body';
import type { AccountPanelProps } from '@/components/shopify/account-panel';
import type { AccountOrdersProps } from '@/components/shopify/account-orders';
import type { RootProps } from './root';
@@ -30,18 +28,12 @@ export type Components = {
'store-assistant': StoreAssistantBlockProps;
'content-section': ContentSectionProps;
policy: PolicyBodyProps;
'account-login': AccountPanelProps;
'account-register': AccountPanelProps;
'account-recover': AccountPanelProps;
'account-reset': AccountPanelProps;
'account-activate': AccountPanelProps;
'account-orders': AccountOrdersProps;
};
export type UserConfig = Config<{
components: Components;
root: RootProps;
categories: ['navigation', 'commerce', 'content', 'account'];
categories: ['navigation', 'commerce', 'content'];
}>;
export type UserData = Data<Components, RootProps>;
-15
View File
@@ -12,20 +12,11 @@ import searchResultsEditor from '@/components/shopify/search-results.editor';
import storeAssistantEditor from '@/components/shopify/store-assistant.editor';
import contentSectionEditor from '@/components/shopify/content-section.editor';
import policyBodyEditor from '@/components/shopify/policy-body.editor';
import accountOrdersEditor from '@/components/shopify/account-orders.editor';
import {
accountActivateEditor,
accountLoginEditor,
accountRecoverEditor,
accountRegisterEditor,
accountResetEditor,
} from '@/components/shopify/account-panel.editor';
const categories = {
navigation: { title: 'Navigation' },
commerce: { title: 'Commerce' },
content: { title: 'Content' },
account: { title: 'Account' },
};
/**
@@ -50,12 +41,6 @@ export const appConfig: UserConfig = {
'store-assistant': storeAssistantEditor,
'content-section': contentSectionEditor,
policy: policyBodyEditor,
'account-login': accountLoginEditor,
'account-register': accountRegisterEditor,
'account-recover': accountRecoverEditor,
'account-reset': accountResetEditor,
'account-activate': accountActivateEditor,
'account-orders': accountOrdersEditor,
} as any,
};
-178
View File
@@ -1,178 +0,0 @@
// Customer account operations (classic Storefront customer accounts).
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
import { gql } from '@shopify/hydrogen';
const CustomerFragment = gql(`
fragment CustomerFragment on Customer {
id
email
firstName
lastName
phone
displayName
acceptsMarketing
createdAt
defaultAddress {
id
firstName
lastName
address1
address2
city
province
zip
country
phone
}
}
`);
export const CUSTOMER_QUERY = gql(
`
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
customer(customerAccessToken: $customerAccessToken) {
...CustomerFragment
orders(first: $orderCount, reverse: true) {
edges {
node {
id
orderNumber
processedAt
financialStatus
fulfillmentStatus
statusUrl
currentTotalPrice {
amount
currencyCode
}
lineItems(first: 5) {
edges {
node {
title
quantity
variant {
image {
url
altText
}
}
}
}
}
}
}
}
}
}
`,
[CustomerFragment]
);
export const CUSTOMER_CREATE_MUTATION = gql(`
mutation CustomerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) {
customer {
id
email
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(`
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
deletedAccessToken
userErrors {
field
message
}
}
}
`);
// Sends the "reset your password" email.
export const CUSTOMER_RECOVER_MUTATION = gql(`
mutation CustomerRecover($email: String!) {
customerRecover(email: $email) {
customerUserErrors {
code
field
message
}
}
}
`);
// Completes the reset using the id + token from the emailed link.
export const CUSTOMER_RESET_MUTATION = gql(`
mutation CustomerReset($id: ID!, $input: CustomerResetInput!) {
customerReset(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`);
// Activation link sent to customers created by the merchant.
export const CUSTOMER_ACTIVATE_MUTATION = gql(`
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`);
export const CUSTOMER_UPDATE_MUTATION = gql(
`
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
customer {
...CustomerFragment
}
customerUserErrors {
code
field
message
}
}
}
`,
[CustomerFragment]
);
+2 -24
View File
@@ -3,8 +3,8 @@
*
* `key` is what the editor's page picker shows and what `onPublish` hands back;
* `dir` is the folder under `app/` holding that route's `page.json`. Keeping
* the list here means the publish API can validate a request against it rather
* than trusting a path from the browser.
* the list here means the publish action can validate a route key against it
* rather than trusting a path from the browser.
*/
export interface PageRoute {
key: string;
@@ -29,28 +29,6 @@ export const PAGE_ROUTES: PageRoute[] = [
},
{ key: '/search', label: 'Search', dir: 'search' },
{ key: '/policies/[handle]', label: 'Policy', dir: 'policies/[handle]' },
{ key: '/account', label: 'Account — orders', dir: 'account' },
{ key: '/account/login', label: 'Account — sign in', dir: 'account/login' },
{
key: '/account/register',
label: 'Account — register',
dir: 'account/register',
},
{
key: '/account/recover',
label: 'Account — recover',
dir: 'account/recover',
},
{
key: '/account/reset/[id]/[token]',
label: 'Account — set password',
dir: 'account/reset/[id]/[token]',
},
{
key: '/account/activate/[id]/[token]',
label: 'Account — activate',
dir: 'account/activate/[id]/[token]',
},
];
export const ROUTE_KEYS = PAGE_ROUTES.map((route) => route.key);
+72
View File
@@ -0,0 +1,72 @@
'use server';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { findPageRoute } from '@/lib/pages';
/**
* Writes a published page straight to its `page.json` on disk.
*
* A server action rather than a route handler: the editor calls it like a
* function, and there is no HTTP endpoint sitting in front of the filesystem.
*
* The path is built from the registry in `lib/pages.ts`, never from the caller,
* so an unknown or crafted route key is rejected outright rather than escaping
* the `app/` directory.
*/
// 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');
export interface PublishResult {
/** Path of the written file, relative to the project root. */
file?: string;
error?: string;
}
export async function publishPage(
routeKey: string,
page: unknown
): Promise<PublishResult> {
const route = findPageRoute(routeKey);
if (!route) return { error: `Unknown route: ${routeKey}` };
if (!page || typeof page !== 'object') {
return { error: 'Expected a page object.' };
}
const appDir = path.join(process.cwd(), 'app');
const file = path.join(appDir, route.dir, 'page.json');
// Belt-and-braces against a registry entry with a traversing `dir`.
if (file !== path.join(appDir, 'page.json') && !file.startsWith(appDir + path.sep)) {
return { error: `Unknown route: ${routeKey}` };
}
// 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, ...pageData } = page as Record<string, unknown>;
try {
await writeFile(file, `${JSON.stringify(pageData, null, 2)}\n`, 'utf8');
if (globals && typeof globals === 'object') {
await writeFile(
GLOBALS_FILE,
`${JSON.stringify(globals, null, 2)}\n`,
'utf8'
);
}
return { 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 {
error:
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
};
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-248
View File
@@ -1,248 +0,0 @@
// Server-safe customer account access.
//
// The customer access token is a credential: it is only ever handled here and
// in the /api/account route handlers, and is stored in an httpOnly cookie so
// client JavaScript can never read it.
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import {
CUSTOMER_QUERY,
CUSTOMER_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
CUSTOMER_RECOVER_MUTATION,
CUSTOMER_RESET_MUTATION,
CUSTOMER_ACTIVATE_MUTATION,
CUSTOMER_UPDATE_MUTATION,
} from '@/graphql/customer';
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
export interface CustomerUserError {
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
code?: string | null;
field?: string[] | null;
message: string;
}
export interface CustomerAddress {
id: string;
firstName?: string | null;
lastName?: string | null;
address1?: string | null;
address2?: string | null;
city?: string | null;
province?: string | null;
zip?: string | null;
country?: string | null;
phone?: string | null;
}
export interface CustomerOrder {
id: string;
orderNumber: number;
processedAt: string;
financialStatus?: string | null;
fulfillmentStatus?: string | null;
statusUrl?: string | null;
currentTotalPrice: { amount: string; currencyCode: string };
lineItems: {
edges: Array<{
node: {
title: string;
quantity: number;
variant?: { image?: { url: string; altText?: string | null } | null } | null;
};
}>;
};
}
export interface Customer {
id: string;
email: string | null;
firstName?: string | null;
lastName?: string | null;
phone?: string | null;
displayName: string;
acceptsMarketing?: boolean;
createdAt?: string;
defaultAddress?: CustomerAddress | null;
orders?: { edges: Array<{ node: CustomerOrder }> };
}
export interface AccessToken {
accessToken: string;
expiresAt: string;
}
/** Either a token (success) or the errors Shopify reported. */
export interface AuthResult {
token: AccessToken | null;
errors: CustomerUserError[];
}
const firstMessage = (errors: CustomerUserError[]) =>
errors[0]?.message ?? 'Something went wrong. Please try again.';
export { firstMessage as customerErrorMessage };
// Shopify returns a null mutation payload when the mutation could not run at
// all. That is not a success, so it surfaces as a generic error rather than an
// empty error list, which callers read as "it worked".
const MUTATION_FAILED: CustomerUserError[] = [
{ message: 'Something went wrong. Please try again.' },
];
export async function createCustomer(input: {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input },
}),
'CustomerCreate'
);
const result = data.customerCreate;
if (!result) return { errors: MUTATION_FAILED };
return { errors: result.customerUserErrors ?? [] };
}
export async function login(
email: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } },
}),
'CustomerAccessTokenCreate'
);
const result = data.customerAccessTokenCreate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function logout(accessToken: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, {
variables: { customerAccessToken: accessToken },
});
} catch (err) {
// The cookie is cleared regardless; a failed revoke shouldn't block logout.
console.error('Failed to revoke customer access token:', err);
}
}
// Always resolves without error detail: revealing whether an address exists
// would leak account membership.
export async function recoverPassword(email: string): Promise<void> {
try {
await storefront.graphql(CUSTOMER_RECOVER_MUTATION, {
variables: { email },
});
} catch (err) {
console.error('Password recovery request failed:', err);
}
}
export async function resetPassword(
id: string,
resetToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } },
}),
'CustomerReset'
);
const result = data.customerReset;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function activateAccount(
id: string,
activationToken: string,
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } },
}),
'CustomerActivate'
);
const result = data.customerActivate;
if (!result) return { token: null, errors: MUTATION_FAILED };
return {
token: result.customerAccessToken ?? null,
errors: result.customerUserErrors ?? [],
};
}
export async function getCustomer(
accessToken: string,
orderCount = 10
): Promise<Customer | null> {
try {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount },
}),
'GetCustomer'
);
return data.customer ?? null;
} catch (err) {
// An expired or revoked token reads as "not signed in".
console.error('Failed to load customer:', err);
return null;
}
}
export async function updateCustomer(
accessToken: string,
customer: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer },
}),
'CustomerUpdate'
);
const result = data.customerUpdate;
if (!result) return { customer: null, errors: MUTATION_FAILED };
return {
customer: result.customer ?? null,
errors: result.customerUserErrors ?? [],
};
}
// Shopify's emailed links carry a numeric id; the mutations want a GID.
export function toCustomerGid(id: string): string {
return id.startsWith('gid://') ? id : `gid://shopify/Customer/${id}`;
}
-31
View File
@@ -1,31 +0,0 @@
// Customer session, stored in an httpOnly cookie. Server-only: importing this
// from a client component will fail, which is deliberate — the access token
// must never reach the browser's JavaScript.
import { cookies } from 'next/headers';
import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer';
export async function getSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null;
}
export async function setSessionToken(
accessToken: string,
expiresAt: string
): Promise<void> {
const store = await cookies();
const expires = new Date(expiresAt);
store.set(CUSTOMER_TOKEN_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: Number.isNaN(expires.getTime()) ? undefined : expires,
});
}
export async function clearSessionToken(): Promise<void> {
const store = await cookies();
store.delete(CUSTOMER_TOKEN_COOKIE);
}