mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
466 lines
21 KiB
React
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>
|
|
);
|
|
}
|