+
{/* Mobile hamburger */}
diff --git a/components/shopify/order-history.tsx b/components/shopify/order-history.tsx
new file mode 100644
index 0000000..8f3f5d6
--- /dev/null
+++ b/components/shopify/order-history.tsx
@@ -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 (
+
+ {label.replace(/_/g, ' ').toLowerCase()}
+
+ );
+};
+
+const OrderHistory: React.FC
= ({ customer }) => {
+ const orders: CustomerOrder[] =
+ customer.orders?.edges.map((edge) => edge.node) ?? [];
+
+ // The Storefront API has no standalone order-by-id query for customers, so
+ // the detail is rendered from the order already loaded in this list.
+ const [selectedId, setSelectedId] = useState(
+ orders[0]?.id ?? null
+ );
+ const selected = orders.find((order) => order.id === selectedId) ?? null;
+
+ if (orders.length === 0) {
+ return (
+
+ You haven't placed any orders yet.
+
+ );
+ }
+
+ return (
+
+ {/* List */}
+
+
Orders
+
+ {orders.map((order) => {
+ const isSelected = order.id === selectedId;
+
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+ {/* Detail — same screen, no navigation */}
+
+ {selected && (
+ <>
+
+
+ Order #{selected.orderNumber}
+
+
+ {formatMoney(
+ selected.currentTotalPrice.amount,
+ selected.currentTotalPrice.currencyCode
+ )}
+
+
+
+
+
+ Placed {formatDate(selected.processedAt)}
+
+
+
+
+
+
+ {selected.lineItems.edges.map(({ node }, index) => (
+ -
+
+ {node.variant?.image ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+ {node.title}
+
+
+ Qty {node.quantity}
+
+
+
+ ))}
+
+
+ {selected.statusUrl && (
+
+ View order status
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
+export default OrderHistory;
diff --git a/graphql/customer.js b/graphql/customer.js
new file mode 100644
index 0000000..b22fb50
--- /dev/null
+++ b/graphql/customer.js
@@ -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
+ }
+ }
+ }
+`;
diff --git a/services/shopify/customer.ts b/services/shopify/customer.ts
new file mode 100644
index 0000000..02d57d4
--- /dev/null
+++ b/services/shopify/customer.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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}`;
+}
diff --git a/services/shopify/session.ts b/services/shopify/session.ts
new file mode 100644
index 0000000..5697e56
--- /dev/null
+++ b/services/shopify/session.ts
@@ -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 {
+ const store = await cookies();
+ return store.get(CUSTOMER_TOKEN_COOKIE)?.value ?? null;
+}
+
+export async function setSessionToken(
+ accessToken: string,
+ expiresAt: string
+): Promise {
+ const store = await cookies();
+ const expires = new Date(expiresAt);
+
+ store.set(CUSTOMER_TOKEN_COOKIE, accessToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ expires: Number.isNaN(expires.getTime()) ? undefined : expires,
+ });
+}
+
+export async function clearSessionToken(): Promise {
+ const store = await cookies();
+ store.delete(CUSTOMER_TOKEN_COOKIE);
+}