import { LanguageSwitcher } from "@/components/language-switcher"; import { useAuth } from "@/hooks/use-auth"; import { api } from "@/lib/api-client"; import { signIn } from "@/lib/auth-client"; import { useTranslation } from "@/lib/i18n"; import { capture } from "@/lib/posthog"; import { toast } from "@/lib/toast"; import { getUserSettings, setUserSetting } from "@/lib/user-settings"; import { Button } from "@sase/ui"; import { Input } from "@sase/ui"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui"; import { Label } from "@sase/ui"; import { Separator } from "@sase/ui"; import { Badge } from "@sase/ui"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@sase/ui"; import { Skeleton } from "@sase/ui"; import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, Bell, Copy, Eye, EyeOff, Gift, Link2, Monitor, Moon, Share2, Shield, SlidersHorizontal, Sun, Trash2, User, } from "lucide-react"; import { useEffect, useState } from "react"; const TAB_ITEMS = [ { value: "profile", icon: User, labelKey: "settings.tabs.profile" }, { value: "preferences", icon: SlidersHorizontal, labelKey: "settings.tabs.preferences" }, { value: "notifications", icon: Bell, labelKey: "settings.tabs.notifications" }, { value: "security", icon: Shield, labelKey: "settings.tabs.security" }, { value: "connections", icon: Link2, labelKey: "settings.tabs.connections" }, { value: "referral", icon: Gift, labelKey: "settings.tabs.referral" }, { value: "account", icon: Trash2, labelKey: "settings.tabs.account" }, ] as const; /** * UI categories rendered by the Notifications tab. Each maps to a bundle of * underlying workflows on the server — see * `apps/api/src/notifications/email-preferences.service.ts * NOTIFICATION_CATEGORIES`. We render two switches, not six, because users * care about "e-mail vs mobile" not "did the day-3 referral mail fire". * * The API also returns each category's label so the server is the source of * truth for copy; the static fallback below just avoids a flash of untitled * rows before the GET completes. */ type NotificationCategoryKey = "email_marketing" | "mobile_push"; const FALLBACK_CATEGORY_LABEL: Record = { email_marketing: "E-posta bildirimleri", mobile_push: "Mobil Bildirim", }; interface NotificationPref { category: NotificationCategoryKey; label: string; optedOut: boolean; } type ThemePref = "light" | "dark" | "system"; const THEME_OPTIONS: { value: ThemePref; icon: typeof Sun; labelKey: string }[] = [ { value: "light", icon: Sun, labelKey: "settings.preferences.themeLight" }, { value: "dark", icon: Moon, labelKey: "settings.preferences.themeDark" }, { value: "system", icon: Monitor, labelKey: "settings.preferences.themeSystem" }, ]; function applyTheme(theme: ThemePref) { const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); document.documentElement.classList.toggle("dark", isDark); } function PasswordField({ id, label, value, onChange, required, minLength, hint, error, }: { id: string; label: string; value: string; onChange: (e: React.ChangeEvent) => void; required?: boolean; minLength?: number; hint?: string; error?: string; }) { const { t } = useTranslation(); const [show, setShow] = useState(false); return (
{error ? (

{error}

) : hint ? (

{hint}

) : null}
); } export function SettingsContent({ tab, onTabChange, }: { tab: string; onTabChange: (next: string) => void; }) { const { t, locale } = useTranslation(); const { user, signOut } = useAuth(); // Profile state const [name, setName] = useState(user?.name || ""); const [savingProfile, setSavingProfile] = useState(false); // Security state const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [changingPassword, setChangingPassword] = useState(false); // Account deletion state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleting, setDeleting] = useState(false); // Referral stats const { data: referralStats, isLoading: referralLoading } = useQuery({ queryKey: ["referral-stats"], queryFn: () => api.get<{ totalReferrals: number; rewardDays: number }>("/referrals/stats"), retry: false, }); // Google connection status const { data: connections, isLoading: connectionsLoading } = useQuery({ queryKey: ["connections"], queryFn: () => api.get<{ google: boolean }>("/users/me/connections"), retry: false, }); const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword; // Appearance const [theme, setTheme] = useState(() => getUserSettings().theme ?? "dark"); function selectTheme(next: ThemePref) { setTheme(next); setUserSetting("theme", next); applyTheme(next); } useEffect(() => { if (user?.name) setName(user.name); }, [user?.name]); async function handleUpdateProfile(e: React.FormEvent) { e.preventDefault(); setSavingProfile(true); try { await api.patch("/users/me", { name }); toast.success(t("settings.profile.updated")); } catch { toast.error(t("settings.profile.updateFailed")); } finally { setSavingProfile(false); } } async function handleChangePassword(e: React.FormEvent) { e.preventDefault(); if (newPassword !== confirmPassword) { toast.error(t("auth.passwordsDoNotMatch")); return; } setChangingPassword(true); try { await api.post("/users/me/change-password", { currentPassword, newPassword, }); toast.success(t("auth.passwordChanged")); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch { toast.error(t("auth.passwordChangeFailed")); } finally { setChangingPassword(false); } } async function handleLinkGoogle() { try { await signIn.social({ provider: "google" }); toast.success(t("settings.connections.linkSuccess")); } catch { toast.error(t("settings.connections.linkFailed")); } } async function handleUnlinkGoogle() { try { await api.delete("/users/me/connections/google"); toast.success(t("settings.connections.unlinkSuccess")); } catch { toast.error(t("errors.generic")); } } async function handleDeleteAccount() { if (deleteConfirmText !== t("settings.account.confirmWord")) return; setDeleting(true); try { await api.delete("/users/me"); toast.success(t("settings.account.deleted")); capture("user_logged_out", { reason: "account_deleted" }); signOut(); } catch { toast.error(t("settings.account.deleteFailed")); } finally { setDeleting(false); } } function copyReferralCode() { if (user?.referralCode) { navigator.clipboard.writeText(user.referralCode); toast.success(t("settings.referral.codeCopied")); } } function copyShareLink() { if (user?.referralCode) { const link = `${window.location.origin}/register?ref=${user.referralCode}`; navigator.clipboard.writeText(link); toast.success(t("settings.referral.linkCopied")); } } return (

{t("settings.title")}

{TAB_ITEMS.map(({ value, icon: Icon, labelKey }) => ( {t(labelKey)} ))}
{/* Profile Tab */} {t("settings.profile.title")} {t("settings.profile.description")}
setName(e.target.value)} />
{user?.createdAt && (
)}
{/* Preferences Tab */} {/* Notifications Tab (audit §9.3 #14) */} {t("settings.preferences.title")} {t("settings.preferences.description")}
{t("settings.preferences.theme")}
{THEME_OPTIONS.map(({ value, icon: Icon, labelKey }) => ( ))}
{/* Security Tab */} {t("settings.security.title")} {t("settings.security.description")}
setCurrentPassword(e.target.value)} required /> setNewPassword(e.target.value)} required minLength={8} hint={t("settings.security.passwordHint")} /> setConfirmPassword(e.target.value)} required minLength={8} error={passwordMismatch ? t("auth.passwordsDoNotMatch") : undefined} />
{/* Connections Tab */} {t("settings.connections.title")} {t("settings.connections.description")}

