Compare commits
1
Commits
63ecc5e284
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11a764426 |
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /about. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/about" />;
|
return <PageEditor routeKey="/about" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account/activate/[id]/[token]. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account/activate/[id]/[token]" />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Activate your account",
|
|
||||||
"description": "Finish setting up your account.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-activate",
|
|
||||||
"props": {
|
|
||||||
"id": "account-activate",
|
|
||||||
"flow": "activate",
|
|
||||||
"title": "Activate your account",
|
|
||||||
"description": "Choose a password to finish setting up your account.",
|
|
||||||
"submitLabel": "Activate account",
|
|
||||||
"successMessage": "",
|
|
||||||
"links": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page);
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account" />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account/login. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account/login" />;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Sign in",
|
|
||||||
"description": "Sign in to your Shop account.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-login",
|
|
||||||
"props": {
|
|
||||||
"id": "account-login",
|
|
||||||
"flow": "login",
|
|
||||||
"title": "Sign in",
|
|
||||||
"description": "",
|
|
||||||
"submitLabel": "Sign in",
|
|
||||||
"successMessage": "",
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"label": "Create an account",
|
|
||||||
"url": "/account/register"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Forgot your password?",
|
|
||||||
"url": "/account/recover"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page, { path: '/account/login' });
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Order history",
|
|
||||||
"description": "Your recent orders.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-orders",
|
|
||||||
"props": {
|
|
||||||
"id": "account-orders",
|
|
||||||
"title": "Order history",
|
|
||||||
"signedOutMessage": "Sign in to see your orders.",
|
|
||||||
"emptyMessage": "You haven't placed any orders yet.",
|
|
||||||
"limit": 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import { getSessionToken } from '@/services/shopify/session';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page, { path: '/account' });
|
|
||||||
|
|
||||||
export default async function Page() {
|
|
||||||
// The order-history block re-reads the session through /api/account/orders,
|
|
||||||
// but bouncing signed-out visitors here avoids rendering the page at all.
|
|
||||||
const token = await getSessionToken();
|
|
||||||
if (!token) redirect('/account/login');
|
|
||||||
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account/recover. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account/recover" />;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Reset password",
|
|
||||||
"description": "Request a password reset link.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-recover",
|
|
||||||
"props": {
|
|
||||||
"id": "account-recover",
|
|
||||||
"flow": "recover",
|
|
||||||
"title": "Reset password",
|
|
||||||
"description": "Enter your email and we'll send you a link to set a new password.",
|
|
||||||
"submitLabel": "Send reset link",
|
|
||||||
"successMessage": "If that email has an account, a reset link is on its way.",
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"label": "Back to sign in",
|
|
||||||
"url": "/account/login"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page, { path: '/account/recover' });
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account/register. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account/register" />;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Create account",
|
|
||||||
"description": "Create a Shop account.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-register",
|
|
||||||
"props": {
|
|
||||||
"id": "account-register",
|
|
||||||
"flow": "register",
|
|
||||||
"title": "Create account",
|
|
||||||
"description": "",
|
|
||||||
"submitLabel": "Create account",
|
|
||||||
"successMessage": "",
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"label": "Already have an account? Sign in",
|
|
||||||
"url": "/account/login"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page, { path: '/account/register' });
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
|
||||||
|
|
||||||
// Editor for /account/reset/[id]/[token]. Sitting under the route it edits means the preview
|
|
||||||
// resolves the same params the public page gets.
|
|
||||||
export default function EditorPage() {
|
|
||||||
return <PageEditor routeKey="/account/reset/[id]/[token]" />;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Set a new password",
|
|
||||||
"description": "Choose a new password.",
|
|
||||||
"ogImage": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"props": {
|
|
||||||
"id": "header"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "account-reset",
|
|
||||||
"props": {
|
|
||||||
"id": "account-reset",
|
|
||||||
"flow": "reset",
|
|
||||||
"title": "Set a new password",
|
|
||||||
"description": "",
|
|
||||||
"submitLabel": "Save password",
|
|
||||||
"successMessage": "",
|
|
||||||
"links": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer"
|
|
||||||
},
|
|
||||||
"synced": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "store-assistant",
|
|
||||||
"props": {
|
|
||||||
"id": "store-assistant"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import PageRender from '@/components/page-render';
|
|
||||||
import { pageMetadata } from '@/lib/page-metadata';
|
|
||||||
import page from './page.json';
|
|
||||||
|
|
||||||
export const metadata = pageMetadata(page);
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <PageRender page={page} />;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import {
|
|
||||||
activateAccount,
|
|
||||||
toCustomerGid,
|
|
||||||
customerErrorMessage,
|
|
||||||
} from '@/services/shopify/customer';
|
|
||||||
import { setSessionToken } from '@/services/shopify/session';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { id, activationToken, password } = await req.json();
|
|
||||||
|
|
||||||
if (!id || !activationToken || !password) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'This activation link is incomplete.' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, errors } = await activateAccount(
|
|
||||||
toCustomerGid(id),
|
|
||||||
activationToken,
|
|
||||||
password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await setSessionToken(token.accessToken, token.expiresAt);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
|
||||||
import { setSessionToken } from '@/services/shopify/session';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { email, password } = await req.json();
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Enter your email and password.' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, errors } = await login(email, password);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
|
||||||
// the form can't be used to enumerate accounts.
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: errors.length
|
|
||||||
? 'Incorrect email or password.'
|
|
||||||
: customerErrorMessage(errors),
|
|
||||||
},
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await setSessionToken(token.accessToken, token.expiresAt);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { logout } from '@/services/shopify/customer';
|
|
||||||
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const token = await getSessionToken();
|
|
||||||
if (token) await logout(token);
|
|
||||||
|
|
||||||
await clearSessionToken();
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { getSessionToken } from '@/services/shopify/session';
|
|
||||||
import { getCustomer } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
// Minimal session probe for the header menu — never returns the access token.
|
|
||||||
export async function GET() {
|
|
||||||
const token = await getSessionToken();
|
|
||||||
if (!token) return Response.json({ customer: null });
|
|
||||||
|
|
||||||
const customer = await getCustomer(token, 0);
|
|
||||||
if (!customer) return Response.json({ customer: null });
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
customer: {
|
|
||||||
displayName: customer.displayName,
|
|
||||||
email: customer.email,
|
|
||||||
firstName: customer.firstName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { getSessionToken } from '@/services/shopify/session';
|
|
||||||
import { getCustomer } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full customer record including orders, for the client-rendered order-history
|
|
||||||
* block. `/api/account/me` stays the lightweight session probe the header uses
|
|
||||||
* — it asks for zero orders — so the two don't fight over payload size.
|
|
||||||
*
|
|
||||||
* The access token never leaves the server: it is read from the session cookie
|
|
||||||
* here and only the resolved customer is returned.
|
|
||||||
*/
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const token = await getSessionToken();
|
|
||||||
if (!token) return Response.json({ customer: null }, { status: 401 });
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const parsed = Number(searchParams.get('orders'));
|
|
||||||
const orderCount = Number.isFinite(parsed)
|
|
||||||
? Math.min(Math.max(Math.trunc(parsed), 1), 50)
|
|
||||||
: 20;
|
|
||||||
|
|
||||||
const customer = await getCustomer(token, orderCount);
|
|
||||||
if (!customer) return Response.json({ customer: null }, { status: 401 });
|
|
||||||
|
|
||||||
return Response.json({ customer });
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { recoverPassword } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { email } = await req.json();
|
|
||||||
|
|
||||||
if (email) await recoverPassword(email);
|
|
||||||
|
|
||||||
// Always the same response, so the form can't reveal who has an account.
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import {
|
|
||||||
createCustomer,
|
|
||||||
login,
|
|
||||||
customerErrorMessage,
|
|
||||||
} from '@/services/shopify/customer';
|
|
||||||
import { setSessionToken } from '@/services/shopify/session';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { email, password, firstName, lastName } = await req.json();
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Enter your email and password.' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { errors } = await createCustomer({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the new customer straight in. Accounts needing email confirmation
|
|
||||||
// won't return a token yet, which is not an error.
|
|
||||||
const { token } = await login(email, password);
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
await setSessionToken(token.accessToken, token.expiresAt);
|
|
||||||
return Response.json({ ok: true, signedIn: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ ok: true, signedIn: false });
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import {
|
|
||||||
resetPassword,
|
|
||||||
toCustomerGid,
|
|
||||||
customerErrorMessage,
|
|
||||||
} from '@/services/shopify/customer';
|
|
||||||
import { setSessionToken } from '@/services/shopify/session';
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { id, resetToken, password } = await req.json();
|
|
||||||
|
|
||||||
if (!id || !resetToken || !password) {
|
|
||||||
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { token, errors } = await resetPassword(
|
|
||||||
toCustomerGid(id),
|
|
||||||
resetToken,
|
|
||||||
password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await setSessionToken(token.accessToken, token.expiresAt);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { findPageRoute } from '@/lib/pages';
|
|
||||||
|
|
||||||
// Touches the filesystem, so it must never be statically optimised.
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a route key to its `page.json` on disk.
|
|
||||||
*
|
|
||||||
* The path is built from the registry in `lib/pages.ts`, never from the request
|
|
||||||
* body, so an unknown or crafted route key is rejected outright rather than
|
|
||||||
* escaping the `app/` directory. The realpath check is belt-and-braces for the
|
|
||||||
* same thing.
|
|
||||||
*/
|
|
||||||
function resolvePageFile(routeKey: string): string | null {
|
|
||||||
const route = findPageRoute(routeKey);
|
|
||||||
if (!route) return null;
|
|
||||||
|
|
||||||
const appDir = path.join(process.cwd(), 'app');
|
|
||||||
const file = path.join(appDir, route.dir, 'page.json');
|
|
||||||
|
|
||||||
return file.startsWith(appDir + path.sep) ? file : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Props of blocks marked `global: true` (header, footer) live in one file that
|
|
||||||
// every page.json references, so editing them once updates every route.
|
|
||||||
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
|
|
||||||
|
|
||||||
async function readJson(file: string): Promise<Record<string, any> | null> {
|
|
||||||
try {
|
|
||||||
return JSON.parse(await readFile(file, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const routeKey = new URL(request.url).searchParams.get('route') ?? '/';
|
|
||||||
const file = resolvePageFile(routeKey);
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await readJson(file);
|
|
||||||
// A route with no page.json yet is a new page, not an error.
|
|
||||||
if (!page) return Response.json({ page: null });
|
|
||||||
|
|
||||||
const globals = await readJson(GLOBALS_FILE);
|
|
||||||
return Response.json({ page: { ...page, globals: globals ?? {} } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
|
||||||
let body: { route?: string; page?: unknown };
|
|
||||||
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Expected a JSON body.' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeKey = body.route ?? '';
|
|
||||||
const file = resolvePageFile(routeKey);
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.page || typeof body.page !== 'object') {
|
|
||||||
return Response.json({ error: 'Expected a page object.' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Globals belong to the whole site, not this route, so they go to their own
|
|
||||||
// file and are stripped from the page before it is written.
|
|
||||||
const { globals, ...page } = body.page as Record<string, unknown>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await writeFile(file, `${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
|
||||||
|
|
||||||
if (globals && typeof globals === 'object') {
|
|
||||||
await writeFile(
|
|
||||||
GLOBALS_FILE,
|
|
||||||
`${JSON.stringify(globals, null, 2)}\n`,
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ file: path.relative(process.cwd(), file) });
|
|
||||||
} catch (err) {
|
|
||||||
// Read-only filesystems (most serverless hosts) land here. Say so plainly
|
|
||||||
// rather than reporting a save that did not happen.
|
|
||||||
console.error(`Failed to write ${file}:`, err);
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/collections/[handle]" />;
|
return <PageEditor routeKey="/collections/[handle]" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /collections. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/collections" />;
|
return <PageEditor routeKey="/collections" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/" />;
|
return <PageEditor routeKey="/" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/policies/[handle]" />;
|
return <PageEditor routeKey="/policies/[handle]" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /products/[handle]. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/products/[handle]" />;
|
return <PageEditor routeKey="/products/[handle]" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import PageEditor from '@/components/page-editor';
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
// Editor for /search. Sitting under the route it edits means the preview
|
// 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() {
|
export default function EditorPage() {
|
||||||
return <PageEditor routeKey="/search" />;
|
return <PageEditor routeKey="/search" page={pageData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-57
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||||
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
||||||
import { Loader } from '@/components/ui/loader';
|
|
||||||
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
||||||
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
||||||
import { appConfig } from '@/editor.config';
|
import { appConfig } from '@/editor.config';
|
||||||
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
|
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
|
// Plugin instances must keep a stable identity across renders, same as
|
||||||
// `appConfig`, so they are built once at module scope.
|
// `appConfig`, so they are built once at module scope.
|
||||||
@@ -32,11 +33,15 @@ const outline = outlinePlugin();
|
|||||||
|
|
||||||
const plugins = [outline, tailwindCdn, shopify];
|
const plugins = [outline, tailwindCdn, shopify];
|
||||||
|
|
||||||
const EMPTY_PAGE: Data = { root: { props: { title: 'Untitled' } }, content: [] };
|
|
||||||
|
|
||||||
export interface PageEditorProps {
|
export interface PageEditorProps {
|
||||||
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
|
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
|
||||||
routeKey: string;
|
routeKey: string;
|
||||||
|
/**
|
||||||
|
* The route's own `page.json`, imported by the `editor/page.tsx` that mounts
|
||||||
|
* this — `import pageData from '../page.json'`. Bundled at build time, so the
|
||||||
|
* editor opens with the page already in hand and no request to wait on.
|
||||||
|
*/
|
||||||
|
page: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,17 +54,25 @@ export interface PageEditorProps {
|
|||||||
* calling `useParams()` inside the preview iframe sees the actual handle from
|
* calling `useParams()` inside the preview iframe sees the actual handle from
|
||||||
* `/products/warrior-club-hoodie/editor` — no stand-in data required.
|
* `/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
|
* Data comes in as a prop — the route's own `page.json`, imported by the
|
||||||
* `/api/pages`, which writes the file on disk.
|
* `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 router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const [data, setData] = useState<Data | null>(null);
|
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// `app.globals.json` holds the props of blocks marked `global: true` (header,
|
||||||
|
// footer), which every page.json references via `"synced": true`. The editor
|
||||||
|
// needs them alongside the page so those blocks render their real content.
|
||||||
|
const data = useMemo(
|
||||||
|
() => ({ ...page, globals }) as unknown as Data,
|
||||||
|
[page]
|
||||||
|
);
|
||||||
|
|
||||||
// The editor lives at `<public path>/editor`; drop that segment to recover
|
// The editor lives at `<public path>/editor`; drop that segment to recover
|
||||||
// the page's own URL for the route descriptor and the URL bar.
|
// the page's own URL for the route descriptor and the URL bar.
|
||||||
const publicPath = useMemo(
|
const publicPath = useMemo(
|
||||||
@@ -75,42 +88,16 @@ export default function PageEditor({ routeKey }: PageEditorProps) {
|
|||||||
return entries;
|
return entries;
|
||||||
}, [params]);
|
}, [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(
|
const handlePublish = useCallback(
|
||||||
async (published: Data) => {
|
async (published: Data) => {
|
||||||
setStatus('Saving…');
|
setStatus('Saving…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/pages', {
|
const result = await publishPage(routeKey, published);
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ route: routeKey, page: published }),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!response.ok) {
|
setStatus(
|
||||||
setStatus(body.error ?? 'Could not save this page.');
|
result.error ? result.error : `Saved to ${result.file}`
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(`Saved to ${body.file}`);
|
|
||||||
} catch {
|
} catch {
|
||||||
setStatus('Could not reach the server.');
|
setStatus('Could not reach the server.');
|
||||||
}
|
}
|
||||||
@@ -118,25 +105,6 @@ export default function PageEditor({ routeKey }: PageEditorProps) {
|
|||||||
[routeKey]
|
[routeKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the fetch: handing <Editor> a placeholder and then swapping it
|
|
||||||
// would seed the undo history with a page the author never wrote.
|
|
||||||
//
|
|
||||||
// Uses the local `components/ui/loader`, not core's export of the same name:
|
|
||||||
// core's is built on Chakra's Spinner and reads a context that only exists
|
|
||||||
// inside `<Editor>`, so it throws when used for a pre-Editor wait state.
|
|
||||||
// This one is provider-free SVG and renders anywhere.
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-screen items-center justify-center text-muted-foreground"
|
|
||||||
role="status"
|
|
||||||
aria-label={`Loading ${routeKey}`}
|
|
||||||
>
|
|
||||||
<Loader size={24} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor
|
<Editor
|
||||||
key={routeKey}
|
key={routeKey}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Loader } from '@/components/ui/loader';
|
|
||||||
|
|
||||||
export interface AccountFormField {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: 'text' | 'email' | 'password';
|
|
||||||
autoComplete?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountFormProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
fields: AccountFormField[];
|
|
||||||
submitLabel: string;
|
|
||||||
endpoint: string;
|
|
||||||
/** Merged into the request body alongside the field values. */
|
|
||||||
extraPayload?: Record<string, string>;
|
|
||||||
/** Where to go on success; omit to show `successMessage` instead. */
|
|
||||||
redirectTo?: string;
|
|
||||||
successMessage?: string;
|
|
||||||
footer?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AccountForm: React.FC<AccountFormProps> = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
fields,
|
|
||||||
submitLabel,
|
|
||||||
endpoint,
|
|
||||||
extraPayload,
|
|
||||||
redirectTo,
|
|
||||||
successMessage,
|
|
||||||
footer,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (submitting) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...values, ...extraPayload }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError(data.error ?? 'Something went wrong. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectTo) {
|
|
||||||
// refresh() so server components re-read the new session cookie.
|
|
||||||
router.push(redirectTo);
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setDone(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Could not reach the server. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-sm">
|
|
||||||
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{done && successMessage ? (
|
|
||||||
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
|
|
||||||
{fields.map((field) => (
|
|
||||||
<label key={field.name} className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{field.label}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type={field.type ?? 'text'}
|
|
||||||
name={field.name}
|
|
||||||
required={field.required ?? true}
|
|
||||||
autoComplete={field.autoComplete}
|
|
||||||
value={values[field.name] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field.name]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting} className="h-11">
|
|
||||||
{submitting && <Loader size={16} />}
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{footer && (
|
|
||||||
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
|
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AccountForm;
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { RiUserLine } from '@remixicon/react';
|
|
||||||
|
|
||||||
interface SessionCustomer {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
firstName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AccountMenu: React.FC = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
// The token lives in an httpOnly cookie, so the signed-in state has to come
|
|
||||||
// from the server rather than being read directly.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
fetch('/api/account/me')
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setCustomer(data.customer ?? null);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setCustomer(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
|
||||||
setOpen(false);
|
|
||||||
await fetch('/api/account/logout', { method: 'POST' });
|
|
||||||
setCustomer(null);
|
|
||||||
router.push('/');
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signed out: straight to the sign-in page, no menu.
|
|
||||||
if (!customer) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Sign in"
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<Link href="/account/login">
|
|
||||||
<RiUserLine className="size-5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Account menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<RiUserLine className="size-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{customer.firstName || customer.displayName}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
|
||||||
{customer.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="my-1 h-px bg-border" />
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/account"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
|
|
||||||
>
|
|
||||||
Order history
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountMenu;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { ComponentConfig } from '@reacteditor/core';
|
|
||||||
import { Receipt } from 'lucide-react';
|
|
||||||
import AccountOrders, {
|
|
||||||
type AccountOrdersProps,
|
|
||||||
} from '@/components/shopify/account-orders';
|
|
||||||
|
|
||||||
const accountOrdersEditor: ComponentConfig<AccountOrdersProps> = {
|
|
||||||
label: 'Order history',
|
|
||||||
icon: <Receipt size={16} />,
|
|
||||||
category: 'account',
|
|
||||||
defaultProps: {
|
|
||||||
title: 'Order history',
|
|
||||||
signedOutMessage: 'Sign in to see your orders.',
|
|
||||||
emptyMessage: "You haven't placed any orders yet.",
|
|
||||||
limit: 20,
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
|
||||||
signedOutMessage: {
|
|
||||||
label: 'Signed-out message',
|
|
||||||
type: 'text',
|
|
||||||
contentEditable: true,
|
|
||||||
},
|
|
||||||
emptyMessage: {
|
|
||||||
label: 'Empty message',
|
|
||||||
type: 'text',
|
|
||||||
contentEditable: true,
|
|
||||||
},
|
|
||||||
limit: { label: 'Orders shown', type: 'number', min: 1, max: 50 },
|
|
||||||
},
|
|
||||||
render: (props) => <AccountOrders {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default accountOrdersEditor;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import OrderHistory from '@/components/shopify/order-history';
|
|
||||||
import type { Customer } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
export interface AccountOrdersProps {
|
|
||||||
title?: string;
|
|
||||||
/** Shown while signed out — the route guard normally redirects first. */
|
|
||||||
signedOutMessage?: string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side wrapper so order history can live in a page.json alongside the
|
|
||||||
* other blocks. `/account/page.tsx` still guards the route server-side; this
|
|
||||||
* re-reads the session through `/api/account/orders` so the block works the
|
|
||||||
* same whether it is rendered by the page or previewed in the editor.
|
|
||||||
*/
|
|
||||||
const AccountOrders: React.FC<AccountOrdersProps> = ({
|
|
||||||
title = 'Order history',
|
|
||||||
signedOutMessage = 'Sign in to see your orders.',
|
|
||||||
emptyMessage = "You haven't placed any orders yet.",
|
|
||||||
limit = 20,
|
|
||||||
}) => {
|
|
||||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
fetch(`/api/account/orders?orders=${limit}`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setCustomer(data.customer ?? null);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setCustomer(null);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [limit]);
|
|
||||||
|
|
||||||
const orderCount = customer?.orders?.edges.length ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{customer && (
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{customer.displayName} · {customer.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-10">
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse space-y-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<div key={index} className="h-20 bg-zinc-100" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : !customer ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
|
|
||||||
) : orderCount === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
||||||
) : (
|
|
||||||
<OrderHistory customer={customer} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountOrders;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { ComponentConfig } from '@reacteditor/core';
|
|
||||||
import { KeyRound, LogIn, UserPlus } from 'lucide-react';
|
|
||||||
import AccountPanel, {
|
|
||||||
type AccountFlow,
|
|
||||||
type AccountPanelProps,
|
|
||||||
} from '@/components/shopify/account-panel';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One component, five registered blocks. `flow` decides the field list and the
|
|
||||||
* API endpoint, so it lives in `defaultProps` and is deliberately absent from
|
|
||||||
* `fields` — it is behaviour, not content. Everything editable here is copy.
|
|
||||||
*/
|
|
||||||
const accountBlock = (
|
|
||||||
flow: AccountFlow,
|
|
||||||
label: string,
|
|
||||||
icon: React.ReactNode,
|
|
||||||
defaults: Omit<AccountPanelProps, 'flow'>
|
|
||||||
): ComponentConfig<AccountPanelProps> => ({
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
category: 'account',
|
|
||||||
defaultProps: { flow, ...defaults },
|
|
||||||
fields: {
|
|
||||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
|
||||||
description: {
|
|
||||||
label: 'Description',
|
|
||||||
type: 'textarea',
|
|
||||||
contentEditable: true,
|
|
||||||
},
|
|
||||||
submitLabel: { label: 'Button label', type: 'text', contentEditable: true },
|
|
||||||
successMessage: {
|
|
||||||
label: 'Success message',
|
|
||||||
type: 'textarea',
|
|
||||||
contentEditable: true,
|
|
||||||
},
|
|
||||||
links: {
|
|
||||||
label: 'Footer links',
|
|
||||||
type: 'array',
|
|
||||||
defaultItemProps: { label: 'Link', url: '/account/login' },
|
|
||||||
getItemSummary: (item) => item?.label || 'Link',
|
|
||||||
arrayFields: {
|
|
||||||
label: { label: 'Label', type: 'text', contentEditable: true },
|
|
||||||
url: { label: 'Link', type: 'text' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <AccountPanel {...props} />,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const accountLoginEditor = accountBlock(
|
|
||||||
'login',
|
|
||||||
'Sign in form',
|
|
||||||
<LogIn size={16} />,
|
|
||||||
{
|
|
||||||
title: 'Sign in',
|
|
||||||
description: '',
|
|
||||||
submitLabel: 'Sign in',
|
|
||||||
successMessage: '',
|
|
||||||
links: [
|
|
||||||
{ label: 'Create an account', url: '/account/register' },
|
|
||||||
{ label: 'Forgot your password?', url: '/account/recover' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const accountRegisterEditor = accountBlock(
|
|
||||||
'register',
|
|
||||||
'Register form',
|
|
||||||
<UserPlus size={16} />,
|
|
||||||
{
|
|
||||||
title: 'Create account',
|
|
||||||
description: '',
|
|
||||||
submitLabel: 'Create account',
|
|
||||||
successMessage: '',
|
|
||||||
links: [{ label: 'Already have an account? Sign in', url: '/account/login' }],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const accountRecoverEditor = accountBlock(
|
|
||||||
'recover',
|
|
||||||
'Password recovery form',
|
|
||||||
<KeyRound size={16} />,
|
|
||||||
{
|
|
||||||
title: 'Reset password',
|
|
||||||
description:
|
|
||||||
"Enter your email and we'll send you a link to set a new password.",
|
|
||||||
submitLabel: 'Send reset link',
|
|
||||||
successMessage:
|
|
||||||
'If that email has an account, a reset link is on its way.',
|
|
||||||
links: [{ label: 'Back to sign in', url: '/account/login' }],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const accountResetEditor = accountBlock(
|
|
||||||
'reset',
|
|
||||||
'Set password form',
|
|
||||||
<KeyRound size={16} />,
|
|
||||||
{
|
|
||||||
title: 'Set a new password',
|
|
||||||
description: '',
|
|
||||||
submitLabel: 'Save password',
|
|
||||||
successMessage: '',
|
|
||||||
links: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const accountActivateEditor = accountBlock(
|
|
||||||
'activate',
|
|
||||||
'Activate account form',
|
|
||||||
<KeyRound size={16} />,
|
|
||||||
{
|
|
||||||
title: 'Activate your account',
|
|
||||||
description: 'Choose a password to finish setting up your account.',
|
|
||||||
submitLabel: 'Activate account',
|
|
||||||
successMessage: '',
|
|
||||||
links: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import AccountForm, {
|
|
||||||
AccountFormLink,
|
|
||||||
type AccountFormField,
|
|
||||||
} from '@/components/shopify/account-form';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The account routes are all the same form with a different field list and a
|
|
||||||
* different endpoint. Those two things are behaviour, not content, so they are
|
|
||||||
* fixed here per flow and are deliberately *not* exposed as editor fields —
|
|
||||||
* the editor only gets the wording (see `account-panel.editor.tsx`).
|
|
||||||
*/
|
|
||||||
export type AccountFlow =
|
|
||||||
| 'login'
|
|
||||||
| 'register'
|
|
||||||
| 'recover'
|
|
||||||
| 'reset'
|
|
||||||
| 'activate';
|
|
||||||
|
|
||||||
const EMAIL: AccountFormField = {
|
|
||||||
name: 'email',
|
|
||||||
label: 'Email',
|
|
||||||
type: 'email',
|
|
||||||
autoComplete: 'email',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLOWS: Record<
|
|
||||||
AccountFlow,
|
|
||||||
{
|
|
||||||
fields: AccountFormField[];
|
|
||||||
endpoint: string;
|
|
||||||
redirectTo?: string;
|
|
||||||
/** Reads Shopify's emailed `/{id}/{token}` link segments into the body. */
|
|
||||||
usesRouteToken?: 'resetToken' | 'activationToken';
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
login: {
|
|
||||||
fields: [
|
|
||||||
EMAIL,
|
|
||||||
{
|
|
||||||
name: 'password',
|
|
||||||
label: 'Password',
|
|
||||||
type: 'password',
|
|
||||||
autoComplete: 'current-password',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endpoint: '/api/account/login',
|
|
||||||
redirectTo: '/account',
|
|
||||||
},
|
|
||||||
register: {
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'firstName',
|
|
||||||
label: 'First name',
|
|
||||||
autoComplete: 'given-name',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lastName',
|
|
||||||
label: 'Last name',
|
|
||||||
autoComplete: 'family-name',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
EMAIL,
|
|
||||||
{
|
|
||||||
name: 'password',
|
|
||||||
label: 'Password',
|
|
||||||
type: 'password',
|
|
||||||
autoComplete: 'new-password',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endpoint: '/api/account/register',
|
|
||||||
redirectTo: '/account',
|
|
||||||
},
|
|
||||||
recover: {
|
|
||||||
fields: [EMAIL],
|
|
||||||
endpoint: '/api/account/recover',
|
|
||||||
},
|
|
||||||
reset: {
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'password',
|
|
||||||
label: 'New password',
|
|
||||||
type: 'password',
|
|
||||||
autoComplete: 'new-password',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endpoint: '/api/account/reset',
|
|
||||||
redirectTo: '/account',
|
|
||||||
usesRouteToken: 'resetToken',
|
|
||||||
},
|
|
||||||
activate: {
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'password',
|
|
||||||
label: 'Password',
|
|
||||||
type: 'password',
|
|
||||||
autoComplete: 'new-password',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endpoint: '/api/account/activate',
|
|
||||||
redirectTo: '/account',
|
|
||||||
usesRouteToken: 'activationToken',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AccountPanelLink {
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountPanelProps {
|
|
||||||
flow?: AccountFlow;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
successMessage?: string;
|
|
||||||
/** Rendered under the form — "New here? Create an account", etc. */
|
|
||||||
links?: AccountPanelLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AccountPanel: React.FC<AccountPanelProps> = ({
|
|
||||||
flow = 'login',
|
|
||||||
title = 'Sign in',
|
|
||||||
description,
|
|
||||||
submitLabel = 'Sign in',
|
|
||||||
successMessage,
|
|
||||||
links = [],
|
|
||||||
}) => {
|
|
||||||
const params = useParams();
|
|
||||||
const config = FLOWS[flow] ?? FLOWS.login;
|
|
||||||
|
|
||||||
const extraPayload = config.usesRouteToken
|
|
||||||
? {
|
|
||||||
id: (params?.id as string) ?? '',
|
|
||||||
[config.usesRouteToken]: (params?.token as string) ?? '',
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
|
||||||
<AccountForm
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
fields={config.fields}
|
|
||||||
submitLabel={submitLabel}
|
|
||||||
endpoint={config.endpoint}
|
|
||||||
extraPayload={extraPayload}
|
|
||||||
redirectTo={config.redirectTo}
|
|
||||||
successMessage={successMessage}
|
|
||||||
footer={
|
|
||||||
links.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{links.map((link) => (
|
|
||||||
<AccountFormLink key={link.url} href={link.url}>
|
|
||||||
{link.label}
|
|
||||||
</AccountFormLink>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountPanel;
|
|
||||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
|||||||
import { useCartStore } from '@/hooks/use-shopify-cart';
|
import { useCartStore } from '@/hooks/use-shopify-cart';
|
||||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||||
import SearchDialog from '@/components/shopify/search-dialog';
|
import SearchDialog from '@/components/shopify/search-dialog';
|
||||||
import AccountMenu from '@/components/shopify/account-menu';
|
|
||||||
import ShopMenu from '@/components/shopify/shop-menu';
|
import ShopMenu from '@/components/shopify/shop-menu';
|
||||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -117,7 +116,6 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SearchDialog />
|
<SearchDialog />
|
||||||
<AccountMenu />
|
|
||||||
<CartIcon />
|
<CartIcon />
|
||||||
|
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { RiImageLine } from '@remixicon/react';
|
|
||||||
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
interface OrderHistoryProps {
|
|
||||||
customer: Customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMoney = (amount: string, currencyCode: string) => {
|
|
||||||
const value = parseFloat(amount);
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currencyCode,
|
|
||||||
}).format(value);
|
|
||||||
} catch {
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
|
|
||||||
if (!label) return null;
|
|
||||||
return (
|
|
||||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
{label.replace(/_/g, ' ').toLowerCase()}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
|
|
||||||
const orders: CustomerOrder[] =
|
|
||||||
customer.orders?.edges.map((edge) => edge.node) ?? [];
|
|
||||||
|
|
||||||
// The Storefront API has no standalone order-by-id query for customers, so
|
|
||||||
// the detail is rendered from the order already loaded in this list.
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(
|
|
||||||
orders[0]?.id ?? null
|
|
||||||
);
|
|
||||||
const selected = orders.find((order) => order.id === selectedId) ?? null;
|
|
||||||
|
|
||||||
if (orders.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You haven't placed any orders yet.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
|
|
||||||
{/* List */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{orders.map((order) => {
|
|
||||||
const isSelected = order.id === selectedId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={order.id}>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedId(order.id)}
|
|
||||||
aria-current={isSelected}
|
|
||||||
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
|
|
||||||
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block text-sm font-medium text-foreground">
|
|
||||||
Order #{order.orderNumber}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-muted-foreground">
|
|
||||||
{formatDate(order.processedAt)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
|
|
||||||
{formatMoney(
|
|
||||||
order.currentTotalPrice.amount,
|
|
||||||
order.currentTotalPrice.currencyCode
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detail — same screen, no navigation */}
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
{selected && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
|
||||||
<h2 className="text-lg text-foreground">
|
|
||||||
Order #{selected.orderNumber}
|
|
||||||
</h2>
|
|
||||||
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
|
|
||||||
{formatMoney(
|
|
||||||
selected.currentTotalPrice.amount,
|
|
||||||
selected.currentTotalPrice.currencyCode
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Placed {formatDate(selected.processedAt)}
|
|
||||||
</span>
|
|
||||||
<StatusPill label={selected.financialStatus} />
|
|
||||||
<StatusPill label={selected.fulfillmentStatus} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-6 flex flex-col gap-4">
|
|
||||||
{selected.lineItems.edges.map(({ node }, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-x-3">
|
|
||||||
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
|
|
||||||
{node.variant?.image ? (
|
|
||||||
<Image
|
|
||||||
src={node.variant.image.url}
|
|
||||||
alt={node.variant.image.altText || node.title}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
|
||||||
<RiImageLine className="size-5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{node.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Qty {node.quantity}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{selected.statusUrl && (
|
|
||||||
<a
|
|
||||||
href={selected.statusUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
||||||
>
|
|
||||||
View order status
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrderHistory;
|
|
||||||
+1
-9
@@ -11,8 +11,6 @@ import type { SearchResultsBlockProps } from '@/components/shopify/search-result
|
|||||||
import type { StoreAssistantBlockProps } from '@/components/shopify/store-assistant.editor';
|
import type { StoreAssistantBlockProps } from '@/components/shopify/store-assistant.editor';
|
||||||
import type { ContentSectionProps } from '@/components/shopify/content-section';
|
import type { ContentSectionProps } from '@/components/shopify/content-section';
|
||||||
import type { PolicyBodyProps } from '@/components/shopify/policy-body';
|
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';
|
import type { RootProps } from './root';
|
||||||
|
|
||||||
@@ -30,18 +28,12 @@ export type Components = {
|
|||||||
'store-assistant': StoreAssistantBlockProps;
|
'store-assistant': StoreAssistantBlockProps;
|
||||||
'content-section': ContentSectionProps;
|
'content-section': ContentSectionProps;
|
||||||
policy: PolicyBodyProps;
|
policy: PolicyBodyProps;
|
||||||
'account-login': AccountPanelProps;
|
|
||||||
'account-register': AccountPanelProps;
|
|
||||||
'account-recover': AccountPanelProps;
|
|
||||||
'account-reset': AccountPanelProps;
|
|
||||||
'account-activate': AccountPanelProps;
|
|
||||||
'account-orders': AccountOrdersProps;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserConfig = Config<{
|
export type UserConfig = Config<{
|
||||||
components: Components;
|
components: Components;
|
||||||
root: RootProps;
|
root: RootProps;
|
||||||
categories: ['navigation', 'commerce', 'content', 'account'];
|
categories: ['navigation', 'commerce', 'content'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type UserData = Data<Components, RootProps>;
|
export type UserData = Data<Components, RootProps>;
|
||||||
|
|||||||
@@ -12,20 +12,11 @@ import searchResultsEditor from '@/components/shopify/search-results.editor';
|
|||||||
import storeAssistantEditor from '@/components/shopify/store-assistant.editor';
|
import storeAssistantEditor from '@/components/shopify/store-assistant.editor';
|
||||||
import contentSectionEditor from '@/components/shopify/content-section.editor';
|
import contentSectionEditor from '@/components/shopify/content-section.editor';
|
||||||
import policyBodyEditor from '@/components/shopify/policy-body.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 = {
|
const categories = {
|
||||||
navigation: { title: 'Navigation' },
|
navigation: { title: 'Navigation' },
|
||||||
commerce: { title: 'Commerce' },
|
commerce: { title: 'Commerce' },
|
||||||
content: { title: 'Content' },
|
content: { title: 'Content' },
|
||||||
account: { title: 'Account' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,12 +41,6 @@ export const appConfig: UserConfig = {
|
|||||||
'store-assistant': storeAssistantEditor,
|
'store-assistant': storeAssistantEditor,
|
||||||
'content-section': contentSectionEditor,
|
'content-section': contentSectionEditor,
|
||||||
policy: policyBodyEditor,
|
policy: policyBodyEditor,
|
||||||
'account-login': accountLoginEditor,
|
|
||||||
'account-register': accountRegisterEditor,
|
|
||||||
'account-recover': accountRecoverEditor,
|
|
||||||
'account-reset': accountResetEditor,
|
|
||||||
'account-activate': accountActivateEditor,
|
|
||||||
'account-orders': accountOrdersEditor,
|
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
// Customer account operations (classic Storefront customer accounts).
|
|
||||||
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
|
|
||||||
import { gql } from '@shopify/hydrogen';
|
|
||||||
|
|
||||||
const CustomerFragment = gql(`
|
|
||||||
fragment CustomerFragment on Customer {
|
|
||||||
id
|
|
||||||
email
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
phone
|
|
||||||
displayName
|
|
||||||
acceptsMarketing
|
|
||||||
createdAt
|
|
||||||
defaultAddress {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
address1
|
|
||||||
address2
|
|
||||||
city
|
|
||||||
province
|
|
||||||
zip
|
|
||||||
country
|
|
||||||
phone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const CUSTOMER_QUERY = gql(
|
|
||||||
`
|
|
||||||
query GetCustomer($customerAccessToken: String!, $orderCount: Int!) {
|
|
||||||
customer(customerAccessToken: $customerAccessToken) {
|
|
||||||
...CustomerFragment
|
|
||||||
orders(first: $orderCount, reverse: true) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
orderNumber
|
|
||||||
processedAt
|
|
||||||
financialStatus
|
|
||||||
fulfillmentStatus
|
|
||||||
statusUrl
|
|
||||||
currentTotalPrice {
|
|
||||||
amount
|
|
||||||
currencyCode
|
|
||||||
}
|
|
||||||
lineItems(first: 5) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
title
|
|
||||||
quantity
|
|
||||||
variant {
|
|
||||||
image {
|
|
||||||
url
|
|
||||||
altText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
[CustomerFragment]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CUSTOMER_CREATE_MUTATION = gql(`
|
|
||||||
mutation CustomerCreate($input: CustomerCreateInput!) {
|
|
||||||
customerCreate(input: $input) {
|
|
||||||
customer {
|
|
||||||
id
|
|
||||||
email
|
|
||||||
}
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = gql(`
|
|
||||||
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
|
|
||||||
customerAccessTokenCreate(input: $input) {
|
|
||||||
customerAccessToken {
|
|
||||||
accessToken
|
|
||||||
expiresAt
|
|
||||||
}
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = gql(`
|
|
||||||
mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
|
|
||||||
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
|
|
||||||
deletedAccessToken
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Sends the "reset your password" email.
|
|
||||||
export const CUSTOMER_RECOVER_MUTATION = gql(`
|
|
||||||
mutation CustomerRecover($email: String!) {
|
|
||||||
customerRecover(email: $email) {
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Completes the reset using the id + token from the emailed link.
|
|
||||||
export const CUSTOMER_RESET_MUTATION = gql(`
|
|
||||||
mutation CustomerReset($id: ID!, $input: CustomerResetInput!) {
|
|
||||||
customerReset(id: $id, input: $input) {
|
|
||||||
customerAccessToken {
|
|
||||||
accessToken
|
|
||||||
expiresAt
|
|
||||||
}
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Activation link sent to customers created by the merchant.
|
|
||||||
export const CUSTOMER_ACTIVATE_MUTATION = gql(`
|
|
||||||
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
|
|
||||||
customerActivate(id: $id, input: $input) {
|
|
||||||
customerAccessToken {
|
|
||||||
accessToken
|
|
||||||
expiresAt
|
|
||||||
}
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const CUSTOMER_UPDATE_MUTATION = gql(
|
|
||||||
`
|
|
||||||
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
|
|
||||||
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
|
|
||||||
customer {
|
|
||||||
...CustomerFragment
|
|
||||||
}
|
|
||||||
customerUserErrors {
|
|
||||||
code
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
[CustomerFragment]
|
|
||||||
);
|
|
||||||
+2
-24
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* `key` is what the editor's page picker shows and what `onPublish` hands back;
|
* `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
|
* `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
|
* the list here means the publish action can validate a route key against it
|
||||||
* than trusting a path from the browser.
|
* rather than trusting a path from the browser.
|
||||||
*/
|
*/
|
||||||
export interface PageRoute {
|
export interface PageRoute {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -29,28 +29,6 @@ export const PAGE_ROUTES: PageRoute[] = [
|
|||||||
},
|
},
|
||||||
{ key: '/search', label: 'Search', dir: 'search' },
|
{ key: '/search', label: 'Search', dir: 'search' },
|
||||||
{ key: '/policies/[handle]', label: 'Policy', dir: 'policies/[handle]' },
|
{ 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);
|
export const ROUTE_KEYS = PAGE_ROUTES.map((route) => route.key);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { findPageRoute } from '@/lib/pages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a published page straight to its `page.json` on disk.
|
||||||
|
*
|
||||||
|
* A server action rather than a route handler: the editor calls it like a
|
||||||
|
* function, and there is no HTTP endpoint sitting in front of the filesystem.
|
||||||
|
*
|
||||||
|
* The path is built from the registry in `lib/pages.ts`, never from the caller,
|
||||||
|
* so an unknown or crafted route key is rejected outright rather than escaping
|
||||||
|
* the `app/` directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Props of blocks marked `global: true` (header, footer) live in one file that
|
||||||
|
// every page.json references, so editing them once updates every route.
|
||||||
|
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
/** Path of the written file, relative to the project root. */
|
||||||
|
file?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishPage(
|
||||||
|
routeKey: string,
|
||||||
|
page: unknown
|
||||||
|
): Promise<PublishResult> {
|
||||||
|
const route = findPageRoute(routeKey);
|
||||||
|
if (!route) return { error: `Unknown route: ${routeKey}` };
|
||||||
|
|
||||||
|
if (!page || typeof page !== 'object') {
|
||||||
|
return { error: 'Expected a page object.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDir = path.join(process.cwd(), 'app');
|
||||||
|
const file = path.join(appDir, route.dir, 'page.json');
|
||||||
|
|
||||||
|
// Belt-and-braces against a registry entry with a traversing `dir`.
|
||||||
|
if (file !== path.join(appDir, 'page.json') && !file.startsWith(appDir + path.sep)) {
|
||||||
|
return { error: `Unknown route: ${routeKey}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals belong to the whole site, not this route, so they go to their own
|
||||||
|
// file and are stripped from the page before it is written.
|
||||||
|
const { globals, ...pageData } = page as Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(file, `${JSON.stringify(pageData, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
if (globals && typeof globals === 'object') {
|
||||||
|
await writeFile(
|
||||||
|
GLOBALS_FILE,
|
||||||
|
`${JSON.stringify(globals, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file: path.relative(process.cwd(), file) };
|
||||||
|
} catch (err) {
|
||||||
|
// Read-only filesystems (most serverless hosts) land here. Say so plainly
|
||||||
|
// rather than reporting a save that did not happen.
|
||||||
|
console.error(`Failed to write ${file}:`, err);
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
// Server-safe customer account access.
|
|
||||||
//
|
|
||||||
// The customer access token is a credential: it is only ever handled here and
|
|
||||||
// in the /api/account route handlers, and is stored in an httpOnly cookie so
|
|
||||||
// client JavaScript can never read it.
|
|
||||||
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
|
|
||||||
import {
|
|
||||||
CUSTOMER_QUERY,
|
|
||||||
CUSTOMER_CREATE_MUTATION,
|
|
||||||
CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
|
|
||||||
CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION,
|
|
||||||
CUSTOMER_RECOVER_MUTATION,
|
|
||||||
CUSTOMER_RESET_MUTATION,
|
|
||||||
CUSTOMER_ACTIVATE_MUTATION,
|
|
||||||
CUSTOMER_UPDATE_MUTATION,
|
|
||||||
} from '@/graphql/customer';
|
|
||||||
|
|
||||||
export const CUSTOMER_TOKEN_COOKIE = 'customerAccessToken';
|
|
||||||
|
|
||||||
export interface CustomerUserError {
|
|
||||||
/** One of `CustomerErrorCode`; kept as a string so new codes don't break. */
|
|
||||||
code?: string | null;
|
|
||||||
field?: string[] | null;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomerAddress {
|
|
||||||
id: string;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName?: string | null;
|
|
||||||
address1?: string | null;
|
|
||||||
address2?: string | null;
|
|
||||||
city?: string | null;
|
|
||||||
province?: string | null;
|
|
||||||
zip?: string | null;
|
|
||||||
country?: string | null;
|
|
||||||
phone?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomerOrder {
|
|
||||||
id: string;
|
|
||||||
orderNumber: number;
|
|
||||||
processedAt: string;
|
|
||||||
financialStatus?: string | null;
|
|
||||||
fulfillmentStatus?: string | null;
|
|
||||||
statusUrl?: string | null;
|
|
||||||
currentTotalPrice: { amount: string; currencyCode: string };
|
|
||||||
lineItems: {
|
|
||||||
edges: Array<{
|
|
||||||
node: {
|
|
||||||
title: string;
|
|
||||||
quantity: number;
|
|
||||||
variant?: { image?: { url: string; altText?: string | null } | null } | null;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Customer {
|
|
||||||
id: string;
|
|
||||||
email: string | null;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName?: string | null;
|
|
||||||
phone?: string | null;
|
|
||||||
displayName: string;
|
|
||||||
acceptsMarketing?: boolean;
|
|
||||||
createdAt?: string;
|
|
||||||
defaultAddress?: CustomerAddress | null;
|
|
||||||
orders?: { edges: Array<{ node: CustomerOrder }> };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccessToken {
|
|
||||||
accessToken: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Either a token (success) or the errors Shopify reported. */
|
|
||||||
export interface AuthResult {
|
|
||||||
token: AccessToken | null;
|
|
||||||
errors: CustomerUserError[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstMessage = (errors: CustomerUserError[]) =>
|
|
||||||
errors[0]?.message ?? 'Something went wrong. Please try again.';
|
|
||||||
|
|
||||||
export { firstMessage as customerErrorMessage };
|
|
||||||
|
|
||||||
// Shopify returns a null mutation payload when the mutation could not run at
|
|
||||||
// all. That is not a success, so it surfaces as a generic error rather than an
|
|
||||||
// empty error list, which callers read as "it worked".
|
|
||||||
const MUTATION_FAILED: CustomerUserError[] = [
|
|
||||||
{ message: 'Something went wrong. Please try again.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function createCustomer(input: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
acceptsMarketing?: boolean;
|
|
||||||
}): Promise<{ errors: CustomerUserError[] }> {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
|
|
||||||
variables: { input },
|
|
||||||
}),
|
|
||||||
'CustomerCreate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = data.customerCreate;
|
|
||||||
if (!result) return { errors: MUTATION_FAILED };
|
|
||||||
|
|
||||||
return { errors: result.customerUserErrors ?? [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
|
|
||||||
variables: { input: { email, password } },
|
|
||||||
}),
|
|
||||||
'CustomerAccessTokenCreate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = data.customerAccessTokenCreate;
|
|
||||||
if (!result) return { token: null, errors: MUTATION_FAILED };
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: result.customerAccessToken ?? null,
|
|
||||||
errors: result.customerUserErrors ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(accessToken: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION, {
|
|
||||||
variables: { customerAccessToken: accessToken },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// The cookie is cleared regardless; a failed revoke shouldn't block logout.
|
|
||||||
console.error('Failed to revoke customer access token:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always resolves without error detail: revealing whether an address exists
|
|
||||||
// would leak account membership.
|
|
||||||
export async function recoverPassword(email: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await storefront.graphql(CUSTOMER_RECOVER_MUTATION, {
|
|
||||||
variables: { email },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Password recovery request failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetPassword(
|
|
||||||
id: string,
|
|
||||||
resetToken: string,
|
|
||||||
password: string
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
|
|
||||||
variables: { id, input: { resetToken, password } },
|
|
||||||
}),
|
|
||||||
'CustomerReset'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = data.customerReset;
|
|
||||||
if (!result) return { token: null, errors: MUTATION_FAILED };
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: result.customerAccessToken ?? null,
|
|
||||||
errors: result.customerUserErrors ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function activateAccount(
|
|
||||||
id: string,
|
|
||||||
activationToken: string,
|
|
||||||
password: string
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
|
|
||||||
variables: { id, input: { activationToken, password } },
|
|
||||||
}),
|
|
||||||
'CustomerActivate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = data.customerActivate;
|
|
||||||
if (!result) return { token: null, errors: MUTATION_FAILED };
|
|
||||||
|
|
||||||
return {
|
|
||||||
token: result.customerAccessToken ?? null,
|
|
||||||
errors: result.customerUserErrors ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCustomer(
|
|
||||||
accessToken: string,
|
|
||||||
orderCount = 10
|
|
||||||
): Promise<Customer | null> {
|
|
||||||
try {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_QUERY, {
|
|
||||||
variables: { customerAccessToken: accessToken, orderCount },
|
|
||||||
}),
|
|
||||||
'GetCustomer'
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.customer ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
// An expired or revoked token reads as "not signed in".
|
|
||||||
console.error('Failed to load customer:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCustomer(
|
|
||||||
accessToken: string,
|
|
||||||
customer: {
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
|
|
||||||
const data = unwrapStorefrontResult(
|
|
||||||
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
|
|
||||||
variables: { customerAccessToken: accessToken, customer },
|
|
||||||
}),
|
|
||||||
'CustomerUpdate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = data.customerUpdate;
|
|
||||||
if (!result) return { customer: null, errors: MUTATION_FAILED };
|
|
||||||
|
|
||||||
return {
|
|
||||||
customer: result.customer ?? null,
|
|
||||||
errors: result.customerUserErrors ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shopify's emailed links carry a numeric id; the mutations want a GID.
|
|
||||||
export function toCustomerGid(id: string): string {
|
|
||||||
return id.startsWith('gid://') ? id : `gid://shopify/Customer/${id}`;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Customer session, stored in an httpOnly cookie. Server-only: importing this
|
|
||||||
// from a client component will fail, which is deliberate — the access token
|
|
||||||
// must never reach the browser's JavaScript.
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { CUSTOMER_TOKEN_COOKIE } from '@/services/shopify/customer';
|
|
||||||
|
|
||||||
export async function getSessionToken(): Promise<string | null> {
|
|
||||||
const store = await cookies();
|
|
||||||
return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setSessionToken(
|
|
||||||
accessToken: string,
|
|
||||||
expiresAt: string
|
|
||||||
): Promise<void> {
|
|
||||||
const store = await cookies();
|
|
||||||
const expires = new Date(expiresAt);
|
|
||||||
|
|
||||||
store.set(CUSTOMER_TOKEN_COOKIE, accessToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
expires: Number.isNaN(expires.getTime()) ? undefined : expires,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionToken(): Promise<void> {
|
|
||||||
const store = await cookies();
|
|
||||||
store.delete(CUSTOMER_TOKEN_COOKIE);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user