Profile page UI and functionality implemented
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,55 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import Button from "../../components/buttons/Button";
|
||||
import { fetchAuthSession, logout } from "../../../lib/create/api";
|
||||
import {
|
||||
deleteAccount,
|
||||
deletePublishedRule,
|
||||
deleteServerDraft,
|
||||
duplicatePublishedRule,
|
||||
fetchAuthSession,
|
||||
fetchMyPublishedRules,
|
||||
fetchServerDraftForProfile,
|
||||
logout,
|
||||
type MyPublishedRule,
|
||||
} from "../../../lib/create/api";
|
||||
import {
|
||||
FIRST_STEP,
|
||||
isValidStep,
|
||||
} from "../create/utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../create/types";
|
||||
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import {
|
||||
ProfilePageSignedOutView,
|
||||
ProfilePageView,
|
||||
} from "./_components/ProfilePage.view";
|
||||
|
||||
function resolveContinueStepState(
|
||||
state: { currentStep?: CreateFlowStep } & Record<string, unknown>,
|
||||
): CreateFlowStep {
|
||||
const s = state.currentStep;
|
||||
if (s && isValidStep(s)) return s;
|
||||
return FIRST_STEP;
|
||||
}
|
||||
|
||||
export default function ProfilePageClient() {
|
||||
const t = useTranslation("pages.profile");
|
||||
const router = useRouter();
|
||||
const { openLogin } = useAuthModal();
|
||||
const [sessionLoaded, setSessionLoaded] = useState(false);
|
||||
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [rules, setRules] = useState<MyPublishedRule[]>([]);
|
||||
const [rulesError, setRulesError] = useState(false);
|
||||
const [draft, setDraft] = useState<
|
||||
Awaited<ReturnType<typeof fetchServerDraftForProfile>>
|
||||
>(null);
|
||||
const [ruleDeleteTargetId, setRuleDeleteTargetId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ruleDeleteBusy, setRuleDeleteBusy] = useState(false);
|
||||
const [draftDeleteOpen, setDraftDeleteOpen] = useState(false);
|
||||
const [draftDeleteBusy, setDraftDeleteBusy] = useState(false);
|
||||
const [accountDeleteOpen, setAccountDeleteOpen] = useState(false);
|
||||
const [accountDeleteBusy, setAccountDeleteBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setActionError(null);
|
||||
const { user: u } = await fetchAuthSession();
|
||||
setUser(u);
|
||||
setSessionLoaded(true);
|
||||
if (!u) {
|
||||
setRules([]);
|
||||
setRulesError(false);
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
const [r, d] = await Promise.all([
|
||||
fetchMyPublishedRules(),
|
||||
fetchServerDraftForProfile(),
|
||||
]);
|
||||
if (r === null) {
|
||||
setRules([]);
|
||||
setRulesError(true);
|
||||
} else {
|
||||
setRules(r);
|
||||
setRulesError(false);
|
||||
}
|
||||
setDraft(d);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchAuthSession().then(({ user: u }) => {
|
||||
if (!cancelled) {
|
||||
setUser(u);
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
setActionError(null);
|
||||
await logout();
|
||||
setUser(null);
|
||||
setRules([]);
|
||||
setDraft(null);
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
|
||||
const handleRequestDeleteRule = useCallback((id: string) => {
|
||||
setActionError(null);
|
||||
setRuleDeleteTargetId(id);
|
||||
}, []);
|
||||
|
||||
const handleCloseDeleteRuleDialog = useCallback(() => {
|
||||
if (ruleDeleteBusy) return;
|
||||
setRuleDeleteTargetId(null);
|
||||
}, [ruleDeleteBusy]);
|
||||
|
||||
const handleConfirmDeleteRule = useCallback(async () => {
|
||||
const id = ruleDeleteTargetId;
|
||||
if (!id || ruleDeleteBusy) return;
|
||||
|
||||
setActionError(null);
|
||||
setRuleDeleteBusy(true);
|
||||
const res = await deletePublishedRule(id);
|
||||
setRuleDeleteBusy(false);
|
||||
if (res.ok === true) {
|
||||
setRuleDeleteTargetId(null);
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
if (res.status === 404) {
|
||||
setActionError(t("notFound"));
|
||||
setRuleDeleteTargetId(null);
|
||||
} else if (res.status === 403) {
|
||||
setActionError(t("forbidden"));
|
||||
setRuleDeleteTargetId(null);
|
||||
} else {
|
||||
setActionError(t("actionError"));
|
||||
}
|
||||
}, [load, ruleDeleteBusy, ruleDeleteTargetId, t]);
|
||||
|
||||
const handleDuplicateRule = useCallback(
|
||||
async (id: string) => {
|
||||
setActionError(null);
|
||||
const res = await duplicatePublishedRule(id);
|
||||
if (res.ok === true) {
|
||||
void load();
|
||||
} else {
|
||||
if (res.status === 404) {
|
||||
setActionError(t("notFound"));
|
||||
} else if (res.status === 403) {
|
||||
setActionError(t("forbidden"));
|
||||
} else {
|
||||
setActionError(t("actionError"));
|
||||
}
|
||||
}
|
||||
},
|
||||
[load, t],
|
||||
);
|
||||
|
||||
const handleContinueDraft = useCallback(() => {
|
||||
if (draft == null || !draft.hasDraft) return;
|
||||
const step = resolveContinueStepState(draft.state);
|
||||
router.push(`/create/${step}`);
|
||||
}, [draft, router]);
|
||||
|
||||
const handleRequestDeleteDraft = useCallback(() => {
|
||||
setActionError(null);
|
||||
setDraftDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDeleteDraftDialog = useCallback(() => {
|
||||
if (draftDeleteBusy) return;
|
||||
setDraftDeleteOpen(false);
|
||||
}, [draftDeleteBusy]);
|
||||
|
||||
const handleConfirmDeleteDraft = useCallback(async () => {
|
||||
if (draftDeleteBusy) return;
|
||||
setActionError(null);
|
||||
setDraftDeleteBusy(true);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
await deleteServerDraft();
|
||||
setDraftDeleteBusy(false);
|
||||
setDraftDeleteOpen(false);
|
||||
void load();
|
||||
}, [draftDeleteBusy, load]);
|
||||
|
||||
const handleConfirmDeleteAccount = useCallback(async () => {
|
||||
setActionError(null);
|
||||
setAccountDeleteBusy(true);
|
||||
const res = await deleteAccount();
|
||||
setAccountDeleteBusy(false);
|
||||
if (res.ok) {
|
||||
setAccountDeleteOpen(false);
|
||||
setUser(null);
|
||||
setRules([]);
|
||||
setDraft(null);
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setActionError(t("actionError"));
|
||||
}, [router, t]);
|
||||
|
||||
/** `lg`+ layout; matches `--breakpoint-lg` in `app/tailwind.css`. */
|
||||
const isProfileLgUp = useMediaQuery("(min-width: 1024px)");
|
||||
/** `List` L + Bricolage section titles — Figma `22143:900247`; matches `--breakpoint-xl` (1440px). */
|
||||
const isProfileXlUp = useMediaQuery("(min-width: 1440px)");
|
||||
|
||||
if (!sessionLoaded) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-16">
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
|
||||
{t("loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<ProfilePageSignedOutView
|
||||
profileLgUp={isProfileLgUp}
|
||||
onSignIn={() => openLogin({ nextPath: "/profile" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showDraftCard = Boolean(
|
||||
draft && draft.hasDraft,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 md:py-24">
|
||||
<h1 className="font-bricolage text-3xl font-extrabold text-[var(--color-content-default-primary)] md:text-4xl">
|
||||
{t("placeholderTitle")}
|
||||
</h1>
|
||||
<p className="mt-4 font-inter text-lg leading-relaxed text-[var(--color-content-default-secondary)]">
|
||||
{t("placeholderBody")}
|
||||
</p>
|
||||
{loaded && user ? (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
ariaLabel={t("signOut")}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfilePageView
|
||||
userEmail={user.email}
|
||||
ruleCardSize={isProfileLgUp ? "L" : "M"}
|
||||
profileLgUp={isProfileLgUp}
|
||||
profileListSize={isProfileXlUp ? "l" : "m"}
|
||||
rules={rules}
|
||||
rulesError={rulesError}
|
||||
draft={draft}
|
||||
showDraftCard={showDraftCard}
|
||||
ruleDeleteOpen={ruleDeleteTargetId !== null}
|
||||
ruleDeleteBusy={ruleDeleteBusy}
|
||||
draftDeleteOpen={draftDeleteOpen}
|
||||
draftDeleteBusy={draftDeleteBusy}
|
||||
accountDeleteOpen={accountDeleteOpen}
|
||||
accountDeleteBusy={accountDeleteBusy}
|
||||
actionError={actionError}
|
||||
onSignOut={handleSignOut}
|
||||
onDeleteRule={handleRequestDeleteRule}
|
||||
onCloseDeleteRule={handleCloseDeleteRuleDialog}
|
||||
onConfirmDeleteRule={handleConfirmDeleteRule}
|
||||
onDuplicateRule={handleDuplicateRule}
|
||||
onContinueDraft={handleContinueDraft}
|
||||
onDeleteDraft={handleRequestDeleteDraft}
|
||||
onCloseDeleteDraft={handleCloseDeleteDraftDialog}
|
||||
onConfirmDeleteDraft={handleConfirmDeleteDraft}
|
||||
onOpenDeleteAccount={() => {
|
||||
setActionError(null);
|
||||
setAccountDeleteOpen(true);
|
||||
}}
|
||||
onCloseDeleteAccount={() => setAccountDeleteOpen(false)}
|
||||
onConfirmDeleteAccount={handleConfirmDeleteAccount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
import Button from "../../../components/buttons/Button";
|
||||
import RuleCard from "../../../components/cards/RuleCard";
|
||||
import List from "../../../components/layout/List";
|
||||
import type { ListItem, ListSize } from "../../../components/layout/List";
|
||||
import Dialog from "../../../components/modals/Dialog";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { CreateFlowState } from "../../create/types";
|
||||
import type {
|
||||
MyPublishedRule,
|
||||
ServerDraftForProfile,
|
||||
} from "../../../../lib/create/api";
|
||||
|
||||
function draftBodyTextFromState(
|
||||
state: CreateFlowState,
|
||||
): string | undefined {
|
||||
const ctx = state.communityContext?.trim();
|
||||
if (ctx) return ctx;
|
||||
const summary = state.summary?.trim();
|
||||
if (summary) return summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ProfilePageViewProps = {
|
||||
userEmail: string;
|
||||
/** `M` below `lg` (1024px); `L` at `lg`+ per Figma Card / Rule. Breakpoints: `md` (640px) → `lg` (1024px) only. */
|
||||
ruleCardSize: "M" | "L";
|
||||
/** `true` at `lg` (1024px)+ — welcome uses {@link HeaderLockup} size `L` per `21962:17220`. */
|
||||
profileLgUp: boolean;
|
||||
/** `m` = {@link List} M; `l` = List L at `xl` per Figma `22143:900256`. */
|
||||
profileListSize: Extract<ListSize, "m" | "l">;
|
||||
rules: MyPublishedRule[];
|
||||
rulesError: boolean;
|
||||
draft: ServerDraftForProfile | null;
|
||||
showDraftCard: boolean;
|
||||
ruleDeleteOpen: boolean;
|
||||
ruleDeleteBusy: boolean;
|
||||
draftDeleteOpen: boolean;
|
||||
draftDeleteBusy: boolean;
|
||||
accountDeleteOpen: boolean;
|
||||
accountDeleteBusy: boolean;
|
||||
actionError: string | null;
|
||||
onSignOut: () => void;
|
||||
onDeleteRule: (id: string) => void;
|
||||
onCloseDeleteRule: () => void;
|
||||
onConfirmDeleteRule: () => void;
|
||||
onDuplicateRule: (id: string) => void;
|
||||
onContinueDraft: () => void;
|
||||
onDeleteDraft: () => void;
|
||||
onCloseDeleteDraft: () => void;
|
||||
onConfirmDeleteDraft: () => void;
|
||||
onOpenDeleteAccount: () => void;
|
||||
onCloseDeleteAccount: () => void;
|
||||
onConfirmDeleteAccount: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Figma: Inter 20/28 from `md` to `lg`+ (e.g. `21962:17224`); at `xl` Bricolage 28/36 (`22143:900251`, `22143:900255` — `Medium/Heading`);
|
||||
* mobile: smaller Bricolage.
|
||||
*/
|
||||
const profileSectionHeadingClass =
|
||||
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
||||
|
||||
/**
|
||||
* Sticky `top` for page content below the product {@link TopNav} (standard variant).
|
||||
* Must match `TopNav.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
|
||||
*/
|
||||
const stickyBelowTopNavTopClass =
|
||||
"top-[41px] lg:top-[85px] xl:top-[89px]";
|
||||
|
||||
export type ProfilePageSignedOutViewProps = {
|
||||
onSignIn: () => void;
|
||||
/** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
|
||||
profileLgUp: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Signed-out profile: same shell as {@link ProfilePageView}
|
||||
* (Figma mobile `22143:900762`, md `22143:900534`, lg `21962:17220` via {@link HeaderLockup}).
|
||||
*/
|
||||
export function ProfilePageSignedOutView({
|
||||
onSignIn,
|
||||
profileLgUp,
|
||||
}: ProfilePageSignedOutViewProps) {
|
||||
const t = useTranslation("pages.profile");
|
||||
const titleId = useId();
|
||||
|
||||
return (
|
||||
<div className="w-full bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]">
|
||||
<div className="flex flex-col gap-6 px-4 pt-4 pb-4 md:px-8 lg:gap-10 lg:px-16">
|
||||
<header
|
||||
className={
|
||||
profileLgUp
|
||||
? `sticky z-10 bg-[var(--color-surface-default-primary)] ${stickyBelowTopNavTopClass}`
|
||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
||||
}
|
||||
>
|
||||
{profileLgUp ? (
|
||||
<HeaderLockup
|
||||
titleId={titleId}
|
||||
title={t("pageTitle")}
|
||||
description={t("signInPrompt")}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
id={titleId}
|
||||
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
|
||||
>
|
||||
{t("pageTitle")}
|
||||
</h1>
|
||||
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
|
||||
{t("signInPrompt")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
className="self-start"
|
||||
onClick={onSignIn}
|
||||
>
|
||||
{t("signInCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: mobile `22143:900762`; tablet `md` `22143:900534` (`@theme --breakpoint-md` 640px);
|
||||
* desktop `lg` `21962:17220` (`@theme --breakpoint-lg` 1024px);
|
||||
* `xl` `22143:900247` (same content spacing as lg; list + section type at `xl` — `List` L `21844:4405`).
|
||||
*/
|
||||
export function ProfilePageView({
|
||||
userEmail,
|
||||
ruleCardSize,
|
||||
profileLgUp,
|
||||
profileListSize,
|
||||
rules,
|
||||
rulesError,
|
||||
draft,
|
||||
showDraftCard,
|
||||
ruleDeleteOpen,
|
||||
ruleDeleteBusy,
|
||||
draftDeleteOpen,
|
||||
draftDeleteBusy,
|
||||
accountDeleteOpen,
|
||||
accountDeleteBusy,
|
||||
actionError,
|
||||
onSignOut,
|
||||
onDeleteRule,
|
||||
onCloseDeleteRule,
|
||||
onConfirmDeleteRule,
|
||||
onDuplicateRule,
|
||||
onContinueDraft,
|
||||
onDeleteDraft,
|
||||
onCloseDeleteDraft,
|
||||
onConfirmDeleteDraft,
|
||||
onOpenDeleteAccount,
|
||||
onCloseDeleteAccount,
|
||||
onConfirmDeleteAccount,
|
||||
}: ProfilePageViewProps) {
|
||||
const t = useTranslation("pages.profile");
|
||||
const titleId = useId();
|
||||
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
|
||||
const welcomeBody =
|
||||
rules.length > 0 ? t("welcomeBodyFirstRule") : t("welcomeBodyNoRules");
|
||||
|
||||
const profileOptionsItems = useMemo((): ListItem[] => {
|
||||
return [
|
||||
{
|
||||
id: "create-custom",
|
||||
title: t("optionCreateCustom"),
|
||||
description: "",
|
||||
href: "/create",
|
||||
leadingIcon: "edit",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "create-template",
|
||||
title: t("optionCreateTemplate"),
|
||||
description: "",
|
||||
href: "/templates?fromFlow=1",
|
||||
leadingIcon: "content_copy",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
title: t("optionLogout"),
|
||||
description: "",
|
||||
onClick: onSignOut,
|
||||
leadingIcon: "log_out",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "change-email",
|
||||
title: t("optionChangeEmail"),
|
||||
description: "",
|
||||
leadingIcon: "mail",
|
||||
variant: "muted",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "delete-account",
|
||||
title: t("deleteAccount"),
|
||||
description: "",
|
||||
onClick: onOpenDeleteAccount,
|
||||
leadingIcon: "warning",
|
||||
variant: "danger",
|
||||
showDescription: false,
|
||||
},
|
||||
];
|
||||
}, [t, onSignOut, onOpenDeleteAccount]);
|
||||
|
||||
const ruleCardShellClass =
|
||||
"w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]">
|
||||
<div className="flex flex-col gap-6 px-4 pt-4 pb-4 md:px-8 lg:gap-10 lg:px-16">
|
||||
<header
|
||||
className={
|
||||
profileLgUp
|
||||
? `lg:sticky lg:z-10 lg:bg-[var(--color-surface-default-primary)] lg:top-[85px] xl:top-[89px]`
|
||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
||||
}
|
||||
>
|
||||
{profileLgUp ? (
|
||||
<HeaderLockup
|
||||
titleId={titleId}
|
||||
title={welcomeTitle}
|
||||
description={welcomeBody}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
id={titleId}
|
||||
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
|
||||
>
|
||||
{welcomeTitle}
|
||||
</h1>
|
||||
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
|
||||
{welcomeBody}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
|
||||
role="alert"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rulesError ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
||||
{t("actionError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:flex-nowrap lg:items-start lg:gap-8">
|
||||
<section
|
||||
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
|
||||
aria-labelledby="profile-rules-heading"
|
||||
>
|
||||
<h2
|
||||
id="profile-rules-heading"
|
||||
className={profileSectionHeadingClass}
|
||||
style={{ fontVariationSettings: "'opsz' 14, 'wdth' 100" }}
|
||||
>
|
||||
{t("yourRulesHeading")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{showDraftCard && draft?.hasDraft ? (
|
||||
<RuleCard
|
||||
title={(() => {
|
||||
const raw = draft.state.title;
|
||||
const s = typeof raw === "string" ? raw.trim() : "";
|
||||
return s || t("draftHeading");
|
||||
})()}
|
||||
description={draftBodyTextFromState(draft.state)}
|
||||
expanded
|
||||
size={ruleCardSize}
|
||||
hasBottomLinks
|
||||
bottomStatusLabel={t("draftInProgressBadge")}
|
||||
bottomLinks={[
|
||||
{
|
||||
id: "continue",
|
||||
label: t("continueDraft"),
|
||||
onClick: onContinueDraft,
|
||||
},
|
||||
{
|
||||
id: "delete-draft",
|
||||
label: t("deleteRule"),
|
||||
onClick: onDeleteDraft,
|
||||
},
|
||||
]}
|
||||
communityInitials={(() => {
|
||||
const raw = draft.state.title;
|
||||
const s = typeof raw === "string" ? raw.trim() : "";
|
||||
return s.charAt(0).toUpperCase() || "·";
|
||||
})()}
|
||||
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
|
||||
className={ruleCardShellClass}
|
||||
/>
|
||||
) : null}
|
||||
{rules.map((rule) => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
title={rule.title}
|
||||
description={rule.summary ?? undefined}
|
||||
expanded
|
||||
size={ruleCardSize}
|
||||
hasBottomLinks
|
||||
bottomLinks={[
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "dup",
|
||||
label: t("duplicate"),
|
||||
onClick: () => onDuplicateRule(rule.id),
|
||||
},
|
||||
{
|
||||
id: "del",
|
||||
label: t("deleteRule"),
|
||||
onClick: () => onDeleteRule(rule.id),
|
||||
},
|
||||
]}
|
||||
communityInitials={
|
||||
rule.title.trim().charAt(0).toUpperCase() || "·"
|
||||
}
|
||||
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
|
||||
className={ruleCardShellClass}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{rules.length === 0 && !rulesError && !showDraftCard ? (
|
||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
||||
{t("yourRulesEmpty")}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
|
||||
aria-labelledby="profile-options-heading"
|
||||
>
|
||||
<h2
|
||||
id="profile-options-heading"
|
||||
className={profileSectionHeadingClass}
|
||||
style={{ fontVariationSettings: "'opsz' 14, 'wdth' 100" }}
|
||||
>
|
||||
{t("yourOptionsHeading")}
|
||||
</h2>
|
||||
<nav aria-label={t("yourOptionsHeading")}>
|
||||
<List
|
||||
items={profileOptionsItems}
|
||||
size={profileListSize}
|
||||
topDivider
|
||||
leadingIcon="edit"
|
||||
/>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
isOpen={ruleDeleteOpen}
|
||||
onClose={() => {
|
||||
if (!ruleDeleteBusy) onCloseDeleteRule();
|
||||
}}
|
||||
backdropVariant="blurredYellow"
|
||||
title={t("deleteRuleModalTitle")}
|
||||
description={t("deleteRuleModalBody")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onCloseDeleteRule}
|
||||
disabled={ruleDeleteBusy}
|
||||
>
|
||||
{t("deleteRuleCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
onClick={onConfirmDeleteRule}
|
||||
disabled={ruleDeleteBusy}
|
||||
>
|
||||
{t("deleteRuleConfirmCta")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={draftDeleteOpen}
|
||||
onClose={() => {
|
||||
if (!draftDeleteBusy) onCloseDeleteDraft();
|
||||
}}
|
||||
backdropVariant="blurredYellow"
|
||||
title={t("deleteDraftModalTitle")}
|
||||
description={t("deleteDraftModalBody")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onCloseDeleteDraft}
|
||||
disabled={draftDeleteBusy}
|
||||
>
|
||||
{t("deleteDraftCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
onClick={onConfirmDeleteDraft}
|
||||
disabled={draftDeleteBusy}
|
||||
>
|
||||
{t("deleteDraftConfirmCta")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={accountDeleteOpen}
|
||||
onClose={() => {
|
||||
if (!accountDeleteBusy) onCloseDeleteAccount();
|
||||
}}
|
||||
backdropVariant="blurredYellow"
|
||||
title={t("deleteAccountModalTitle")}
|
||||
description={t("deleteAccountModalBody")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onCloseDeleteAccount}
|
||||
disabled={accountDeleteBusy}
|
||||
>
|
||||
{t("deleteAccountCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
onClick={onConfirmDeleteAccount}
|
||||
disabled={accountDeleteBusy}
|
||||
>
|
||||
{t("deleteAccountConfirm")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Profile uses the public marketing footer; other `(app)` routes stay footer-free. */
|
||||
const Footer = dynamic(() => import("../../components/navigation/Footer"), {
|
||||
loading: () => (
|
||||
<footer className="w-full min-h-[200px] bg-[var(--color-surface-default-primary)]" />
|
||||
),
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
export default function ProfileLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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 modal’s 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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Dialog.container";
|
||||
export type { DialogProps } from "./Dialog.types";
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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-72–CR-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-link–style 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 **10–11** 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 7–8**; **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 13–14** 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 **10–11** 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 7–8**; **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 13–14** 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-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-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-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-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 **10–11** 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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 7–8** — 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).
|
||||
|
||||
|
||||
@@ -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 user’s 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 user’s 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
{
|
||||
"placeholderTitle": "Your profile",
|
||||
"placeholderBody": "We’re building this space for your CommunityRules and account options. Check back soon.",
|
||||
"signOut": "Sign out"
|
||||
"pageTitle": "Your profile",
|
||||
"welcomeTitle": "Congrats {{name}}!",
|
||||
"welcomeBodyFirstRule": "You’ve 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."
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\)\]/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user