Template
Add customer accounts: login, signup, reset, activate, orders
- Storefront customer operations in graphql/customer.js and a server-safe services/shopify/customer.ts - Session held in an httpOnly, sameSite=lax cookie set by the /api/account handlers; the access token never reaches client JS - Pages: /account/login, /register, /recover, /reset/[id]/[token] and /activate/[id]/[token] for Shopify's emailed links - /account renders order history as master-detail on one screen, since the Storefront API has no standalone order-by-id query for customers - Header user icon: links to sign-in when signed out, otherwise a menu with name, email, order history, and sign out - Login errors are collapsed and password recovery responds identically for known and unknown emails, so neither form enumerates accounts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
8163f99bd7
commit
8167acb231
@@ -0,0 +1,39 @@
|
|||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import AccountForm from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Activate your account — Shop' };
|
||||||
|
|
||||||
|
// Shopify's emailed activation link is /account/activate/{id}/{token}.
|
||||||
|
export default async function Page({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; token: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, token } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title="Activate your account"
|
||||||
|
description="Choose a password to finish setting up your account."
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
submitLabel="Activate account"
|
||||||
|
endpoint="/api/account/activate"
|
||||||
|
extraPayload={{ id, activationToken: token }}
|
||||||
|
redirectTo="/account"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Sign in — Shop' };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title="Sign in"
|
||||||
|
fields={[
|
||||||
|
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
submitLabel="Sign in"
|
||||||
|
endpoint="/api/account/login"
|
||||||
|
redirectTo="/account"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
New here?{' '}
|
||||||
|
<AccountFormLink href="/account/register">
|
||||||
|
Create an account
|
||||||
|
</AccountFormLink>
|
||||||
|
</span>
|
||||||
|
<AccountFormLink href="/account/recover">
|
||||||
|
Forgot your password?
|
||||||
|
</AccountFormLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import OrderHistory from '@/components/shopify/order-history';
|
||||||
|
import { getSessionToken } from '@/services/shopify/session';
|
||||||
|
import { getCustomer } from '@/services/shopify/customer';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Order history — Shop' };
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (!token) redirect('/account/login');
|
||||||
|
|
||||||
|
const customer = await getCustomer(token, 20);
|
||||||
|
// An expired or revoked token reads as signed out.
|
||||||
|
if (!customer) redirect('/account/login');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Order history
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{customer.displayName} · {customer.email}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<OrderHistory customer={customer} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Reset password — Shop' };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title="Reset password"
|
||||||
|
description="Enter your email and we'll send you a link to set a new password."
|
||||||
|
fields={[
|
||||||
|
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||||
|
]}
|
||||||
|
submitLabel="Send reset link"
|
||||||
|
endpoint="/api/account/recover"
|
||||||
|
successMessage="If that email has an account, a reset link is on its way."
|
||||||
|
footer={
|
||||||
|
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Create account — Shop' };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title="Create account"
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: 'firstName',
|
||||||
|
label: 'First name',
|
||||||
|
autoComplete: 'given-name',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastName',
|
||||||
|
label: 'Last name',
|
||||||
|
autoComplete: 'family-name',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
submitLabel="Create account"
|
||||||
|
endpoint="/api/account/register"
|
||||||
|
redirectTo="/account"
|
||||||
|
footer={
|
||||||
|
<span>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<AccountFormLink href="/account/login">Sign in</AccountFormLink>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Header from '@/components/shopify/header';
|
||||||
|
import Footer from '@/components/shopify/footer';
|
||||||
|
import AccountForm from '@/components/shopify/account-form';
|
||||||
|
|
||||||
|
export const metadata = { title: 'Set a new password — Shop' };
|
||||||
|
|
||||||
|
// Shopify's emailed reset link is /account/reset/{id}/{token}.
|
||||||
|
export default async function Page({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; token: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, token } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header storeName="Shop" logoUrl="" />
|
||||||
|
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||||
|
<AccountForm
|
||||||
|
title="Set a new password"
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'New password',
|
||||||
|
type: 'password',
|
||||||
|
autoComplete: 'new-password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
submitLabel="Save password"
|
||||||
|
endpoint="/api/account/reset"
|
||||||
|
extraPayload={{ id, resetToken: token }}
|
||||||
|
redirectTo="/account"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer storeName="Shop" copyright="© 2026 Shop. All rights reserved." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'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 '@/app/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;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'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;
|
||||||
@@ -5,6 +5,7 @@ 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 { 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';
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ 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 */}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
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 ? (
|
||||||
|
<img
|
||||||
|
src={node.variant.image.url}
|
||||||
|
alt={node.variant.image.altText || node.title}
|
||||||
|
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;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Customer account operations (classic Storefront customer accounts).
|
||||||
|
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
|
||||||
|
|
||||||
|
const CustomerFragment = `
|
||||||
|
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 = `
|
||||||
|
${CustomerFragment}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CUSTOMER_CREATE_MUTATION = `
|
||||||
|
mutation CustomerCreate($input: CustomerCreateInput!) {
|
||||||
|
customerCreate(input: $input) {
|
||||||
|
customer {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
}
|
||||||
|
customerUserErrors {
|
||||||
|
code
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION = `
|
||||||
|
mutation CustomerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
|
||||||
|
customerAccessTokenCreate(input: $input) {
|
||||||
|
customerAccessToken {
|
||||||
|
accessToken
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
customerUserErrors {
|
||||||
|
code
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CUSTOMER_ACCESS_TOKEN_DELETE_MUTATION = `
|
||||||
|
mutation CustomerAccessTokenDelete($customerAccessToken: String!) {
|
||||||
|
customerAccessTokenDelete(customerAccessToken: $customerAccessToken) {
|
||||||
|
deletedAccessToken
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Sends the "reset your password" email.
|
||||||
|
export const CUSTOMER_RECOVER_MUTATION = `
|
||||||
|
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 = `
|
||||||
|
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 = `
|
||||||
|
mutation CustomerActivate($id: ID!, $input: CustomerActivateInput!) {
|
||||||
|
customerActivate(id: $id, input: $input) {
|
||||||
|
customerAccessToken {
|
||||||
|
accessToken
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
customerUserErrors {
|
||||||
|
code
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CUSTOMER_UPDATE_MUTATION = `
|
||||||
|
${CustomerFragment}
|
||||||
|
mutation CustomerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {
|
||||||
|
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
|
||||||
|
customer {
|
||||||
|
...CustomerFragment
|
||||||
|
}
|
||||||
|
customerUserErrors {
|
||||||
|
code
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// 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 { shopifyFetch } 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 {
|
||||||
|
code?: string;
|
||||||
|
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;
|
||||||
|
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 };
|
||||||
|
|
||||||
|
export async function createCustomer(input: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
acceptsMarketing?: boolean;
|
||||||
|
}): Promise<{ errors: CustomerUserError[] }> {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_CREATE_MUTATION,
|
||||||
|
variables: { input },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { errors: response.data.customerCreate.customerUserErrors ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
|
||||||
|
variables: { input: { email, password } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.customerAccessTokenCreate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: result.customerAccessToken ?? null,
|
||||||
|
errors: result.customerUserErrors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(accessToken: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await shopifyFetch({
|
||||||
|
query: 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 shopifyFetch({
|
||||||
|
query: 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 response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_RESET_MUTATION,
|
||||||
|
variables: { id, input: { resetToken, password } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.customerReset;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: result.customerAccessToken ?? null,
|
||||||
|
errors: result.customerUserErrors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateAccount(
|
||||||
|
id: string,
|
||||||
|
activationToken: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_ACTIVATE_MUTATION,
|
||||||
|
variables: { id, input: { activationToken, password } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.customerActivate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: result.customerAccessToken ?? null,
|
||||||
|
errors: result.customerUserErrors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomer(
|
||||||
|
accessToken: string,
|
||||||
|
orderCount = 10
|
||||||
|
): Promise<Customer | null> {
|
||||||
|
try {
|
||||||
|
const response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_QUERY,
|
||||||
|
variables: { customerAccessToken: accessToken, orderCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.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 response = await shopifyFetch({
|
||||||
|
query: CUSTOMER_UPDATE_MUTATION,
|
||||||
|
variables: { customerAccessToken: accessToken, customer },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.customerUpdate;
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 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