-
{/* 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.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);
-}