{t("settings.connections.google")}

{connectionsLoading ? ( ) : ( {connections?.google ? t("settings.connections.linked") : t("settings.connections.notLinked")} )}
{connectionsLoading ? ( ) : connections?.google ? ( ) : ( )}
{/* Referral Tab */} {t("settings.referral.title")} {t("settings.referral.description")} {user?.referralCode ? ( <> {/* Referral Code */}
{/* Share Link */}
{/* Referral Stats */}

{t("settings.referral.stats")}

{referralLoading ? ( ) : (

{referralStats?.totalReferrals ?? 0}

)}

{t("settings.referral.totalReferrals")}

{referralLoading ? ( ) : (

{referralStats?.rewardDays ?? 0}

)}

{t("settings.referral.rewardDays")}

) : (

{t("settings.referral.noCode")}

)}
{/* Account Tab */} {t("settings.account.title")} {t("settings.account.description")} {t("settings.account.deleteConfirmTitle")} {t("settings.account.deleteConfirmDescription")}
setDeleteConfirmText(e.target.value)} autoComplete="off" />
); } /** * Per-workflow opt-out toggles for the lifecycle / engagement e-mails. * Pure presentation — heavy lifting (HMAC token, audit row) is on the API * side. Auth + payment mail is unaffected (`OPTIONAL_WORKFLOWS` on the * server is the canonical list). */ function NotificationsCard() { const { t } = useTranslation(); const [prefs, setPrefs] = useState(null); const [pending, setPending] = useState>(new Set()); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; api .get("/email/preferences") .then((data) => { if (!cancelled) setPrefs(data); }) .catch((err) => { if (!cancelled) setError((err as Error).message || "Hata"); }); return () => { cancelled = true; }; }, []); async function toggle(category: NotificationCategoryKey, currentlyOptedOut: boolean) { const nextOptedOut = !currentlyOptedOut; // Optimistic update — flip the local row immediately so the switch feels // instant; revert on failure. setPrefs((cur) => cur ? cur.map((p) => (p.category === category ? { ...p, optedOut: nextOptedOut } : p)) : cur, ); setPending((s) => new Set(s).add(category)); try { await api.post("/email/preferences", { category, optedOut: nextOptedOut }); capture(nextOptedOut ? "notifications_category_opted_out" : "notifications_category_opted_in", { category, }); toast.success(nextOptedOut ? "Bildirim kapatıldı." : "Bildirim açıldı."); } catch (err) { setPrefs((cur) => cur ? cur.map((p) => p.category === category ? { ...p, optedOut: currentlyOptedOut } : p, ) : cur, ); toast.error((err as Error).message || "Güncellenemedi"); } finally { setPending((s) => { const ns = new Set(s); ns.delete(category); return ns; }); } } // Show two rows regardless of API state: keys are stable, so we render the // skeleton in their slots until the GET resolves. const categoryKeys: NotificationCategoryKey[] = ["email_marketing", "mobile_push"]; return ( {t("settings.notifications.title")} {t("settings.notifications.description")} {error ? (

{error}

) : ( categoryKeys.map((key) => { const row = prefs?.find((p) => p.category === key); const isLoading = prefs === null; if (isLoading) { return ; } const label = row?.label ?? FALLBACK_CATEGORY_LABEL[key]; const optedOut = row?.optedOut ?? false; const isPending = pending.has(key); return (

{label}

); }) )}

Doğrulama, şifre sıfırlama ve ödeme bildirimleri buradan kapatılamaz — hesabını yönetebilmen için gerekli.

); }