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