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,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;
|
||||
Reference in New Issue
Block a user