chore: add client/src/pages/account.tsx
This commit is contained in:
parent
d9e77adf5c
commit
c3b09e39ea
1 changed files with 197 additions and 0 deletions
197
client/src/pages/account.tsx
Normal file
197
client/src/pages/account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue