chore: add client/src/pages/account.tsx

This commit is contained in:
notshop 2026-04-26 16:35:06 +00:00
parent d9e77adf5c
commit c3b09e39ea

View file

@ -0,0 +1,197 @@
import { useState, useEffect } from "react";
import { useSearch, Link } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "../lib/queryClient";
import { toast } from "../hooks/use-toast";
import { Package, User, LogOut, Mail } from "lucide-react";
interface Customer {
id: number;
email: string;
firstName?: string;
lastName?: string;
phone?: string;
}
interface Order {
id: number;
orderNumber: string;
status: string;
paymentStatus: string;
fulfillmentStatus: string;
total: string;
createdAt: string;
trackingNumber?: string;
trackingCarrier?: string;
}
function LoginForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await apiRequest("POST", "/api/auth/magic-link", { email });
setSent(true);
} catch (err: any) {
toast({ title: "Error", description: err.message, variant: "destructive" });
} finally {
setLoading(false);
}
};
if (sent) {
return (
<div className="text-center py-8">
<Mail size={40} className="mx-auto mb-4 text-primary" />
<h2 className="text-xl font-semibold mb-2">Check your email</h2>
<p className="text-muted-foreground">We sent a sign-in link to <strong>{email}</strong>. Click it to sign in.</p>
<p className="text-sm text-muted-foreground mt-3">Link expires in 15 minutes.</p>
</div>
);
}
return (
<div className="max-w-sm mx-auto">
<h2 className="text-2xl font-bold mb-2 text-center">Sign in</h2>
<p className="text-muted-foreground text-sm text-center mb-6">We'll send a magic link to your email no password needed.</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Email address</label>
<input
type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
placeholder="you@example.com"
className="w-full px-3 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
data-testid="input-login-email"
/>
</div>
<button type="submit" disabled={loading}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-semibold hover:bg-primary/90 transition-colors disabled:opacity-60"
data-testid="button-send-magic-link">
{loading ? "Sending..." : "Send magic link"}
</button>
</form>
</div>
);
}
function AccountDashboard({ customer }: { customer: Customer }) {
const { data: orders, isLoading } = useQuery<Order[]>({
queryKey: ["/api/account/orders"],
queryFn: async () => { const r = await fetch("/api/account/orders", { credentials: "include" }); return r.json(); },
});
const logoutMutation = useMutation({
mutationFn: () => apiRequest("POST", "/api/auth/logout"),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/auth/me"] }),
});
const statusBadge = (status: string) => {
const colors: Record<string, string> = {
confirmed: "bg-blue-100 text-blue-700",
pending: "bg-yellow-100 text-yellow-700",
cancelled: "bg-red-100 text-red-700",
fulfilled: "bg-green-100 text-green-700",
};
return colors[status] || "bg-muted text-muted-foreground";
};
return (
<div className="max-w-3xl mx-auto" data-testid="account-dashboard">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">My Account</h1>
<p className="text-muted-foreground text-sm">{customer.email}</p>
</div>
<button
onClick={() => logoutMutation.mutate()}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
data-testid="button-logout"
>
<LogOut size={14} /> Sign out
</button>
</div>
<section>
<h2 className="font-semibold mb-4 flex items-center gap-2"><Package size={16} /> Orders</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <div key={i} className="h-16 bg-muted rounded-lg animate-pulse" />)}
</div>
) : !orders?.length ? (
<div className="text-center py-12 border border-border rounded-xl text-muted-foreground">
<Package size={32} className="mx-auto mb-3 opacity-30" />
<p>No orders yet</p>
<Link href="/" className="text-sm text-primary hover:underline mt-2 inline-block">Start shopping</Link>
</div>
) : (
<div className="space-y-3">
{orders.map((order) => (
<div key={order.id} className="border border-border rounded-xl p-4 hover:bg-muted/20 transition-colors" data-testid={`order-row-${order.id}`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium font-mono text-sm" data-testid={`order-number-${order.id}`}>#{order.orderNumber}</p>
<p className="text-xs text-muted-foreground mt-0.5">{new Date(order.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</p>
</div>
<div className="flex gap-2 items-center flex-wrap justify-end">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(order.status)}`}>{order.status}</span>
<span className="font-semibold text-sm">${parseFloat(order.total).toFixed(2)}</span>
</div>
</div>
{order.trackingNumber && (
<p className="text-xs text-muted-foreground mt-2">Tracking: <span className="font-mono">{order.trackingNumber}</span>{order.trackingCarrier && ` (${order.trackingCarrier})`}</p>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}
export default function AccountPage() {
const search = useSearch();
const token = new URLSearchParams(search).get("token");
const [verifying, setVerifying] = useState(!!token);
const { data, isLoading } = useQuery<{ customer: Customer | null }>({
queryKey: ["/api/auth/me"],
queryFn: async () => { const r = await fetch("/api/auth/me", { credentials: "include" }); return r.json(); },
});
useEffect(() => {
if (!token) return;
(async () => {
try {
const res = await fetch(`/api/auth/verify?token=${token}`, { credentials: "include" });
if (res.ok) {
await queryClient.invalidateQueries({ queryKey: ["/api/auth/me"] });
toast({ title: "Signed in!", description: "Welcome back." });
} else {
toast({ title: "Link expired", description: "Please request a new sign-in link.", variant: "destructive" });
}
} finally {
setVerifying(false);
window.history.replaceState({}, "", "/account");
}
})();
}, [token]);
if (isLoading || verifying) {
return <div className="max-w-3xl mx-auto px-4 py-20 text-center text-muted-foreground">Loading...</div>;
}
return (
<div className="max-w-3xl mx-auto px-4 py-10" data-testid="account-page">
{data?.customer ? (
<AccountDashboard customer={data.customer} />
) : (
<LoginForm />
)}
</div>
);
}