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
+1 -1
View File
@@ -3,7 +3,7 @@ description: Behavioral guidelines to reduce common LLM coding mistakes. Use whe
alwaysApply: true
---
# Karpathy behavioral guidelines
# Coding behavioral guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+1 -1
View File
@@ -31,7 +31,7 @@ Reach for these before writing new markup:
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
| Card-click → structured creation modal | `Create` with `backdropVariant="loginYellow"` |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
component** rather than duplicate. Local section components inside a screen
+3 -2
View File
@@ -15,7 +15,7 @@ the file tree without affecting URLs.
| Group | URL surface | Audience | Chrome |
|---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | TopNav (via root) + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | TopNav (via root) — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | TopNav (via root) — no footer |
| `app/api/` | API routes | n/a | n/a |
@@ -33,7 +33,8 @@ the folder next to `(marketing)/`.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
and appends the public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
wrap with `<main className="flex-1">`. No footer.
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
appends the marketing `<Footer />` for `/profile` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers.
+24 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
@@ -11,6 +11,10 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
import {
isValidStep,
parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -36,6 +40,8 @@ export function SignedInDraftHydration({
sessionResolved: boolean;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const syncDraftParam = searchParams.get("syncDraft");
const { replaceState, interactionTouched } = useCreateFlow();
const touchedRef = useRef(interactionTouched);
@@ -82,7 +88,15 @@ export function SignedInDraftHydration({
}
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
replaceState(serverDraft as CreateFlowState);
const next = serverDraft as CreateFlowState;
replaceState(next);
const saved = next.currentStep;
if (saved && isValidStep(saved)) {
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
if (urlStep !== saved) {
router.replace(`/create/${saved}`);
}
}
}
finishedUserIdRef.current = userId;
} finally {
@@ -93,7 +107,14 @@ export function SignedInDraftHydration({
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
}, [
sessionResolved,
sessionUser,
syncDraftParam,
replaceState,
pathname,
router,
]);
if (!loadingHydration) return null;
@@ -175,7 +175,7 @@ export function FinalReviewChipEditModal({
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
@@ -47,7 +47,7 @@ export function CreateFlowScreenView({
<CreateFlowTextFieldScreen
messageNamespace="create.community.communityContext"
stateField="communityContext"
maxLength={48}
maxLength={200}
mainAlign="center"
/>
);
@@ -213,7 +213,7 @@ export function CommunicationMethodsScreen() {
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<CommunicationMethodEditFields
@@ -215,7 +215,7 @@ export function ConflictManagementScreen() {
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<ConflictManagementEditFields
@@ -212,7 +212,7 @@ export function MembershipMethodsScreen() {
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<MembershipMethodEditFields
@@ -1,12 +1,17 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import CommunityRuleDocument from "../../../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../../../components/modals/Alert";
import { useMessages } from "../../../../contexts/MessagesContext";
import { fetchPublishedRuleDetail } from "../../../../../lib/create/api";
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../../../lib/create/lastPublishedRule";
import {
readLastPublishedRule,
writeLastPublishedRule,
} from "../../../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
@@ -14,40 +19,112 @@ import {
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
function initialCompletedUi(
ruleIdFromUrl: string | null,
): {
headerTitle: string;
headerDescription: string | undefined;
documentSections: CommunityRuleDocumentSection[];
} {
if (ruleIdFromUrl) {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
}
if (typeof sessionStorage === "undefined") {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
}
const stored = readLastPublishedRule();
if (!stored) {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
}
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) {
return {
headerTitle: "",
headerDescription: undefined,
documentSections: [],
};
}
const sum =
typeof stored.summary === "string" ? stored.summary.trim() : "";
return {
headerTitle: stored.title,
headerDescription: sum.length > 0 ? sum : undefined,
documentSections: parsed,
};
}
export function CompletedScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const ruleIdParam = searchParams.get("ruleId");
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.reviewAndComplete.completed;
const fallbackSections = useMemo(
() =>
[...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
[completed.fallbackDocumentSections],
);
const initial = initialCompletedUi(ruleIdParam);
const [toastDismissed, setToastDismissed] = useState(false);
const [headerTitle, setHeaderTitle] = useState(
() => completed.fallbackTitle,
);
const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
>(() => completed.fallbackDescription);
>(initial.headerDescription);
const [documentSections, setDocumentSections] =
useState<CommunityRuleDocumentSection[]>(fallbackSections);
useState<CommunityRuleDocumentSection[]>(initial.documentSections);
useEffect(() => {
const stored = readLastPublishedRule();
if (!stored) return;
const parsed = parseDocumentSectionsForDisplay(stored.document);
if (parsed.length === 0) return;
queueMicrotask(() => {
setDocumentSections(parsed);
setHeaderTitle(stored.title);
const sum =
typeof stored.summary === "string" ? stored.summary.trim() : "";
setHeaderDescription(sum.length > 0 ? sum : undefined);
});
}, []);
if (!ruleIdParam) return;
let cancelled = false;
void (async () => {
const detail = await fetchPublishedRuleDetail(ruleIdParam);
if (cancelled) return;
if (
!detail ||
!detail.viewerIsOwner ||
detail.rule.document === null ||
typeof detail.rule.document !== "object" ||
Array.isArray(detail.rule.document)
) {
router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
return;
}
const doc = detail.rule.document as Record<string, unknown>;
writeLastPublishedRule({
id: detail.rule.id,
title: detail.rule.title,
summary: detail.rule.summary,
document: doc,
});
const parsed = parseDocumentSectionsForDisplay(doc);
if (parsed.length === 0) {
router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
return;
}
queueMicrotask(() => {
setDocumentSections(parsed);
setHeaderTitle(detail.rule.title);
const sum =
typeof detail.rule.summary === "string"
? detail.rule.summary.trim()
: "";
setHeaderDescription(sum.length > 0 ? sum : undefined);
});
router.replace("/create/completed");
})();
return () => {
cancelled = true;
};
}, [ruleIdParam, router]);
const toast = !toastDismissed ? (
<div
@@ -244,7 +244,7 @@ export function DecisionApproachesScreen() {
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<DecisionApproachEditFields
@@ -383,7 +383,7 @@ export function CoreValuesSelectScreen() {
<Create
isOpen={activeModalChipId !== null}
onClose={handleModalDismiss}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
+3 -3
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
// Signed-in product surfaces (`/create/*`, `/login`, `/profile`) intentionally
// run without the marketing footer. Per-route chrome (e.g. CreateFlow's own
// header/footer lockup) is composed in nested layouts.
// Signed-in product surfaces (`/create/*`, `/login`) run without the marketing
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
// CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
}
+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 />
</>
);
}
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../../lib/server/env";
import {
dbUnavailable,
forbidden,
notFound,
unauthorized,
} from "../../../../../lib/server/responses";
import { getSessionUser } from "../../../../../lib/server/session";
import { apiRoute } from "../../../../../lib/server/apiRoute";
type RouteContext = { params: Promise<{ id: string }> };
export const POST = apiRoute<RouteContext>(
"rules.byId.duplicate",
async (_request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { id } = await context.params;
const source = await prisma.publishedRule.findUnique({
where: { id },
});
if (!source) {
return notFound();
}
if (source.userId !== user.id) {
return forbidden("You do not have permission to duplicate this rule");
}
const newRule = await prisma.publishedRule.create({
data: {
userId: user.id,
title: `${source.title} (Copy)`,
summary: source.summary,
document: source.document,
},
});
return NextResponse.json({
rule: {
id: newRule.id,
title: newRule.title,
summary: newRule.summary,
createdAt: newRule.createdAt,
updatedAt: newRule.updatedAt,
},
});
},
);
+50 -2
View File
@@ -1,7 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import { dbUnavailable, notFound } from "../../../../lib/server/responses";
import {
dbUnavailable,
forbidden,
notFound,
unauthorized,
} from "../../../../lib/server/responses";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { getSessionUser } from "../../../../lib/server/session";
import { apiRoute } from "../../../../lib/server/apiRoute";
type RouteContext = { params: Promise<{ id: string }> };
@@ -20,6 +27,47 @@ export const GET = apiRoute<RouteContext>(
return notFound();
}
return NextResponse.json({ rule });
const user = await getSessionUser();
let viewerIsOwner = false;
if (user) {
const ownerRow = await prisma.publishedRule.findUnique({
where: { id },
select: { userId: true },
});
viewerIsOwner = ownerRow?.userId === user.id;
}
return NextResponse.json({ rule, viewerIsOwner });
},
);
export const DELETE = apiRoute<RouteContext>(
"rules.byId.delete",
async (_request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { id } = await context.params;
const row = await prisma.publishedRule.findUnique({
where: { id },
select: { id: true, userId: true },
});
if (!row) {
return notFound();
}
if (row.userId !== user.id) {
return forbidden("You do not have permission to delete this rule");
}
await prisma.publishedRule.delete({ where: { id: row.id } });
return NextResponse.json({ ok: true });
},
);
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
import {
dbUnavailable,
internalError,
unauthorized,
} from "../../../../lib/server/responses";
import { getSessionUser } from "../../../../lib/server/session";
import { apiRoute } from "../../../../lib/server/apiRoute";
export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
const rules = await listPublishedRulesForUser(user.id, take);
if (rules === null) {
return internalError("Failed to list rules");
}
return NextResponse.json({ rules });
});
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import {
dbUnavailable,
internalError,
unauthorized,
} from "../../../../lib/server/responses";
import {
clearSessionCookie,
getSessionUser,
} from "../../../../lib/server/session";
import { apiRoute } from "../../../../lib/server/apiRoute";
/**
* Delete the signed-in user and associated data.
*
* **Policy (CR-86 / Ticket 15):** Prisma `User` deletion cascades `Session` and
* `RuleDraft`. `PublishedRule` uses `onDelete: SetNull` on `userId`, so published
* rules remain public with `userId = null` (anonymous/orphan rows) rather than
* being removed with the account. Change would require a schema migration if
* product later requires deleting all published rules with the user.
*/
export const DELETE = apiRoute("user.me.delete", async () => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
try {
await prisma.user.delete({ where: { id: user.id } });
} catch {
return internalError("Failed to delete account");
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
});
+63 -18
View File
@@ -1,19 +1,56 @@
"use client";
import { memo } from "react";
import ContentCopyIcon from "./icon/content_copy.svg";
import EditIcon from "./icon/edit.svg";
import ExclamationIcon from "./icon/exclamation.svg";
import ChevronRightIcon from "./icon/chevron_right.svg";
import LogOutIcon from "./icon/log_out.svg";
import MailIcon from "./icon/mail.svg";
import WarningIcon from "./icon/warning.svg";
export type IconName = "exclamation";
export const ICON_NAME_OPTIONS = [
"chevron_right",
"content_copy",
"edit",
"exclamation",
"log_out",
"mail",
"warning",
] as const;
export type IconName = (typeof ICON_NAME_OPTIONS)[number];
type SvgComponent =
| React.ComponentType<React.SVGProps<SVGSVGElement>>
| { default: React.ComponentType<React.SVGProps<SVGSVGElement>> };
/** SVG import may be a React component or a module object { default: Component } (e.g. with Turbopack) */
const iconMap: Record<
IconName,
| React.ComponentType<React.SVGProps<SVGSVGElement>>
| { default: React.ComponentType<React.SVGProps<SVGSVGElement>> }
> = {
const iconMap: Record<IconName, SvgComponent> = {
chevron_right: ChevronRightIcon,
content_copy: ContentCopyIcon,
edit: EditIcon,
exclamation: ExclamationIcon,
log_out: LogOutIcon,
mail: MailIcon,
warning: WarningIcon,
};
function resolveSvgComponent(module: SvgComponent) {
if (
typeof module === "object" &&
module !== null &&
"default" in module
) {
return (
module as {
default: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
).default;
}
return module as React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
export interface IconProps {
name: IconName;
className?: string;
@@ -30,18 +67,26 @@ function IconComponent({
}: IconProps) {
const SvgModule = iconMap[name];
if (!SvgModule) return null;
// Turbopack/bundler may expose SVG as { default: Component } instead of the component directly
const Svg =
typeof SvgModule === "object" &&
SvgModule !== null &&
"default" in SvgModule
? (
SvgModule as {
default: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
).default
: (SvgModule as React.ComponentType<React.SVGProps<SVGSVGElement>>);
if (typeof Svg !== "function") return null;
const resolved = resolveSvgComponent(SvgModule);
// Turbopack/webpack mismatch: `.svg` may be a URL string instead of SVGR output.
if (typeof resolved === "string") {
return (
<img
src={resolved}
width={size}
height={size}
className={className}
alt=""
aria-hidden={ariaHidden}
/>
);
}
if (resolved == null) return null;
const Svg = resolved as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return (
<Svg
width={size}
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9462 12L8.34616 7.40002L9.39999 6.34619L15.0538 12L9.39999 17.6538L8.34616 16.6L12.9462 12Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.0577 17.5C8.55256 17.5 8.125 17.325 7.77502 16.975C7.42502 16.625 7.25002 16.1974 7.25002 15.6923V4.3077C7.25002 3.80257 7.42502 3.375 7.77502 3.025C8.125 2.675 8.55256 2.5 9.0577 2.5H17.4423C17.9474 2.5 18.3749 2.675 18.7249 3.025C19.0749 3.375 19.2499 3.80257 19.2499 4.3077V15.6923C19.2499 16.1974 19.0749 16.625 18.7249 16.975C18.3749 17.325 17.9474 17.5 17.4423 17.5H9.0577ZM9.0577 16H17.4423C17.5192 16 17.5897 15.9679 17.6538 15.9038C17.7179 15.8397 17.75 15.7692 17.75 15.6923V4.3077C17.75 4.23077 17.7179 4.16024 17.6538 4.09613C17.5897 4.03203 17.5192 3.99998 17.4423 3.99998H9.0577C8.98076 3.99998 8.91025 4.03203 8.84615 4.09613C8.78203 4.16024 8.74997 4.23077 8.74997 4.3077V15.6923C8.74997 15.7692 8.78203 15.8397 8.84615 15.9038C8.91025 15.9679 8.98076 16 9.0577 16ZM5.55772 20.9999C5.0526 20.9999 4.62505 20.8249 4.27505 20.4749C3.92505 20.1249 3.75005 19.6973 3.75005 19.1922V6.3077H5.25002V19.1922C5.25002 19.2692 5.28207 19.3397 5.34617 19.4038C5.41029 19.4679 5.4808 19.5 5.55772 19.5H15.4423V20.9999H5.55772Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.15385 19H6.39038L15.6501 9.74036L14.4135 8.50381L5.15385 17.7635V19ZM18.8577 8.65576L15.4827 5.31158L16.7866 4.00776C17.0802 3.71417 17.4372 3.56738 17.8577 3.56738C18.2782 3.56738 18.6352 3.71417 18.9288 4.00776L20.1461 5.22503C20.4397 5.51862 20.5916 5.87053 20.6019 6.28078C20.6121 6.69103 20.4705 7.04295 20.1769 7.33653L18.8577 8.65576ZM17.7731 9.75573L7.02883 20.5H3.6539V17.125L14.3981 6.38078L17.7731 9.75573Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.67315 3.48079C1.67315 2.97566 1.84815 2.54809 2.19815 2.19809C2.54813 1.84809 2.97569 1.67309 3.48082 1.67309L13.8654 1.6731C14.3705 1.6731 14.7981 1.8481 15.1481 2.1981C15.4981 2.5481 15.6731 2.97566 15.6731 3.4808L15.6731 9.03845L14.1731 9.03845L14.1731 3.4808C14.1731 3.40386 14.141 3.33334 14.0769 3.26922C14.0128 3.20512 13.9423 3.17307 13.8654 3.17307L3.48082 3.17307C3.40389 3.17307 3.33337 3.20512 3.26925 3.26922C3.20515 3.33334 3.1731 3.40386 3.1731 3.48079L3.1731 20.8653C3.1731 20.9423 3.20515 21.0128 3.26925 21.0769C3.33337 21.141 3.40389 21.1731 3.48082 21.1731L13.8654 21.1731C13.9423 21.1731 14.0128 21.141 14.0769 21.0769C14.141 21.0128 14.1731 20.9423 14.1731 20.8653L14.1731 15.3077L15.6731 15.3077L15.6731 20.8653C15.6731 21.3705 15.4981 21.798 15.1481 22.148C14.7981 22.498 14.3705 22.673 13.8654 22.673L3.48082 22.673C2.97569 22.673 2.54812 22.498 2.19812 22.148C1.84812 21.798 1.67312 21.3705 1.67312 20.8653L1.67315 3.48079ZM8.4231 11.4231L19.4538 11.4231L17.6038 9.57307L18.6731 8.51924L22.3269 12.1731L18.6731 15.8269L17.6038 14.7731L19.4539 12.923L8.42312 12.923L8.4231 11.4231Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.30773 19.5C3.8026 19.5 3.37503 19.325 3.02503 18.975C2.67503 18.625 2.50003 18.1974 2.50003 17.6923V6.3077C2.50003 5.80257 2.67503 5.375 3.02503 5.025C3.37503 4.675 3.8026 4.5 4.30773 4.5H19.6923C20.1974 4.5 20.625 4.675 20.975 5.025C21.325 5.375 21.5 5.80257 21.5 6.3077V17.6923C21.5 18.1974 21.325 18.625 20.975 18.975C20.625 19.325 20.1974 19.5 19.6923 19.5H4.30773ZM12 12.5576L4.00001 7.44225V17.6923C4.00001 17.782 4.02886 17.8557 4.08656 17.9134C4.14426 17.9711 4.21798 18 4.30773 18H19.6923C19.782 18 19.8558 17.9711 19.9135 17.9134C19.9712 17.8557 20 17.782 20 17.6923V7.44225L12 12.5576ZM12 11L19.8462 5.99998H4.15386L12 11ZM4.00001 7.44225V5.99998V17.6923C4.00001 17.782 4.02886 17.8557 4.08656 17.9134C4.14426 17.9711 4.21798 18 4.30773 18H4.00001V7.44225Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 919 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.86545 20.4999L12 3L22.1346 20.4999H1.86545ZM4.45 18.9999H19.55L12 5.99993L4.45 18.9999ZM12 17.8076C12.2288 17.8076 12.4207 17.7302 12.5755 17.5754C12.7303 17.4206 12.8077 17.2288 12.8077 16.9999C12.8077 16.7711 12.7303 16.5793 12.5755 16.4245C12.4207 16.2697 12.2288 16.1923 12 16.1923C11.7711 16.1923 11.5793 16.2697 11.4245 16.4245C11.2697 16.5793 11.1923 16.7711 11.1923 16.9999C11.1923 17.2288 11.2697 17.4206 11.4245 17.5754C11.5793 17.7302 11.7711 17.8076 12 17.8076ZM11.25 15.1923H12.75V10.1923H11.25V15.1923Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

+1 -1
View File
@@ -1,3 +1,3 @@
export { default as Icon } from "./Icon";
export { default as Icon, ICON_NAME_OPTIONS } from "./Icon";
export type { IconName, IconProps } from "./Icon";
export { default as Logo } from "./logo";
+5 -5
View File
@@ -42,14 +42,14 @@ const Logo = memo<LogoProps>(
},
footer: {
containerHeight:
"h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
"h-[41px] md:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] md:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
textSize:
"text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
"text-[21.97px] md:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
lineHeight:
"leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
"leading-[27.05px] md:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
iconSize:
"w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
"w-[27.05px] h-[27.05px] md:w-[calc(27.05px*1.37)] md:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
},
createFlow: {
containerHeight: "h-[30px] md:h-[41px]",
@@ -17,6 +17,10 @@ declare global {
}
}
/**
* Figma: "Card / Rule" — e.g. profile `22143:900771` when **Has bottom link** is on
* (`hasBottomLinks` + `bottomLinks` / optional `bottomStatusLabel`).
*/
const RuleCardContainer = memo<RuleCardProps>(
({
title,
@@ -32,10 +36,14 @@ const RuleCardContainer = memo<RuleCardProps>(
logoAlt,
communityInitials,
hideCategoryAddButton = false,
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
}) => {
const size = sizeProp ?? "L";
const handleClick = () => {
if (hasBottomLinks) return;
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
@@ -56,6 +64,7 @@ const RuleCardContainer = memo<RuleCardProps>(
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (hasBottomLinks) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
@@ -69,8 +78,8 @@ const RuleCardContainer = memo<RuleCardProps>(
icon={icon}
backgroundColor={backgroundColor}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
onClick={hasBottomLinks ? undefined : handleClick}
onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
expanded={expanded}
size={size}
categories={categories}
@@ -78,6 +87,9 @@ const RuleCardContainer = memo<RuleCardProps>(
logoAlt={logoAlt}
communityInitials={communityInitials}
hideCategoryAddButton={hideCategoryAddButton}
hasBottomLinks={hasBottomLinks}
bottomStatusLabel={bottomStatusLabel}
bottomLinks={bottomLinks}
/>
);
},
@@ -13,6 +13,14 @@ export interface Category {
onCustomChipClose?: (categoryName: string, chipId: string) => void;
}
/** Bottom row for `Card / Rule` when Figma **Has bottom link** is on (profile, etc.). */
export interface RuleCardBottomLink {
id: string;
label: string;
href?: string;
onClick?: () => void;
}
export interface RuleCardProps {
title: string;
description?: string;
@@ -28,6 +36,15 @@ export interface RuleCardProps {
communityInitials?: string;
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
hideCategoryAddButton?: boolean;
/**
* Figma `Card / Rule` variant: description + optional status chip + text links
* (e.g. Duplicate / Delete, or Continue / Start new rule). When set, the card
* is not a single interactive button — links handle their own actions.
*/
hasBottomLinks?: boolean;
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
bottomStatusLabel?: string;
bottomLinks?: RuleCardBottomLink[];
}
export interface RuleCardViewProps {
@@ -36,8 +53,8 @@ export interface RuleCardViewProps {
icon?: React.ReactNode;
backgroundColor: string;
className: string;
onClick: () => void;
onKeyDown: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
onClick?: () => void;
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean;
size: "XS" | "S" | "M" | "L";
categories?: Category[];
@@ -45,4 +62,7 @@ export interface RuleCardViewProps {
logoAlt?: string;
communityInitials?: string;
hideCategoryAddButton?: boolean;
hasBottomLinks?: boolean;
bottomStatusLabel?: string;
bottomLinks?: RuleCardBottomLink[];
}
+113 -48
View File
@@ -3,7 +3,8 @@
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect";
import type { RuleCardViewProps } from "./RuleCard.types";
import NavigationLink from "../../navigation/Link";
import type { RuleCardBottomLink, RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
title,
@@ -20,9 +21,13 @@ export function RuleCardView({
logoAlt,
communityInitials,
hideCategoryAddButton = false,
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
}: RuleCardViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
const interactiveCard = !hasBottomLinks;
// Size-based styling
const isLarge = size === "L";
@@ -70,15 +75,14 @@ export function RuleCardView({
: "" // XS and S: no fixed width
: "";
// Logo/Icon dimensions - use CSS responsive classes
// For S: 80px container with 12px padding = 56px icon area
// For XS: 72px container with 16px padding = 40px icon (72 - 16*2 = 40px)
const logoSize = 103; // Use max size, CSS will resize
// Logo/Icon dimensions (inner circle) after Figma header `pl-1 pr-2 py-2` in icon cell
// (Card / Rule — e.g. `22143:900771` / `19706:12110`); outer column width holds padding + this.
const logoSize = 103; // `next/image` prop; actual box comes from `logoContainerClass`
const logoContainerClass = `
max-[639px]:size-[72px]
min-[640px]:max-[1023px]:size-[80px]
max-[639px]:size-[56px]
min-[640px]:max-[1023px]:size-[64px]
min-[1024px]:max-[1439px]:size-[56px]
min-[1440px]:size-[103px]
min-[1440px]:size-[88px]
`;
// Title typography - use CSS responsive classes
@@ -106,7 +110,7 @@ export function RuleCardView({
logoUrl.startsWith("http://localhost") ||
logoUrl.startsWith("https://localhost");
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`;
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity`;
if (isLocalhost) {
return (
@@ -139,7 +143,7 @@ export function RuleCardView({
if (icon) {
return (
<div
className={`${logoContainerClass} flex items-center justify-center max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`}
className={`${logoContainerClass} flex items-center justify-center`}
>
{icon}
</div>
@@ -150,8 +154,7 @@ export function RuleCardView({
const initialsSize = `
max-[639px]:text-[16px]
min-[640px]:max-[1023px]:text-[20px]
min-[1024px]:max-[1439px]:text-[24px]
min-[1440px]:text-[36px]
min-[1024px]:text-[36px]
`;
return (
<div
@@ -178,54 +181,72 @@ export function RuleCardView({
? "rounded-[var(--measures-radius-300,12px)]"
: "rounded-[var(--radius-measures-radius-small)]";
function renderBottomLink(link: RuleCardBottomLink) {
const shared = {
variant: "paragraph" as const,
type: "primary" as const,
theme: "light" as const,
className: "shrink-0",
children: link.label,
};
if (link.href) {
return (
<NavigationLink
key={link.id}
{...shared}
href={link.href}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<NavigationLink
key={link.id}
{...shared}
onClick={(e) => {
e.stopPropagation();
link.onClick?.();
}}
/>
);
}
return (
<div
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200 flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={0}
role="button"
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{/* Outermost container with bottom border - taller to match Figma */}
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
<div
className={`
border-b border-solid border-[var(--color-content-invert-primary)] flex items-center relative shrink-0 w-full
max-[639px]:h-[72px]
min-[640px]:max-[1023px]:h-[80px]
min-[1024px]:max-[1439px]:h-[88px]
min-[1440px]:h-[136px]
`}
className="
border-b border-solid border-[var(--color-content-invert-primary)] flex
w-full shrink-0 items-center gap-px
"
>
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */}
{renderLogo() && (
<div
className={`
flex items-center justify-center shrink-0
max-[639px]:w-[72px] max-[639px]:h-[72px] max-[639px]:border-r max-[639px]:border-solid max-[639px]:border-[var(--color-content-invert-primary)]
min-[640px]:max-[1023px]:w-[80px] min-[640px]:max-[1023px]:h-[80px] min-[640px]:max-[1023px]:border-r min-[640px]:max-[1023px]:border-solid min-[640px]:max-[1023px]:border-[var(--color-content-invert-primary)]
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[103px] min-[1440px]:h-[103px]
`}
className="
flex shrink-0 items-center justify-center
pl-[4px] pr-[8px] py-[8px]
max-[639px]:w-[72px]
min-[640px]:max-[1023px]:w-[80px]
min-[1024px]:w-[119px]
"
>
{renderLogo()}
</div>
)}
{/* Spacing between icon and title */}
<div
className="
max-[1023px]:hidden
min-[1024px]:w-[16px] min-[1024px]:shrink-0
"
/>
{/* Container with no padding and left border - extends full height to touch bottom */}
{title && (
<div
className={`
flex-1 min-w-0 h-full flex
max-[1023px]:border-0
min-[1024px]:border-l min-[1024px]:border-solid min-[1024px]:border-[var(--color-content-invert-primary)]
flex min-w-0 flex-1 flex-col justify-center
min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:min-h-[88px] min-[1440px]:min-h-[136px]
border-l border-solid border-[var(--color-content-invert-primary)]
`}
>
{/* Inner container for header text with padding */}
@@ -234,8 +255,7 @@ export function RuleCardView({
flex items-center justify-center w-full
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
min-[1024px]:max-[1439px]:px-[16px] min-[1024px]:max-[1439px]:py-[12px]
min-[1440px]:px-[16px] min-[1440px]:py-[24px]
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
`}
>
<h3
@@ -248,7 +268,51 @@ export function RuleCardView({
)}
</div>
{expanded ? (
{hasBottomLinks ? (
<div
className={`flex w-full shrink-0 flex-col ${isLarge ? "gap-6" : "gap-4"}`}
>
{description ? (
<p
className={`w-full ${descriptionClass} text-[var(--color-content-invert-primary)]`}
>
{description}
</p>
) : null}
{bottomLinks && bottomLinks.length > 0 ? (
<div
className={[
"flex w-full min-w-0 flex-nowrap items-center",
bottomStatusLabel ? "justify-between gap-2" : "justify-end",
].join(" ")}
data-figma-node="21867:47400"
>
{bottomStatusLabel ? (
<span className="shrink-0 rounded-[2px] bg-[var(--color-surface-default-tertiary)] px-1 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 text-[var(--color-surface-invert-brand-teal)]">
{bottomStatusLabel}
</span>
) : null}
{/**
* Figma `22143:900539` / `21867:46099`: one row — status (optional) + all links in
* a single `flex-nowrap` group (`space/800` = 32px between links on large).
* If the row is too narrow, scroll horizontally; links never wrap.
*/}
<div
className={[
"flex min-w-0 flex-nowrap items-center justify-end overflow-x-auto [scrollbar-width:thin]",
bottomStatusLabel ? "min-w-0 flex-1" : "w-auto",
isLarge
? "gap-3 sm:gap-6 lg:gap-8"
: "gap-2 min-[400px]:gap-3 sm:gap-4 lg:gap-8",
].join(" ")}
data-figma-node="21867:46099"
>
{bottomLinks.map((link) => renderBottomLink(link))}
</div>
</div>
) : null}
</div>
) : expanded ? (
<>
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
@@ -314,3 +378,4 @@ export function RuleCardView({
</div>
);
}
+4 -1
View File
@@ -1,2 +1,5 @@
export { default } from "./RuleCard.container";
export type { RuleCardProps } from "./RuleCard.types";
export type {
RuleCardBottomLink,
RuleCardProps,
} from "./RuleCard.types";
@@ -36,7 +36,7 @@ export function TemplateChipDetailModal({
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
@@ -29,6 +29,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
showHelpIcon = true,
textHint = false,
formHeader = true,
maxLength,
...props
},
ref,
@@ -242,6 +243,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
focusRingClasses={stateStyles.focusRing}
textHint={textHint}
formHeader={formHeader}
maxLength={maxLength}
{...props}
/>
);
@@ -64,4 +64,5 @@ export interface TextInputViewProps {
focusRingClasses?: string;
textHint?: boolean | string;
formHeader?: boolean;
maxLength?: number;
}
@@ -28,6 +28,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
focusRingClasses = "",
textHint = false,
formHeader = true,
maxLength,
},
ref,
) => {
@@ -70,6 +71,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onBlur={handleBlur}
onMouseDown={handleMouseDown}
disabled={disabled}
maxLength={maxLength}
className={inputClasses}
style={{ borderRadius }}
/>
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { ListView } from "./List.view";
import type { ListProps } from "./List.types";
/**
* Figma: "List Edit" list frame — S (21863:45631), M (21863:45493), L (21844:4405).
* Composes {@link ListEntry} rows with a shared list-level top rule when enabled.
*/
const ListContainer = memo<ListProps>((props) => {
return <ListView {...props} />;
});
ListContainer.displayName = "List";
export default ListContainer;
+29
View File
@@ -0,0 +1,29 @@
import type { IconName } from "../../asset/Icon";
import type {
ListEntryVariant,
ListSize,
} from "../ListEntry/ListEntry.types";
export type ListItem = {
id: string;
title: string;
description: string;
href?: string;
onClick?: () => void;
/** Per-row icon; falls back to list-level {@link ListProps.leadingIcon}. */
leadingIcon?: IconName;
variant?: ListEntryVariant;
showDescription?: boolean;
};
export type ListProps = {
items: ListItem[];
size?: ListSize;
topDivider?: boolean;
leadingIcon?: IconName;
className?: string;
};
export type { ListEntryVariant, ListSize };
export type ListViewProps = ListProps;
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import Divider from "../../utility/Divider";
import ListEntry from "../ListEntry";
import { FIGMA_LIST_ROOT } from "../listSizeLayout";
import type { ListViewProps } from "./List.types";
export const ListView = memo(function ListView({
items,
size = "m",
topDivider = true,
leadingIcon = "edit",
className = "",
}: ListViewProps) {
return (
<div
className={`flex w-full max-w-[1590px] flex-col items-start ${className}`}
data-figma-node={FIGMA_LIST_ROOT[size]}
>
{topDivider ? <Divider type="content" orientation="horizontal" /> : null}
<ul className="m-0 flex w-full list-none flex-col items-start p-0">
{items.map((item) => (
<li
key={item.id}
className="flex w-full flex-col items-stretch [list-style:none]"
>
<ListEntry
title={item.title}
description={item.description}
showDescription={item.showDescription}
href={item.href}
onClick={item.onClick}
size={size}
leadingIcon={item.leadingIcon ?? leadingIcon}
variant={item.variant}
topDivider={false}
bottomDivider
/>
</li>
))}
</ul>
</div>
);
});
ListView.displayName = "ListView";
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./List.container";
export type { ListProps, ListItem, ListSize, ListViewProps } from "./List.types";
export { LIST_SIZE_OPTIONS } from "../ListEntry/ListEntry.types";
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { ListEntryView } from "./ListEntry.view";
import type { ListEntryProps } from "./ListEntry.types";
/**
* Figma: "Base / Interactive" (21844:4118). Single list row: optional top rule,
* leading icon, title, optional description, chevron, optional bottom rule.
*/
const ListEntryContainer = memo<ListEntryProps>((props) => {
return <ListEntryView {...props} />;
});
ListEntryContainer.displayName = "ListEntry";
export default ListEntryContainer;
@@ -0,0 +1,27 @@
import type { IconName } from "../../asset/Icon";
export const LIST_SIZE_OPTIONS = ["s", "m", "l"] as const;
export type ListSize = (typeof LIST_SIZE_OPTIONS)[number];
export const LIST_ENTRY_VARIANT_OPTIONS = ["default", "danger", "muted"] as const;
export type ListEntryVariant = (typeof LIST_ENTRY_VARIANT_OPTIONS)[number];
export type ListEntryProps = {
title: string;
description?: string;
/** @default true */
showDescription?: boolean;
href?: string;
onClick?: () => void;
size?: ListSize;
leadingIcon?: IconName;
/** Row tone (e.g. profile destructive / disabled rows). @default "default" */
variant?: ListEntryVariant;
/** Renders a line above the row (Base / Interactive). @default false */
topDivider?: boolean;
/** Renders a line under the row. @default true */
bottomDivider?: boolean;
className?: string;
};
export type ListEntryViewProps = ListEntryProps;
@@ -0,0 +1,173 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import Icon, { type IconName } from "../../asset/Icon";
import Divider from "../../utility/Divider";
import { FIGMA_LIST_ENTRY_OUTER, listEntrySizeLayout } from "../listSizeLayout";
import type {
ListEntryViewProps,
ListEntryVariant,
ListSize,
} from "./ListEntry.types";
type RowCoreProps = {
title: string;
description?: string;
showDescription: boolean;
href?: string;
onClick?: () => void;
leadingIcon: IconName;
size: ListSize;
variant: ListEntryVariant;
};
const ListEntryRow = memo(function ListEntryRow({
title,
description,
showDescription,
href,
onClick,
leadingIcon,
size,
variant,
}: RowCoreProps) {
const layout = listEntrySizeLayout[size];
const leadingBoxClass =
size === "s"
? "flex h-6 w-6 shrink-0 items-center justify-center"
: size === "m"
? "flex size-8 shrink-0 items-center justify-center"
: "flex size-10 shrink-0 items-center justify-center";
const chevronSize = size === "s" ? 16 : size === "l" ? 32 : 24;
const shellExtra =
variant === "muted" ? "opacity-60 hover:!bg-transparent" : "";
const titleClass =
variant === "danger"
? `${layout.title} !text-[var(--color-content-default-negative-primary)]`
: layout.title;
const leadingToneClass =
variant === "danger"
? "text-[var(--color-content-default-negative-primary)]"
: "text-[var(--color-content-default-primary)]";
const chevronToneClass =
variant === "danger"
? "text-[var(--color-content-default-negative-primary)]"
: "text-[var(--color-content-default-primary)]";
const leadingSlot = (
<div className={`${leadingBoxClass} ${leadingToneClass}`}>
<Icon name={leadingIcon} size={24} />
</div>
);
const chevronSlot = (
<div
className={
size === "s"
? `flex size-4 shrink-0 items-center justify-center ${chevronToneClass}`
: size === "l"
? `flex size-8 shrink-0 items-center justify-center ${chevronToneClass}`
: `flex size-6 shrink-0 items-center justify-center ${chevronToneClass}`
}
>
<Icon name="chevron_right" size={chevronSize} />
</div>
);
const textBlock = (
<>
<div className="flex w-full min-w-0 items-center justify-between">
<p className={titleClass}>{title}</p>
</div>
{showDescription && description != null && description !== "" ? (
<p className={layout.description}>{description}</p>
) : null}
</>
);
const inner = (
<>
{leadingSlot}
<div className={layout.textCol}>{textBlock}</div>
{chevronSlot}
</>
);
const shellClass = `${layout.shell} ${shellExtra}`.trim();
if (href) {
return (
<Link
href={href}
className={shellClass}
data-figma-node={layout.rowFigma}
>
{inner}
</Link>
);
}
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={shellClass}
data-figma-node={layout.rowFigma}
>
{inner}
</button>
);
}
return (
<div className={shellClass} data-figma-node={layout.rowFigma}>
{inner}
</div>
);
});
ListEntryRow.displayName = "ListEntryRow";
export const ListEntryView = memo(function ListEntryView({
title,
description = "",
showDescription = true,
href,
onClick,
size = "m",
leadingIcon = "edit",
variant = "default",
topDivider = false,
bottomDivider = true,
className = "",
}: ListEntryViewProps) {
return (
<div
className={`flex w-full flex-col items-start ${className}`}
data-figma-node={FIGMA_LIST_ENTRY_OUTER[size]}
>
{topDivider ? <Divider type="content" orientation="horizontal" /> : null}
<ListEntryRow
title={title}
description={description}
showDescription={showDescription}
href={href}
onClick={onClick}
leadingIcon={leadingIcon}
size={size}
variant={variant}
/>
{bottomDivider ? <Divider type="content" orientation="horizontal" /> : null}
</div>
);
});
ListEntryView.displayName = "ListEntryView";
@@ -0,0 +1,3 @@
export { default } from "./ListEntry.container";
export type { ListEntryProps, ListSize } from "./ListEntry.types";
export { LIST_SIZE_OPTIONS } from "./ListEntry.types";
+69
View File
@@ -0,0 +1,69 @@
import type { ListSize } from "./ListEntry/ListEntry.types";
export const rowShellBase =
"flex w-full cursor-pointer items-center text-left text-[var(--color-content-default-primary)] outline-none " +
"transition-colors " +
"hover:bg-[var(--color-surface-default-tertiary)] " +
"focus-visible:ring-2 focus-visible:ring-[var(--color-content-default-primary)] " +
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
/**
* Figma: "ListEntry" / Base Interactive (21844:4118) — S/M/L row + outer shell node ids.
* Full list frame roots: 21863:45631 (S), 21863:45493 (M), 21844:4405 (L).
*/
export const FIGMA_LIST_ENTRY_OUTER: Record<ListSize, string> = {
s: "21863:45436",
m: "21863:45422",
l: "21844:4119",
};
export const FIGMA_LIST_ROOT: Record<ListSize, string> = {
s: "21863:45631",
m: "21863:45493",
l: "21844:4405",
};
export const FIGMA_LIST_ENTRY_ROW: Record<ListSize, string> = {
s: "21863:45438",
m: "21863:45424",
l: "21844:4120",
};
type RowLayout = {
shell: string;
textCol: string;
title: string;
description: string;
rowFigma: string;
};
export const listEntrySizeLayout: Record<ListSize, RowLayout> = {
s: {
shell: `${rowShellBase} min-h-0 gap-1.5 py-[var(--spacing-scale-012)]`,
textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
title:
"min-w-0 flex-1 font-inter text-sm font-medium leading-[18px] text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-xs font-normal leading-4 text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.s,
},
m: {
shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)]`,
textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
title:
"min-w-0 flex-1 font-inter text-lg font-medium leading-6 text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-base font-normal leading-6 text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.m,
},
l: {
shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-012)] py-[var(--spacing-scale-016)]`,
textCol:
"flex min-w-0 flex-1 flex-col items-start justify-center gap-[var(--spacing-scale-004)]",
title:
"min-w-0 flex-1 font-inter text-2xl font-normal leading-7 text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-lg font-normal leading-[1.3] text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.l,
},
};
@@ -1,8 +1,9 @@
"use client";
import { memo, useEffect, useRef } from "react";
import { memo, useRef } from "react";
import { CreateView } from "./Create.view";
import type { CreateProps } from "./Create.types";
import { useCreateModalA11y } from "./useCreateModalA11y";
const CreateContainer = memo<CreateProps>(
({
@@ -29,85 +30,8 @@ const CreateContainer = memo<CreateProps>(
}) => {
const createRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// Handle ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
// Focus trap and body scroll lock
useEffect(() => {
if (!isOpen) return;
// Store previous active element
previousActiveElementRef.current = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = "hidden";
// Focus the first focusable element in the create dialog
if (createRef.current) {
const focusableElements = createRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
// Fallback: make create dialog focusable and focus it
createRef.current.setAttribute("tabindex", "-1");
createRef.current.focus();
}
}
// Focus trap
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !createRef.current) return;
const focusableElements = createRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
// Restore focus to previous element
previousActiveElementRef.current?.focus();
};
}, [isOpen]);
useCreateModalA11y(isOpen, onClose, createRef);
return (
<CreateView
+8 -5
View File
@@ -1,3 +1,6 @@
import type { RefObject } from "react";
import type { CreateModalBackdropVariant } from "./CreateModalFrame.view";
export interface CreateProps {
isOpen: boolean;
onClose: () => void;
@@ -28,10 +31,10 @@ export interface CreateProps {
upload?: boolean;
proportion?: boolean;
/**
* Backdrop behind the dialog. `loginYellow` matches the Login modals blurred brand overlay.
* Backdrop behind the dialog. `blurredYellow` matches the login-style blurred brand overlay.
* @default "default"
*/
backdropVariant?: "default" | "loginYellow";
backdropVariant?: CreateModalBackdropVariant;
}
export interface CreateViewProps {
@@ -54,7 +57,7 @@ export interface CreateViewProps {
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
createRef: React.RefObject<HTMLDivElement>;
overlayRef: React.RefObject<HTMLDivElement>;
backdropVariant: "default" | "loginYellow";
createRef: RefObject<HTMLDivElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
backdropVariant: CreateModalBackdropVariant;
}
+40 -71
View File
@@ -1,20 +1,11 @@
"use client";
import { createPortal } from "react-dom";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import { CreateModalFrameView } from "./CreateModalFrame.view";
import type { CreateViewProps } from "./Create.types";
const backdropOverlayClasses: Record<
CreateViewProps["backdropVariant"],
string
> = {
default: "fixed inset-0 bg-black/50 z-[9998]",
loginYellow:
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
export function CreateView({
isOpen,
onClose,
@@ -39,70 +30,48 @@ export function CreateView({
overlayRef,
backdropVariant,
}: CreateViewProps) {
if (!isOpen) return null;
return (
<CreateModalFrameView
isOpen={isOpen}
onOverlayClick={onClose}
backdropVariant={backdropVariant}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
overlayRef={overlayRef}
dialogRef={createRef}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
const createContent = (
<>
{/* Overlay */}
<div
ref={overlayRef}
className={backdropOverlayClasses[backdropVariant]}
onClick={onClose}
aria-hidden="true"
/>
{/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
<div
ref={createRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header: custom headerContent (when provided) or default title/description */}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
) : null}
{/* Content Area (scrollable when content overflows) */}
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
) : null}
{/* Footer (always visible at bottom of modal) */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
</>
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
</CreateModalFrameView>
);
// Portal to body
if (typeof window !== "undefined") {
return createPortal(createContent, document.body);
}
return null;
}
@@ -0,0 +1,69 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { createPortal } from "react-dom";
/** Matches {@link CreateView} overlay options — shared with {@link DialogView}. */
export type CreateModalBackdropVariant = "default" | "blurredYellow";
const backdropOverlayClasses: Record<CreateModalBackdropVariant, string> = {
default: "fixed inset-0 bg-black/50 z-[9998]",
blurredYellow:
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
export type CreateModalFrameViewProps = {
isOpen: boolean;
onOverlayClick: () => void;
backdropVariant: CreateModalBackdropVariant;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
overlayRef: RefObject<HTMLDivElement | null>;
dialogRef: RefObject<HTMLDivElement | null>;
children: ReactNode;
};
/**
* Portal + dimmed overlay + centered dialog shell used by Create and Dialog.
*/
export function CreateModalFrameView({
isOpen,
onOverlayClick,
backdropVariant,
className,
ariaLabel,
ariaLabelledBy,
overlayRef,
dialogRef,
children,
}: CreateModalFrameViewProps) {
if (!isOpen) return null;
const content = (
<>
<div
ref={overlayRef}
className={backdropOverlayClasses[backdropVariant]}
onClick={onOverlayClick}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{children}
</div>
</>
);
if (typeof window !== "undefined") {
return createPortal(content, document.body);
}
return null;
}
@@ -0,0 +1,82 @@
"use client";
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
*/
export function useCreateModalA11y(
isOpen: boolean,
onClose: () => void,
dialogRef: RefObject<HTMLDivElement | null>,
): void {
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
if (dialogRef.current) {
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
dialogRef.current.setAttribute("tabindex", "-1");
dialogRef.current.focus();
}
}
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus();
};
}, [isOpen]);
}
@@ -0,0 +1,50 @@
"use client";
import { memo, useId, useRef } from "react";
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
import { DialogView } from "./Dialog.view";
import type { DialogProps } from "./Dialog.types";
const DialogContainer = memo<DialogProps>(
({
isOpen,
onClose,
title,
description,
footer,
children,
className = "",
ariaLabel,
ariaLabelledBy: ariaLabelledByProp,
backdropVariant = "default",
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const autoTitleId = useId();
const titleId = ariaLabelledByProp ?? autoTitleId;
useCreateModalA11y(isOpen, onClose, dialogRef);
return (
<DialogView
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={footer}
children={children}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={titleId}
titleId={titleId}
backdropVariant={backdropVariant}
overlayRef={overlayRef}
dialogRef={dialogRef}
/>
);
},
);
DialogContainer.displayName = "Dialog";
export default DialogContainer;
@@ -0,0 +1,37 @@
import type { ReactNode, RefObject } from "react";
import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view";
export interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
/** Primary actions row (e.g. Cancel + Confirm) — use design-system `Button`s. */
footer: ReactNode;
/** Optional body below the title block (scrolls when tall). */
children?: ReactNode;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* Same backdrop options as the Create modal shell.
* @default "default"
*/
backdropVariant?: CreateModalBackdropVariant;
}
export interface DialogViewProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
footer: ReactNode;
children?: ReactNode;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
titleId: string;
backdropVariant: CreateModalBackdropVariant;
overlayRef: RefObject<HTMLDivElement | null>;
dialogRef: RefObject<HTMLDivElement | null>;
}
@@ -0,0 +1,68 @@
"use client";
import { memo } from "react";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import { CreateModalFrameView } from "../Create/CreateModalFrame.view";
import type { DialogViewProps } from "./Dialog.types";
export const DialogView = memo(function DialogView({
isOpen,
onClose,
title,
description,
footer,
children,
className,
ariaLabel,
ariaLabelledBy,
titleId,
backdropVariant,
overlayRef,
dialogRef,
}: DialogViewProps) {
return (
<CreateModalFrameView
isOpen={isOpen}
onOverlayClick={onClose}
backdropVariant={backdropVariant}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
overlayRef={overlayRef}
dialogRef={dialogRef}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
titleId={titleId}
/>
</div>
{children ? (
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
) : null}
<ModalFooter
showBackButton={false}
showNextButton={false}
stepper={false}
footerContent={
<div className="absolute right-[16px] top-[12px] flex max-w-[calc(100%-32px)] flex-wrap items-center justify-end gap-3">
{footer}
</div>
}
/>
</CreateModalFrameView>
);
});
DialogView.displayName = "DialogView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Dialog.container";
export type { DialogProps } from "./Dialog.types";
+156 -103
View File
@@ -7,9 +7,28 @@ import Logo from "../asset/logo";
import Separator from "../utility/Separator";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
/**
* Figma: "Navigation / Footer" (18411-62917).
* Tiers: smallest viewports (below `md`), `md` through `lg`, `lg` and up.
* Matches `--breakpoint-md: 640px`, `--breakpoint-lg: 1024px` in `app/tailwind.css`.
*/
const Footer = memo(() => {
const t = useTranslation("footer");
const linkFocusClass =
"hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity";
const bodyTextClass =
"text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] lg:text-2xl lg:font-normal lg:leading-7";
/** Figma 18411:62925 (1024+): org name is one line, `w-full whitespace-nowrap`. */
const orgNameClass = `${bodyTextClass} lg:whitespace-nowrap`;
const primaryLinkClass = `text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer lg:text-2xl lg:font-normal lg:leading-7`;
/** Figma 18411:62944: 40px gaps, w-[396px] link block; `p-2` on links overruns 396px—tighten x at `md+` row. */
const legalLinkClass = `text-[var(--color-content-default-secondary)] font-inter text-sm font-normal leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer underline decoration-solid [text-decoration-skip-ink:none] md:px-0 md:py-1 md:mx-0 md:text-xs md:leading-4 md:whitespace-nowrap md:no-underline md:text-[var(--color-content-default-primary)] lg:text-sm lg:leading-5 lg:text-[var(--color-content-default-primary)]`;
// Schema markup for organization information
const schemaData = {
"@context": "https://schema.org",
@@ -28,126 +47,160 @@ const Footer = memo(() => {
/>
<footer className="bg-[var(--color-surface-default-primary)] w-full">
<div
className="flex flex-col items-start mx-auto
className="mx-auto flex max-w-[1920px] flex-col
gap-[var(--spacing-measures-spacing-040)]
px-[var(--spacing-measures-spacing-016)]
py-[var(--spacing-measures-spacing-040)]
gap-[var(--spacing-measures-spacing-040)]
sm:px-[var(--spacing-measures-spacing-032)]
sm:py-[var(--spacing-measures-spacing-024)]
sm:gap-[var(--spacing-measures-spacing-024)]
lg:px-[var(--spacing-measures-spacing-120,120px)]
lg:py-[var(--spacing-measures-spacing-096,96px)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]"
md:gap-[var(--spacing-measures-spacing-024)]
md:px-[var(--spacing-measures-spacing-032)]
md:py-[var(--spacing-measures-spacing-024)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]
lg:px-[var(--spacing-scale-064)]
lg:py-[var(--spacing-scale-096)]"
>
{/* Logo */}
<Logo size="footer" wordmark />
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-032)]
md:gap-[var(--spacing-scale-048)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]"
>
<Logo size="footer" wordmark />
{/* Content section */}
<div className="flex flex-col items-start w-full gap-[var(--spacing-measures-spacing-048,48px)] sm:flex-row sm:justify-between sm:gap-0">
{/* Branding Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-064,64px)] order-2 sm:order-1">
{/* Contact info */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("organization.name")}
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-048)]
md:flex-row md:items-start md:justify-between md:gap-0"
>
<div
className="order-2 flex flex-col
gap-[var(--spacing-scale-048)]
md:order-1 md:max-w-[min(100%,334px)]
lg:max-w-[min(100%,334px)]
lg:gap-[var(--spacing-scale-064)]"
>
<div
className="flex flex-col
gap-[var(--spacing-measures-spacing-016,16px)]"
>
<div className={orgNameClass}>{t("organization.name")}</div>
<a
href={`mailto:${t("organization.email")}`}
className={`${bodyTextClass} ${linkFocusClass} p-2 -m-2 cursor-pointer`}
>
{t("organization.email")}
</a>
</div>
<a
href={`mailto:${t("organization.email")}`}
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
<div
className="flex flex-col
gap-[var(--spacing-measures-spacing-016,16px)]"
>
{t("organization.email")}
</a>
<a
href={t("social.bluesky.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.bluesky.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
width={24}
height={22}
className="h-[21px] w-[24px] flex-shrink-0 transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.bluesky.handle")}</div>
</a>
<a
href={t("social.gitlab.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.gitlab.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
width={22}
height={22}
className="h-5 w-[22px] flex-shrink-0 grayscale transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.gitlab.handle")}</div>
</a>
</div>
</div>
{/* Social media links */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<a
href={t("social.bluesky.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.bluesky.ariaLabel")}
<nav
aria-label="Footer"
className="order-1 flex w-full max-w-full flex-col
items-start
gap-[var(--spacing-scale-032)]
md:order-2 md:w-auto md:items-end md:text-right
md:gap-[var(--spacing-scale-032)]"
>
<Link
href="#"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
width={24}
height={22}
className="flex-shrink-0 group-hover:scale-110 transition-transform"
/>
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("social.bluesky.handle")}
</div>
</a>
<a
href={t("social.gitlab.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.gitlab.ariaLabel")}
{t("navigation.useCases")}
</Link>
<Link
href="/learn"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
width={22}
height={22}
className="flex-shrink-0 grayscale group-hover:scale-110 transition-transform"
/>
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("social.gitlab.handle")}
</div>
</a>
</div>
</div>
{/* Links Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] order-1 sm:order-2">
<Link
href="#"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.useCases")}
</Link>
<Link
href="/learn"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.learn")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.about")}
</Link>
{t("navigation.learn")}
</Link>
<Link
href="#"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{t("navigation.about")}
</Link>
</nav>
</div>
</div>
<Separator />
{/* Legal Links */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] sm:flex-row sm:gap-[var(--spacing-measures-spacing-024,24px)]">
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-032)]
text-[var(--color-content-default-primary)]
md:flex-row md:items-start md:justify-between md:gap-[var(--spacing-scale-040)]
md:whitespace-nowrap
md:text-xs md:leading-4
lg:text-sm lg:leading-5"
>
<p
className="w-full font-inter text-sm font-normal leading-5 tracking-[0%]
text-[var(--color-content-default-secondary)]
md:w-auto
md:text-xs md:leading-4
lg:text-sm lg:leading-5"
>
{t("legal.privacyPolicy")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
{t("copyright")}
</p>
<div
className="flex w-full min-w-0 flex-col flex-wrap
gap-[var(--spacing-scale-032)]
font-inter text-sm
text-[var(--color-content-default-primary)]
md:max-w-[min(100%,396px)]
md:flex-row md:flex-nowrap md:content-center md:items-center md:justify-end
md:gap-[var(--spacing-scale-040)]
md:text-xs md:leading-4
lg:max-w-none
lg:gap-10
lg:text-sm lg:leading-5"
>
{t("legal.termsOfService")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("legal.cookiesSettings")}
</Link>
</div>
{/* Copyright */}
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
{t("copyright")}
<Link href="#" className={legalLinkClass}>
{t("legal.privacyPolicy")}
</Link>
<Link href="#" className={legalLinkClass}>
{t("legal.termsOfService")}
</Link>
<Link href="#" className={legalLinkClass}>
{t("legal.cookiesSettings")}
</Link>
</div>
</div>
</div>
</footer>
@@ -0,0 +1,58 @@
"use client";
import { memo } from "react";
import LinkView from "./Link.view";
import type { LinkProps } from "./Link.types";
/**
* Figma: "Link" in Navigation — "Link, CTA" (21861:21428). Paragraph uses the
* same border-b + pb-0.5 spacing as default, with the rule visible at rest.
*/
const Link = memo<LinkProps>(
({
children,
className = "",
type: linkType = "primary",
variant = "default",
theme = "light",
leadingIcon = true,
trailingIcon = true,
href,
onClick,
prefetch,
replace,
scroll,
rel,
target,
id,
"aria-label": ariaLabel,
"aria-current": ariaCurrent,
}) => {
return (
<LinkView
className={className}
type={linkType}
variant={variant}
theme={theme}
leadingIcon={variant === "default" ? leadingIcon : false}
trailingIcon={variant === "default" ? trailingIcon : false}
href={href}
onClick={onClick}
prefetch={prefetch}
replace={replace}
scroll={scroll}
rel={rel}
target={target}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
>
{children}
</LinkView>
);
},
);
Link.displayName = "Link";
export default Link;
@@ -0,0 +1,61 @@
import type { AriaAttributes, ReactNode } from "react";
export const LINK_TYPE_OPTIONS = ["primary", "secondary"] as const;
export type LinkTypeValue = (typeof LINK_TYPE_OPTIONS)[number];
export const LINK_VARIANT_OPTIONS = ["default", "paragraph"] as const;
export type LinkVariantValue = (typeof LINK_VARIANT_OPTIONS)[number];
export const LINK_THEME_OPTIONS = ["light", "dark"] as const;
export type LinkThemeValue = (typeof LINK_THEME_OPTIONS)[number];
/**
* Figma: "Link" in Navigation — `21861:21428`. Interaction states are
* implemented with CSS; there is no `state` prop.
*/
export type LinkProps = {
children: ReactNode;
className?: string;
/** Figma: Type (primary or secondary). */
type?: LinkTypeValue;
/** Figma: default (with icons) or paragraph (underlined). */
variant?: LinkVariantValue;
/** Figma: light or dark surface. */
theme?: LinkThemeValue;
/** Figma "default" variant: 16px plus before text. Ignored for `paragraph`. */
leadingIcon?: boolean;
/** Figma "default" variant: 16px plus after text. Ignored for `paragraph`. */
trailingIcon?: boolean;
href?: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
/** Passed to `next/link` when `href` is set. */
prefetch?: boolean;
replace?: boolean;
scroll?: boolean;
rel?: string;
target?: string;
id?: string;
"aria-label"?: string;
"aria-current"?: AriaAttributes["aria-current"];
};
export type LinkViewProps = {
children: ReactNode;
className?: string;
type: LinkTypeValue;
variant: LinkVariantValue;
theme: LinkThemeValue;
leadingIcon: boolean;
trailingIcon: boolean;
href?: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
dataFigmaNode?: string;
prefetch?: boolean;
replace?: boolean;
scroll?: boolean;
rel?: string;
target?: string;
id?: string;
"aria-label"?: string;
"aria-current"?: AriaAttributes["aria-current"];
};
@@ -0,0 +1,192 @@
"use client";
import NextLink from "next/link";
import { memo } from "react";
import type { MouseEventHandler, ReactNode } from "react";
import type { LinkTypeValue, LinkViewProps, LinkThemeValue, LinkVariantValue } from "./Link.types";
const FIGMA_ROOT = "21861:21428";
/** Profile & card small viewports: Figma Sizing/300 + label line (350). ≥640px: 18px / 1.3. */
const LINK_TYPOGRAPHY =
"font-inter font-normal text-[length:var(--sizing-300)] leading-[var(--sizing-350)] min-[640px]:text-[18px] min-[640px]:leading-[1.3]";
function linkFocusRing(theme: LinkThemeValue) {
return theme === "light"
? "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-border-link-focus)] focus-visible:rounded-lg"
: "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-border-link-invert-focus)] focus-visible:rounded-lg";
}
function defaultRootClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
const focusRing = linkFocusRing(theme);
if (theme === "light" && linkType === "primary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-primary)] hover:text-[var(--color-link-primary-hover)] focus-visible:text-[var(--color-link-primary-focus)] active:text-[var(--color-link-primary-active)] ${focusRing}`;
}
if (theme === "light" && linkType === "secondary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-secondary)] hover:text-[var(--color-link-secondary-hover)] focus-visible:text-[var(--color-link-secondary-focus)] active:text-[var(--color-link-secondary-active)] ${focusRing}`;
}
if (theme === "dark" && linkType === "primary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-invert-primary)] hover:text-[var(--color-link-invert-primary-hover)] focus-visible:text-[var(--color-link-invert-primary-focus)] active:text-[var(--color-link-invert-primary-active)] ${focusRing}`;
}
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-invert-secondary)] hover:text-[var(--color-link-invert-secondary-hover)] focus-visible:text-[var(--color-link-invert-secondary-focus)] active:text-[var(--color-link-invert-secondary-active)] ${focusRing}`;
}
function defaultUnderlineClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
if (theme === "light" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-primary-hover)] group-focus-visible:border-[var(--color-link-primary-focus)] group-active:border-[var(--color-link-primary-active)]";
}
if (theme === "light" && linkType === "secondary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-secondary-hover)] group-focus-visible:border-[var(--color-link-secondary-focus)] group-active:border-[var(--color-link-secondary-active)]";
}
if (theme === "dark" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-primary-hover)] group-focus-visible:border-[var(--color-link-invert-primary-focus)] group-active:border-[var(--color-link-invert-primary-active)]";
}
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-secondary-hover)] group-focus-visible:border-[var(--color-link-invert-secondary-focus)] group-active:border-[var(--color-link-invert-secondary-active)]";
}
/** Same `pb-0.5` + `border-b` as default, but the rule is visible at rest. */
function paragraphUnderlineClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
if (theme === "light" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-primary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-primary-hover)] group-focus-visible:border-[var(--color-link-primary-focus)] group-active:border-[var(--color-link-primary-active)]";
}
if (theme === "light" && linkType === "secondary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-secondary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-secondary-hover)] group-focus-visible:border-[var(--color-link-secondary-focus)] group-active:border-[var(--color-link-secondary-active)]";
}
if (theme === "dark" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-invert-primary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-primary-hover)] group-focus-visible:border-[var(--color-link-invert-primary-focus)] group-active:border-[var(--color-link-invert-primary-active)]";
}
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-invert-secondary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-secondary-hover)] group-focus-visible:border-[var(--color-link-invert-secondary-focus)] group-active:border-[var(--color-link-invert-secondary-active)]";
}
function LinkPlus12() {
return (
<span className="inline-flex size-4 shrink-0 items-center justify-center text-inherit" aria-hidden>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
>
<path
d="M5.25 0h1.5v4.5H12v1.5H6.75V12h-1.5V6.75H0V5.25h5.25V0Z"
fill="currentColor"
/>
</svg>
</span>
);
}
function LinkViewInner({
variant,
theme,
type,
leadingIcon,
trailingIcon,
children,
}: {
variant: LinkVariantValue;
theme: LinkThemeValue;
type: LinkTypeValue;
leadingIcon: boolean;
trailingIcon: boolean;
children: ReactNode;
}) {
if (variant === "paragraph") {
return (
<span className={`min-h-0 min-w-0 max-w-full shrink ${paragraphUnderlineClass(theme, type)}`}>
<span className="block min-w-0 whitespace-normal [overflow-wrap:anywhere] text-inherit">
{children}
</span>
</span>
);
}
return (
<>
{leadingIcon ? <LinkPlus12 /> : null}
<span className={`min-h-0 min-w-0 max-w-full shrink ${defaultUnderlineClass(theme, type)}`}>
<span className="block min-w-0 whitespace-normal [overflow-wrap:anywhere] text-inherit">
{children}
</span>
</span>
{trailingIcon ? <LinkPlus12 /> : null}
</>
);
}
function LinkView({
children,
className,
type,
variant,
theme,
leadingIcon,
trailingIcon,
href,
onClick,
dataFigmaNode = FIGMA_ROOT,
prefetch,
replace,
scroll,
rel,
target,
id,
"aria-label": ariaLabel,
"aria-current": ariaCurrent,
}: LinkViewProps) {
const root = [defaultRootClass(theme, type), className]
.filter(Boolean)
.join(" ");
const content = (
<LinkViewInner
variant={variant}
theme={theme}
type={type}
leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
>
{children}
</LinkViewInner>
);
if (href) {
return (
<NextLink
href={href}
className={root}
data-figma-node={dataFigmaNode}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
prefetch={prefetch}
replace={replace}
scroll={scroll}
rel={rel}
target={target}
onClick={onClick as MouseEventHandler<HTMLAnchorElement> | undefined}
>
{content}
</NextLink>
);
}
return (
<button
type="button"
className={`${root} m-0 cursor-pointer border-0 bg-transparent p-0 text-left font-inherit [font-family:inherit]`}
data-figma-node={dataFigmaNode}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
onClick={onClick as MouseEventHandler<HTMLButtonElement> | undefined}
>
{content}
</button>
);
}
LinkView.displayName = "LinkView";
export default memo(LinkView);
+7
View File
@@ -0,0 +1,7 @@
export { default } from "./Link.container";
export type { LinkProps, LinkTypeValue, LinkVariantValue, LinkThemeValue } from "./Link.types";
export {
LINK_TYPE_OPTIONS,
LINK_VARIANT_OPTIONS,
LINK_THEME_OPTIONS,
} from "./Link.types";
@@ -162,9 +162,17 @@ function TopNavView({
aria-label={t("ariaLabels.mainNavigationHeader")}
>
<nav
className="flex items-center gap-[var(--spacing-scale-002)] sm:justify-between mx-auto h-[var(--spacing-scale-040)] lg:h-[84px] xl:h-[88px] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] sm:py-[var(--spacing-measures-spacing-008)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-measures-spacing-016,16px)] sm:gap-0"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
className="flex items-center gap-[var(--spacing-scale-002)] sm:justify-between mx-auto
h-[var(--spacing-scale-040)]
lg:h-auto
px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)]
sm:px-[var(--spacing-measures-spacing-016)] sm:py-[var(--spacing-measures-spacing-008)]
lg:px-[var(--spacing-measures-spacing-64,64px)]
lg:py-[var(--spacing-scale-020)]
xl:py-[var(--spacing-scale-024)]
sm:gap-0"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
>
{/* Logo - Consistent left positioning across all breakpoints */}
<Logo
@@ -11,6 +11,7 @@ const HeaderLockupContainer = memo<HeaderLockupProps>(
justification: justificationProp = "left",
size: sizeProp = "L",
palette: paletteProp = "default",
titleId,
}) => {
const justification = justificationProp;
const size = sizeProp;
@@ -23,6 +24,7 @@ const HeaderLockupContainer = memo<HeaderLockupProps>(
justification={justification}
size={size}
palette={palette}
titleId={titleId}
/>
);
},
@@ -25,6 +25,10 @@ export interface HeaderLockupProps {
* Palette. default = light text (dark bg); inverse = dark text (light bg).
*/
palette?: HeaderLockupPaletteValue;
/**
* Optional DOM id for the title `h1` (e.g. skip-link / `aria-labelledby` targets).
*/
titleId?: string;
}
export interface HeaderLockupViewProps {
@@ -33,4 +37,5 @@ export interface HeaderLockupViewProps {
justification: "left" | "center";
size: "L" | "M";
palette: "default" | "inverse";
titleId?: string;
}
@@ -9,6 +9,7 @@ function HeaderLockupView({
justification,
size,
palette,
titleId,
}: HeaderLockupViewProps) {
const isL = size === "L";
const isLeft = justification === "left";
@@ -30,6 +31,7 @@ function HeaderLockupView({
{/* Title */}
<div className="flex items-center relative shrink-0 w-full">
<h1
id={titleId}
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative ${titleColorClass} text-ellipsis whitespace-pre-wrap ${
isLeft ? "text-left" : "text-center"
} ${
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { DividerView } from "./Divider.view";
import type { DividerProps } from "./Divider.types";
/**
* Figma: "Utility / Divider" (450:1941). Content vs Menu line weight; horizontal
* or vertical.
*/
const DividerContainer = memo<DividerProps>((props) => {
return <DividerView {...props} />;
});
DividerContainer.displayName = "Divider";
export default DividerContainer;
@@ -0,0 +1,19 @@
export const DIVIDER_ORIENTATION_OPTIONS = ["horizontal", "vertical"] as const;
export type DividerOrientation = (typeof DIVIDER_ORIENTATION_OPTIONS)[number];
export const DIVIDER_TYPE_OPTIONS = ["content", "menu"] as const;
export type DividerType = (typeof DIVIDER_TYPE_OPTIONS)[number];
export type DividerProps = {
/** @default "horizontal" */
orientation?: DividerOrientation;
/**
* Content: `--color-border-default-secondary` (subtle, lists / panels).
* Menu: `--color-border-default-tertiary` (navigation chrome).
* @default "content"
*/
type?: DividerType;
className?: string;
};
export type DividerViewProps = DividerProps;
@@ -0,0 +1,46 @@
"use client";
import { memo } from "react";
import type { DividerViewProps } from "./Divider.types";
const lineColor: Record<"content" | "menu", string> = {
content: "bg-[var(--color-border-default-secondary)]",
menu: "bg-[var(--color-border-default-tertiary)]",
};
/**
* Figma: "Utility / Divider" — horizontal Content (6894:22988), vertical Content
* (6894:22990), Menu horizontal (450:1940), Menu vertical (2002:30943).
*/
export const DividerView = memo(function DividerView({
orientation = "horizontal",
type: dividerType = "content",
className = "",
}: DividerViewProps) {
const color = lineColor[dividerType];
if (orientation === "vertical") {
return (
<div
className={`w-px shrink-0 self-stretch ${color} ${className}`}
data-figma-node={dividerType === "content" ? "6894:22990" : "2002:30943"}
aria-hidden="true"
/>
);
}
return (
<div
className={`flex w-full flex-col items-center ${className}`}
data-figma-node={dividerType === "content" ? "6894:22988" : "450:1940"}
>
<div
className={`h-px w-full shrink-0 ${color}`}
data-figma-node={dividerType === "content" ? "6894:22989" : "2002:30856"}
aria-hidden="true"
/>
</div>
);
});
DividerView.displayName = "DividerView";
+6
View File
@@ -0,0 +1,6 @@
export { default } from "./Divider.container";
export type { DividerProps, DividerOrientation, DividerType } from "./Divider.types";
export {
DIVIDER_ORIENTATION_OPTIONS,
DIVIDER_TYPE_OPTIONS,
} from "./Divider.types";
+20
View File
@@ -573,6 +573,26 @@
--color-border-invert-warning-primary-light: var(--color-yellow-yellow300);
--color-border-invert-negative-primary-light: var(--color-red-red300);
/* Navigation / Link — Figma "Link" (21861:21428) */
--color-link-primary: #090909;
--color-link-primary-hover: #6d6d6d;
--color-link-primary-focus: #6d6d6d;
--color-link-primary-active: #6d6d6d;
--color-link-secondary: #6d6d6d;
--color-link-secondary-hover: #373737;
--color-link-secondary-focus: #373737;
--color-link-secondary-active: #373737;
--color-link-invert-primary: #ffffff;
--color-link-invert-primary-hover: #b4b4b4;
--color-link-invert-primary-focus: #b4b4b4;
--color-link-invert-primary-active: #b4b4b4;
--color-link-invert-secondary: #b4b4b4;
--color-link-invert-secondary-hover: #f5f5f5;
--color-link-invert-secondary-focus: #f5f5f5;
--color-link-invert-secondary-active: #f5f5f5;
--color-border-link-focus: #090909;
--color-border-link-invert-focus: #ffffff;
/* Content */
--color-content-brand-darker-accent-2: var(--color-yellow-yellow200);
--color-content-brand-kiwi: var(--color-kiwi-kiwi600);
+2 -2
View File
@@ -90,9 +90,9 @@ Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticke
---
## Known implementation gaps (tracked on CR-86)
## Known implementation gaps
- **Server draft + URL alignment:** `SignedInDraftHydration` may merge server JSON without navigating to the saved step; **profile** will own listing drafts, **Continue** at last step, and **New rule** vs stale server draft — see **[CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile)** (“Rule drafts + create-flow resume”).
- **Profile + drafts (CR-86):** The profile page lists the server draft, **Continue** deep-links to `/create/{currentStep}`, and **Start new rule** clears local + server draft before opening the wizard. `SignedInDraftHydration` calls `router.replace` to the saved step when it applies a server draft so the URL matches hydrated state. Remaining edge cases (e.g. template review routes) are handled when they surface in QA.
- **Inner “text/select shells”:** deferred until Create Community is stable; screens use **`CreateFlowStepShell`** only for Stage 1.
---
+42 -6
View File
@@ -6,12 +6,12 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**.
### Review sync (relevant feedback only)
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **Done**), **CR-85** (session lifecycle — **Done**)—see **Linear** table at the end of this doc.
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **Done**), **CR-85** (session lifecycle — **Done**)—see **Linear** table at the end of this doc. **Change account email** is **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (split from Ticket 15 scope).
### Audit note (Linear CR-72+ vs repo, 2026-04)
- **Done in Linear and shipped:** **CR-72CR-76**, **CR-77** (publish from create flow), **CR-78** (template seed), **CR-79**, **CR-88**, **CR-89**. The **CR-72 → CR-83** numbering is the original **sequential plan**, not current blocking order; the **core product vertical** through publish + templates is effectively complete in-repo.
- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). **CR-82** (migrate smoke): **local** `npm run migrate:smoke` + [CONTRIBUTING.md](../../CONTRIBUTING.md) / [docs/testing-guide.md](../testing-guide.md) — in-repo Gitea workflow YAML **removed**; optional future remote job if hosted runners return. **CR-84 Done** — canonical error contract `{ error: { code, message }, details? }` and `x-request-id` propagation shipped via `lib/server/{responses,requestId,apiRoute}.ts`; auth + drafts + rules routes migrated, remaining `app/api/*` are a follow-up pass. **CR-85 Done** — multi-device session policy + lazy expired-row cleanup (per-user prune on every sign-in plus ~5% global sweep, no cron); ADR comment block in [`lib/server/session.ts`](../../lib/server/session.ts).
- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-103** (change account email — Ticket 20), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). **CR-82** (migrate smoke): **local** `npm run migrate:smoke` + [CONTRIBUTING.md](../../CONTRIBUTING.md) / [docs/testing-guide.md](../testing-guide.md) — in-repo Gitea workflow YAML **removed**; optional future remote job if hosted runners return. **CR-84 Done** — canonical error contract `{ error: { code, message }, details? }` and `x-request-id` propagation shipped via `lib/server/{responses,requestId,apiRoute}.ts`; auth + drafts + rules routes migrated, remaining `app/api/*` are a follow-up pass. **CR-85 Done** — multi-device session policy + lazy expired-row cleanup (per-user prune on every sign-in plus ~5% global sweep, no cron); ADR comment block in [`lib/server/session.ts`](../../lib/server/session.ts).
- **CR-83 Done (admin handoff + cutover plan):** [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md) shipped. Cloudron admin access on `cloud.medlab.host` granted; doc now covers (a) what's in place, (b) the side-by-side → apex cutover plan, and (c) the two open product questions + registry decision still outstanding. Steady-state operator runbook is split out into a follow-up — see [Ticket 12 / CR-83 follow-ups](#follow-up-tickets-filed-under-cr-83) below. Key new finding: legacy `communityrule.info` is a single Cloudron **LAMP** app (`lamp.cloudronapp.php74@5.1.2`) hosting marketing site + Express/MySQL backend + a broken Flask chatbot all in one container; all three retire together via CR-99 + CR-101.
- **CR-86** is **no longer blocked** by publish — **CR-77** is **Done**; profile work is gated by **implementation**, not waiting on publish wiring.
- **Not in this ticket list** but called out in **[docs/backend-roadmap.md](backend-roadmap.md):** shared **rate-limit store** (e.g. Redis) before multi-instance; **`GET /api/create-flow/methods`** exists for facet scoring (Ticket 16 / CR-88) but is not duplicated as a separate doc ticket.
@@ -459,6 +459,40 @@ _Section B — Final Review screen `+` button per category:_
---
## Ticket 20 — Change account email (verified new address)
**Depends on:** **Ticket 15 / [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile)** (signed-in profile shell); **Ticket 3 / [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done)** (magic-link + mail patterns).
**Server / admin:** Same SMTP / DNS expectations as magic-link when email must work on **staging/production** (see Ticket 3 table).
**Goal:** Let a signed-in user **change their login email** after **verifying control of the new address** (magic-linkstyle flow). Today `User.email` is **unique** and magic-link verify **upserts** on that field; profile shows **“Change email — coming soon”** only.
**Context:** Explicitly **out of scope for Ticket 15**; this ticket is the dedicated backend + product slice. See [docs/backend-roadmap.md](backend-roadmap.md) §1 / §6. Canonical Linear body: **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)**.
**Implementation (sketch):**
1. **Persistence:** Pending email-change token (`userId`, `newEmail`, `tokenHash`, `expiresAt`)—separate from sign-in `MagicLinkToken` or clearly discriminated so flows cannot be confused.
2. **API:** Authenticated **request** (submit new email → mail link); **verify** (token → update `User.email` in a transaction, cleanup pending rows).
3. **Conflicts:** If `newEmail` already belongs to another `User`, return a clear error (merge accounts out of scope unless product decides otherwise).
4. **Sessions:** Decide whether successful change **invalidates other sessions** for that user; document in code + roadmap.
5. **Rate limits:** Align with [`app/api/auth/magic-link/request/route.ts`](../../app/api/auth/magic-link/request/route.ts) patterns.
6. **Mail:** Distinct template/copy from sign-in in [`lib/server/mail.ts`](../../lib/server/mail.ts).
7. **UI:** Replace profile “coming soon” with real flow; i18n under `messages/`.
8. **Tests:** Route tests for happy path, expired/invalid token, duplicate email, unauthenticated request.
**Acceptance criteria:**
- [ ] New email is confirmed **only** after the user completes the link sent to that inbox; then `User.email` updates.
- [ ] Duplicate-email and rate-limit cases are handled with accessible errors (`CR-84` shape).
- [ ] Profile reflects the new address after success.
- [ ] Documented session policy after email change.
**Files (expected):** `prisma/schema.prisma`, new `app/api/user/...` or `app/api/auth/...` routes, [`lib/server/mail.ts`](../../lib/server/mail.ts), [`app/(app)/profile/`](../../app/(app)/profile/), [`messages/en/pages/profile.json`](../../messages/en/pages/profile.json), tests under `tests/unit/`.
**Linear:** [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) (**Backlog**). **Related:** **CR-86** (profile); **CR-84** (errors).
---
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
**Depends on:** none (orthogonal).
@@ -648,7 +682,7 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
**Out of scope for this ticket**
- **Change your account email** (shown in Figma options): **deferred**—no backend in this slice. Product may **hide** the row, show **“Coming soon,”** or backlog until a **future ticket** (verified email change, conflicts, sessions).
- **Change your account email** (shown in Figma options): **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)**—not part of this slice until that issue ships. Until then, product may keep **“Coming soon”** or hide the row.
- **`displayName` / new `User` fields:** not required—use **static** welcome copy, generic greeting, or **email local-part in UI only** until a later schema/product decision.
**Context:** Today `GET /api/rules` is a **public** list of all published rules; there is no authenticated **my rules** endpoint, no owner **DELETE** / **duplicate**, and no **delete user** API. See [docs/backend-roadmap.md](backend-roadmap.md) §1 “profile / account — not implemented yet” and §6.
@@ -666,7 +700,7 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
- [ ] Duplicate and delete actions work for **owner** only; errors are clear.
- [ ] Logout still works from profile context.
- [ ] Delete account flow matches agreed policy and is confirmed in UI.
- [ ] No verified **email change** shipped in this ticket; Figma row handled per product (hide/disabled/backlog).
- [ ] No verified **email change** in this ticket (tracked in **CR-103** / Ticket 20); Figma row handled per product (hide/disabled/coming soon).
**Files:** new `app/` routes and components, `app/api/rules/...` (or new segment handlers), [lib/create/api.ts](lib/create/api.ts) as needed, [prisma/schema.prisma](prisma/schema.prisma) only if account-delete policy requires schema tweaks, [messages/en/](messages/en/) for copy.
@@ -697,16 +731,17 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
| 17 | 17 | Canon create-flow (custom path) |
| 18 | 18 | Stakeholder invites (confirm-stakeholders) |
| 19 | 19 | `Add` button behavior (custom-rule pages + Final Review) |
| 20 | 20 | Change account email (verified) **Backlog — CR-103** |
**Follow-up (no doc ticket #):** **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)** — marketing template grids ranked by user facets (API-ready; tests deferred with that issue).
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 78**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 1314** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands.
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 78**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 1314** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** tracks **verified change account email** (split from Ticket 15). **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands.
---
## Linear (Community-rule team)
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior).
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior).
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
@@ -737,6 +772,7 @@ Tickets **1011** can be deferred without blocking the core “auth + drafts +
| — | [CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates) | Template grid + facet ranking (product) |
| 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) |
| 19 | [CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final) | `Add` button behavior (custom-rule + Final Review) |
| 20 | [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) | Change account email (verify new address) **Backlog** |
---
+3 -3
View File
@@ -40,7 +40,7 @@ Planned for the signed-in profile/dashboard ([Figma profile frame](https://www.f
- Owner-only **delete** and **duplicate** (clone) for published rules.
- **Delete account** (authenticated), with an explicit policy for drafts, sessions, and linked rules.
**Future (separate ticket):** **Change email** with verification (e.g. magic link to a new address, conflict handling)—**out of scope** for the profile milestone above.
**Tracked separately:** **Change email** with verification (e.g. magic link to a new address, conflict handling)—**[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** / **Ticket 20** in [docs/guides/backend-linear-tickets.md](guides/backend-linear-tickets.md); **out of scope** for the profile milestone above.
---
@@ -109,7 +109,7 @@ Match the current API behavior; tighten as product evolves:
- **`POST /api/rules`:** Authenticated user only; rule is stored with **`userId`** (owner).
- **`GET /api/rules`:** **Public list** of published rules (metadata: id, title, summary, timestamps)—no auth required today. **Not** a private “my rules” feed unless you add a separate route later (see §1 “profile / account — not implemented yet” and Ticket 15).
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; plan a **future ticket** for verified email updates.
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; implement via **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (Ticket 20 — verified email updates).
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
---
@@ -229,7 +229,7 @@ npm run dev
**Step 4.** On publish, call `POST /api/rules` from the completed step when the backend is required (wire when the final review UI is ready).
**Step 5.** **Profile / dashboard** (`/profile` or agreed path): signed-in hub for **my rules** (after Ticket 15 APIs exist), **duplicate** / **delete** rule actions, **logout**, **delete account**—aligned with [Figma profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069). **Change email** in design is **deferred** (hide, “coming soon,” or backlog) until a future account ticket; greeting copy can stay **static** or use **email local-part in UI only**—no `displayName` field required for MVP.
**Step 5.** **Profile / dashboard** (`/profile` or agreed path): signed-in hub for **my rules** (after Ticket 15 APIs exist), **duplicate** / **delete** rule actions, **logout**, **delete account**—aligned with [Figma profile](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22143-900069). **Change email** in design ships under **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (Ticket 20); until then, **hide**, **“coming soon,”** or backlog per product. Greeting copy can stay **static** or use **full email in UI**—no `displayName` field required for MVP.
**Step 6.** **Templates:** **Tickets 78** — seed `RuleTemplate` and load **`GET /api/templates`** in home / create surfaces (flat list, optional `featured`). **Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)** — add **facet-based recommendations** and **spreadsheet ingestion** when product is ready (matrix rows + dimension columns like the decision-making workbook).
+210
View File
@@ -207,3 +207,213 @@ export async function publishRule(input: {
};
}
}
export type MyPublishedRule = {
id: string;
title: string;
summary: string | null;
createdAt: string;
updatedAt: string;
};
/**
* Lists the signed-in users published rules (newest first). Returns `null` on
* network failure or unauthenticated response.
*/
export async function fetchMyPublishedRules(): Promise<
MyPublishedRule[] | null
> {
try {
const res = await fetch("/api/rules/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rules?: MyPublishedRule[];
} | null;
if (!data || !Array.isArray(data.rules)) return null;
return data.rules;
} catch {
return null;
}
}
export type PublishedRuleDetailForClient = {
id: string;
title: string;
summary: string | null;
document: unknown;
};
export type FetchPublishedRuleDetailResult = {
rule: PublishedRuleDetailForClient;
viewerIsOwner: boolean;
};
/**
* Fetches a published rule for the browser (credentials included).
* Returns `null` on network failure or non-OK response.
*/
export async function fetchPublishedRuleDetail(
id: string,
): Promise<FetchPublishedRuleDetailResult | null> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = (await safeParseJsonResponse(res)) as {
rule?: PublishedRuleDetailForClient;
viewerIsOwner?: unknown;
} | null;
if (
!data ||
!data.rule ||
typeof data.rule.id !== "string" ||
typeof data.rule.title !== "string" ||
typeof data.viewerIsOwner !== "boolean"
) {
return null;
}
return { rule: data.rule, viewerIsOwner: data.viewerIsOwner };
} catch {
return null;
}
}
export type DeleteRuleResult =
| { ok: true }
| { ok: false; error: string; status: number };
export async function deletePublishedRule(
id: string,
): Promise<DeleteRuleResult> {
try {
const res = await fetch(`/api/rules/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
status: res.status,
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DuplicateRuleResult =
| { ok: true; id: string; title: string }
| { ok: false; error: string; status: number };
export async function duplicatePublishedRule(
id: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/rules/${encodeURIComponent(id)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteAccountResult = { ok: true } | { ok: false; error: string };
/**
* Permanently deletes the signed-in user. Caller should redirect and refresh UI.
*/
export async function deleteAccount(): Promise<DeleteAccountResult> {
try {
const res = await fetch("/api/user/me", {
method: "DELETE",
credentials: "include",
});
if (res.ok) {
return { ok: true as const };
}
const data = await safeParseJsonResponse(res);
return {
ok: false as const,
error: readApiErrorMessage(data),
};
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export type ServerDraftForProfile =
| { hasDraft: false }
| { hasDraft: true; updatedAt: string; state: CreateFlowState };
/**
* Fetches the signed-in users server draft for the profile page. Returns
* `null` on auth/transport failure.
*/
export async function fetchServerDraftForProfile(): Promise<
ServerDraftForProfile | null
> {
try {
const res = await fetch("/api/drafts/me", { credentials: "include" });
if (res.status === 401) return null;
if (!res.ok) return null;
const data = (await parseJson(res)) as {
draft: { payload: unknown; updatedAt: string } | null;
};
if (!data.draft) {
return { hasDraft: false };
}
const payload = data.draft.payload;
const state: CreateFlowState =
payload && typeof payload === "object"
? migrateLegacyCreateFlowState(
payload as Record<string, unknown>,
)
: {};
const rawUpdated = data.draft.updatedAt;
const updatedAt =
typeof rawUpdated === "string"
? rawUpdated
: new Date().toISOString();
return { hasDraft: true, updatedAt, state };
} catch {
return null;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Bridges final-review completed without query strings.
* Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists.
* Bridges final-review completed without query strings, and re-opens a rule
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id].
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
+41
View File
@@ -46,3 +46,44 @@ export async function getPublicPublishedRuleById(
return null;
}
}
/** Metadata for signed-in “my rules” profile list (no full `document` JSON). */
const PUBLISHED_RULE_OWNER_LIST_SELECT = {
id: true,
title: true,
summary: true,
createdAt: true,
updatedAt: true,
} as const;
export type OwnerPublishedRuleListItem = {
id: string;
title: string;
summary: string | null;
createdAt: Date;
updatedAt: Date;
};
/**
* Lists published rules owned by the given user (alphabetical by title, then id).
* Returns `null` when the database is not configured or the query throws.
*/
export async function listPublishedRulesForUser(
userId: string,
take: number,
): Promise<OwnerPublishedRuleListItem[] | null> {
if (!isDatabaseConfigured()) return null;
if (typeof userId !== "string" || userId.trim() === "") return null;
const clamped = Math.min(Math.max(0, take), 100);
if (clamped === 0) return [];
try {
return await prisma.publishedRule.findMany({
where: { userId },
orderBy: [{ title: "asc" }, { id: "asc" }],
take: clamped,
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
});
} catch {
return null;
}
}
+4
View File
@@ -62,6 +62,10 @@ export function notFound(message = "Not found"): NextResponse {
return errorJson("not_found", message, 404);
}
export function forbidden(message = "Forbidden"): NextResponse {
return errorJson("forbidden", message, 403);
}
export function rateLimited(retryAfterMs: number): NextResponse {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
return errorJson("rate_limited", "Too many requests", 429, {
+1 -1
View File
@@ -81,7 +81,7 @@ export const createFlowStateSchema = z
.object({
title: z.string().max(500).optional(),
summary: z.string().max(8000).optional(),
communityContext: z.string().max(48).optional(),
communityContext: z.string().max(200).optional(),
communitySaveEmail: z.string().max(320).optional(),
selectedCommunitySizeIds: z.array(z.string()).optional(),
selectedOrganizationTypeIds: z.array(z.string()).optional(),
@@ -1,73 +1,4 @@
{
"fallbackTitle": "Mutual Aid Mondays",
"fallbackDescription": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.",
"toastTitle": "This is what folks see when you share your CommunityRule",
"toastDescription": "Your group can use this document as an operating manual.",
"fallbackDocumentSections": [
{
"categoryName": "Values",
"entries": [
{
"title": "Solidarity Forever",
"body": "Food Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."
},
{
"title": "Shared Leadership",
"body": "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader."
},
{
"title": "Organizing Offline",
"body": "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face connection and resist the pull of digital metrics."
},
{
"title": "Circular Food Systems",
"body": "We intervene in the ecological crisis by addressing food waste and food recovery. We redirect surplus food to where it is needed and model a circular economy at the scale of our communities."
}
]
},
{
"categoryName": "Communication",
"entries": [
{
"title": "Signal",
"body": "We use Signal for sensitive coordination. Encrypted messaging helps protect our members and our plans from surveillance."
}
]
},
{
"categoryName": "Membership",
"entries": [
{
"title": "Open Admission",
"body": "Anyone who shares our values and is willing to contribute is welcome. We do not require applications or approval processes for general participation."
}
]
},
{
"categoryName": "Decision-making",
"entries": [
{
"title": "Lazy Consensus",
"body": "We use lazy consensus for most decisions: proposals move forward unless someone raises a blocking concern. This keeps us moving without requiring everyone to approve every detail."
},
{
"title": "Modified Consensus",
"body": "For larger or more consequential decisions we use modified consensus, with clear timelines and a fallback to a supermajority vote if needed."
}
]
},
{
"categoryName": "Conflict management",
"entries": [
{
"title": "Code of Conduct",
"body": "We have a code of conduct that sets expectations for behavior and outlines how we address harm."
},
{
"title": "Restorative Justice",
"body": "When conflict arises we prioritize restoration and learning over punishment. We use facilitated circles and other restorative practices where appropriate."
}
]
}
]
"toastDescription": "Your group can use this document as an operating manual."
}
+49 -3
View File
@@ -1,5 +1,51 @@
{
"placeholderTitle": "Your profile",
"placeholderBody": "Were building this space for your CommunityRules and account options. Check back soon.",
"signOut": "Sign out"
"pageTitle": "Your profile",
"welcomeTitle": "Congrats {{name}}!",
"welcomeBodyFirstRule": "Youve made your first CommunityRule. Make sure to link it in your community resources and evolve it as your group grows and changes.",
"welcomeBodyNoRules": "Create your first CommunityRule to capture how your group makes decisions—then link it from your community resources as you grow.",
"yourOptionsHeading": "Your Options",
"optionCreateCustom": "Create new custom Rule",
"optionCreateTemplate": "Create new Rule from template",
"optionLogout": "Log out of CommunityRule",
"optionChangeEmail": "Change your account email",
"intro": "Manage your CommunityRules, saved progress, and account.",
"signInPrompt": "Sign in to view your profile and saved rules.",
"signInCta": "Log in",
"loading": "Loading…",
"yourRulesHeading": "Your CommunityRules",
"yourRulesEmpty": "You have not published a rule yet. Create one to see it here.",
"draftHeading": "Saved in progress",
"draftInProgressBadge": "In progress",
"continueDraft": "Continue",
"createHeading": "Create",
"createCustomDescription": "Start a new custom rule from the beginning.",
"createCustomCta": "Create a rule",
"createTemplateDescription": "Browse templates and adapt one for your community.",
"createTemplateCta": "Browse templates",
"viewPublic": "View",
"duplicate": "Duplicate",
"deleteRule": "Delete",
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
"deleteRuleModalTitle": "Delete this rule?",
"deleteRuleModalBody": "This cannot be undone.",
"deleteRuleCancel": "Cancel",
"deleteRuleConfirmCta": "Delete rule",
"deleteDraftConfirm": "Delete this saved draft? This cannot be undone.",
"deleteDraftModalTitle": "Delete this draft?",
"deleteDraftModalBody": "This cannot be undone.",
"deleteDraftCancel": "Cancel",
"deleteDraftConfirmCta": "Delete draft",
"accountHeading": "Account",
"emailLabel": "Email",
"changeEmailComingSoon": "Change email — coming soon",
"signOut": "Sign out",
"deleteAccount": "Delete account",
"deleteAccountIntro": "Permanently delete your account and sign-in data. Your published rules will remain visible without an owner name.",
"deleteAccountCancel": "Cancel",
"deleteAccountConfirm": "Delete my account",
"deleteAccountModalTitle": "Delete your account?",
"deleteAccountModalBody": "This will remove your account and sign you out. Published rules you created stay public as anonymous community rules.",
"actionError": "Something went wrong. Try again.",
"notFound": "Not found",
"forbidden": "You do not have permission for this action."
}
+14
View File
@@ -5,6 +5,20 @@ import createMDX from "@next/mdx";
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@prisma/client"],
/**
* `next dev --turbopack` does not use `webpack()`; without this, `.svg`
* imports resolve as asset URLs and {@link app/components/asset/Icon.tsx}
* cannot render them as components.
*/
turbopack: {
rules: {
"*.svg": {
condition: { not: "foreign" },
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
// Performance optimizations
experimental: {
optimizeCss: true,
+2 -2
View File
@@ -1,4 +1,4 @@
import { Icon } from "../../app/components/asset";
import { Icon, ICON_NAME_OPTIONS } from "../../app/components/asset";
export default {
title: "Components/Asset/Icon",
@@ -9,7 +9,7 @@ export default {
argTypes: {
name: {
control: "select",
options: ["exclamation"],
options: [...ICON_NAME_OPTIONS],
description: "Name of the icon to render",
},
size: {
+97
View File
@@ -0,0 +1,97 @@
import List from "../../app/components/layout/List";
const fiveItems = [
{
id: "1",
title: "Item",
description: "Description/text only option here.",
href: "#",
},
{
id: "2",
title: "Item",
description: "Description/text only option here.",
href: "#",
},
{
id: "3",
title: "Item",
description: "Description/text only option here.",
href: "#",
},
{
id: "4",
title: "Item",
description: "Description/text only option here.",
href: "#",
},
{
id: "5",
title: "Item",
description: "Description/text only option here.",
href: "#",
},
];
export default {
title: "Components/Layout/List",
component: List,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
docs: {
description: {
component:
"Figma list frames: S (21863:45631), M (21863:45493), L (21844:4405). Built from ListEntry (21844:4118).",
},
},
},
decorators: [
(Story) => (
<div className="min-h-[480px] bg-[var(--color-surface-default-primary)] p-6">
<div className="mx-auto max-w-[1044px]">
<Story />
</div>
</div>
),
],
argTypes: {
size: { control: { type: "select" }, options: ["s", "m", "l"] },
topDivider: { control: { type: "boolean" } },
leadingIcon: {
control: { type: "select" },
options: ["edit", "mail", "warning"],
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
items: fiveItems,
size: "m",
topDivider: true,
leadingIcon: "edit",
},
};
export const NoTopDivider = {
args: {
...Default.args,
topDivider: false,
},
};
export const Small = {
args: {
...Default.args,
size: "s",
},
};
export const Large = {
args: {
...Default.args,
size: "l",
},
};
+63
View File
@@ -0,0 +1,63 @@
import ListEntry from "../../app/components/layout/ListEntry";
export default {
title: "Components/Layout/ListEntry",
component: ListEntry,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
docs: {
description: {
component:
'Figma "Base / Interactive" (21844:4118). One row: rules, icon, title, description, chevron.',
},
},
},
decorators: [
(Story) => (
<div className="min-h-[200px] bg-[var(--color-surface-default-primary)] p-6">
<div className="mx-auto max-w-[1044px]">
<Story />
</div>
</div>
),
],
argTypes: {
size: { control: { type: "select" }, options: ["s", "m", "l"] },
topDivider: { control: { type: "boolean" } },
bottomDivider: { control: { type: "boolean" } },
showDescription: { control: { type: "boolean" } },
leadingIcon: {
control: { type: "select" },
options: ["edit", "mail", "warning"],
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
title: "Item",
description: "Description/text only option here.",
size: "m",
href: "#",
topDivider: true,
bottomDivider: true,
showDescription: true,
leadingIcon: "edit",
},
};
export const SizeSmall = {
args: {
...Default.args,
size: "s",
},
};
export const SizeLarge = {
args: {
...Default.args,
size: "l",
},
};
+2 -2
View File
@@ -34,7 +34,7 @@ export default {
},
backdropVariant: {
control: { type: "select" },
options: ["default", "loginYellow"],
options: ["default", "blurredYellow"],
},
currentStep: {
control: { type: "number", min: 1, max: 5 },
@@ -174,7 +174,7 @@ LoginYellowBackdrop.args = {
isOpen: true,
title: "Horizontalism",
description: "Edit or add to this description to describe what this value means to your community.",
backdropVariant: "loginYellow",
backdropVariant: "blurredYellow",
children: (
<div className="space-y-4">
<p className="text-[var(--color-content-default-primary)]">
+106
View File
@@ -0,0 +1,106 @@
import Link from "../../app/components/navigation/Link";
export default {
title: "Components/Navigation/Link",
component: Link,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Figma Navigation / Link (21861:21428). States are CSS-only (hover, focus-visible, active).",
},
},
},
argTypes: {
type: {
control: { type: "select" },
options: ["primary", "secondary"],
},
variant: {
control: { type: "select" },
options: ["default", "paragraph"],
},
theme: {
control: { type: "select" },
options: ["light", "dark"],
},
leadingIcon: { control: { type: "boolean" } },
trailingIcon: { control: { type: "boolean" } },
href: { control: { type: "text" } },
children: { control: { type: "text" } },
},
tags: ["autodocs"],
};
export const Default = {
args: {
children: "Link Text",
type: "primary",
variant: "paragraph",
theme: "light",
href: "#",
},
};
/**
* Static grid: type × variant × theme (use browser :hover / Tab for states).
*/
export const Matrix = {
render: () => (
<div className="flex flex-col gap-8 p-6">
<div>
<p className="mb-3 font-inter text-sm text-[var(--color-content-default-secondary)]">
Light background
</p>
<div className="flex flex-col gap-4 rounded-lg bg-white p-4">
<div className="flex flex-wrap gap-6">
<Link type="primary" variant="default" theme="light" href="#">
Primary / default
</Link>
<Link type="primary" variant="paragraph" theme="light" href="#">
Primary / paragraph
</Link>
<Link type="secondary" variant="default" theme="light" href="#">
Secondary / default
</Link>
<Link type="secondary" variant="paragraph" theme="light" href="#">
Secondary / paragraph
</Link>
</div>
</div>
</div>
<div>
<p className="mb-3 font-inter text-sm text-[var(--color-content-default-secondary)]">
Dark background
</p>
<div className="flex flex-col gap-4 rounded-lg bg-[var(--color-gray-800)] p-4">
<div className="flex flex-wrap gap-6">
<Link type="primary" variant="default" theme="dark" href="#">
Primary / default
</Link>
<Link type="primary" variant="paragraph" theme="dark" href="#">
Primary / paragraph
</Link>
<Link type="secondary" variant="default" theme="dark" href="#">
Secondary / default
</Link>
<Link type="secondary" variant="paragraph" theme="dark" href="#">
Secondary / paragraph
</Link>
</div>
</div>
</div>
</div>
),
};
export const AsButton = {
args: {
children: "Action",
type: "primary",
variant: "paragraph",
theme: "light",
onClick: () => {},
},
};
+33 -5
View File
@@ -1,4 +1,24 @@
import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
import { CREATE_FLOW_LAST_PUBLISHED_KEY } from "../../lib/create/lastPublishedRule";
const storySessionFixture = {
id: "story-rule",
title: "Storybook Community Rule",
summary: "Preview copy loaded from sessionStorage for this story.",
document: {
sections: [
{
categoryName: "Values",
entries: [
{
title: "Preview value",
body: "Example body so the document column is populated in Storybook.",
},
],
},
],
},
};
export default {
title: "Pages/Create Flow/Completed",
@@ -13,11 +33,19 @@ export default {
},
},
decorators: [
(Story) => (
<div className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)] flex flex-col items-center">
<Story />
</div>
),
(Story) => {
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY,
JSON.stringify(storySessionFixture),
);
}
return (
<div className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)] flex flex-col items-center">
<Story />
</div>
);
},
],
tags: ["autodocs"],
};
+74
View File
@@ -0,0 +1,74 @@
import Divider from "../../app/components/utility/Divider";
export default {
title: "Components/Utility/Divider",
component: Divider,
parameters: {
layout: "padded",
backgrounds: { default: "dark" },
docs: {
description: {
component:
"Figma Utility / Divider (450:1941). Content uses border secondary; Menu uses tertiary. Horizontal and vertical orientations.",
},
},
},
argTypes: {
orientation: { control: { type: "select" }, options: ["horizontal", "vertical"] },
type: { control: { type: "select" }, options: ["content", "menu"] },
},
tags: ["autodocs"],
};
export const ContentHorizontal = {
args: {
type: "content",
orientation: "horizontal",
},
decorators: [
(Story) => (
<div className="w-full max-w-md bg-[var(--color-surface-default-primary)] p-4">
<Story />
</div>
),
],
};
export const MenuHorizontal = {
args: {
type: "menu",
orientation: "horizontal",
},
decorators: [ContentHorizontal.decorators[0]],
};
export const ContentVertical = {
args: {
type: "content",
orientation: "vertical",
},
render: (args) => (
<div className="flex h-20 w-full max-w-md items-stretch bg-[var(--color-surface-default-primary)] p-4">
<span className="text-xs text-[var(--color-content-default-secondary)]">A</span>
<Divider {...args} className="mx-2" />
<span className="text-xs text-[var(--color-content-default-secondary)]">B</span>
</div>
),
};
export const Matrix = {
render: () => (
<div className="space-y-8 bg-[var(--color-surface-default-primary)] p-6 text-[var(--color-content-default-primary)]">
<div>
<p className="mb-2 text-xs text-[var(--color-content-default-tertiary)]">Content</p>
<div className="max-w-sm space-y-1">
<Divider type="content" orientation="horizontal" />
</div>
<p className="mb-2 mt-6 text-xs text-[var(--color-content-default-tertiary)]">Menu</p>
<div className="max-w-sm space-y-1">
<Divider type="menu" orientation="horizontal" />
</div>
</div>
</div>
),
};
+53 -27
View File
@@ -1,47 +1,73 @@
import { describe, it, expect } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
import { CREATE_FLOW_LAST_PUBLISHED_KEY } from "../../lib/create/lastPublishedRule";
const storedRuleFixture = {
id: "rule-fixture-1",
title: "Fixture Community Rule",
summary: "A short summary for tests.",
document: {
sections: [
{
categoryName: "Values",
entries: [
{
title: "Fixture value title",
body: "Fixture value body text for the test document.",
},
],
},
{
categoryName: "Communication",
entries: [
{
title: "Fixture channel",
body: "How we talk to each other.",
},
],
},
],
},
};
describe("CompletedScreen", () => {
beforeEach(() => {
sessionStorage.removeItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
});
afterEach(() => {
sessionStorage.removeItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
});
it("renders without crashing", () => {
render(<CompletedScreen />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
it("renders HeaderLockup with expected title", () => {
it("shows no placeholder title or document when session is empty", () => {
render(<CompletedScreen />);
const h1 = screen.getByRole("heading", { level: 1 });
expect(h1.textContent).toBe("");
expect(screen.queryByText("Values")).not.toBeInTheDocument();
});
it("renders header and document from sessionStorage", () => {
sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY,
JSON.stringify(storedRuleFixture),
);
render(<CompletedScreen />);
expect(
screen.getByRole("heading", {
name: "Mutual Aid Mondays",
name: "Fixture Community Rule",
}),
).toBeInTheDocument();
});
it("renders HeaderLockup with expected description", () => {
render(<CompletedScreen />);
expect(
screen.getByText(
/Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i,
),
).toBeInTheDocument();
});
it("renders Community Rule document with section labels", () => {
render(<CompletedScreen />);
expect(screen.getByText("A short summary for tests.")).toBeInTheDocument();
expect(screen.getByText("Values")).toBeInTheDocument();
expect(screen.getByText("Communication")).toBeInTheDocument();
expect(screen.getByText("Membership")).toBeInTheDocument();
expect(screen.getByText("Decision-making")).toBeInTheDocument();
expect(screen.getByText("Conflict management")).toBeInTheDocument();
});
it("renders document entry titles", () => {
render(<CompletedScreen />);
expect(screen.getByText("Solidarity Forever")).toBeInTheDocument();
expect(screen.getByText("Shared Leadership")).toBeInTheDocument();
expect(screen.getByText("Organizing Offline")).toBeInTheDocument();
expect(screen.getByText("Circular Food Systems")).toBeInTheDocument();
expect(screen.getByText("Fixture value title")).toBeInTheDocument();
});
it("renders toast alert when page loads", () => {
+2 -2
View File
@@ -59,11 +59,11 @@ describe("Create", () => {
}
});
it("uses login yellow backdrop when backdropVariant is loginYellow", () => {
it("uses blurred yellow backdrop when backdropVariant is blurredYellow", () => {
renderWithProviders(
<Create
{...defaultProps}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
headerContent={<div>Header</div>}
/>,
);
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import Dialog from "../../app/components/modals/Dialog";
type Props = React.ComponentProps<typeof Dialog>;
describe("Dialog", () => {
const defaultProps: Props = {
isOpen: true,
onClose: vi.fn(),
title: "Confirm action",
description: "This cannot be undone.",
footer: (
<>
<button type="button">Cancel</button>
<button type="button">Confirm</button>
</>
),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders when isOpen is true", () => {
renderWithProviders(<Dialog {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Confirm action")).toBeInTheDocument();
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByText("Confirm")).toBeInTheDocument();
});
it("does not render when isOpen is false", () => {
renderWithProviders(<Dialog {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose when close control is activated", () => {
const onClose = vi.fn();
renderWithProviders(<Dialog {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByLabelText("Close dialog"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
renderWithProviders(<Dialog {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("locks body scroll when open", () => {
renderWithProviders(<Dialog {...defaultProps} />);
expect(document.body.style.overflow).toBe("hidden");
});
});
+8
View File
@@ -89,4 +89,12 @@ describe("HeaderLockup (behavioral tests)", () => {
render(<HeaderLockup title="Test Title" justification="left" size="L" />);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
it("forwards titleId to the h1", () => {
render(<HeaderLockup title="Test Title" titleId="profile-welcome" />);
expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute(
"id",
"profile-welcome",
);
});
});
+7
View File
@@ -46,4 +46,11 @@ describe("TextInput (size tests)", () => {
expect(input).toHaveClass("h-[32px]");
});
it("forwards maxLength to the native input", () => {
const { container } = render(
<TextInput label="Test" maxLength={200} />,
);
const input = container.querySelector("input");
expect(input).toHaveAttribute("maxLength", "200");
});
});
+90
View File
@@ -0,0 +1,90 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import List from "../../../app/components/layout/List";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../../utils/componentTestSuite";
const items = [
{
id: "a",
title: "First",
description: "First description",
href: "/first",
},
{
id: "b",
title: "Second",
description: "Second description",
onClick: () => {},
},
];
type Props = React.ComponentProps<typeof List>;
const config: ComponentTestSuiteConfig<Props> = {
component: List,
name: "List",
props: { items } as Props,
primaryRole: "list",
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false,
disabledState: false,
errorState: false,
},
};
describe("List", () => {
componentTestSuite<Props>(config);
it("renders a link row when item has href", () => {
render(
<List
items={[
{
id: "1",
title: "T",
description: "D",
href: "/x",
},
]}
/>,
);
expect(screen.getByRole("link", { name: /T/ })).toHaveAttribute("href", "/x");
});
it("calls onClick for button rows", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<List
items={[
{
id: "1",
title: "Action",
description: "Desc",
onClick,
},
]}
/>,
);
await user.click(screen.getByRole("button", { name: /Action/ }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies size s and l Figma data attributes on the list root", () => {
const { container: a, rerender } = render(<List items={items} size="s" />);
expect(
a.querySelector('[data-figma-node="21863:45631"]'),
).toBeInTheDocument();
rerender(<List items={items} size="l" />);
expect(
a.querySelector('[data-figma-node="21844:4405"]'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import ListEntry from "../../../app/components/layout/ListEntry";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../../utils/componentTestSuite";
type Props = React.ComponentProps<typeof ListEntry>;
const base: Props = {
title: "Item",
description: "Description",
href: "#",
topDivider: false,
bottomDivider: true,
};
const config: ComponentTestSuiteConfig<Props> = {
component: ListEntry,
name: "ListEntry",
props: base,
primaryRole: "link",
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: true,
disabledState: false,
errorState: false,
},
};
describe("ListEntry", () => {
componentTestSuite<Props>(config);
it("uses Base / Interactive Figma id for size m", () => {
const { container } = render(
<ListEntry
title="A"
description="B"
size="m"
href="#"
topDivider={false}
bottomDivider={false}
/>,
);
expect(
container.querySelector('[data-figma-node="21863:45422"]'),
).toBeInTheDocument();
});
it("omits description when showDescription is false", () => {
render(
<ListEntry
title="Only"
description="Hidden"
showDescription={false}
href="#"
topDivider={false}
bottomDivider={false}
/>,
);
expect(screen.getByRole("link", { name: "Only" })).toBeInTheDocument();
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
});
it("button fires onClick", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ListEntry
title="Tap"
description="D"
onClick={onClick}
topDivider={false}
bottomDivider={false}
/>,
);
await user.click(screen.getByRole("button", { name: /Tap/ }));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
+75
View File
@@ -0,0 +1,75 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import "@testing-library/jest-dom/vitest";
import Link from "../../../app/components/navigation/Link";
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children?: React.ReactNode;
href?: string;
[key: string]: unknown;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
const FIGMA = "21861:21428";
describe("Link (Navigation)", () => {
it("renders an anchor with href and data-figma-node", () => {
const { container } = render(
<Link
href="/rules/1"
variant="paragraph"
type="primary"
theme="light"
>
View
</Link>,
);
const a = screen.getByRole("link", { name: /view/i });
expect(a).toHaveAttribute("href", "/rules/1");
expect(a).toHaveAttribute("data-figma-node", FIGMA);
expect(container.querySelector("a")?.className).toMatch(
/text-\[var\(--color-link-primary\)\]/,
);
});
it("renders a button when href is omitted", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<Link
variant="paragraph"
type="primary"
theme="light"
onClick={onClick}
>
Delete
</Link>,
);
const btn = screen.getByRole("button", { name: /delete/i });
expect(btn).toHaveAttribute("data-figma-node", FIGMA);
expect(btn).toHaveAttribute("type", "button");
await user.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies secondary + dark classes for that combination", () => {
const { container } = render(
<Link href="#" variant="paragraph" type="secondary" theme="dark">
More
</Link>,
);
const el = container.querySelector("a");
expect(el?.className).toMatch(/text-\[var\(--color-link-invert-secondary\)\]/);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import Divider from "../../../app/components/utility/Divider";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../../utils/componentTestSuite";
type Props = React.ComponentProps<typeof Divider>;
const config: ComponentTestSuiteConfig<Props> = {
component: Divider,
name: "Divider",
props: {} as Props,
testCases: {
renders: true,
accessibility: true,
keyboardNavigation: false,
disabledState: false,
errorState: false,
},
};
describe("Divider", () => {
componentTestSuite<Props>(config);
it("renders horizontal content line with Figma line node", () => {
const { container } = render(
<Divider type="content" orientation="horizontal" />,
);
expect(
container.querySelector('[data-figma-node="6894:22989"]'),
).toBeInTheDocument();
});
it("renders menu horizontal with tertiary line", () => {
const { container } = render(
<Divider type="menu" orientation="horizontal" />,
);
const line = container.querySelector('[data-figma-node="2002:30856"]');
expect(line).toBeInTheDocument();
expect(line).toHaveClass("bg-[var(--color-border-default-tertiary)]");
});
it("renders vertical content bar", () => {
const { container } = render(
<Divider type="content" orientation="vertical" />,
);
expect(
container.querySelector('[data-figma-node="6894:22990"]'),
).toBeInTheDocument();
});
});
+7
View File
@@ -79,6 +79,13 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(false);
});
it("rejects communityContext longer than 200 chars", () => {
const r = createFlowStateSchema.safeParse({
communityContext: "x".repeat(201),
});
expect(r.success).toBe(false);
});
it("accepts communityStructureChipSnapshots with custom chip rows", () => {
const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: {
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const findUniqueMock = vi.fn();
const deleteMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
delete: (...args: unknown[]) => deleteMock(...args),
},
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
import { DELETE } from "../../app/api/rules/[id]/route";
function makeContext(id: string) {
return { params: Promise.resolve({ id }) };
}
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
findUniqueMock.mockReset();
deleteMock.mockReset();
getSessionUserMock.mockReset();
});
describe("DELETE /api/rules/[id]", () => {
it("returns 401 when not signed in", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue(null);
const res = await DELETE(
new NextRequest("https://x.test/api/rules/r1"),
makeContext("r1"),
);
expect(res.status).toBe(401);
expect(findUniqueMock).not.toHaveBeenCalled();
});
it("returns 404 when the rule does not exist", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
findUniqueMock.mockResolvedValueOnce(null);
const res = await DELETE(
new NextRequest("https://x.test/api/rules/missing"),
makeContext("missing"),
);
expect(res.status).toBe(404);
});
it("returns 403 when the rule is owned by another user", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
findUniqueMock.mockResolvedValueOnce({
id: "r1",
userId: "other",
});
const res = await DELETE(
new NextRequest("https://x.test/api/rules/r1"),
makeContext("r1"),
);
expect(res.status).toBe(403);
expect(deleteMock).not.toHaveBeenCalled();
});
it("deletes and returns 200 when the user owns the rule", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
findUniqueMock.mockResolvedValueOnce({
id: "r1",
userId: "u1",
});
deleteMock.mockResolvedValueOnce(undefined);
const res = await DELETE(
new NextRequest("https://x.test/api/rules/r1"),
makeContext("r1"),
);
expect(res.status).toBe(200);
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "r1" } });
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
});
+58 -1
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const findUniqueMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
@@ -16,6 +17,10 @@ vi.mock("../../lib/server/db", () => ({
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
import { GET } from "../../app/api/rules/[id]/route";
function makeContext(id: string) {
@@ -25,6 +30,8 @@ function makeContext(id: string) {
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
findUniqueMock.mockReset();
getSessionUserMock.mockReset();
getSessionUserMock.mockResolvedValue(null);
});
describe("GET /api/rules/[id]", () => {
@@ -79,7 +86,7 @@ describe("GET /api/rules/[id]", () => {
expect(res.status).toBe(404);
});
it("returns 200 with { rule } when a published rule exists", async () => {
it("returns 200 with { rule, viewerIsOwner: false } when a published rule exists and the viewer is anonymous", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
const row = {
id: "rule-1",
@@ -97,9 +104,59 @@ describe("GET /api/rules/[id]", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as {
rule: { id: string; title: string; summary: string | null };
viewerIsOwner: boolean;
};
expect(body.rule.id).toBe("rule-1");
expect(body.rule.title).toBe("Mutual Aid Mondays");
expect(body.rule.summary).toBe("A grassroots community in Denver.");
expect(body.viewerIsOwner).toBe(false);
expect(findUniqueMock).toHaveBeenCalledTimes(1);
});
it("returns viewerIsOwner true when the signed-in user owns the rule", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "user-1", email: "a@b.c" });
const row = {
id: "rule-1",
title: "Mutual Aid Mondays",
summary: "A grassroots community in Denver.",
document: { sections: [] },
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
};
findUniqueMock
.mockResolvedValueOnce(row)
.mockResolvedValueOnce({ userId: "user-1" });
const res = await GET(
new NextRequest("https://x.test/api/rules/rule-1"),
makeContext("rule-1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { viewerIsOwner: boolean };
expect(body.viewerIsOwner).toBe(true);
expect(findUniqueMock).toHaveBeenCalledTimes(2);
});
it("returns viewerIsOwner false when the signed-in user does not own the rule", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "user-1", email: "a@b.c" });
const row = {
id: "rule-1",
title: "Mutual Aid Mondays",
summary: "A grassroots community in Denver.",
document: { sections: [] },
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
};
findUniqueMock
.mockResolvedValueOnce(row)
.mockResolvedValueOnce({ userId: "other" });
const res = await GET(
new NextRequest("https://x.test/api/rules/rule-1"),
makeContext("rule-1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { viewerIsOwner: boolean };
expect(body.viewerIsOwner).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More