Files
community-rule/app/(app)/profile/_components/ProfilePage.view.tsx
T
2026-04-29 07:20:16 -06:00

644 lines
21 KiB
TypeScript

"use client";
import { useId, useMemo } from "react";
import Button from "../../../components/buttons/Button";
import Rule from "../../../components/cards/Rule";
import TextInput from "../../../components/controls/TextInput";
import List from "../../../components/layout/List";
import type { ListItem, ListSize } from "../../../components/layout/List";
import Icon from "../../../components/asset/icon";
import Dialog from "../../../components/modals/Dialog";
import Alert from "../../../components/modals/Alert";
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;
profileSuccessMessage: string | null;
emailChangeOpen: boolean;
emailChangeValue: string;
onEmailChangeValueChange: (value: string) => void;
emailChangeBusy: boolean;
emailChangeRequestSent: boolean;
emailChangeModalError: string | null;
onDismissEmailChangeModalError: () => void;
onOpenEmailChange: () => void;
onCloseEmailChange: () => void;
onSubmitEmailChange: () => void;
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;
onDismissProfileSuccess: () => void;
onDismissActionError: () => void;
onDismissRulesError: () => 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 Top} (standard variant).
* Must match `Top.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
*/
const stickyBelowTopTopClass =
"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)] ${stickyBelowTopTopClass}`
: `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,
profileSuccessMessage,
emailChangeOpen,
emailChangeValue,
onEmailChangeValueChange,
emailChangeBusy,
emailChangeRequestSent,
emailChangeModalError,
onDismissEmailChangeModalError,
onOpenEmailChange,
onCloseEmailChange,
onSubmitEmailChange,
onSignOut,
onDeleteRule,
onCloseDeleteRule,
onConfirmDeleteRule,
onDuplicateRule,
onContinueDraft,
onDeleteDraft,
onCloseDeleteDraft,
onConfirmDeleteDraft,
onOpenDeleteAccount,
onCloseDeleteAccount,
onConfirmDeleteAccount,
onDismissProfileSuccess,
onDismissActionError,
onDismissRulesError,
}: ProfilePageViewProps) {
const t = useTranslation("pages.profile");
const tLogin = useTranslation("pages.login");
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: "",
onClick: onOpenEmailChange,
leadingIcon: "mail",
showDescription: false,
},
{
id: "delete-account",
title: t("deleteAccount"),
description: "",
onClick: onOpenDeleteAccount,
leadingIcon: "warning",
variant: "danger",
showDescription: false,
},
];
}, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]);
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>
<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 ? (
<Rule
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) => (
<Rule
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>
</>
}
/>
<Dialog
isOpen={emailChangeOpen}
onClose={() => {
if (!emailChangeBusy) onCloseEmailChange();
}}
backdropVariant="blurredYellow"
title={
emailChangeRequestSent
? tLogin("successTitle")
: t("emailChangeModalTitle")
}
description={
emailChangeRequestSent
? tLogin("successBody")
: t("emailChangeModalDescription")
}
footer={
emailChangeRequestSent ? (
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseEmailChange}
>
{t("emailChangeConfirmationClose")}
</Button>
) : (
<>
<Button
type="button"
size="medium"
buttonType="outline"
palette="default"
onClick={onCloseEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeCancel")}
</Button>
<Button
type="button"
size="medium"
buttonType="filled"
palette="default"
onClick={onSubmitEmailChange}
disabled={emailChangeBusy}
>
{t("emailChangeSubmit")}
</Button>
</>
)
}
>
{emailChangeRequestSent ? (
<div className="flex flex-col gap-3 pt-2">
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
<Icon name="mail" size={22} aria-hidden />
</div>
</div>
) : (
<TextInput
type="email"
inputSize="medium"
label={t("emailChangeNewEmailLabel")}
placeholder={t("emailChangeNewEmailPlaceholder")}
value={emailChangeValue}
onChange={(e) => onEmailChangeValueChange(e.target.value)}
disabled={emailChangeBusy}
error={Boolean(emailChangeModalError)}
autoComplete="email"
/>
)}
</Dialog>
{(profileSuccessMessage || actionError || rulesError) && (
<div
className="pointer-events-none fixed left-0 right-0 top-[41px] z-[60] flex justify-center px-4 pt-3 md:px-8 lg:top-[85px] lg:px-16 xl:top-[89px]"
aria-live="polite"
>
<div className="pointer-events-auto flex w-full max-w-[640px] flex-col gap-2">
{profileSuccessMessage ? (
<Alert
type="banner"
status="positive"
size="s"
title={profileSuccessMessage}
hasBodyText={false}
hasLeadingIcon
onClose={onDismissProfileSuccess}
className="w-full"
/>
) : null}
{actionError ? (
<Alert
type="banner"
status="danger"
size="s"
title={actionError}
hasBodyText={false}
hasLeadingIcon
onClose={onDismissActionError}
className="w-full"
/>
) : null}
{rulesError ? (
<Alert
type="banner"
status="warning"
size="s"
title={t("rulesLoadBannerTitle")}
description={t("rulesLoadBannerDescription")}
hasLeadingIcon
onClose={onDismissRulesError}
className="w-full"
/>
) : null}
</div>
</div>
)}
{emailChangeOpen && emailChangeModalError ? (
<div className="pointer-events-none fixed inset-x-0 top-6 z-[10001] flex justify-center px-4 md:top-10">
<div className="pointer-events-auto w-full max-w-[min(480px,calc(100%-2rem))]">
<Alert
type="banner"
status="danger"
size="s"
title={emailChangeModalError}
hasBodyText={false}
hasLeadingIcon
onClose={onDismissEmailChangeModalError}
className="w-full"
/>
</div>
</div>
) : null}
</>
);
}