Profile page UI and functionality implemented
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user