diff --git a/app/about/editor/page.tsx b/app/about/editor/page.tsx index ae096ec..6d48ec2 100644 --- a/app/about/editor/page.tsx +++ b/app/about/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/account/activate/[id]/[token]/editor/page.tsx b/app/account/activate/[id]/[token]/editor/page.tsx deleted file mode 100644 index d47147d..0000000 --- a/app/account/activate/[id]/[token]/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/activate/[id]/[token]/page.json b/app/account/activate/[id]/[token]/page.json deleted file mode 100644 index 1e4b575..0000000 --- a/app/account/activate/[id]/[token]/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/activate/[id]/[token]/page.tsx b/app/account/activate/[id]/[token]/page.tsx deleted file mode 100644 index c1b1279..0000000 --- a/app/account/activate/[id]/[token]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/editor/page.tsx b/app/account/editor/page.tsx deleted file mode 100644 index de5b899..0000000 --- a/app/account/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/login/editor/page.tsx b/app/account/login/editor/page.tsx deleted file mode 100644 index 97bc32c..0000000 --- a/app/account/login/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/login/page.json b/app/account/login/page.json deleted file mode 100644 index 3e60439..0000000 --- a/app/account/login/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/login/page.tsx b/app/account/login/page.tsx deleted file mode 100644 index 14a813b..0000000 --- a/app/account/login/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/page.json b/app/account/page.json deleted file mode 100644 index f316f05..0000000 --- a/app/account/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/page.tsx b/app/account/page.tsx deleted file mode 100644 index 91536bf..0000000 --- a/app/account/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/recover/editor/page.tsx b/app/account/recover/editor/page.tsx deleted file mode 100644 index 9795655..0000000 --- a/app/account/recover/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/recover/page.json b/app/account/recover/page.json deleted file mode 100644 index 9efe589..0000000 --- a/app/account/recover/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/recover/page.tsx b/app/account/recover/page.tsx deleted file mode 100644 index 5b99121..0000000 --- a/app/account/recover/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/register/editor/page.tsx b/app/account/register/editor/page.tsx deleted file mode 100644 index 868bbb7..0000000 --- a/app/account/register/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/register/page.json b/app/account/register/page.json deleted file mode 100644 index 88d1ba3..0000000 --- a/app/account/register/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/register/page.tsx b/app/account/register/page.tsx deleted file mode 100644 index c814158..0000000 --- a/app/account/register/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/reset/[id]/[token]/editor/page.tsx b/app/account/reset/[id]/[token]/editor/page.tsx deleted file mode 100644 index f3e2620..0000000 --- a/app/account/reset/[id]/[token]/editor/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/account/reset/[id]/[token]/page.json b/app/account/reset/[id]/[token]/page.json deleted file mode 100644 index 3999413..0000000 --- a/app/account/reset/[id]/[token]/page.json +++ /dev/null @@ -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" - } - } - ] -} diff --git a/app/account/reset/[id]/[token]/page.tsx b/app/account/reset/[id]/[token]/page.tsx deleted file mode 100644 index c1b1279..0000000 --- a/app/account/reset/[id]/[token]/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/api/account/activate/route.ts b/app/api/account/activate/route.ts deleted file mode 100644 index 98e77c0..0000000 --- a/app/api/account/activate/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts deleted file mode 100644 index 27eea71..0000000 --- a/app/api/account/login/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts deleted file mode 100644 index 175c55e..0000000 --- a/app/api/account/logout/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/me/route.ts b/app/api/account/me/route.ts deleted file mode 100644 index 46e1950..0000000 --- a/app/api/account/me/route.ts +++ /dev/null @@ -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, - }, - }); -} diff --git a/app/api/account/orders/route.ts b/app/api/account/orders/route.ts deleted file mode 100644 index 24e84ea..0000000 --- a/app/api/account/orders/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/recover/route.ts b/app/api/account/recover/route.ts deleted file mode 100644 index 15fc1c9..0000000 --- a/app/api/account/recover/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts deleted file mode 100644 index 9ed72a2..0000000 --- a/app/api/account/register/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/account/reset/route.ts b/app/api/account/reset/route.ts deleted file mode 100644 index 96ada16..0000000 --- a/app/api/account/reset/route.ts +++ /dev/null @@ -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 }); -} diff --git a/app/api/pages/route.ts b/app/api/pages/route.ts deleted file mode 100644 index 68e3437..0000000 --- a/app/api/pages/route.ts +++ /dev/null @@ -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 | 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; - - 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 } - ); - } -} diff --git a/app/collections/[handle]/editor/page.tsx b/app/collections/[handle]/editor/page.tsx index f36a08b..472bc1b 100644 --- a/app/collections/[handle]/editor/page.tsx +++ b/app/collections/[handle]/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/collections/editor/page.tsx b/app/collections/editor/page.tsx index ec8c0b8..eefa688 100644 --- a/app/collections/editor/page.tsx +++ b/app/collections/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 98c3119..063dd43 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/policies/[handle]/editor/page.tsx b/app/policies/[handle]/editor/page.tsx index 1660611..1299fb1 100644 --- a/app/policies/[handle]/editor/page.tsx +++ b/app/policies/[handle]/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/products/[handle]/editor/page.tsx b/app/products/[handle]/editor/page.tsx index 9c6e8f2..805f1e7 100644 --- a/app/products/[handle]/editor/page.tsx +++ b/app/products/[handle]/editor/page.tsx @@ -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 ; + return ; } diff --git a/app/search/editor/page.tsx b/app/search/editor/page.tsx index f101259..fa127f6 100644 --- a/app/search/editor/page.tsx +++ b/app/search/editor/page.tsx @@ -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 ; + return ; } diff --git a/components/page-editor.tsx b/components/page-editor.tsx index d0c84e9..4d52d9d 100644 --- a/components/page-editor.tsx +++ b/components/page-editor.tsx @@ -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; } /** @@ -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(null); const [status, setStatus] = useState(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 `/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 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 ``, so it throws when used for a pre-Editor wait state. - // This one is provider-free SVG and renders anywhere. - if (!data) { - return ( -
- -
- ); - } - return ( ; - /** Where to go on success; omit to show `successMessage` instead. */ - redirectTo?: string; - successMessage?: string; - footer?: React.ReactNode; -} - -const AccountForm: React.FC = ({ - title, - description, - fields, - submitLabel, - endpoint, - extraPayload, - redirectTo, - successMessage, - footer, -}) => { - const router = useRouter(); - const [values, setValues] = useState>({}); - const [error, setError] = useState(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 ( -
-

{title}

- {description && ( -

{description}

- )} - - {done && successMessage ? ( -

{successMessage}

- ) : ( -
- {fields.map((field) => ( - - ))} - - {error &&

{error}

} - - -
- )} - - {footer && ( -
- {footer} -
- )} -
- ); -}; - -export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({ - href, - children, -}) => ( - - {children} - -); - -export default AccountForm; diff --git a/components/shopify/account-menu.tsx b/components/shopify/account-menu.tsx deleted file mode 100644 index 7697cbe..0000000 --- a/components/shopify/account-menu.tsx +++ /dev/null @@ -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(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 ( - - ); - } - - return ( -
- - - {open && ( - <> -
setOpen(false)} /> -
-
-

- {customer.firstName || customer.displayName} -

-

- {customer.email} -

-
- -
- - setOpen(false)} - className="block px-4 py-2 text-sm text-foreground hover:bg-accent" - > - Order history - - -
- - )} -
- ); -}; - -export default AccountMenu; diff --git a/components/shopify/account-orders.editor.tsx b/components/shopify/account-orders.editor.tsx deleted file mode 100644 index 8191cdc..0000000 --- a/components/shopify/account-orders.editor.tsx +++ /dev/null @@ -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 = { - label: 'Order history', - icon: , - 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) => , -}; - -export default accountOrdersEditor; diff --git a/components/shopify/account-orders.tsx b/components/shopify/account-orders.tsx deleted file mode 100644 index 273021e..0000000 --- a/components/shopify/account-orders.tsx +++ /dev/null @@ -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 = ({ - title = 'Order history', - signedOutMessage = 'Sign in to see your orders.', - emptyMessage = "You haven't placed any orders yet.", - limit = 20, -}) => { - const [customer, setCustomer] = useState(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 ( -
-

- {title} -

- - {customer && ( -

- {customer.displayName} · {customer.email} -

- )} - -
- {loading ? ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} -
- ) : !customer ? ( -

{signedOutMessage}

- ) : orderCount === 0 ? ( -

{emptyMessage}

- ) : ( - - )} -
-
- ); -}; - -export default AccountOrders; diff --git a/components/shopify/account-panel.editor.tsx b/components/shopify/account-panel.editor.tsx deleted file mode 100644 index 0805f87..0000000 --- a/components/shopify/account-panel.editor.tsx +++ /dev/null @@ -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 -): ComponentConfig => ({ - 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) => , -}); - -export const accountLoginEditor = accountBlock( - 'login', - 'Sign in form', - , - { - 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', - , - { - 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', - , - { - 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', - , - { - title: 'Set a new password', - description: '', - submitLabel: 'Save password', - successMessage: '', - links: [], - } -); - -export const accountActivateEditor = accountBlock( - 'activate', - 'Activate account form', - , - { - title: 'Activate your account', - description: 'Choose a password to finish setting up your account.', - submitLabel: 'Activate account', - successMessage: '', - links: [], - } -); diff --git a/components/shopify/account-panel.tsx b/components/shopify/account-panel.tsx deleted file mode 100644 index 9c71f36..0000000 --- a/components/shopify/account-panel.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
- 0 ? ( - <> - {links.map((link) => ( - - {link.label} - - ))} - - ) : undefined - } - /> -
- ); -}; - -export default AccountPanel; diff --git a/components/shopify/header.tsx b/components/shopify/header.tsx index e778b1b..66c83f2 100644 --- a/components/shopify/header.tsx +++ b/components/shopify/header.tsx @@ -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 = ({ {/* Actions */}
- {/* Mobile hamburger */} diff --git a/components/shopify/order-history.tsx b/components/shopify/order-history.tsx deleted file mode 100644 index 62831f4..0000000 --- a/components/shopify/order-history.tsx +++ /dev/null @@ -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 ( - - {label.replace(/_/g, ' ').toLowerCase()} - - ); -}; - -const OrderHistory: React.FC = ({ 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( - orders[0]?.id ?? null - ); - const selected = orders.find((order) => order.id === selectedId) ?? null; - - if (orders.length === 0) { - return ( -

- You haven't placed any orders yet. -

- ); - } - - return ( -
- {/* List */} -
-

Orders

-
    - {orders.map((order) => { - const isSelected = order.id === selectedId; - - return ( -
  • - -
  • - ); - })} -
-
- - {/* Detail — same screen, no navigation */} -
- {selected && ( - <> -
-

- Order #{selected.orderNumber} -

- - {formatMoney( - selected.currentTotalPrice.amount, - selected.currentTotalPrice.currencyCode - )} - -
- -
- - Placed {formatDate(selected.processedAt)} - - - -
- -
    - {selected.lineItems.edges.map(({ node }, index) => ( -
  • -
    - {node.variant?.image ? ( - {node.variant.image.altText - ) : ( -
    - -
    - )} -
    -
    -

    - {node.title} -

    -

    - Qty {node.quantity} -

    -
    -
  • - ))} -
- - {selected.statusUrl && ( - - View order status - - )} - - )} -
-
- ); -}; - -export default OrderHistory; diff --git a/config/types.ts b/config/types.ts index 9aa1677..5bad25b 100644 --- a/config/types.ts +++ b/config/types.ts @@ -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; diff --git a/editor.config.tsx b/editor.config.tsx index 410ab66..2c39433 100644 --- a/editor.config.tsx +++ b/editor.config.tsx @@ -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, }; diff --git a/graphql/customer.ts b/graphql/customer.ts deleted file mode 100644 index f592b25..0000000 --- a/graphql/customer.ts +++ /dev/null @@ -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] -); diff --git a/lib/pages.ts b/lib/pages.ts index 7e20383..3017a5e 100644 --- a/lib/pages.ts +++ b/lib/pages.ts @@ -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); diff --git a/lib/publish-page.ts b/lib/publish-page.ts new file mode 100644 index 0000000..b00c93a --- /dev/null +++ b/lib/publish-page.ts @@ -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 { + 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; + + 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.', + }; + } +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/services/shopify/customer.ts b/services/shopify/customer.ts deleted file mode 100644 index 53589f1..0000000 --- a/services/shopify/customer.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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}`; -} diff --git a/services/shopify/session.ts b/services/shopify/session.ts deleted file mode 100644 index 5697e56..0000000 --- a/services/shopify/session.ts +++ /dev/null @@ -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 { - const store = await cookies(); - return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null; -} - -export async function setSessionToken( - accessToken: string, - expiresAt: string -): Promise { - 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 { - const store = await cookies(); - store.delete(CUSTOMER_TOKEN_COOKIE); -}