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