Profile page UI and functionality implemented

This commit is contained in:
adilallo
2026-04-25 17:57:58 -06:00
parent 7dd2562bae
commit 68517796a9
103 changed files with 4439 additions and 1476 deletions
+237 -36
View File
@@ -1,55 +1,256 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useTranslation } from "../../contexts/MessagesContext";
import Button from "../../components/buttons/Button";
import { fetchAuthSession, logout } from "../../../lib/create/api";
import {
deleteAccount,
deletePublishedRule,
deleteServerDraft,
duplicatePublishedRule,
fetchAuthSession,
fetchMyPublishedRules,
fetchServerDraftForProfile,
logout,
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 { 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 [loaded, setLoaded] = useState(false);
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 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(() => {
let cancelled = false;
void fetchAuthSession().then(({ user: u }) => {
if (!cancelled) {
setUser(u);
setLoaded(true);
}
});
return () => {
cancelled = true;
};
}, []);
void load();
}, [load]);
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);
router.push(`/create/${step}`);
}, [draft, 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 (
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
<h1 className="font-bricolage text-3xl font-extrabold text-[var(--color-content-default-primary)] md:text-4xl">
{t("placeholderTitle")}
</h1>
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
{t("placeholderBody")}
</p>
{loaded && user ? (
<div className="mt-8">
<Button
buttonType="outline"
palette="default"
size="small"
type="button"
onClick={() => void handleSignOut()}
ariaLabel={t("signOut")}
>
{t("signOut")}
</Button>
</div>
) : null}
</div>
<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}
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}
/>
);
}
@@ -0,0 +1,489 @@
"use client";
import { useId, useMemo } from "react";
import Button from "../../../components/buttons/Button";
import RuleCard from "../../../components/cards/RuleCard";
import List from "../../../components/layout/List";
import type { ListItem, ListSize } from "../../../components/layout/List";
import Dialog from "../../../components/modals/Dialog";
import HeaderLockup from "../../../components/type/HeaderLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowState } from "../../create/types";
import type {
MyPublishedRule,
ServerDraftForProfile,
} from "../../../../lib/create/api";
function draftBodyTextFromState(
state: CreateFlowState,
): string | undefined {
const ctx = state.communityContext?.trim();
if (ctx) return ctx;
const summary = state.summary?.trim();
if (summary) return summary;
return undefined;
}
export type ProfilePageViewProps = {
userEmail: string;
/** `M` below `lg` (1024px); `L` at `lg`+ per Figma Card / Rule. Breakpoints: `md` (640px) → `lg` (1024px) only. */
ruleCardSize: "M" | "L";
/** `true` at `lg` (1024px)+ — welcome uses {@link HeaderLockup} size `L` per `21962:17220`. */
profileLgUp: boolean;
/** `m` = {@link List} M; `l` = List L at `xl` per Figma `22143:900256`. */
profileListSize: Extract<ListSize, "m" | "l">;
rules: MyPublishedRule[];
rulesError: boolean;
draft: ServerDraftForProfile | null;
showDraftCard: boolean;
ruleDeleteOpen: boolean;
ruleDeleteBusy: boolean;
draftDeleteOpen: boolean;
draftDeleteBusy: boolean;
accountDeleteOpen: boolean;
accountDeleteBusy: boolean;
actionError: string | null;
onSignOut: () => void;
onDeleteRule: (id: string) => void;
onCloseDeleteRule: () => void;
onConfirmDeleteRule: () => void;
onDuplicateRule: (id: string) => void;
onContinueDraft: () => void;
onDeleteDraft: () => void;
onCloseDeleteDraft: () => void;
onConfirmDeleteDraft: () => void;
onOpenDeleteAccount: () => void;
onCloseDeleteAccount: () => void;
onConfirmDeleteAccount: () => void;
};
/**
* Figma: Inter 20/28 from `md` to `lg`+ (e.g. `21962:17224`); at `xl` Bricolage 28/36 (`22143:900251`, `22143:900255` — `Medium/Heading`);
* mobile: smaller Bricolage.
*/
const profileSectionHeadingClass =
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
/**
* Sticky `top` for page content below the product {@link TopNav} (standard variant).
* Must match `TopNav.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
*/
const stickyBelowTopNavTopClass =
"top-[41px] lg:top-[85px] xl:top-[89px]";
export type ProfilePageSignedOutViewProps = {
onSignIn: () => void;
/** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
profileLgUp: boolean;
};
/**
* Signed-out profile: same shell as {@link ProfilePageView}
* (Figma mobile `22143:900762`, md `22143:900534`, lg `21962:17220` via {@link HeaderLockup}).
*/
export function ProfilePageSignedOutView({
onSignIn,
profileLgUp,
}: ProfilePageSignedOutViewProps) {
const t = useTranslation("pages.profile");
const titleId = useId();
return (
<div className="w-full bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]">
<div className="flex flex-col gap-6 px-4 pt-4 pb-4 md:px-8 lg:gap-10 lg:px-16">
<header
className={
profileLgUp
? `sticky z-10 bg-[var(--color-surface-default-primary)] ${stickyBelowTopNavTopClass}`
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
}
>
{profileLgUp ? (
<HeaderLockup
titleId={titleId}
title={t("pageTitle")}
description={t("signInPrompt")}
size="L"
justification="left"
/>
) : (
<>
<h1
id={titleId}
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
>
{t("pageTitle")}
</h1>
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
{t("signInPrompt")}
</p>
</>
)}
</header>
<Button
type="button"
size="small"
buttonType="filled"
palette="default"
className="self-start"
onClick={onSignIn}
>
{t("signInCta")}
</Button>
</div>
</div>
);
}
/**
* Figma: mobile `22143:900762`; tablet `md` `22143:900534` (`@theme --breakpoint-md` 640px);
* desktop `lg` `21962:17220` (`@theme --breakpoint-lg` 1024px);
* `xl` `22143:900247` (same content spacing as lg; list + section type at `xl` — `List` L `21844:4405`).
*/
export function ProfilePageView({
userEmail,
ruleCardSize,
profileLgUp,
profileListSize,
rules,
rulesError,
draft,
showDraftCard,
ruleDeleteOpen,
ruleDeleteBusy,
draftDeleteOpen,
draftDeleteBusy,
accountDeleteOpen,
accountDeleteBusy,
actionError,
onSignOut,
onDeleteRule,
onCloseDeleteRule,
onConfirmDeleteRule,
onDuplicateRule,
onContinueDraft,
onDeleteDraft,
onCloseDeleteDraft,
onConfirmDeleteDraft,
onOpenDeleteAccount,
onCloseDeleteAccount,
onConfirmDeleteAccount,
}: ProfilePageViewProps) {
const t = useTranslation("pages.profile");
const titleId = useId();
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
const welcomeBody =
rules.length > 0 ? t("welcomeBodyFirstRule") : t("welcomeBodyNoRules");
const profileOptionsItems = useMemo((): ListItem[] => {
return [
{
id: "create-custom",
title: t("optionCreateCustom"),
description: "",
href: "/create",
leadingIcon: "edit",
showDescription: false,
},
{
id: "create-template",
title: t("optionCreateTemplate"),
description: "",
href: "/templates?fromFlow=1",
leadingIcon: "content_copy",
showDescription: false,
},
{
id: "logout",
title: t("optionLogout"),
description: "",
onClick: onSignOut,
leadingIcon: "log_out",
showDescription: false,
},
{
id: "change-email",
title: t("optionChangeEmail"),
description: "",
leadingIcon: "mail",
variant: "muted",
showDescription: false,
},
{
id: "delete-account",
title: t("deleteAccount"),
description: "",
onClick: onOpenDeleteAccount,
leadingIcon: "warning",
variant: "danger",
showDescription: false,
},
];
}, [t, onSignOut, onOpenDeleteAccount]);
const ruleCardShellClass =
"w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
return (
<>
<div className="w-full bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]">
<div className="flex flex-col gap-6 px-4 pt-4 pb-4 md:px-8 lg:gap-10 lg:px-16">
<header
className={
profileLgUp
? `lg:sticky lg:z-10 lg:bg-[var(--color-surface-default-primary)] lg:top-[85px] xl:top-[89px]`
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
}
>
{profileLgUp ? (
<HeaderLockup
titleId={titleId}
title={welcomeTitle}
description={welcomeBody}
size="L"
justification="left"
/>
) : (
<>
<h1
id={titleId}
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
>
{welcomeTitle}
</h1>
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
{welcomeBody}
</p>
</>
)}
</header>
{actionError ? (
<p
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
role="alert"
>
{actionError}
</p>
) : null}
{rulesError ? (
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
{t("actionError")}
</p>
) : null}
<div className="flex flex-col gap-8 lg:flex-row lg:flex-nowrap lg:items-start lg:gap-8">
<section
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
aria-labelledby="profile-rules-heading"
>
<h2
id="profile-rules-heading"
className={profileSectionHeadingClass}
style={{ fontVariationSettings: "'opsz' 14, 'wdth' 100" }}
>
{t("yourRulesHeading")}
</h2>
<div className="flex flex-col gap-3">
{showDraftCard && draft?.hasDraft ? (
<RuleCard
title={(() => {
const raw = draft.state.title;
const s = typeof raw === "string" ? raw.trim() : "";
return s || t("draftHeading");
})()}
description={draftBodyTextFromState(draft.state)}
expanded
size={ruleCardSize}
hasBottomLinks
bottomStatusLabel={t("draftInProgressBadge")}
bottomLinks={[
{
id: "continue",
label: t("continueDraft"),
onClick: onContinueDraft,
},
{
id: "delete-draft",
label: t("deleteRule"),
onClick: onDeleteDraft,
},
]}
communityInitials={(() => {
const raw = draft.state.title;
const s = typeof raw === "string" ? raw.trim() : "";
return s.charAt(0).toUpperCase() || "·";
})()}
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
className={ruleCardShellClass}
/>
) : null}
{rules.map((rule) => (
<RuleCard
key={rule.id}
title={rule.title}
description={rule.summary ?? undefined}
expanded
size={ruleCardSize}
hasBottomLinks
bottomLinks={[
{
id: "view",
label: t("viewPublic"),
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
},
{
id: "dup",
label: t("duplicate"),
onClick: () => onDuplicateRule(rule.id),
},
{
id: "del",
label: t("deleteRule"),
onClick: () => onDeleteRule(rule.id),
},
]}
communityInitials={
rule.title.trim().charAt(0).toUpperCase() || "·"
}
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
className={ruleCardShellClass}
/>
))}
</div>
{rules.length === 0 && !rulesError && !showDraftCard ? (
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
{t("yourRulesEmpty")}
</p>
) : null}
</section>
<section
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
aria-labelledby="profile-options-heading"
>
<h2
id="profile-options-heading"
className={profileSectionHeadingClass}
style={{ fontVariationSettings: "'opsz' 14, 'wdth' 100" }}
>
{t("yourOptionsHeading")}
</h2>
<nav aria-label={t("yourOptionsHeading")}>
<List
items={profileOptionsItems}
size={profileListSize}
topDivider
leadingIcon="edit"
/>
</nav>
</section>
</div>
</div>
</div>
<Dialog
isOpen={ruleDeleteOpen}
onClose={() => {
if (!ruleDeleteBusy) onCloseDeleteRule();
}}
backdropVariant="blurredYellow"
title={t("deleteRuleModalTitle")}
description={t("deleteRuleModalBody")}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseDeleteRule}
disabled={ruleDeleteBusy}
>
{t("deleteRuleCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onConfirmDeleteRule}
disabled={ruleDeleteBusy}
>
{t("deleteRuleConfirmCta")}
</Button>
</>
}
/>
<Dialog
isOpen={draftDeleteOpen}
onClose={() => {
if (!draftDeleteBusy) onCloseDeleteDraft();
}}
backdropVariant="blurredYellow"
title={t("deleteDraftModalTitle")}
description={t("deleteDraftModalBody")}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseDeleteDraft}
disabled={draftDeleteBusy}
>
{t("deleteDraftCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onConfirmDeleteDraft}
disabled={draftDeleteBusy}
>
{t("deleteDraftConfirmCta")}
</Button>
</>
}
/>
<Dialog
isOpen={accountDeleteOpen}
onClose={() => {
if (!accountDeleteBusy) onCloseDeleteAccount();
}}
backdropVariant="blurredYellow"
title={t("deleteAccountModalTitle")}
description={t("deleteAccountModalBody")}
footer={
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseDeleteAccount}
disabled={accountDeleteBusy}
>
{t("deleteAccountCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onConfirmDeleteAccount}
disabled={accountDeleteBusy}
>
{t("deleteAccountConfirm")}
</Button>
</>
}
/>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
/** Profile uses the public marketing footer; other `(app)` routes stay footer-free. */
const Footer = dynamic(() => import("../../components/navigation/Footer"), {
loading: () => (
<footer className="w-full min-h-[200px] bg-[var(--color-surface-default-primary)]" />
),
ssr: true,
});
export default function ProfileLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<Footer />
</>
);
}