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:
co-authored by
Claude Opus 5
parent
63ecc5e284
commit
f11a764426
@@ -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} />;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
+1
-9
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user