Files
2026-04-29 07:34:40 -06:00

378 lines
12 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useTranslation } from "../../contexts/MessagesContext";
import {
deleteAccount,
deletePublishedRule,
deleteServerDraft,
duplicatePublishedRule,
fetchAuthSession,
fetchMyPublishedRules,
fetchServerDraftForProfile,
logout,
requestEmailChange,
type MyPublishedRule,
} from "../../../lib/create/api";
import {
FIRST_STEP,
isValidStep,
} from "../create/utils/flowSteps";
import type { CreateFlowStep } from "../create/types";
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import {
ProfilePageSignedOutView,
ProfilePageView,
} from "./_components/ProfilePage.view";
function resolveContinueStepState(
state: { currentStep?: CreateFlowStep } & Record<string, unknown>,
): CreateFlowStep {
const s = state.currentStep;
if (s && isValidStep(s)) return s;
return FIRST_STEP;
}
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
const router = useRouter();
const { openLogin } = useAuthModal();
const [sessionLoaded, setSessionLoaded] = useState(false);
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
const [rules, setRules] = useState<MyPublishedRule[]>([]);
const [rulesError, setRulesError] = useState(false);
const [draft, setDraft] = useState<
Awaited<ReturnType<typeof fetchServerDraftForProfile>>
>(null);
const [ruleDeleteTargetId, setRuleDeleteTargetId] = useState<string | null>(
null,
);
const [ruleDeleteBusy, setRuleDeleteBusy] = useState(false);
const [draftDeleteOpen, setDraftDeleteOpen] = useState(false);
const [draftDeleteBusy, setDraftDeleteBusy] = useState(false);
const [accountDeleteOpen, setAccountDeleteOpen] = useState(false);
const [accountDeleteBusy, setAccountDeleteBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [emailChangeOpen, setEmailChangeOpen] = useState(false);
const [emailChangeInput, setEmailChangeInput] = useState("");
const [emailChangeBusy, setEmailChangeBusy] = useState(false);
const [emailChangeModalError, setEmailChangeModalError] = useState<
string | null
>(null);
const [emailChangeRequestSent, setEmailChangeRequestSent] = useState(false);
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
string | null
>(null);
const emailChangeQueryHandledRef = useRef(false);
const load = useCallback(async () => {
setActionError(null);
const { user: u } = await fetchAuthSession();
setUser(u);
setSessionLoaded(true);
if (!u) {
setRules([]);
setRulesError(false);
setDraft(null);
return;
}
const [r, d] = await Promise.all([
fetchMyPublishedRules(),
fetchServerDraftForProfile(),
]);
if (r === null) {
setRules([]);
setRulesError(true);
} else {
setRules(r);
setRulesError(false);
}
setDraft(d);
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (emailChangeQueryHandledRef.current) return;
if (typeof window === "undefined") return;
const search = window.location.search;
if (!search) return;
const params = new URLSearchParams(search);
const ok = params.get("email_change");
const err = params.get("error");
if (ok !== "ok" && !err?.startsWith("email_change_")) return;
emailChangeQueryHandledRef.current = true;
if (ok === "ok") {
setProfileSuccessMessage(t("emailChangeSuccess"));
void load().then(() => {
router.refresh();
});
} else if (err === "email_change_expired") {
setActionError(t("emailChangeVerifyExpired"));
} else if (err === "email_change_invalid") {
setActionError(t("emailChangeVerifyInvalid"));
} else if (err === "email_change_taken") {
setActionError(t("emailChangeVerifyTaken"));
} else if (err === "email_change_server") {
setActionError(t("actionError"));
}
router.replace("/profile", { scroll: false });
}, [load, router, t]);
const handleOpenEmailChange = useCallback(() => {
if (!user) return;
setActionError(null);
setProfileSuccessMessage(null);
setEmailChangeModalError(null);
setEmailChangeRequestSent(false);
setEmailChangeInput(user.email);
setEmailChangeOpen(true);
}, [user]);
const handleCloseEmailChange = useCallback(() => {
if (emailChangeBusy) return;
setEmailChangeOpen(false);
setEmailChangeRequestSent(false);
}, [emailChangeBusy]);
const handleDismissProfileSuccess = useCallback(() => {
setProfileSuccessMessage(null);
}, []);
const handleDismissActionError = useCallback(() => {
setActionError(null);
}, []);
const handleDismissRulesError = useCallback(() => {
setRulesError(false);
}, []);
const handleDismissEmailChangeModalError = useCallback(() => {
setEmailChangeModalError(null);
}, []);
const handleSubmitEmailChange = useCallback(async () => {
const trimmed = emailChangeInput.trim();
if (!trimmed || emailChangeBusy) return;
setEmailChangeModalError(null);
setEmailChangeBusy(true);
const res = await requestEmailChange(trimmed);
setEmailChangeBusy(false);
if (res.ok === false) {
if (res.retryAfterMs != null && res.retryAfterMs > 0) {
const sec = Math.max(1, Math.ceil(res.retryAfterMs / 1000));
setEmailChangeModalError(
t("emailChangeRateLimited").replace(/\{\{seconds\}\}/g, String(sec)),
);
} else {
setEmailChangeModalError(res.error);
}
} else {
setEmailChangeRequestSent(true);
}
}, [emailChangeBusy, emailChangeInput, t]);
const handleSignOut = useCallback(async () => {
setActionError(null);
await logout();
setUser(null);
setRules([]);
setDraft(null);
router.refresh();
}, [router]);
const handleRequestDeleteRule = useCallback((id: string) => {
setActionError(null);
setRuleDeleteTargetId(id);
}, []);
const handleCloseDeleteRuleDialog = useCallback(() => {
if (ruleDeleteBusy) return;
setRuleDeleteTargetId(null);
}, [ruleDeleteBusy]);
const handleConfirmDeleteRule = useCallback(async () => {
const id = ruleDeleteTargetId;
if (!id || ruleDeleteBusy) return;
setActionError(null);
setRuleDeleteBusy(true);
const res = await deletePublishedRule(id);
setRuleDeleteBusy(false);
if (res.ok === true) {
setRuleDeleteTargetId(null);
void load();
return;
}
if (res.status === 404) {
setActionError(t("notFound"));
setRuleDeleteTargetId(null);
} else if (res.status === 403) {
setActionError(t("forbidden"));
setRuleDeleteTargetId(null);
} else {
setActionError(t("actionError"));
}
}, [load, ruleDeleteBusy, ruleDeleteTargetId, t]);
const handleDuplicateRule = useCallback(
async (id: string) => {
setActionError(null);
const res = await duplicatePublishedRule(id);
if (res.ok === true) {
void load();
} else {
if (res.status === 404) {
setActionError(t("notFound"));
} else if (res.status === 403) {
setActionError(t("forbidden"));
} else {
setActionError(t("actionError"));
}
}
},
[load, t],
);
const handleContinueDraft = useCallback(() => {
if (draft == null || !draft.hasDraft) return;
const step = resolveContinueStepState(draft.state);
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
router.push(`/create/${step}`);
}, [draft, router]);
const handleStartNewCustomRule = useCallback(() => {
void (async () => {
await prepareFreshCreateFlowEntry();
router.push("/create");
})();
}, [router]);
const handleRequestDeleteDraft = useCallback(() => {
setActionError(null);
setDraftDeleteOpen(true);
}, []);
const handleCloseDeleteDraftDialog = useCallback(() => {
if (draftDeleteBusy) return;
setDraftDeleteOpen(false);
}, [draftDeleteBusy]);
const handleConfirmDeleteDraft = useCallback(async () => {
if (draftDeleteBusy) return;
setActionError(null);
setDraftDeleteBusy(true);
clearAnonymousCreateFlowStorage();
await deleteServerDraft();
setDraftDeleteBusy(false);
setDraftDeleteOpen(false);
void load();
}, [draftDeleteBusy, load]);
const handleConfirmDeleteAccount = useCallback(async () => {
setActionError(null);
setAccountDeleteBusy(true);
const res = await deleteAccount();
setAccountDeleteBusy(false);
if (res.ok) {
setAccountDeleteOpen(false);
setUser(null);
setRules([]);
setDraft(null);
router.push("/");
router.refresh();
return;
}
setActionError(t("actionError"));
}, [router, t]);
/** `lg`+ layout; matches `--breakpoint-lg` in `app/tailwind.css`. */
const isProfileLgUp = useMediaQuery("(min-width: 1024px)");
/** `List` L + Bricolage section titles — Figma `22143:900247`; matches `--breakpoint-xl` (1440px). */
const isProfileXlUp = useMediaQuery("(min-width: 1440px)");
if (!sessionLoaded) {
return (
<div className="mx-auto max-w-3xl px-4 py-16">
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
{t("loading")}
</p>
</div>
);
}
if (!user) {
return (
<ProfilePageSignedOutView
profileLgUp={isProfileLgUp}
onSignIn={() => openLogin({ nextPath: "/profile" })}
/>
);
}
const showDraftCard = Boolean(
draft && draft.hasDraft,
);
return (
<ProfilePageView
userEmail={user.email}
ruleCardSize={isProfileLgUp ? "L" : "M"}
profileLgUp={isProfileLgUp}
profileListSize={isProfileXlUp ? "l" : "m"}
rules={rules}
rulesError={rulesError}
draft={draft}
showDraftCard={showDraftCard}
ruleDeleteOpen={ruleDeleteTargetId !== null}
ruleDeleteBusy={ruleDeleteBusy}
draftDeleteOpen={draftDeleteOpen}
draftDeleteBusy={draftDeleteBusy}
accountDeleteOpen={accountDeleteOpen}
accountDeleteBusy={accountDeleteBusy}
actionError={actionError}
profileSuccessMessage={profileSuccessMessage}
emailChangeOpen={emailChangeOpen}
emailChangeValue={emailChangeInput}
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
emailChangeBusy={emailChangeBusy}
emailChangeRequestSent={emailChangeRequestSent}
emailChangeModalError={emailChangeModalError}
onDismissProfileSuccess={handleDismissProfileSuccess}
onDismissActionError={handleDismissActionError}
onDismissRulesError={handleDismissRulesError}
onDismissEmailChangeModalError={handleDismissEmailChangeModalError}
onOpenEmailChange={handleOpenEmailChange}
onCloseEmailChange={handleCloseEmailChange}
onSubmitEmailChange={handleSubmitEmailChange}
onSignOut={handleSignOut}
onDeleteRule={handleRequestDeleteRule}
onCloseDeleteRule={handleCloseDeleteRuleDialog}
onConfirmDeleteRule={handleConfirmDeleteRule}
onDuplicateRule={handleDuplicateRule}
onContinueDraft={handleContinueDraft}
onDeleteDraft={handleRequestDeleteDraft}
onCloseDeleteDraft={handleCloseDeleteDraftDialog}
onConfirmDeleteDraft={handleConfirmDeleteDraft}
onOpenDeleteAccount={() => {
setActionError(null);
setAccountDeleteOpen(true);
}}
onCloseDeleteAccount={() => setAccountDeleteOpen(false)}
onConfirmDeleteAccount={handleConfirmDeleteAccount}
onStartNewCustomRule={handleStartNewCustomRule}
/>
);
}