Merge pull request 'Profile, email change, alerts, and not-found' (#48) from adilallo/feature/ProfileShareAndUtility into main
Reviewed-on: #48
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Unified Alert (toast/banner) for app notifications — Figma + drift prevention
|
||||
globs: app/**/*.tsx, stories/modals/Alert.stories.js, tests/components/Alert.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Alerts and notifications
|
||||
|
||||
## Source of truth
|
||||
|
||||
- **Figma:** [Community Rule System — Modal / Alert](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646) (node **6351-14646**).
|
||||
- **Code:** `app/components/modals/Alert` — default export `Alert` from `Alert.container.tsx` (Figma docstring on the container).
|
||||
|
||||
## When to use `Alert`
|
||||
|
||||
Use **`Alert`** for **app-level, section-level, and shell-level** success, warning, error, and neutral status messages that should read as a designed system surface (not body copy alone).
|
||||
|
||||
Do **not** recreate the same job with ad-hoc UI: bordered `<p>`, free-standing `role="alert"` blocks, or raw `text-[var(--color-border-default-utility-negative)]` paragraphs for product messaging.
|
||||
|
||||
## Props (lowercase in code; match Figma intent)
|
||||
|
||||
| Concern | Prop | Notes |
|
||||
| --- | --- | --- |
|
||||
| Layout | `type` | `toast` — bottom accent bar, top rounded corners; `banner` — full rounded block, inline or stacked. |
|
||||
| Intent | `status` | `default` \| `positive` \| `warning` \| `danger`. |
|
||||
| Density | `size` | `s` \| `m` (Figma S/M). Typography and padding are implemented inside `Alert.container.tsx` — do not fork spacing per call site. |
|
||||
| Copy | `title`, `description?` | Required title; optional description when `hasBodyText` is true. |
|
||||
| Icon | `hasLeadingIcon?` | Default `true`. |
|
||||
| Body | `hasBodyText?` | Default `true`; set `false` for title-only. |
|
||||
| Dismiss | `onClose?`, `hasTrailingIcon?` | Close control shows only when `onClose` is provided **and** `hasTrailingIcon` is not `false`. Omit `onClose` for non-dismissible messages. |
|
||||
|
||||
Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormalization.ts`.
|
||||
|
||||
## Choosing toast vs banner
|
||||
|
||||
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
|
||||
- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
|
||||
|
||||
## Exemptions (do not force `Alert`)
|
||||
|
||||
1. **Single-field validation** under a control — keep `TextInput` / `TextArea` `error` and helper text (e.g. invalid email on the login form) unless design explicitly moves that line into `Alert`.
|
||||
2. **Marketing layout** — `HeroBanner`, `ContentBanner` are not system alerts.
|
||||
3. **Landmarks** — `role="banner"` on headers/nav is not the `Alert` “banner” type.
|
||||
4. **A11y-only live regions** — e.g. tooltip / incrementer `aria-live` for widget state, not product notifications.
|
||||
|
||||
## Copy
|
||||
|
||||
All user-visible strings go through **`messages/`** and `useTranslation` / message modules per `localization.mdc`.
|
||||
@@ -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.
|
||||
|
||||
@@ -15,6 +15,7 @@ Single-locale (English) today; designed for i18n via `messages/`.
|
||||
| If you're touching… | Load this rule |
|
||||
| --- | --- |
|
||||
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
|
||||
| `Alert`, or user-visible notifications / shell errors / success banners | `alerts.mdc` (and `localization.mdc` for copy) |
|
||||
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
|
||||
| `app/api/**` | `api-routes.mdc` |
|
||||
| `app/hooks/**` | `hooks.mdc` |
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||
import { saveDraftToServer } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
@@ -139,12 +140,27 @@ export function PostLoginDraftTransfer({
|
||||
|
||||
if (!transferError) return null;
|
||||
|
||||
const [titleLine, ...rest] = transferError.split(/\n\n+/);
|
||||
const title = (titleLine ?? transferError).trim();
|
||||
const description = rest.join("\n\n").trim() || undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mx-auto max-w-[640px] px-5 py-3 text-center font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{transferError}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[150] flex justify-center px-5 md:bottom-6">
|
||||
<div className="pointer-events-auto w-full max-w-[640px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={title}
|
||||
description={description}
|
||||
hasBodyText={Boolean(description)}
|
||||
hasLeadingIcon
|
||||
onClose={() => {
|
||||
setTransferError(null);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,11 @@ import {
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import {
|
||||
isValidStep,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
} from "./utils/flowSteps";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
@@ -36,6 +41,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 +89,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,17 +108,31 @@ export function SignedInDraftHydration({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
|
||||
}, [
|
||||
sessionResolved,
|
||||
sessionUser,
|
||||
syncDraftParam,
|
||||
replaceState,
|
||||
pathname,
|
||||
router,
|
||||
]);
|
||||
|
||||
if (!loadingHydration) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
|
||||
>
|
||||
{messages.create.draftHydration.loadingSavedProgress}
|
||||
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
|
||||
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="default"
|
||||
size="s"
|
||||
title={messages.create.draftHydration.loadingSavedProgress}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon={false}
|
||||
hasTrailingIcon={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,19 +86,28 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<>
|
||||
<div
|
||||
className={`flex shrink-0 flex-col gap-4 pb-8 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
className="pointer-events-none fixed left-0 right-0 top-14 z-[120] flex justify-center px-5 pt-3 md:top-20 md:px-12"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={t("errors.loadFailed")}
|
||||
description={error ?? t("errors.notFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={t("errors.loadFailed")}
|
||||
description={error ?? t("errors.notFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div
|
||||
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</CreateFlowStepShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,365 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, 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,
|
||||
requestEmailChange,
|
||||
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 [emailChangeOpen, setEmailChangeOpen] = useState(false);
|
||||
const [emailChangeInput, setEmailChangeInput] = useState("");
|
||||
const [emailChangeBusy, setEmailChangeBusy] = useState(false);
|
||||
const [emailChangeModalError, setEmailChangeModalError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [emailChangeRequestSent, setEmailChangeRequestSent] = useState(false);
|
||||
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const emailChangeQueryHandledRef = useRef(false);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailChangeQueryHandledRef.current) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const search = window.location.search;
|
||||
if (!search) return;
|
||||
const params = new URLSearchParams(search);
|
||||
const ok = params.get("email_change");
|
||||
const err = params.get("error");
|
||||
if (ok !== "ok" && !err?.startsWith("email_change_")) return;
|
||||
|
||||
emailChangeQueryHandledRef.current = true;
|
||||
|
||||
if (ok === "ok") {
|
||||
setProfileSuccessMessage(t("emailChangeSuccess"));
|
||||
void load().then(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} else if (err === "email_change_expired") {
|
||||
setActionError(t("emailChangeVerifyExpired"));
|
||||
} else if (err === "email_change_invalid") {
|
||||
setActionError(t("emailChangeVerifyInvalid"));
|
||||
} else if (err === "email_change_taken") {
|
||||
setActionError(t("emailChangeVerifyTaken"));
|
||||
} else if (err === "email_change_server") {
|
||||
setActionError(t("actionError"));
|
||||
}
|
||||
|
||||
router.replace("/profile", { scroll: false });
|
||||
}, [load, router, t]);
|
||||
|
||||
const handleOpenEmailChange = useCallback(() => {
|
||||
if (!user) return;
|
||||
setActionError(null);
|
||||
setProfileSuccessMessage(null);
|
||||
setEmailChangeModalError(null);
|
||||
setEmailChangeRequestSent(false);
|
||||
setEmailChangeInput(user.email);
|
||||
setEmailChangeOpen(true);
|
||||
}, [user]);
|
||||
|
||||
const handleCloseEmailChange = useCallback(() => {
|
||||
if (emailChangeBusy) return;
|
||||
setEmailChangeOpen(false);
|
||||
setEmailChangeRequestSent(false);
|
||||
}, [emailChangeBusy]);
|
||||
|
||||
const handleDismissProfileSuccess = useCallback(() => {
|
||||
setProfileSuccessMessage(null);
|
||||
}, []);
|
||||
|
||||
const handleDismissActionError = useCallback(() => {
|
||||
setActionError(null);
|
||||
}, []);
|
||||
|
||||
const handleDismissRulesError = useCallback(() => {
|
||||
setRulesError(false);
|
||||
}, []);
|
||||
|
||||
const handleDismissEmailChangeModalError = useCallback(() => {
|
||||
setEmailChangeModalError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmitEmailChange = useCallback(async () => {
|
||||
const trimmed = emailChangeInput.trim();
|
||||
if (!trimmed || emailChangeBusy) return;
|
||||
setEmailChangeModalError(null);
|
||||
setEmailChangeBusy(true);
|
||||
const res = await requestEmailChange(trimmed);
|
||||
setEmailChangeBusy(false);
|
||||
if (res.ok === false) {
|
||||
if (res.retryAfterMs != null && res.retryAfterMs > 0) {
|
||||
const sec = Math.max(1, Math.ceil(res.retryAfterMs / 1000));
|
||||
setEmailChangeModalError(
|
||||
t("emailChangeRateLimited").replace(/\{\{seconds\}\}/g, String(sec)),
|
||||
);
|
||||
} else {
|
||||
setEmailChangeModalError(res.error);
|
||||
}
|
||||
} else {
|
||||
setEmailChangeRequestSent(true);
|
||||
}
|
||||
}, [emailChangeBusy, emailChangeInput, t]);
|
||||
|
||||
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}
|
||||
profileSuccessMessage={profileSuccessMessage}
|
||||
emailChangeOpen={emailChangeOpen}
|
||||
emailChangeValue={emailChangeInput}
|
||||
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
|
||||
emailChangeBusy={emailChangeBusy}
|
||||
emailChangeRequestSent={emailChangeRequestSent}
|
||||
emailChangeModalError={emailChangeModalError}
|
||||
onDismissProfileSuccess={handleDismissProfileSuccess}
|
||||
onDismissActionError={handleDismissActionError}
|
||||
onDismissRulesError={handleDismissRulesError}
|
||||
onDismissEmailChangeModalError={handleDismissEmailChangeModalError}
|
||||
onOpenEmailChange={handleOpenEmailChange}
|
||||
onCloseEmailChange={handleCloseEmailChange}
|
||||
onSubmitEmailChange={handleSubmitEmailChange}
|
||||
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,643 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
import Button from "../../../components/buttons/Button";
|
||||
import RuleCard from "../../../components/cards/RuleCard";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import List from "../../../components/layout/List";
|
||||
import type { ListItem, ListSize } from "../../../components/layout/List";
|
||||
import Icon from "../../../components/asset/Icon";
|
||||
import Dialog from "../../../components/modals/Dialog";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { CreateFlowState } from "../../create/types";
|
||||
import type {
|
||||
MyPublishedRule,
|
||||
ServerDraftForProfile,
|
||||
} from "../../../../lib/create/api";
|
||||
|
||||
function draftBodyTextFromState(
|
||||
state: CreateFlowState,
|
||||
): string | undefined {
|
||||
const ctx = state.communityContext?.trim();
|
||||
if (ctx) return ctx;
|
||||
const summary = state.summary?.trim();
|
||||
if (summary) return summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ProfilePageViewProps = {
|
||||
userEmail: string;
|
||||
/** `M` below `lg` (1024px); `L` at `lg`+ per Figma Card / Rule. Breakpoints: `md` (640px) → `lg` (1024px) only. */
|
||||
ruleCardSize: "M" | "L";
|
||||
/** `true` at `lg` (1024px)+ — welcome uses {@link HeaderLockup} size `L` per `21962:17220`. */
|
||||
profileLgUp: boolean;
|
||||
/** `m` = {@link List} M; `l` = List L at `xl` per Figma `22143:900256`. */
|
||||
profileListSize: Extract<ListSize, "m" | "l">;
|
||||
rules: MyPublishedRule[];
|
||||
rulesError: boolean;
|
||||
draft: ServerDraftForProfile | null;
|
||||
showDraftCard: boolean;
|
||||
ruleDeleteOpen: boolean;
|
||||
ruleDeleteBusy: boolean;
|
||||
draftDeleteOpen: boolean;
|
||||
draftDeleteBusy: boolean;
|
||||
accountDeleteOpen: boolean;
|
||||
accountDeleteBusy: boolean;
|
||||
actionError: string | null;
|
||||
profileSuccessMessage: string | null;
|
||||
emailChangeOpen: boolean;
|
||||
emailChangeValue: string;
|
||||
onEmailChangeValueChange: (value: string) => void;
|
||||
emailChangeBusy: boolean;
|
||||
emailChangeRequestSent: boolean;
|
||||
emailChangeModalError: string | null;
|
||||
onDismissEmailChangeModalError: () => void;
|
||||
onOpenEmailChange: () => void;
|
||||
onCloseEmailChange: () => void;
|
||||
onSubmitEmailChange: () => void;
|
||||
onSignOut: () => void;
|
||||
onDeleteRule: (id: string) => void;
|
||||
onCloseDeleteRule: () => void;
|
||||
onConfirmDeleteRule: () => void;
|
||||
onDuplicateRule: (id: string) => void;
|
||||
onContinueDraft: () => void;
|
||||
onDeleteDraft: () => void;
|
||||
onCloseDeleteDraft: () => void;
|
||||
onConfirmDeleteDraft: () => void;
|
||||
onOpenDeleteAccount: () => void;
|
||||
onCloseDeleteAccount: () => void;
|
||||
onConfirmDeleteAccount: () => void;
|
||||
onDismissProfileSuccess: () => void;
|
||||
onDismissActionError: () => void;
|
||||
onDismissRulesError: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Figma: Inter 20/28 from `md` to `lg`+ (e.g. `21962:17224`); at `xl` Bricolage 28/36 (`22143:900251`, `22143:900255` — `Medium/Heading`);
|
||||
* mobile: smaller Bricolage.
|
||||
*/
|
||||
const profileSectionHeadingClass =
|
||||
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
||||
|
||||
/**
|
||||
* Sticky `top` for page content below the product {@link 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,
|
||||
profileSuccessMessage,
|
||||
emailChangeOpen,
|
||||
emailChangeValue,
|
||||
onEmailChangeValueChange,
|
||||
emailChangeBusy,
|
||||
emailChangeRequestSent,
|
||||
emailChangeModalError,
|
||||
onDismissEmailChangeModalError,
|
||||
onOpenEmailChange,
|
||||
onCloseEmailChange,
|
||||
onSubmitEmailChange,
|
||||
onSignOut,
|
||||
onDeleteRule,
|
||||
onCloseDeleteRule,
|
||||
onConfirmDeleteRule,
|
||||
onDuplicateRule,
|
||||
onContinueDraft,
|
||||
onDeleteDraft,
|
||||
onCloseDeleteDraft,
|
||||
onConfirmDeleteDraft,
|
||||
onOpenDeleteAccount,
|
||||
onCloseDeleteAccount,
|
||||
onConfirmDeleteAccount,
|
||||
onDismissProfileSuccess,
|
||||
onDismissActionError,
|
||||
onDismissRulesError,
|
||||
}: ProfilePageViewProps) {
|
||||
const t = useTranslation("pages.profile");
|
||||
const tLogin = useTranslation("pages.login");
|
||||
const titleId = useId();
|
||||
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
|
||||
const welcomeBody =
|
||||
rules.length > 0 ? t("welcomeBodyFirstRule") : t("welcomeBodyNoRules");
|
||||
|
||||
const profileOptionsItems = useMemo((): ListItem[] => {
|
||||
return [
|
||||
{
|
||||
id: "create-custom",
|
||||
title: t("optionCreateCustom"),
|
||||
description: "",
|
||||
href: "/create",
|
||||
leadingIcon: "edit",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "create-template",
|
||||
title: t("optionCreateTemplate"),
|
||||
description: "",
|
||||
href: "/templates?fromFlow=1",
|
||||
leadingIcon: "content_copy",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
title: t("optionLogout"),
|
||||
description: "",
|
||||
onClick: onSignOut,
|
||||
leadingIcon: "log_out",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "change-email",
|
||||
title: t("optionChangeEmail"),
|
||||
description: "",
|
||||
onClick: onOpenEmailChange,
|
||||
leadingIcon: "mail",
|
||||
showDescription: false,
|
||||
},
|
||||
{
|
||||
id: "delete-account",
|
||||
title: t("deleteAccount"),
|
||||
description: "",
|
||||
onClick: onOpenDeleteAccount,
|
||||
leadingIcon: "warning",
|
||||
variant: "danger",
|
||||
showDescription: false,
|
||||
},
|
||||
];
|
||||
}, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]);
|
||||
|
||||
const ruleCardShellClass =
|
||||
"w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)]">
|
||||
<div className="flex flex-col gap-6 px-4 pt-4 pb-4 md:px-8 lg:gap-10 lg:px-16">
|
||||
<header
|
||||
className={
|
||||
profileLgUp
|
||||
? `lg:sticky lg:z-10 lg:bg-[var(--color-surface-default-primary)] lg:top-[85px] xl:top-[89px]`
|
||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
||||
}
|
||||
>
|
||||
{profileLgUp ? (
|
||||
<HeaderLockup
|
||||
titleId={titleId}
|
||||
title={welcomeTitle}
|
||||
description={welcomeBody}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
id={titleId}
|
||||
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
|
||||
>
|
||||
{welcomeTitle}
|
||||
</h1>
|
||||
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
|
||||
{welcomeBody}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:flex-nowrap lg:items-start lg:gap-8">
|
||||
<section
|
||||
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
|
||||
aria-labelledby="profile-rules-heading"
|
||||
>
|
||||
<h2
|
||||
id="profile-rules-heading"
|
||||
className={profileSectionHeadingClass}
|
||||
style={{ fontVariationSettings: "'opsz' 14, 'wdth' 100" }}
|
||||
>
|
||||
{t("yourRulesHeading")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{showDraftCard && draft?.hasDraft ? (
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={emailChangeOpen}
|
||||
onClose={() => {
|
||||
if (!emailChangeBusy) onCloseEmailChange();
|
||||
}}
|
||||
backdropVariant="blurredYellow"
|
||||
title={
|
||||
emailChangeRequestSent
|
||||
? tLogin("successTitle")
|
||||
: t("emailChangeModalTitle")
|
||||
}
|
||||
description={
|
||||
emailChangeRequestSent
|
||||
? tLogin("successBody")
|
||||
: t("emailChangeModalDescription")
|
||||
}
|
||||
footer={
|
||||
emailChangeRequestSent ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onCloseEmailChange}
|
||||
>
|
||||
{t("emailChangeConfirmationClose")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
onClick={onCloseEmailChange}
|
||||
disabled={emailChangeBusy}
|
||||
>
|
||||
{t("emailChangeCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
onClick={onSubmitEmailChange}
|
||||
disabled={emailChangeBusy}
|
||||
>
|
||||
{t("emailChangeSubmit")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{emailChangeRequestSent ? (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
|
||||
<Icon name="mail" size={22} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput
|
||||
type="email"
|
||||
inputSize="medium"
|
||||
label={t("emailChangeNewEmailLabel")}
|
||||
placeholder={t("emailChangeNewEmailPlaceholder")}
|
||||
value={emailChangeValue}
|
||||
onChange={(e) => onEmailChangeValueChange(e.target.value)}
|
||||
disabled={emailChangeBusy}
|
||||
error={Boolean(emailChangeModalError)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{(profileSuccessMessage || actionError || rulesError) && (
|
||||
<div
|
||||
className="pointer-events-none fixed left-0 right-0 top-[41px] z-[60] flex justify-center px-4 pt-3 md:px-8 lg:top-[85px] lg:px-16 xl:top-[89px]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="pointer-events-auto flex w-full max-w-[640px] flex-col gap-2">
|
||||
{profileSuccessMessage ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="positive"
|
||||
size="s"
|
||||
title={profileSuccessMessage}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon
|
||||
onClose={onDismissProfileSuccess}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={actionError}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon
|
||||
onClose={onDismissActionError}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
{rulesError ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="warning"
|
||||
size="s"
|
||||
title={t("rulesLoadBannerTitle")}
|
||||
description={t("rulesLoadBannerDescription")}
|
||||
hasLeadingIcon
|
||||
onClose={onDismissRulesError}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emailChangeOpen && emailChangeModalError ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-6 z-[10001] flex justify-center px-4 md:top-10">
|
||||
<div className="pointer-events-auto w-full max-w-[min(480px,calc(100%-2rem))]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={emailChangeModalError}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon
|
||||
onClose={onDismissEmailChangeModalError}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import {
|
||||
hashSessionToken,
|
||||
newSessionToken,
|
||||
} from "../../../../../lib/server/hash";
|
||||
import { sendEmailChangeEmail } from "../../../../../lib/server/mail";
|
||||
import { rateLimitKey } from "../../../../../lib/server/rateLimit";
|
||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
rateLimited,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
} from "../../../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../../../lib/server/session";
|
||||
import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
|
||||
import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas";
|
||||
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
|
||||
|
||||
const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000;
|
||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||
const IP_MIN_INTERVAL_MS = 20 * 1000;
|
||||
const SCOPE = "user.emailChange.request";
|
||||
|
||||
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const limited = await readLimitedJson(request);
|
||||
if (limited.ok === false) {
|
||||
return limited.response;
|
||||
}
|
||||
|
||||
const parsed = emailChangeRequestBodySchema.safeParse(limited.value);
|
||||
if (!parsed.success) {
|
||||
return jsonFromZodError(parsed.error);
|
||||
}
|
||||
|
||||
const { newEmail } = parsed.data;
|
||||
if (newEmail === user.email) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"New email must be different from your current email",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
const rlEmail = rateLimitKey(
|
||||
`email-change-email:${newEmail}`,
|
||||
EMAIL_MIN_INTERVAL_MS,
|
||||
);
|
||||
if (rlEmail.ok === false) {
|
||||
return rateLimited(rlEmail.retryAfterMs);
|
||||
}
|
||||
|
||||
const rlIp = rateLimitKey(`email-change-ip:${ip}`, IP_MIN_INTERVAL_MS);
|
||||
if (rlIp.ok === false) {
|
||||
return rateLimited(rlIp.retryAfterMs);
|
||||
}
|
||||
|
||||
const rlUser = rateLimitKey(
|
||||
`email-change-user:${user.id}`,
|
||||
EMAIL_MIN_INTERVAL_MS,
|
||||
);
|
||||
if (rlUser.ok === false) {
|
||||
return rateLimited(rlUser.retryAfterMs);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: newEmail } });
|
||||
if (existing && existing.id !== user.id) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"That email is already used by another account",
|
||||
400,
|
||||
{ details: { field: "newEmail" } },
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return serverMisconfigured();
|
||||
}
|
||||
|
||||
const token = newSessionToken();
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const expiresAt = new Date(Date.now() + EMAIL_CHANGE_TTL_MS);
|
||||
|
||||
await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.emailChangeToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
newEmail,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
const verifyUrl = `${origin}/api/user/email-change/verify?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
await sendEmailChangeEmail(newEmail, verifyUrl);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err, {
|
||||
phase: "sendEmailChangeEmail",
|
||||
newEmail,
|
||||
});
|
||||
await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
|
||||
return errorJson("mail_failed", "Could not send email", 502);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/server/db";
|
||||
import {
|
||||
getSessionPepper,
|
||||
isDatabaseConfigured,
|
||||
} from "../../../../../lib/server/env";
|
||||
import { hashSessionToken } from "../../../../../lib/server/hash";
|
||||
import {
|
||||
createSessionForUser,
|
||||
getValidatedSessionTokenHashForUser,
|
||||
setSessionCookie,
|
||||
} from "../../../../../lib/server/session";
|
||||
import { dbUnavailable } from "../../../../../lib/server/responses";
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
getOrCreateRequestId,
|
||||
logRouteError,
|
||||
} from "../../../../../lib/server/requestId";
|
||||
|
||||
const SCOPE = "user.emailChange.verify";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = getOrCreateRequestId(request);
|
||||
|
||||
if (!isDatabaseConfigured()) {
|
||||
const res = dbUnavailable();
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token || token.length < 10) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_invalid",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_server",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const row = await prisma.emailChangeToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!row || row.expiresAt < new Date()) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_expired",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const keepSessionTokenHash = await getValidatedSessionTokenHashForUser(
|
||||
row.userId,
|
||||
);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const claim = await tx.emailChangeToken.findUnique({
|
||||
where: { id: row.id },
|
||||
});
|
||||
if (!claim || claim.expiresAt < new Date()) {
|
||||
throw Object.assign(new Error("expired"), { __expired: true });
|
||||
}
|
||||
|
||||
const taken = await tx.user.findFirst({
|
||||
where: {
|
||||
email: claim.newEmail,
|
||||
NOT: { id: claim.userId },
|
||||
},
|
||||
});
|
||||
if (taken) {
|
||||
await tx.emailChangeToken.delete({ where: { id: claim.id } });
|
||||
throw Object.assign(new Error("taken"), { __taken: true });
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: claim.userId },
|
||||
data: { email: claim.newEmail },
|
||||
});
|
||||
await tx.emailChangeToken.delete({ where: { id: claim.id } });
|
||||
|
||||
if (keepSessionTokenHash) {
|
||||
await tx.session.deleteMany({
|
||||
where: {
|
||||
userId: claim.userId,
|
||||
tokenHash: { not: keepSessionTokenHash },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.session.deleteMany({
|
||||
where: { userId: claim.userId },
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"__taken" in err &&
|
||||
(err as { __taken?: boolean }).__taken
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_taken",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"__expired" in err &&
|
||||
(err as { __expired?: boolean }).__expired
|
||||
) {
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_expired",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
logRouteError(SCOPE, requestId, err, { phase: "transaction" });
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_server",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepSessionTokenHash) {
|
||||
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||
row.userId,
|
||||
);
|
||||
await setSessionCookie(sessionToken, expiresAt);
|
||||
}
|
||||
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?email_change=ok",
|
||||
requestId,
|
||||
);
|
||||
} catch (err) {
|
||||
logRouteError(SCOPE, requestId, err);
|
||||
return redirectWithRequestId(
|
||||
request,
|
||||
"/profile?error=email_change_server",
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithRequestId(
|
||||
request: NextRequest,
|
||||
path: string,
|
||||
requestId: string,
|
||||
): NextResponse {
|
||||
const res = NextResponse.redirect(new URL(path, request.url));
|
||||
res.headers.set(REQUEST_ID_HEADER, requestId);
|
||||
return res;
|
||||
}
|
||||
@@ -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,59 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ArrowBackIcon from "./icon/arrow_back.svg";
|
||||
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 = [
|
||||
"arrow_back",
|
||||
"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> = {
|
||||
arrow_back: ArrowBackIcon,
|
||||
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 +70,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="M7.37306 12.75L13.0692 18.4461L12 19.5L4.50003 12L12 4.5L13.0692 5.55383L7.37306 11.25H19.5V12.75H7.37306Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 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="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,52 +1,117 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Modal / Alert" (6351-14646)
|
||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { AlertView } from "./Alert.view";
|
||||
import type { AlertProps } from "./Alert.types";
|
||||
|
||||
function layoutFor(
|
||||
type: NonNullable<AlertProps["type"]>,
|
||||
size: NonNullable<AlertProps["size"]>,
|
||||
): {
|
||||
containerClasses: string;
|
||||
titleClasses: string;
|
||||
descriptionClasses: string;
|
||||
} {
|
||||
if (type === "toast") {
|
||||
const padH =
|
||||
size === "s"
|
||||
? "px-[var(--space-500)]"
|
||||
: "px-[var(--space-1200)]";
|
||||
const containerClasses = `flex gap-[var(--space-300)] items-center ${padH} pb-[var(--space-500)] pt-[var(--space-400)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)] border-solid`;
|
||||
if (size === "s") {
|
||||
return {
|
||||
containerClasses,
|
||||
titleClasses:
|
||||
"font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
|
||||
descriptionClasses:
|
||||
"font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
containerClasses,
|
||||
titleClasses:
|
||||
"font-inter text-[18px] leading-[24px] font-medium tracking-[0%]",
|
||||
descriptionClasses:
|
||||
"font-inter text-[18px] leading-[1.3] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||
};
|
||||
}
|
||||
|
||||
if (size === "s") {
|
||||
return {
|
||||
containerClasses:
|
||||
"flex gap-[var(--space-300)] items-center p-[var(--space-300)] rounded-[var(--radius-200,8px)] border-solid",
|
||||
titleClasses:
|
||||
"font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
|
||||
descriptionClasses:
|
||||
"font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
containerClasses:
|
||||
"flex gap-[var(--space-300)] items-center px-[var(--space-600)] py-[var(--space-400)] rounded-[var(--radius-200,8px)] border-solid",
|
||||
titleClasses:
|
||||
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
||||
descriptionClasses:
|
||||
"font-inter text-[16px] leading-[24px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||
};
|
||||
}
|
||||
|
||||
const AlertContainer = memo<AlertProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
status: statusProp = "default",
|
||||
type: typeProp = "toast",
|
||||
size: sizeProp = "m",
|
||||
hasLeadingIcon = true,
|
||||
hasBodyText = true,
|
||||
hasTrailingIcon: hasTrailingIconProp,
|
||||
onClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const status = statusProp;
|
||||
const type = typeProp;
|
||||
// Determine background and border colors based on status and type
|
||||
const size = sizeProp;
|
||||
|
||||
const getStatusStyles = () => {
|
||||
switch (status) {
|
||||
case "positive":
|
||||
return {
|
||||
background: "bg-[var(--color-kiwi-kiwi0)]",
|
||||
background:
|
||||
"bg-[var(--color-surface-invert-positive-secondary,var(--color-kiwi-kiwi0))]",
|
||||
borderColor:
|
||||
type === "toast"
|
||||
? "var(--color-border-invert-positive-primary)"
|
||||
: undefined,
|
||||
titleColor: "text-[var(--color-content-invert-primary)]",
|
||||
descriptionColor: "text-[var(--color-content-invert-secondary)]",
|
||||
descriptionColor:
|
||||
"text-[var(--color-content-invert-secondary)]",
|
||||
iconColor: "var(--color-kiwi-kiwi500)",
|
||||
closeButtonIconColor: "var(--color-content-invert-primary)",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
background: "bg-[var(--color-yellow-yellow0)]",
|
||||
background:
|
||||
"bg-[var(--color-surface-invert-warning-secondary,var(--color-yellow-yellow0))]",
|
||||
borderColor:
|
||||
type === "toast"
|
||||
? "var(--color-border-invert-warning-primary)"
|
||||
: undefined,
|
||||
titleColor: "text-[var(--color-content-invert-primary)]",
|
||||
descriptionColor: "text-[var(--color-content-invert-secondary)]",
|
||||
descriptionColor:
|
||||
"text-[var(--color-content-invert-secondary)]",
|
||||
iconColor: "var(--color-yellow-yellow500)",
|
||||
closeButtonIconColor: "var(--color-content-invert-primary)",
|
||||
};
|
||||
case "danger":
|
||||
return {
|
||||
background: "bg-[var(--color-red-red0)]",
|
||||
background:
|
||||
"bg-[var(--color-surface-invert-negative-secondary,var(--color-red-red0))]",
|
||||
borderColor:
|
||||
type === "toast"
|
||||
? "var(--color-border-invert-negative-primary)"
|
||||
@@ -67,18 +132,14 @@ const AlertContainer = memo<AlertProps>(
|
||||
titleColor: "text-[var(--color-content-default-primary)]",
|
||||
descriptionColor: "text-[var(--color-content-default-primary)]",
|
||||
iconColor: "var(--color-content-default-brand-primary)",
|
||||
closeButtonIconColor: "var(--color-content-default-brand-primary)",
|
||||
closeButtonIconColor:
|
||||
"var(--color-content-default-brand-primary)",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusStyles = getStatusStyles();
|
||||
|
||||
const containerClasses = `flex gap-[var(--space-300)] items-center ${
|
||||
type === "toast"
|
||||
? `pb-[var(--space-500)] pt-[var(--space-400)] px-[var(--space-1200)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)]`
|
||||
: `px-[var(--spacing-scale-024)] py-[var(--spacing-scale-016)] rounded-[var(--radius-200,8px)]`
|
||||
} ${statusStyles.background} border-solid`;
|
||||
const layout = layoutFor(type, size);
|
||||
|
||||
const containerStyle =
|
||||
type === "toast" && statusStyles.borderColor
|
||||
@@ -88,15 +149,14 @@ const AlertContainer = memo<AlertProps>(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const titleClasses =
|
||||
type === "banner"
|
||||
? `font-inter text-[16px] leading-[20px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`
|
||||
: `font-inter text-[18px] leading-[24px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`;
|
||||
const containerClasses = `${layout.containerClasses} ${statusStyles.background}`;
|
||||
|
||||
const descriptionClasses =
|
||||
type === "banner"
|
||||
? `font-inter text-[16px] leading-[24px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`
|
||||
: `font-inter text-[18px] leading-[23.4px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`;
|
||||
const titleClasses = `${layout.titleClasses} ${statusStyles.titleColor} relative shrink-0 w-full`;
|
||||
const descriptionClasses = `${layout.descriptionClasses} ${statusStyles.descriptionColor} relative shrink-0 w-full`;
|
||||
|
||||
const hasTrailingIcon =
|
||||
hasTrailingIconProp ?? Boolean(onClose);
|
||||
const showClose = hasTrailingIcon && Boolean(onClose);
|
||||
|
||||
return (
|
||||
<AlertView
|
||||
@@ -106,6 +166,7 @@ const AlertContainer = memo<AlertProps>(
|
||||
type={type}
|
||||
hasLeadingIcon={hasLeadingIcon}
|
||||
hasBodyText={hasBodyText}
|
||||
hasTrailingIcon={showClose}
|
||||
className={className}
|
||||
containerClasses={containerClasses}
|
||||
containerStyle={containerStyle}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AlertSizeValue } from "../../../../lib/propNormalization";
|
||||
|
||||
export type AlertStatusValue = "default" | "positive" | "warning" | "danger";
|
||||
|
||||
export type AlertTypeValue = "toast" | "banner";
|
||||
|
||||
export type { AlertSizeValue };
|
||||
|
||||
export interface AlertProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -13,6 +17,11 @@ export interface AlertProps {
|
||||
* Alert type.
|
||||
*/
|
||||
type?: AlertTypeValue;
|
||||
/**
|
||||
* Density / typography scale (Figma Modal Alert S | M).
|
||||
* @default "m"
|
||||
*/
|
||||
size?: AlertSizeValue;
|
||||
/**
|
||||
* Whether to show the leading icon (Figma prop).
|
||||
* @default true
|
||||
@@ -23,6 +32,11 @@ export interface AlertProps {
|
||||
* @default true
|
||||
*/
|
||||
hasBodyText?: boolean;
|
||||
/**
|
||||
* Trailing dismiss control (Figma `hasTrailingIcon`).
|
||||
* When omitted, defaults to `true` when `onClose` is provided, else `false`.
|
||||
*/
|
||||
hasTrailingIcon?: boolean;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -34,6 +48,7 @@ export interface AlertViewProps {
|
||||
type: "toast" | "banner";
|
||||
hasLeadingIcon: boolean;
|
||||
hasBodyText: boolean;
|
||||
hasTrailingIcon: boolean;
|
||||
className: string;
|
||||
containerClasses: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function AlertView({
|
||||
type: _type,
|
||||
hasLeadingIcon,
|
||||
hasBodyText,
|
||||
hasTrailingIcon,
|
||||
className,
|
||||
containerClasses,
|
||||
containerStyle,
|
||||
@@ -54,40 +55,42 @@ export function AlertView({
|
||||
<p className={descriptionClasses}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
size="large"
|
||||
onClick={onClose}
|
||||
ariaLabel="Close alert"
|
||||
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{hasTrailingIcon && onClose ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
size="large"
|
||||
onClick={onClose}
|
||||
ariaLabel="Close alert"
|
||||
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
|
||||
>
|
||||
<mask
|
||||
id="mask0_21296_8285"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_21296_8285)">
|
||||
<path
|
||||
d="M5.33327 15.5448L4.45508 14.6666L9.12174 9.99993L4.45508 5.33327L5.33327 4.45508L9.99993 9.12174L14.6666 4.45508L15.5448 5.33327L10.8781 9.99993L15.5448 14.6666L14.6666 15.5448L9.99993 10.8781L5.33327 15.5448Z"
|
||||
fill={closeButtonIconColor}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</Button>
|
||||
<mask
|
||||
id="mask0_21296_8285"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_21296_8285)">
|
||||
<path
|
||||
d="M5.33327 15.5448L4.45508 14.6666L9.12174 9.99993L4.45508 5.33327L5.33327 4.45508L9.99993 9.12174L14.6666 4.45508L15.5448 5.33327L10.8781 9.99993L15.5448 14.6666L14.6666 15.5448L9.99993 10.8781L5.33327 15.5448Z"
|
||||
fill={closeButtonIconColor}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +7,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Button from "../../buttons/Button";
|
||||
import TextInput from "../../controls/TextInput";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import Alert from "../Alert";
|
||||
import { requestMagicLink } from "../../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
|
||||
@@ -55,7 +56,6 @@ export default function LoginForm({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const formAlertId = useId();
|
||||
const emailErrorId = useId();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -166,26 +166,40 @@ export default function LoginForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{urlErrorMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{urlErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<p
|
||||
id={formAlertId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
{(urlErrorMessage || formError) && (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-4 z-[10000] flex justify-center px-4 md:top-6">
|
||||
<div className="pointer-events-auto flex w-full max-w-[560px] flex-col gap-2">
|
||||
{urlErrorMessage ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={urlErrorMessage}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon
|
||||
onClose={() => {
|
||||
stripErrorQuery();
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
{formError ? (
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
size="s"
|
||||
title={formError}
|
||||
hasBodyText={false}
|
||||
hasLeadingIcon
|
||||
onClose={() => {
|
||||
setFormError("");
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sent ? (
|
||||
<form
|
||||
|
||||
@@ -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";
|
||||
@@ -149,6 +149,12 @@ function TopNavView({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard marketing / app top nav.
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559) — horizontal
|
||||
* padding, logo ~200px left, menu cluster centered in the bar (`left-1/2` + translate),
|
||||
* log in + create rule on the right. Breakpoints and MenuBar sizes unchanged from prior map.
|
||||
*/
|
||||
// Render standard variant (Header style)
|
||||
return (
|
||||
<>
|
||||
@@ -157,52 +163,79 @@ function TopNavView({
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||
/>
|
||||
<header
|
||||
className="sticky top-0 z-50 bg-[var(--color-surface-default-primary)] w-full border-b border-[var(--border-color-default-tertiary)]"
|
||||
className="relative z-50 w-full border-b border-[var(--border-color-default-tertiary)] bg-[var(--color-surface-default-primary)]"
|
||||
role="banner"
|
||||
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="relative flex w-full items-center
|
||||
px-[var(--spacing-scale-016)]
|
||||
py-[var(--spacing-scale-016)]
|
||||
sm:px-[var(--spacing-measures-spacing-016)]
|
||||
sm:py-[var(--spacing-scale-016)]
|
||||
lg:px-[var(--spacing-measures-spacing-64,64px)]
|
||||
lg:py-[var(--spacing-scale-016)]
|
||||
xl:py-[var(--spacing-scale-016)]
|
||||
min-h-[var(--spacing-scale-040)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
{/* Logo - Consistent left positioning across all breakpoints */}
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
palette={folderTop ? "inverse" : "default"}
|
||||
/>
|
||||
<div
|
||||
className="relative z-20 min-w-0 shrink-0 sm:w-[200px] sm:max-w-[200px] sm:shrink-0"
|
||||
data-topnav="logo"
|
||||
>
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
palette={folderTop ? "inverse" : "default"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links - Consistent center positioning */}
|
||||
<div className="flex items-center flex-1 justify-end sm:flex-none sm:justify-center">
|
||||
{/* XSmall breakpoint - Navigation items in Actions section (flex-1, justify-end) */}
|
||||
<div className="block sm:hidden" data-testid="nav-xs">
|
||||
{/* XSmall: nav + login in flow (flex-1) — same as before */}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center justify-end sm:hidden"
|
||||
data-topnav="nav-xs-flow"
|
||||
>
|
||||
<div className="block" data-testid="nav-xs">
|
||||
<MenuBar size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</MenuBar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 430-639px (sm: breakpoint): MenuBar X Small */}
|
||||
<div className="hidden sm:block md:hidden" data-testid="nav-sm">
|
||||
{/* sm+ — Figma: nav cluster centered in bar (not between logo and actions) */}
|
||||
<div
|
||||
className="pointer-events-none hidden sm:absolute sm:left-1/2 sm:top-1/2 sm:z-10 sm:flex sm:-translate-x-1/2 sm:-translate-y-1/2 sm:items-center sm:justify-center"
|
||||
data-topnav="nav-center"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto hidden sm:flex md:hidden"
|
||||
data-testid="nav-sm"
|
||||
>
|
||||
<MenuBar size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</MenuBar>
|
||||
</div>
|
||||
|
||||
{/* 640-1023px (md: breakpoint): MenuBar X Small (different from folderTop=true) */}
|
||||
<div className="hidden md:block lg:hidden" data-testid="nav-md">
|
||||
<div
|
||||
className="pointer-events-auto hidden md:flex lg:hidden"
|
||||
data-testid="nav-md"
|
||||
>
|
||||
<MenuBar size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
</MenuBar>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block xl:hidden" data-testid="nav-lg">
|
||||
<div
|
||||
className="pointer-events-auto hidden lg:flex xl:hidden"
|
||||
data-testid="nav-lg"
|
||||
>
|
||||
<MenuBar size="Large">{renderNavigationItems("large")}</MenuBar>
|
||||
</div>
|
||||
|
||||
<div className="hidden xl:block" data-testid="nav-xl">
|
||||
<div
|
||||
className="pointer-events-auto hidden xl:flex"
|
||||
data-testid="nav-xl"
|
||||
>
|
||||
<MenuBar size="X Large">
|
||||
{renderNavigationItems("xlarge")}
|
||||
</MenuBar>
|
||||
@@ -210,7 +243,7 @@ function TopNavView({
|
||||
</div>
|
||||
|
||||
{/* Authentication Elements - Consistent right alignment across all breakpoints */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<div className="relative z-20 ml-auto flex shrink-0 items-center">
|
||||
{/* XSmall breakpoint - Only Create Rule button */}
|
||||
<div className="block sm:hidden shrink-0" data-testid="auth-xs">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
|
||||
@@ -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";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthModalProvider } from "./contexts/AuthModalContext";
|
||||
import { MessagesProvider } from "./contexts/MessagesContext";
|
||||
@@ -37,6 +37,12 @@ const spaceGrotesk = Space_Grotesk({
|
||||
fallback: ["system-ui", "arial"],
|
||||
});
|
||||
|
||||
/** Viewport and favicon use the Metadata / Viewport APIs; avoid a manual `<head>` with a second viewport `meta` (duplicates Next’s head injection). */
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CommunityRule - Build operating manuals for successful communities",
|
||||
description:
|
||||
@@ -51,6 +57,9 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL("https://communityrule.com"),
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.ico", sizes: "16x16", type: "image/x-icon" }],
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
@@ -87,16 +96,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<html lang="en" className="font-sans">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon.ico"
|
||||
type="image/x-icon"
|
||||
sizes="16x16"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,132 @@
|
||||
import Link from "next/link";
|
||||
import messages from "../messages/en/index";
|
||||
import { getTranslation } from "../lib/i18n/getTranslation";
|
||||
import { getGovernanceTemplateCatalogEntry } from "../lib/templates/governanceTemplateCatalog";
|
||||
import Icon from "./components/asset/Icon";
|
||||
import Button from "./components/buttons/Button";
|
||||
import HeroDecor from "./components/sections/HeroBanner/HeroDecor";
|
||||
|
||||
const NOT_FOUND_TEMPLATE_SLUGS = [
|
||||
"consensus",
|
||||
"do-ocracy",
|
||||
"devolution",
|
||||
"quadratic-governance",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Figma: 404 page frame 22078-808557; 480px lockup 22078-808903; title + CTA group 22078-808908
|
||||
* (filled / Go home left, outline / Browse right, 16px between).
|
||||
* Same [HeroDecor](app/components/sections/HeroBanner/HeroDecor.tsx) SVG as home; 404 places it only behind the title stack.
|
||||
* Shell: [app/layout.tsx](app/layout.tsx) `TopNav` only — no site footer.
|
||||
* Template chip row: Figma 22078-809968 — one row, 16px gaps, 20px to hint (no inner scroll; page handles overflow if needed).
|
||||
* Hero pattern: behind the 404 + bar + h1; wide SVG is painted with overflow-x-clip on `main` so it does not widen the scrollport.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
const t = (key: string) => getTranslation(messages, key);
|
||||
|
||||
const templateEntries = NOT_FOUND_TEMPLATE_SLUGS.map((slug) =>
|
||||
getGovernanceTemplateCatalogEntry(slug),
|
||||
).filter(
|
||||
(e): e is NonNullable<typeof e> => e != null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F4F3F1] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-[var(--color-content-default-primary)] mb-4">
|
||||
404
|
||||
</h1>
|
||||
<p className="text-[var(--color-content-default-secondary)]">
|
||||
Page Not Found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<main
|
||||
className="relative flex min-h-0 w-full min-w-0 max-w-full flex-1 flex-col overflow-x-clip bg-[var(--color-surface-default-primary)]"
|
||||
aria-labelledby="not-found-heading"
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 w-full min-w-0 max-w-full flex-1 flex-col overflow-x-clip px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]"
|
||||
>
|
||||
<div className="relative z-10 flex min-h-0 w-full max-w-full flex-1 flex-col items-center justify-center py-[var(--spacing-scale-040)] sm:py-[var(--spacing-scale-048)]">
|
||||
{/*
|
||||
Vertical rhythm: 22078-808903 + 22078-808908 — 404→bar 8px, bar→h1 32px, h1→body 16px,
|
||||
body→CTAs 48px, CTA→templates 40px (lockup flex gap), template→hint 20px
|
||||
*/}
|
||||
<div className="mx-auto flex w-full max-w-[480px] flex-col items-center gap-[var(--spacing-scale-040)] text-center">
|
||||
<div className="flex w-full min-w-0 flex-col items-center">
|
||||
<div className="relative flex w-full flex-col items-center">
|
||||
<HeroDecor
|
||||
className="pointer-events-none absolute left-1/2 top-[40%] -z-10 h-[645px] w-[1540px] max-w-none
|
||||
-translate-x-1/2 -translate-y-1/2
|
||||
scale-[0.41] sm:scale-[0.45] md:scale-[0.47] lg:scale-[0.5] xl:scale-[0.53]"
|
||||
/>
|
||||
<p
|
||||
className="w-full text-center font-bricolage-grotesque font-extrabold leading-none tracking-[-0.04em] text-[clamp(5.5rem,16vw,13.75rem)] text-[var(--color-content-default-brand-primary)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{t("pages.notFoundPage.codeTitle")}
|
||||
</p>
|
||||
<div
|
||||
className="mt-[var(--spacing-scale-008)] h-1.5 w-[120px] shrink-0 rounded-full bg-[var(--color-yellow-yellow200)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1
|
||||
id="not-found-heading"
|
||||
className="mt-[var(--spacing-scale-032)] max-w-full text-center font-bricolage-grotesque text-[2.5rem] font-medium leading-[1.1] text-[var(--color-content-default-primary)] min-[400px]:text-[44px] min-[400px]:leading-[1.1]"
|
||||
>
|
||||
{t("pages.notFoundPage.heading")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-[var(--spacing-scale-016)] w-full max-w-[443px] text-center font-inter text-lg font-normal leading-[1.3] text-[var(--color-content-default-secondary)]">
|
||||
{t("pages.notFoundPage.description")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
dir="ltr"
|
||||
className="mt-[var(--spacing-scale-048)] flex w-full min-w-0 flex-col items-center justify-center gap-[var(--spacing-scale-016)] min-[400px]:flex-nowrap min-[400px]:flex-row min-[400px]:items-center min-[400px]:justify-center"
|
||||
>
|
||||
<Button
|
||||
href="/"
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
className="inline-flex w-max max-w-full shrink-0 items-center justify-center gap-[var(--spacing-scale-010)]"
|
||||
>
|
||||
<Icon
|
||||
name="arrow_back"
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
{t("pages.notFoundPage.goHomeCta")}
|
||||
</Button>
|
||||
<Button
|
||||
href="/templates"
|
||||
size="large"
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
className="inline-flex w-max max-w-full shrink-0 items-center justify-center"
|
||||
>
|
||||
{t("pages.notFoundPage.browseTemplatesCta")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templateEntries.length > 0 ? (
|
||||
<div className="flex w-full min-w-0 max-w-[36rem] flex-col items-center gap-[var(--spacing-scale-020)] self-stretch">
|
||||
<div
|
||||
className="flex w-full min-w-0 max-md:flex-wrap md:flex-nowrap items-center justify-center gap-x-[var(--spacing-scale-016)] gap-y-[var(--spacing-scale-012)]"
|
||||
role="list"
|
||||
>
|
||||
{templateEntries.map((entry) => (
|
||||
<Link
|
||||
key={entry.slug}
|
||||
href={`/create/review-template/${entry.slug}`}
|
||||
role="listitem"
|
||||
className={`${entry.backgroundColor} inline-flex h-[37px] shrink-0 items-center justify-center rounded-full px-[20px] py-0 text-center font-bricolage-grotesque text-sm font-extrabold leading-[21px] text-[var(--color-content-invert-primary)] no-underline transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]`}
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="w-full text-center font-inter text-[13px] font-normal leading-[1.2] text-[var(--color-gray-500)]">
|
||||
{t("pages.notFoundPage.templateHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Figma → component registry
|
||||
|
||||
Quick map from the Figma file **Community Rule System** (`agv0VBLiBlcnSAaiAORgPR`) to this repo’s [`app/components/`](/app/components/). Figma uses eleven top-level “❖” areas; `app/components` adds a few app-only buckets (not 1:1 with Figma pages).
|
||||
|
||||
| Figma (page) | Code | Notes |
|
||||
| --- | --- | --- |
|
||||
| [Utility](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20515-15809) | `utility/` | Create chrome, modals header/footer, tag, scroll, sidebar, dividers, etc. |
|
||||
| [Asset](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=1240-9089) | `asset/` + `icons/` | Icons, logos; Avatar component currently under `icons/Avatar.tsx` |
|
||||
| [Button](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=497-3016) | `buttons/` | Figma `Button/`; code uses plural folder name. |
|
||||
| [Card](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17865-24349) | `cards/` | Step / rule / icon / selection style cards. |
|
||||
| [Control](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-58611) | `controls/` | Inputs, toggles, select, switch, upload, etc. |
|
||||
| [Layout](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21836-20542) | `layout/` | List / list entry / list edit. |
|
||||
| [Modals](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-47704) | `modals/` | Alert, create, dialog, login, tooltip, context menu, … |
|
||||
| [Navigation](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-69518) | `navigation/` | Top nav, footer, menu bar, link. |
|
||||
| [Progress](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21163-24443) | `progress/` | Stepper, proportion bar. |
|
||||
| [Sections](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17865-24546) | `sections/` | Page-level / marketing-style compositions. |
|
||||
| [Type](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21473-29498) | `type/` | Some “section header” / lockup patterns also live in `sections/`; check both. |
|
||||
| — | `content/` | Not a Figma DS page; app content shells / thumbnails. |
|
||||
| — | `localization/` | Not a Figma DS page; i18n UI. |
|
||||
|
||||
*Update this when you add a new top-level `app/components/*` package or a new Figma canvas.*
|
||||
@@ -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:**
|
||||
|
||||
- [x] New email is confirmed **only** after the user completes the link sent to that inbox; then `User.email` updates.
|
||||
- [x] Duplicate-email and rate-limit cases are handled with accessible errors (`CR-84` shape).
|
||||
- [x] Profile reflects the new address after success.
|
||||
- [x] 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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ Mirrors [CONTRIBUTING.md](../CONTRIBUTING.md) **API routes** table (including `/
|
||||
| GET | `/api/auth/session` | Current user or null |
|
||||
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
|
||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
||||
| POST | `/api/user/email-change/request` | Authenticated: send verify link to new email ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)) |
|
||||
| GET | `/api/user/email-change/verify` | Validate email-change token; update `User.email`; session policy; redirect |
|
||||
| POST | `/api/auth/logout` | Clear session |
|
||||
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
||||
| GET / POST | `/api/rules` | List or publish rules |
|
||||
@@ -40,7 +42,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,13 +80,14 @@ Plain-English entities (names can evolve):
|
||||
| **User** | Identified by email after **magic link verification** (primary v1 path). An optional **display name** (or preferred name) could be added later for richer greetings; it does **not** block the profile page—no schema commitment in this roadmap pass alone. |
|
||||
| **Session** | **Custom v1:** HttpOnly cookie; opaque token; **hash** stored in DB ([`lib/server/session.ts`](lib/server/session.ts)). Not NextAuth/Lucia. |
|
||||
| **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. |
|
||||
| **EmailChangeToken** | One pending row per user (`userId` unique): hashed token + `newEmail` until verify ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)); separate from `MagicLinkToken`. |
|
||||
| **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. |
|
||||
| **PublishedRule** | Saved rule after publish (title, summary, document JSON). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. |
|
||||
| **RuleTemplate** | Curated templates (slug, category, ordering, `body` JSON). **v1 API** lists rows for cards / create entry; **not** yet a recommendation engine (see below). |
|
||||
|
||||
**RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 7–8 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no)** (**Done** — committed JSON + seed; no runtime `.xlsx`). Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync.
|
||||
|
||||
**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
|
||||
**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Exception — verified email change ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)):** after `User.email` updates via `/api/user/email-change/verify`, all other sessions for that user are revoked; the current browser keeps its session when it opened the verify link with a valid cookie, otherwise a **new** session is issued on the device that completed verify. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on other privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
|
||||
|
||||
**RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them.
|
||||
|
||||
@@ -96,7 +99,7 @@ Align JSON shapes with `app/(app)/create/types.ts` as it matures.
|
||||
|
||||
- **Decision:** **Custom** database-backed sessions + **email magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest.
|
||||
- **Rate limiting (magic-link request):** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure.
|
||||
- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts).
|
||||
- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); **email change ([CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session))** revokes other sessions as documented in [`lib/server/session.ts`](../../lib/server/session.ts) §4; lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts).
|
||||
- Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead.
|
||||
|
||||
---
|
||||
@@ -109,7 +112,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 +232,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).
|
||||
|
||||
|
||||
@@ -70,6 +70,39 @@ export async function logout(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** CR-103: send verify link to `newEmail` for the signed-in user. */
|
||||
export async function requestEmailChange(
|
||||
newEmail: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
|
||||
const res = await fetch("/api/user/email-change/request", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ newEmail }),
|
||||
});
|
||||
const data: unknown = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
let retryAfterMs: number | undefined;
|
||||
if (
|
||||
res.status === 429 &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"details" in data
|
||||
) {
|
||||
const d = (data as { details?: { retryAfterMs?: unknown } }).details;
|
||||
if (d && typeof d.retryAfterMs === "number") {
|
||||
retryAfterMs = d.retryAfterMs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: readApiErrorMessage(data),
|
||||
retryAfterMs,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||
const res = await fetch("/api/drafts/me", { credentials: "include" });
|
||||
if (res.status === 401) return null;
|
||||
@@ -207,3 +240,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";
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ export type AlertStatusValue = (typeof ALERT_STATUS_OPTIONS)[number];
|
||||
export const ALERT_TYPE_OPTIONS = ["toast", "banner"] as const;
|
||||
export type AlertTypeValue = (typeof ALERT_TYPE_OPTIONS)[number];
|
||||
|
||||
export const ALERT_SIZE_OPTIONS = ["s", "m"] as const;
|
||||
export type AlertSizeValue = (typeof ALERT_SIZE_OPTIONS)[number];
|
||||
|
||||
export const TOOLTIP_POSITION_OPTIONS = ["top", "bottom"] as const;
|
||||
export type TooltipPositionValue = (typeof TOOLTIP_POSITION_OPTIONS)[number];
|
||||
|
||||
|
||||
@@ -25,3 +25,30 @@ export async function sendMagicLinkEmail(
|
||||
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
|
||||
});
|
||||
}
|
||||
|
||||
/** CR-103: confirm control of the new inbox before `User.email` is updated. */
|
||||
export async function sendEmailChangeEmail(
|
||||
to: string,
|
||||
verifyUrl: string,
|
||||
): Promise<void> {
|
||||
const url = process.env.SMTP_URL;
|
||||
|
||||
if (!url) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`);
|
||||
return;
|
||||
}
|
||||
throw new Error("SMTP_URL is not configured");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(url);
|
||||
const from = process.env.SMTP_FROM ?? "noreply@localhost";
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: "Confirm your new Community Rule email",
|
||||
text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -26,6 +26,13 @@ import { hashSessionToken, newSessionToken } from "./hash";
|
||||
* a global sweep so rows from users who never return are still bounded
|
||||
* over months. Cleanup is best-effort: a prune failure never fails the
|
||||
* sign-in itself.
|
||||
* 4. **Email change (CR-103).** After a verified email update, revoke every
|
||||
* `Session` for that `userId` **except** the current browser's session when
|
||||
* the verify link is opened with a valid `cr_session` cookie for the same
|
||||
* user. If there is no such session (e.g. user opened the link on another
|
||||
* device), all sessions are removed and the verify handler issues a new
|
||||
* session cookie so that device is signed in. Other devices must sign in
|
||||
* again.
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE_NAME = "cr_session";
|
||||
@@ -56,6 +63,36 @@ export async function getSessionUser(): Promise<User | null> {
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* When completing email change (CR-103), returns the current request's session
|
||||
* `tokenHash` if the cookie maps to a non-expired session for `userId`;
|
||||
* otherwise `null` (caller will drop all sessions and create a new one).
|
||||
*/
|
||||
export async function getValidatedSessionTokenHashForUser(
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
let pepper: string;
|
||||
try {
|
||||
pepper = getSessionPepper();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token, pepper);
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date() || session.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired `Session` rows. Scoped to a single user when `userId` is
|
||||
* provided (uses the `@@index([userId])` lookup); otherwise sweeps the
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** POST `/api/user/email-change/request` body (CR-103). */
|
||||
export const emailChangeRequestBodySchema = z.object({
|
||||
newEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.transform((s) => s.toLowerCase())
|
||||
.pipe(z.string().email({ message: "Valid email required" })),
|
||||
});
|
||||
|
||||
export type EmailChangeRequestBody = z.infer<
|
||||
typeof emailChangeRequestBodySchema
|
||||
>;
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import learn from "./pages/learn.json";
|
||||
import monitor from "./pages/monitor.json";
|
||||
import login from "./pages/login.json";
|
||||
import profile from "./pages/profile.json";
|
||||
import notFoundPage from "./pages/notFoundPage.json";
|
||||
import navigation from "./navigation.json";
|
||||
import metadata from "./metadata.json";
|
||||
|
||||
@@ -72,6 +73,7 @@ export default {
|
||||
monitor,
|
||||
login,
|
||||
profile,
|
||||
notFoundPage,
|
||||
},
|
||||
create: {
|
||||
community: {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"_comment": "Global 404 — Figma 22078-808557",
|
||||
"codeTitle": "404",
|
||||
"heading": "Page not found",
|
||||
"description": "Looks like this page didn't make it to a consensus. It may have moved, been removed, or never existed.",
|
||||
"goHomeCta": "Go back home",
|
||||
"browseTemplatesCta": "Browse templates",
|
||||
"templateHint": "Maybe one of these templates can help?"
|
||||
}
|
||||
@@ -1,5 +1,64 @@
|
||||
{
|
||||
"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",
|
||||
"emailChangeModalTitle": "Change your account email",
|
||||
"emailChangeModalDescription": "We will send a confirmation link to the new address. Your email does not change until you open that link.",
|
||||
"emailChangeNewEmailLabel": "New email",
|
||||
"emailChangeNewEmailPlaceholder": "you@example.com",
|
||||
"emailChangeCancel": "Cancel",
|
||||
"emailChangeSubmit": "Send confirmation link",
|
||||
"emailChangeConfirmationClose": "Close",
|
||||
"emailChangeSuccess": "Your account email was updated.",
|
||||
"emailChangeVerifyExpired": "That confirmation link expired or was already used. Start a new email change from your profile.",
|
||||
"emailChangeVerifyInvalid": "That confirmation link is not valid.",
|
||||
"emailChangeVerifyTaken": "That address was claimed by another account while you were confirming. Try a different email.",
|
||||
"emailChangeRateLimited": "Too many requests. Try again in about {{seconds}} seconds.",
|
||||
"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.",
|
||||
"rulesLoadBannerTitle": "Could not load your CommunityRules",
|
||||
"rulesLoadBannerDescription": "Check your connection and refresh the page.",
|
||||
"notFound": "Not found",
|
||||
"forbidden": "You do not have permission for this action."
|
||||
}
|
||||
|
||||