Files
starr-philproperties/src/modules/client/pages/AccountSettings.jsx
T
2026-08-15 11:09:30 +08:00

466 lines
21 KiB
React

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { toast } from "sonner";
// ─── Section wrapper ──────────────────────────────────────────────────────────
function Section({ icon: Icon, title, description, children }) {
return (
<Card>
<CardHeader className="">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
{title}
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<Separator />
<CardContent className="space-y-4">
{children}
</CardContent>
</Card>
);
}
// ─── Security ─────────────────────────────────────────────────────────────────
function SecuritySection({ user, logout }) {
const navigate = useNavigate();
const isGoogle = user?.reg_type === "google";
const [form, setForm] = useState({ current_password: "", new_password: "", confirm: "" });
const [show, setShow] = useState({ current: false, new: false, confirm: false });
const [loading, setLoading] = useState(false);
const toggle = (field) => setShow((p) => ({ ...p, [field]: !p[field] }));
const set = (field, val) => setForm((p) => ({ ...p, [field]: val }));
const handleSubmit = async (e) => {
e.preventDefault();
if (form.new_password !== form.confirm) {
toast("New passwords do not match.");
return;
}
if (form.new_password.length < 8) {
toast("New password must be at least 8 characters.");
return;
}
setLoading(true);
try {
await api.post("/auth/change-password", {
current_password: form.current_password,
new_password: form.new_password,
});
toast("Password changed. Logging you out…");
setTimeout(async () => {
await logout();
navigate("/login");
}, 1500);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not change password.");
} finally {
setLoading(false);
}
};
if (isGoogle) {
return (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400">
Your account uses Google Sign-In. Password management is handled through Google.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
{[
{ key: "current", label: "Current password", field: "current_password" },
{ key: "new", label: "New password", field: "new_password" },
{ key: "confirm", label: "Confirm new password", field: "confirm" },
].map(({ key, label, field }) => (
<div key={key} className="space-y-1.5">
<Label>{label}</Label>
<div className="relative">
<Input
type={show[key] ? "text" : "password"}
value={form[field]}
onChange={(e) => set(field, e.target.value)}
className="pr-10"
required
/>
<button
type="button"
onClick={() => toggle(key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{show[key] ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
))}
<Button type="submit" disabled={loading}>
{loading ? "Saving…" : "Change password"}
</Button>
</form>
);
}
// ─── Subscription ─────────────────────────────────────────────────────────────
const TIER_COLORS = {
free: "bg-muted text-muted-foreground",
premium: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
exclusive: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
};
function SubscriptionSection() {
const navigate = useNavigate();
const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers();
const { fmtDate, fmtNumber } = useDateFormat();
useEffect(() => {
getMyTier();
getMyPayments();
}, []);
const tier = myTier?.tier ?? "free";
const expiresAt = myTier?.expires_at ? fmtDate(myTier.expires_at) : null;
return (
<div className="space-y-5">
{/* Current plan */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-2">Current plan</p>
{tierLoading ? (
<Skeleton className="h-9 w-40" />
) : (
<div className="flex items-center gap-3 flex-wrap">
<div className={`capitalize rounded-md border text-sm px-3 py-0.5 ${TIER_COLORS[tier]}`}>{tier}</div>
{expiresAt && (
<span className="text-sm text-muted-foreground">Expires {expiresAt}</span>
)}
{tier === "free" && (
<Button size="sm" variant="outline" onClick={() => navigate("/subscriptions")}>
Upgrade plan <ChevronRight className="size-3.5" />
</Button>
)}
</div>
)}
</div>
<Separator />
{/* Payment history */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">Payment history</p>
{paymentsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : payments.length === 0 ? (
<p className="text-sm text-muted-foreground">No payments yet.</p>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0 z-10">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Date</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Plan</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Amount</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Status</th>
</tr>
</thead>
</table>
<ScrollArea className="h-[192px]">
<table className="w-full text-sm">
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground">
{fmtDate(p.createdAt)}
</td>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3">
{p.currency} {fmtNumber(p.amount ?? 0)}
</td>
<td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
)}
</div>
</div>
);
}
// ─── Advertisements ───────────────────────────────────────────────────────────
const AD_OPTIONS = [
{
key: "show_popup_ads",
label: "Popup ads",
description: "Show promotional popups when you open pages like the dashboard.",
},
{
key: "show_other_ads",
label: "Other ads",
description: "Show banner, hero, and sidebar advertisements across the site.",
},
];
function AdvertisementsSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
const [confirmPopupOff, setConfirmPopupOff] = useState(false);
useEffect(() => {
getProfile();
}, []);
const showPopupAds = profile?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profile?.personal_info?.show_other_ads ?? true;
// No separate stored flag — "hidden" just means both underlying toggles are off,
// so it can never drift out of sync with them.
const hideAllAds = !showPopupAds && !showOtherAds;
const handleToggle = async (key, value) => {
// Turning popup ads off also turns off other ads — confirm first since
// it's a bigger change than the switch being flipped suggests.
if (key === "show_popup_ads" && value === false) {
setConfirmPopupOff(true);
return;
}
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast("Preference saved.", {
description: "Reload the page for this to take effect."
});
}
};
const confirmTurnOffPopupAndOther = async () => {
setConfirmPopupOff(false);
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
if (result?.success) {
toast("Preference saved.", {
description: "Reload the page for this to take effect."
});
}
};
const handleHideAllToggle = async (hide) => {
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
if (result?.success) {
toast("Preference saved.", {
description: "Reload the page for this to take effect."
});
}
};
return (
<>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Hide all ads</p>
<p className="text-xs text-muted-foreground">
Turn off every advertisement across the platform, popups included.
</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch checked={hideAllAds} onCheckedChange={handleHideAllToggle} />
)}
</div>
<Separator className="mt-4" />
</div>
{AD_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? true}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < AD_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
<AlertDialog open={confirmPopupOff} onOpenChange={setConfirmPopupOff}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Turn off popup ads?</AlertDialogTitle>
<AlertDialogDescription>
This will also turn off Other ads (banner, hero, and sidebar advertisements).
You can turn either back on here anytime.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmTurnOffPopupAndOther}>
Turn off both
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ─── Delete Account ───────────────────────────────────────────────────────────
// Disabled while still in development — keep implemented for when we're ready
// to expose self-service account deletion.
//
// function DeleteAccountSection({ logout }) {
// const navigate = useNavigate();
// const [open, setOpen] = useState(false);
// const [loading, setLoading] = useState(false);
//
// const handleDelete = async () => {
// setLoading(true);
// try {
// await api.delete("/client/profile");
// toast("Account deleted. Goodbye!", {
// action: {
// label: "Close",
// onClick: () => {}
// }
// });
// await logout();
// navigate("/login");
// } catch (err) {
// toast(err?.response?.data?.message ?? "Could not delete account.", {
// action: {
// label: "Close",
// onClick: () => {}
// }
// });
// setLoading(false);
// }
// };
//
// return (
// <>
// <div className="flex items-start justify-between gap-4">
// <div className="space-y-1">
// <p className="text-sm font-medium text-destructive">Delete account</p>
// <p className="text-xs text-muted-foreground">
// Permanently remove your account and all associated data. This action cannot be undone.
// </p>
// </div>
// <Button
// variant="destructive"
// size="sm"
// className="shrink-0"
// onClick={() => setOpen(true)}
// >
// <Trash2 className="size-3.5 mr-1.5" />
// Delete account
// </Button>
// </div>
//
// <AlertDialog open={open} onOpenChange={setOpen}>
// <AlertDialogContent>
// <AlertDialogHeader>
// <AlertDialogTitle>Delete your account?</AlertDialogTitle>
// <AlertDialogDescription>
// This will permanently delete your account and sign you out of all sessions.
// Your data cannot be recovered after deletion.
// </AlertDialogDescription>
// </AlertDialogHeader>
// <AlertDialogFooter>
// <AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
// <AlertDialogAction
// onClick={handleDelete}
// disabled={loading}
// className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
// >
// {loading ? "Deleting…" : "Yes, delete my account"}
// </AlertDialogAction>
// </AlertDialogFooter>
// </AlertDialogContent>
// </AlertDialog>
// </>
// );
// }
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AccountSettings() {
const { user, logout } = useAuth();
return (
<div className="mt-17 bg-muted min-h-screen">
<div className="p-6 lg:container lg:max-w-2xl lg:mx-auto space-y-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">Account Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your security, subscription, and preferences.</p>
</div>
<Section icon={KeyRound} title="Security" description="Update your password. You'll be logged out of all sessions after changing.">
<SecuritySection user={user} logout={logout} />
</Section>
<Section icon={CreditCard} title="Subscription" description="Your current plan and billing history.">
<SubscriptionSection />
</Section>
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
<AdvertisementsSection />
</Section>
{/* <Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
<DeleteAccountSection logout={logout} />
</Section> */}
</div>
</div>
);
}