adilallo/feature/BackendImplementation3 #45
+6
-1
@@ -6,7 +6,8 @@
|
||||
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
|
||||
3. Install dependencies: `npm ci`
|
||||
4. Apply migrations: `npx prisma migrate dev`
|
||||
5. Run the app: `npm run dev`
|
||||
5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created.
|
||||
6. Run the app: `npm run dev`
|
||||
|
||||
Use `npx prisma studio` to inspect the database.
|
||||
|
||||
@@ -41,6 +42,10 @@ Use `npx prisma studio` to inspect the database.
|
||||
|
||||
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish.
|
||||
|
||||
### Create flow URLs (custom wizard)
|
||||
|
||||
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
|
||||
|
||||
## Frontend / tests
|
||||
|
||||
See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md).
|
||||
|
||||
@@ -904,7 +904,7 @@ export default function ComponentsPreview() {
|
||||
className="w-[525px]"
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath("assets/Icon_Sociocracy.svg")}
|
||||
src={getAssetPath("assets/template-mark/consensus-clusters.svg")}
|
||||
alt="Sociocracy"
|
||||
width={103}
|
||||
height={103}
|
||||
@@ -921,7 +921,7 @@ export default function ComponentsPreview() {
|
||||
className="w-[525px]"
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath("assets/Icon_Consensus.svg")}
|
||||
src={getAssetPath("assets/template-mark/consensus.svg")}
|
||||
alt="Consensus"
|
||||
width={103}
|
||||
height={103}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
|
||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
|
||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
|
||||
|
||||
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
|
||||
loading: () => (
|
||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||
),
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Server-loaded “Popular templates” row so the first paint has card data without a client fetch.
|
||||
*/
|
||||
export async function MarketingRuleStackSection() {
|
||||
const rows = await listRuleTemplatesFromDb();
|
||||
const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback(
|
||||
rows,
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
);
|
||||
return <RuleStack initialGridEntries={initialGridEntries} />;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense } from "react";
|
||||
import messages from "../../messages/en/index";
|
||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||
import HeroBanner from "../components/sections/HeroBanner";
|
||||
import AskOrganizer from "../components/sections/AskOrganizer";
|
||||
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
|
||||
|
||||
// Code split below-the-fold components to reduce initial bundle size
|
||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||
@@ -22,13 +24,6 @@ const NumberedCards = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
|
||||
loading: () => (
|
||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||
),
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
const FeatureGrid = dynamic(
|
||||
() => import("../components/sections/FeatureGrid"),
|
||||
{
|
||||
@@ -98,7 +93,13 @@ export default function Page() {
|
||||
<HeroBanner {...heroBannerData} />
|
||||
<LogoWall />
|
||||
<NumberedCards {...numberedCardsData} />
|
||||
<RuleStack />
|
||||
<Suspense
|
||||
fallback={
|
||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||
}
|
||||
>
|
||||
<MarketingRuleStackSection />
|
||||
</Suspense>
|
||||
<FeatureGrid {...featureGridData} />
|
||||
<QuoteBlock />
|
||||
<AskOrganizer {...askOrganizerData} />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
export interface TemplatesPageClientProps {
|
||||
initialGridEntries: TemplateGridCardEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
|
||||
* `initialGridEntries` is computed on the server to avoid a client-side loading flash.
|
||||
*/
|
||||
export default function TemplatesPageClient({
|
||||
initialGridEntries,
|
||||
}: TemplatesPageClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.templates");
|
||||
|
||||
return (
|
||||
<div className="w-full bg-black text-[var(--color-content-default-primary,white)]">
|
||||
<div
|
||||
className="
|
||||
mx-auto w-full max-w-[1280px]
|
||||
px-[20px] py-[32px]
|
||||
min-[640px]:px-[32px] min-[640px]:py-[40px]
|
||||
min-[1024px]:px-[64px] min-[1024px]:py-[48px]
|
||||
"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2 py-3">
|
||||
<HeaderLockup
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 min-[1024px]:mt-8">
|
||||
<GovernanceTemplateGrid
|
||||
entries={initialGridEntries}
|
||||
onTemplateClick={(slug) => {
|
||||
router.push(
|
||||
`/create/review-template/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
|
||||
import TemplatesPageClient from "./TemplatesPageClient";
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const rows = await listRuleTemplatesFromDb();
|
||||
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
||||
return <TemplatesPageClient initialGridEntries={initialGridEntries} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { dbUnavailable } from "../../../lib/server/responses";
|
||||
|
||||
/**
|
||||
@@ -11,18 +11,7 @@ export async function GET() {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const templates = await prisma.ruleTemplate.findMany({
|
||||
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
featured: true,
|
||||
},
|
||||
});
|
||||
const templates = await listRuleTemplatesFromDb();
|
||||
|
||||
return NextResponse.json({ templates });
|
||||
}
|
||||
|
||||
@@ -39,13 +39,19 @@ export function RuleCardView({
|
||||
className?.includes("pb-[");
|
||||
const hasResponsiveGap = className?.includes("gap-[");
|
||||
|
||||
const cardPadding = hasResponsivePadding
|
||||
? "" // If className has responsive padding, don't add size-based padding
|
||||
: isLarge || isSmall
|
||||
? "p-[24px]"
|
||||
: isMedium
|
||||
? "p-[16px]"
|
||||
: "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding
|
||||
// Expanded + size: uniform padding on all sides (overrides conflicting utilities from `className`).
|
||||
const cardPadding =
|
||||
expanded && isLarge
|
||||
? "!p-[24px]"
|
||||
: expanded && isMedium
|
||||
? "!p-[16px]"
|
||||
: hasResponsivePadding
|
||||
? ""
|
||||
: isLarge || isSmall
|
||||
? "p-[24px]"
|
||||
: isMedium
|
||||
? "p-[16px]"
|
||||
: "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding
|
||||
const cardGap = expanded
|
||||
? "gap-[16px]"
|
||||
: hasResponsiveGap
|
||||
@@ -184,7 +190,7 @@ export function RuleCardView({
|
||||
{/* Outermost container with bottom border - taller to match Figma */}
|
||||
<div
|
||||
className={`
|
||||
border-b border-black border-solid flex items-center relative shrink-0 w-full
|
||||
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]
|
||||
@@ -196,8 +202,8 @@ export function RuleCardView({
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center shrink-0
|
||||
max-[639px]:w-[72px] max-[639px]:h-[72px] max-[639px]:border-r max-[639px]:border-black max-[639px]:border-solid
|
||||
min-[640px]:max-[1023px]:w-[80px] min-[640px]:max-[1023px]:h-[80px] min-[640px]:max-[1023px]:border-r min-[640px]:max-[1023px]:border-black min-[640px]:max-[1023px]:border-solid
|
||||
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]
|
||||
`}
|
||||
@@ -218,7 +224,7 @@ export function RuleCardView({
|
||||
className={`
|
||||
flex-1 min-w-0 h-full flex
|
||||
max-[1023px]:border-0
|
||||
min-[1024px]:border-l min-[1024px]:border-black min-[1024px]:border-solid
|
||||
min-[1024px]:border-l min-[1024px]:border-solid min-[1024px]:border-[var(--color-content-invert-primary)]
|
||||
`}
|
||||
>
|
||||
{/* Inner container for header text with padding */}
|
||||
@@ -232,7 +238,7 @@ export function RuleCardView({
|
||||
`}
|
||||
>
|
||||
<h3
|
||||
className={`${titleClass} text-black overflow-hidden text-ellipsis w-full`}
|
||||
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
@@ -245,7 +251,11 @@ export function RuleCardView({
|
||||
<>
|
||||
{/* Categories Section - Using MultiSelect */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="flex flex-col gap-[16px] items-start px-[12px] relative shrink-0 w-full">
|
||||
<div
|
||||
className={`flex flex-col gap-[16px] items-start relative shrink-0 w-full ${
|
||||
expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"
|
||||
}`}
|
||||
>
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<MultiSelect
|
||||
key={categoryIndex}
|
||||
@@ -279,8 +289,12 @@ export function RuleCardView({
|
||||
)}
|
||||
{/* Footer: Description */}
|
||||
{description && (
|
||||
<div className="border-t border-black border-solid pt-[16px] relative shrink-0 w-full">
|
||||
<p className={`${descriptionClass} text-black`}>{description}</p>
|
||||
<div className="border-t border-solid border-[var(--color-content-invert-primary)] pt-[16px] relative shrink-0 w-full">
|
||||
<p
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -288,7 +302,9 @@ export function RuleCardView({
|
||||
/* Collapsed State: Description */
|
||||
description && (
|
||||
<div className="flex items-center justify-center relative shrink-0 w-full">
|
||||
<p className={`${descriptionClass} text-black flex-1`}>
|
||||
<p
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)] flex-1`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import RuleCard from "../RuleCard";
|
||||
import type { RuleCardProps } from "../RuleCard/RuleCard.types";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
|
||||
import {
|
||||
templateBodyToCategories,
|
||||
templateSummaryFromBody,
|
||||
} from "../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
getGovernanceTemplateCatalogEntry,
|
||||
} from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
|
||||
|
||||
export interface TemplateReviewCardProps {
|
||||
template: RuleTemplateDto;
|
||||
/** Merged onto RuleCard `className` (e.g. final-review desktop vs mobile radius/padding). */
|
||||
ruleCardClassName?: string;
|
||||
/** RuleCard size; create-flow passes `L` at/above `md`, `M` below (640px). */
|
||||
size?: RuleCardProps["size"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
|
||||
* tag rows from API `body`.
|
||||
*/
|
||||
export function TemplateReviewCard({
|
||||
template,
|
||||
ruleCardClassName = "",
|
||||
size = "L",
|
||||
}: TemplateReviewCardProps) {
|
||||
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
|
||||
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
||||
const categories = templateBodyToCategories(template.body);
|
||||
const summary = templateSummaryFromBody(template.description, template.body);
|
||||
|
||||
return (
|
||||
<RuleCard
|
||||
title={template.title}
|
||||
description={summary}
|
||||
expanded
|
||||
size={size}
|
||||
categories={categories}
|
||||
backgroundColor={pres.backgroundColor}
|
||||
className={ruleCardClassName}
|
||||
onClick={() => {}}
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(pres.iconPath)}
|
||||
alt={template.title}
|
||||
width={90}
|
||||
height={90}
|
||||
className="
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
||||
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
||||
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
||||
"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TemplateReviewCard } from "./TemplateReviewCard";
|
||||
export type { TemplateReviewCardProps } from "./TemplateReviewCard";
|
||||
@@ -49,10 +49,10 @@ export interface TextAreaProps extends Omit<
|
||||
className?: string;
|
||||
rows?: number;
|
||||
/**
|
||||
* Whether to show hint text below textarea (Figma prop).
|
||||
* Hint below the textarea: `true` shows placeholder copy, or pass a string (e.g. character count).
|
||||
* @default false
|
||||
*/
|
||||
textHint?: boolean;
|
||||
textHint?: boolean | string;
|
||||
/**
|
||||
* Whether to show form header (label and help icon) above textarea (Figma prop).
|
||||
* @default true
|
||||
@@ -92,7 +92,7 @@ export interface TextAreaViewProps {
|
||||
handleChange: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleFocus: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
handleBlur: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
textHint?: boolean;
|
||||
textHint?: boolean | string;
|
||||
formHeader?: boolean;
|
||||
showHelpIcon?: boolean;
|
||||
appearance?: "default" | "embedded";
|
||||
|
||||
@@ -78,13 +78,13 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{textHint && (
|
||||
{textHint ? (
|
||||
<div className="flex items-start relative shrink-0 w-full">
|
||||
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
|
||||
Hint text here
|
||||
{typeof textHint === "string" ? textHint : "Hint text here"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,12 +5,20 @@ import UploadView from "./Upload.view";
|
||||
import type { UploadProps } from "./Upload.types";
|
||||
|
||||
const UploadContainer = memo<UploadProps>(
|
||||
({ active = true, label, showHelpIcon = true, onClick, className = "" }) => {
|
||||
({
|
||||
active = true,
|
||||
label,
|
||||
showHelpIcon = true,
|
||||
hintText = "Add image from your device",
|
||||
onClick,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<UploadView
|
||||
active={active}
|
||||
label={label}
|
||||
showHelpIcon={showHelpIcon}
|
||||
hintText={hintText}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface UploadProps {
|
||||
* @default true
|
||||
*/
|
||||
showHelpIcon?: boolean;
|
||||
/**
|
||||
* Copy beside the upload button (Figma Flow — Upload `20094:41524`).
|
||||
* @default "Add image from your device"
|
||||
*/
|
||||
hintText?: string;
|
||||
/**
|
||||
* Callback when upload button is clicked
|
||||
*/
|
||||
@@ -29,6 +34,7 @@ export interface UploadViewProps {
|
||||
active: boolean;
|
||||
label?: string;
|
||||
showHelpIcon: boolean;
|
||||
hintText: string;
|
||||
onClick?: () => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ function UploadView({
|
||||
active = true,
|
||||
label,
|
||||
showHelpIcon = true,
|
||||
hintText,
|
||||
onClick,
|
||||
className = "",
|
||||
}: UploadViewProps) {
|
||||
@@ -54,7 +55,7 @@ function UploadView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip p-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
|
||||
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
|
||||
aria-label="Upload"
|
||||
>
|
||||
{/* Upload icon */}
|
||||
@@ -105,9 +106,7 @@ function UploadView({
|
||||
<div
|
||||
className={`flex flex-[1_0_0] flex-col font-inter font-normal h-[32px] justify-center leading-[0] min-h-px min-w-px relative text-[length:var(--sizing-350,14px)] ${descriptionTextColor}`}
|
||||
>
|
||||
<p className="leading-[20px] whitespace-pre-wrap">
|
||||
Add images, PDFs, and other files to the policy
|
||||
</p>
|
||||
<p className="leading-[20px] whitespace-pre-wrap">{hintText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import TextInput from "../../controls/TextInput";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import { requestMagicLink } from "../../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||
import { setTransferPendingFlag } from "../../../create/anonymousDraftStorage";
|
||||
import { setTransferPendingFlag } from "../../../create/utils/anonymousDraftStorage";
|
||||
|
||||
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
|
||||
function MailIconInline() {
|
||||
|
||||
@@ -197,7 +197,7 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
size={buttonSize}
|
||||
buttonType={buttonType}
|
||||
palette={palette}
|
||||
onClick={() => router.push("/create/informational")}
|
||||
onClick={() => router.push("/create")}
|
||||
ariaLabel={t("ariaLabels.createNewRule")}
|
||||
>
|
||||
{renderAvatarGroup(containerSize, avatarSize)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { normalizeProportionBarVariant } from "../../../../lib/propNormalization";
|
||||
import { ProportionBarView } from "./ProportionBar.view";
|
||||
import type { ProportionBarProps } from "./ProportionBar.types";
|
||||
|
||||
const ProportionBarContainer = memo<ProportionBarProps>(
|
||||
({ progress = "3-2", className = "" }) => {
|
||||
({ progress = "3-2", className = "", variant: variantProp }) => {
|
||||
const variant = normalizeProportionBarVariant(variantProp);
|
||||
const barClasses = `h-[8px] relative w-full`;
|
||||
|
||||
return (
|
||||
@@ -13,6 +15,7 @@ const ProportionBarContainer = memo<ProportionBarProps>(
|
||||
progress={progress}
|
||||
className={className}
|
||||
barClasses={barClasses}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProportionBarVariantValue } from "../../../../lib/propNormalization";
|
||||
|
||||
export type ProportionBarState =
|
||||
| "1-0"
|
||||
| "1-1"
|
||||
@@ -12,13 +14,20 @@ export type ProportionBarState =
|
||||
| "3-1"
|
||||
| "3-2";
|
||||
|
||||
export type ProportionBarVariant = ProportionBarVariantValue;
|
||||
|
||||
export interface ProportionBarProps {
|
||||
progress?: ProportionBarState;
|
||||
className?: string;
|
||||
/**
|
||||
* `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment.
|
||||
*/
|
||||
variant?: ProportionBarVariant;
|
||||
}
|
||||
|
||||
export interface ProportionBarViewProps {
|
||||
progress: ProportionBarState;
|
||||
className: string;
|
||||
barClasses: string;
|
||||
variant: "default" | "segmented";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ export function ProportionBarView({
|
||||
progress,
|
||||
className,
|
||||
barClasses,
|
||||
variant,
|
||||
}: ProportionBarViewProps) {
|
||||
// Proportion bar type
|
||||
const [fullSegments, partialSegment] = progress.split("-").map(Number);
|
||||
const segmented = variant === "segmented";
|
||||
// Calculate total progress:
|
||||
// - For 1-X: first section is (X+1)/6 filled
|
||||
// - For 2-X: first section full, second section X/3 filled
|
||||
@@ -58,7 +60,11 @@ export function ProportionBarView({
|
||||
<div className="flex-1 h-full relative">
|
||||
{fullSegments === 1 ? (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
|
||||
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)] ${
|
||||
segmented && partialSegment < 5
|
||||
? "rounded-r-[var(--radius-full)]"
|
||||
: ""
|
||||
}`.trim()}
|
||||
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
|
||||
/>
|
||||
) : fullSegments >= 2 ? (
|
||||
@@ -70,7 +76,11 @@ export function ProportionBarView({
|
||||
{fullSegments === 2 ? (
|
||||
partialSegment > 0 ? (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
|
||||
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
|
||||
segmented
|
||||
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
|
||||
: ""
|
||||
}`.trim()}
|
||||
style={{ width: `${(partialSegment / 3) * 100}%` }}
|
||||
/>
|
||||
) : null
|
||||
@@ -84,8 +94,12 @@ export function ProportionBarView({
|
||||
{fullSegments === 3 && partialSegment > 0 ? (
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
|
||||
partialSegment >= 3 ? "rounded-r-[var(--radius-full)]" : ""
|
||||
}`}
|
||||
segmented
|
||||
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
|
||||
: partialSegment >= 3
|
||||
? "rounded-r-[var(--radius-full)]"
|
||||
: ""
|
||||
}`.trim()}
|
||||
style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||
import RuleCard from "../../cards/RuleCard";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
export interface GovernanceTemplateGridProps {
|
||||
entries: GovernanceTemplateCatalogEntry[];
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
}
|
||||
|
||||
export function GovernanceTemplateGrid({
|
||||
entries,
|
||||
onTemplateClick,
|
||||
}: GovernanceTemplateGridProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const isMax639 = useMediaQuery("(max-width: 639px)");
|
||||
const isMin640Max1023 = useMediaQuery(
|
||||
"(min-width: 640px) and (max-width: 1023px)",
|
||||
);
|
||||
const isMin1024Max1439 = useMediaQuery(
|
||||
"(min-width: 1024px) and (max-width: 1439px)",
|
||||
);
|
||||
const isMin1440 = useMediaQuery("(min-width: 1440px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- breakpoint sizing after mount (matches SSR default "M")
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const cardSize = isMounted
|
||||
? isMax639
|
||||
? "XS"
|
||||
: isMin640Max1023
|
||||
? "S"
|
||||
: isMin1024Max1439
|
||||
? "M"
|
||||
: isMin1440
|
||||
? "L"
|
||||
: "M"
|
||||
: "M";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-[18px]
|
||||
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
|
||||
min-[1024px]:gap-[24px]
|
||||
`}
|
||||
>
|
||||
{entries.map((card) => (
|
||||
<RuleCard
|
||||
key={card.slug}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
size={cardSize}
|
||||
className={`
|
||||
select-none
|
||||
cursor-pointer
|
||||
max-[639px]:rounded-[var(--measures-radius-200,8px)]
|
||||
min-[640px]:max-[1023px]:rounded-[var(--measures-radius-300,12px)]
|
||||
min-[1024px]:rounded-[var(--radius-measures-radius-small)]
|
||||
max-[639px]:pb-[24px] max-[639px]:pt-[12px] max-[639px]:px-[12px]
|
||||
min-[640px]:max-[1023px]:p-[24px]
|
||||
min-[1024px]:max-[1439px]:p-[16px]
|
||||
min-[1440px]:p-[24px]
|
||||
max-[1023px]:gap-[18px]
|
||||
min-[1024px]:max-[1439px]:gap-[12px]
|
||||
min-[1440px]:gap-[10px]
|
||||
`}
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(card.iconPath)}
|
||||
alt=""
|
||||
width={90}
|
||||
height={90}
|
||||
draggable={false}
|
||||
className="
|
||||
cursor-inherit
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
||||
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
||||
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
||||
"
|
||||
/>
|
||||
}
|
||||
backgroundColor={card.backgroundColor}
|
||||
onClick={() => {
|
||||
onTemplateClick(card.slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Placeholder grid matching GovernanceTemplateGrid layout (loading state).
|
||||
*/
|
||||
export function GovernanceTemplateGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
flex flex-col gap-[18px]
|
||||
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
|
||||
min-[1024px]:gap-[24px]
|
||||
"
|
||||
aria-busy
|
||||
aria-label="Loading templates"
|
||||
>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="
|
||||
flex min-h-[120px] animate-pulse flex-col gap-3 rounded-[var(--measures-radius-200,8px)]
|
||||
bg-[var(--color-surface-default-secondary,#262626)] p-4
|
||||
min-[640px]:min-h-[140px] min-[640px]:rounded-[var(--measures-radius-300,12px)]
|
||||
min-[1024px]:min-h-[160px] min-[1024px]:rounded-[var(--radius-measures-radius-small)]
|
||||
"
|
||||
>
|
||||
<div className="h-10 w-10 rounded bg-[var(--color-surface-default-tertiary,#404040)] min-[640px]:h-14 min-[640px]:w-14" />
|
||||
<div className="h-4 w-[55%] max-w-[280px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
|
||||
<div className="h-3 w-full max-w-[400px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
|
||||
<div className="h-3 w-[72%] max-w-[360px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GovernanceTemplateGrid } from "./GovernanceTemplateGrid";
|
||||
export type { GovernanceTemplateGridProps } from "./GovernanceTemplateGrid";
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import {
|
||||
fetchTemplates,
|
||||
isTemplatesFetchAborted,
|
||||
} from "../../../../lib/create/fetchTemplates";
|
||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../../lib/templates/templateGridPresentation";
|
||||
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
|
||||
import { RuleStackView } from "./RuleStack.view";
|
||||
import type { RuleStackProps } from "./RuleStack.types";
|
||||
|
||||
@@ -18,31 +26,81 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName: string) => {
|
||||
const RuleStackContainer = memo<RuleStackProps>(
|
||||
({ className = "", initialGridEntries }) => {
|
||||
const router = useRouter();
|
||||
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
||||
() => initialGridEntries ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialGridEntries !== undefined) {
|
||||
return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchTemplates({ signal: ac.signal });
|
||||
if (cancelled) return;
|
||||
if ("error" in result) {
|
||||
setGridEntries(
|
||||
gridEntriesForSlugOrderWithCatalogFallback(
|
||||
[],
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setGridEntries(
|
||||
gridEntriesForSlugOrderWithCatalogFallback(
|
||||
result,
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (cancelled || isTemplatesFetchAborted(e)) return;
|
||||
setGridEntries(
|
||||
gridEntriesForSlugOrderWithCatalogFallback(
|
||||
[],
|
||||
GOVERNANCE_TEMPLATE_HOME_SLUGS,
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ac.abort();
|
||||
};
|
||||
}, [initialGridEntries]);
|
||||
|
||||
const handleTemplateClick = (slug: string) => {
|
||||
// Basic analytics tracking
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.gtag) {
|
||||
window.gtag("event", "template_click", {
|
||||
template_name: templateName,
|
||||
template_slug: slug,
|
||||
});
|
||||
}
|
||||
if (window.analytics) {
|
||||
window.analytics.track("Template Clicked", {
|
||||
templateName: templateName,
|
||||
templateSlug: slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.debug(`${templateName} template clicked`);
|
||||
logger.debug(`${slug} template clicked`);
|
||||
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<RuleStackView
|
||||
className={className}
|
||||
onTemplateClick={handleTemplateClick}
|
||||
gridEntries={gridEntries}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
RuleStackContainer.displayName = "RuleStack";
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
|
||||
|
||||
export interface RuleStackProps {
|
||||
className?: string;
|
||||
/**
|
||||
* When set (e.g. from a Server Component), first paint uses this data and
|
||||
* the client skips the `/api/templates` request.
|
||||
*/
|
||||
initialGridEntries?: TemplateGridCardEntry[];
|
||||
}
|
||||
|
||||
export interface RuleStackViewProps {
|
||||
className: string;
|
||||
onTemplateClick: (_templateName: string) => void;
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
/** `null` while loading curated templates from the API. */
|
||||
gridEntries: TemplateGridCardEntry[] | null;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||
import RuleCard from "../../cards/RuleCard";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import Button from "../../buttons/Button";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
|
||||
import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
|
||||
import type { RuleStackViewProps } from "./RuleStack.types";
|
||||
|
||||
export function RuleStackView({
|
||||
className,
|
||||
onTemplateClick,
|
||||
gridEntries,
|
||||
}: RuleStackViewProps) {
|
||||
const t = useTranslation("pages.home.ruleStack");
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Debug: Log button text to ensure translation works
|
||||
const buttonText = t("button.seeAllTemplates");
|
||||
|
||||
// Determine current breakpoint for RuleCard size
|
||||
// 320-639: XS, 640-767: S, 768-1023: S, 1024-1439: M, 1440+: L
|
||||
const isMax639 = useMediaQuery("(max-width: 639px)");
|
||||
const isMin640Max1023 = useMediaQuery(
|
||||
"(min-width: 640px) and (max-width: 1023px)",
|
||||
);
|
||||
const isMin1024Max1439 = useMediaQuery(
|
||||
"(min-width: 1024px) and (max-width: 1439px)",
|
||||
);
|
||||
const isMin1440 = useMediaQuery("(min-width: 1440px)");
|
||||
|
||||
// Handle hydration: only use media queries after mount
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer breakpoint until after mount to avoid hydration mismatch
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Use CSS classes for responsive sizing to avoid hydration mismatch
|
||||
// Default to M size for SSR, then let CSS handle the responsive sizing
|
||||
const cardSize = isMounted
|
||||
? isMax639
|
||||
? "XS"
|
||||
: isMin640Max1023
|
||||
? "S"
|
||||
: isMin1024Max1439
|
||||
? "M"
|
||||
: isMin1440
|
||||
? "L"
|
||||
: "M"
|
||||
: "M";
|
||||
|
||||
// Icon sizes: XS=40px, S=56px, M=56px, L=90px
|
||||
// Use a large default (90px) and let CSS handle responsive sizing
|
||||
|
||||
// Card data
|
||||
const cards = [
|
||||
{
|
||||
title: t("cards.consensusClusters.title"),
|
||||
description: t("cards.consensusClusters.description"),
|
||||
iconAlt: t("cards.consensusClusters.iconAlt"),
|
||||
iconPath: "assets/Icon_Sociocracy.svg",
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-lime)]",
|
||||
},
|
||||
{
|
||||
title: t("cards.consensus.title"),
|
||||
description: t("cards.consensus.description"),
|
||||
iconAlt: t("cards.consensus.iconAlt"),
|
||||
iconPath: "assets/Icon_Consensus.svg",
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-rust)]",
|
||||
},
|
||||
{
|
||||
title: t("cards.electedBoard.title"),
|
||||
description: t("cards.electedBoard.description"),
|
||||
iconAlt: t("cards.electedBoard.iconAlt"),
|
||||
iconPath: "assets/Icon_ElectedBoard.svg",
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-red)]",
|
||||
},
|
||||
{
|
||||
title: t("cards.petition.title"),
|
||||
description: t("cards.petition.description"),
|
||||
iconAlt: t("cards.petition.iconAlt"),
|
||||
iconPath: "assets/Icon_Petition.svg",
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-teal)]",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`
|
||||
@@ -101,60 +30,21 @@ export function RuleStackView({
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* Section Header */}
|
||||
<SectionHeader
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
variant="multi-line"
|
||||
/>
|
||||
|
||||
{/* Cards Container */}
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-[18px]
|
||||
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
|
||||
min-[1024px]:gap-[24px]
|
||||
`}
|
||||
>
|
||||
{cards.map((card, index) => (
|
||||
<RuleCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
size={cardSize}
|
||||
className="
|
||||
max-[639px]:rounded-[var(--measures-radius-200,8px)]
|
||||
min-[640px]:max-[1023px]:rounded-[var(--measures-radius-300,12px)]
|
||||
min-[1024px]:rounded-[var(--radius-measures-radius-small)]
|
||||
max-[639px]:pb-[24px] max-[639px]:pt-[12px] max-[639px]:px-[12px]
|
||||
min-[640px]:max-[1023px]:p-[24px]
|
||||
min-[1024px]:max-[1439px]:p-[16px]
|
||||
min-[1440px]:p-[24px]
|
||||
max-[1023px]:gap-[18px]
|
||||
min-[1024px]:max-[1439px]:gap-[12px]
|
||||
min-[1440px]:gap-[10px]
|
||||
"
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(card.iconPath)}
|
||||
alt={card.iconAlt}
|
||||
width={90}
|
||||
height={90}
|
||||
className="
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
||||
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
||||
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
||||
"
|
||||
/>
|
||||
}
|
||||
backgroundColor={card.backgroundColor}
|
||||
onClick={() => onTemplateClick(card.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{gridEntries === null ? (
|
||||
<GovernanceTemplateGridSkeleton count={4} />
|
||||
) : (
|
||||
<GovernanceTemplateGrid
|
||||
entries={gridEntries}
|
||||
onTemplateClick={onTemplateClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* See all templates button */}
|
||||
<div
|
||||
className="
|
||||
flex justify-center w-full
|
||||
@@ -163,7 +53,12 @@ export function RuleStackView({
|
||||
min-[1024px]:mt-[var(--measures-spacing-1000,40px)]
|
||||
"
|
||||
>
|
||||
<Button buttonType="outline" palette="default" size="large">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="large"
|
||||
href="/templates"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type HeaderLockupJustificationValue =
|
||||
| "left"
|
||||
| "center"
|
||||
@@ -16,9 +18,9 @@ export interface HeaderLockupProps {
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Description text (optional)
|
||||
* Description (optional). String for plain copy, or ReactNode for rich inline content (e.g. linked words).
|
||||
*/
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Text justification. Accepts both PascalCase (Figma) and lowercase (codebase).
|
||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
||||
@@ -38,7 +40,7 @@ export interface HeaderLockupProps {
|
||||
|
||||
export interface HeaderLockupViewProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
justification: "left" | "center";
|
||||
size: "L" | "M";
|
||||
palette: "default" | "inverse";
|
||||
|
||||
@@ -43,17 +43,18 @@ function HeaderLockupView({
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p
|
||||
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
|
||||
isLeft ? "" : "text-center"
|
||||
} ${
|
||||
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{description != null &&
|
||||
!(typeof description === "string" && description.length === 0) && (
|
||||
<p
|
||||
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
|
||||
isLeft ? "" : "text-center"
|
||||
} ${
|
||||
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title = "",
|
||||
description = "",
|
||||
layout = "default",
|
||||
headerLockupSize,
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -74,6 +75,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title={title}
|
||||
description={description}
|
||||
layout={layout}
|
||||
headerLockupSize={headerLockupSize}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
|
||||
export interface CardStackItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,6 +21,8 @@ export interface CardStackProps {
|
||||
description?: string;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
|
||||
headerLockupSize?: HeaderLockupSizeValue;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -34,5 +38,6 @@ export interface CardStackViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
layout: "default" | "singleStack";
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ export function CardStackView({
|
||||
title,
|
||||
description,
|
||||
layout,
|
||||
headerLockupSize,
|
||||
className,
|
||||
}: CardStackViewProps) {
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: recommended only (up to 5). Expanded: all cards.
|
||||
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
|
||||
@@ -33,7 +35,7 @@ export function CardStackView({
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -73,7 +75,7 @@ export function CardStackView({
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -5,11 +5,20 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view";
|
||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
|
||||
const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
||||
({ secondButton, progressBar = true, onBackClick, className = "" }) => {
|
||||
({
|
||||
secondButton,
|
||||
progressBar = true,
|
||||
proportionBarProgress,
|
||||
proportionBarVariant,
|
||||
onBackClick,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<CreateFlowFooterView
|
||||
secondButton={secondButton}
|
||||
progressBar={progressBar}
|
||||
proportionBarProgress={proportionBarProgress}
|
||||
proportionBarVariant={proportionBarVariant}
|
||||
onBackClick={onBackClick}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
ProportionBarState,
|
||||
ProportionBarVariant,
|
||||
} from "../../progress/ProportionBar/ProportionBar.types";
|
||||
|
||||
/**
|
||||
* Type definitions for CreateFlowFooter component
|
||||
*
|
||||
@@ -13,6 +18,16 @@ export interface CreateFlowFooterProps {
|
||||
* @default true
|
||||
*/
|
||||
progressBar?: boolean;
|
||||
/**
|
||||
* `ProportionBar` state when the bar is shown (driven by create-flow step).
|
||||
* @default "1-0"
|
||||
*/
|
||||
proportionBarProgress?: ProportionBarState;
|
||||
/**
|
||||
* `ProportionBar` layout variant (Figma create-flow footer uses `segmented`).
|
||||
* @default "default"
|
||||
*/
|
||||
proportionBarVariant?: ProportionBarVariant;
|
||||
/**
|
||||
* Callback function for Back button click
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeProportionBarVariant } from "../../../../lib/propNormalization";
|
||||
import ProportionBar from "../../progress/ProportionBar";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
@@ -5,9 +6,14 @@ import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
export function CreateFlowFooterView({
|
||||
secondButton,
|
||||
progressBar = true,
|
||||
proportionBarProgress = "1-0",
|
||||
proportionBarVariant: proportionBarVariantProp,
|
||||
onBackClick,
|
||||
className = "",
|
||||
}: CreateFlowFooterProps) {
|
||||
const proportionBarVariant = normalizeProportionBarVariant(
|
||||
proportionBarVariantProp,
|
||||
);
|
||||
return (
|
||||
<footer
|
||||
className={`bg-black w-full ${className}`}
|
||||
@@ -17,7 +23,10 @@ export function CreateFlowFooterView({
|
||||
{/* Progress Bar - Top */}
|
||||
{progressBar && (
|
||||
<div className="px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] pt-[var(--spacing-measures-spacing-300,12px)]">
|
||||
<ProportionBar progress="1-0" />
|
||||
<ProportionBar
|
||||
progress={proportionBarProgress}
|
||||
variant={proportionBarVariant}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onShare}
|
||||
ariaLabel="Share"
|
||||
ariaLabel={t("shareAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
Share
|
||||
{t("share")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -56,10 +56,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onExport}
|
||||
ariaLabel="Export"
|
||||
ariaLabel={t("exportAriaLabel")}
|
||||
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
<span>Export</span>
|
||||
<span>{t("export")}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -83,10 +83,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onEdit}
|
||||
ariaLabel="Edit"
|
||||
ariaLabel={t("editAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
Edit
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,14 +12,28 @@ import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
||||
import { getStepIndex } from "./utils/flowSteps";
|
||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
||||
import { fetchAuthSession, publishRule } from "../../lib/create/api";
|
||||
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
publishRule,
|
||||
requestMagicLink,
|
||||
} from "../../lib/create/api";
|
||||
import { safeInternalPath } from "../../lib/safeInternalPath";
|
||||
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
|
||||
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
type RuleTemplateDto,
|
||||
} from "../../lib/create/fetchTemplates";
|
||||
import messages from "../../messages/en/index";
|
||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import Alert from "../components/modals/Alert";
|
||||
@@ -28,8 +42,8 @@ import {
|
||||
useCreateFlowDraftSaveBanner,
|
||||
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||
|
||||
/** First step where Save & Exit is offered (after informational + name / `text`). */
|
||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
|
||||
/** First step where Save & Exit is offered (first Create Community select per Figma). */
|
||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
||||
|
||||
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||
const [sessionUser, setSessionUser] = useState<
|
||||
@@ -72,6 +86,10 @@ function CreateFlowLayoutContent({
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { create } = useMessages();
|
||||
const footer = create.footer;
|
||||
const communitySaveMessages = create.communitySave;
|
||||
const tLogin = useTranslation("pages.login");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { openLogin } = useAuthModal();
|
||||
@@ -82,13 +100,35 @@ function CreateFlowLayoutContent({
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
} = useCreateFlowNavigation();
|
||||
const { state, clearState } = useCreateFlow();
|
||||
const { state, clearState, updateState } = useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||
useState(false);
|
||||
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||
useState(false);
|
||||
|
||||
const templateReviewMatch = pathname?.match(
|
||||
/\/create\/review-template\/([^/?#]+)/,
|
||||
);
|
||||
const templateReviewSlug = templateReviewMatch?.[1]
|
||||
? decodeURIComponent(templateReviewMatch[1])
|
||||
: null;
|
||||
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
|
||||
const isTemplateReviewRoute = Boolean(
|
||||
pathname?.includes("/create/review-template/"),
|
||||
);
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
@@ -134,6 +174,39 @@ function CreateFlowLayoutContent({
|
||||
);
|
||||
}, [state, router, openLogin]);
|
||||
|
||||
const handleUseTemplateWithoutChanges = useCallback(async () => {
|
||||
if (!templateReviewSlug) return;
|
||||
setTemplateReviewApplyError(null);
|
||||
setIsApplyingTemplate(true);
|
||||
const result = await fetchTemplateBySlug(templateReviewSlug);
|
||||
setIsApplyingTemplate(false);
|
||||
if (result === null) {
|
||||
setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
|
||||
return;
|
||||
}
|
||||
if ("error" in result) {
|
||||
setTemplateReviewApplyError(result.error);
|
||||
return;
|
||||
}
|
||||
const template: RuleTemplateDto = result;
|
||||
const doc = template.body;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
|
||||
return;
|
||||
}
|
||||
const summaryRaw =
|
||||
typeof template.description === "string"
|
||||
? template.description.trim()
|
||||
: "";
|
||||
writeLastPublishedRule({
|
||||
id: `template:${template.slug}`,
|
||||
title: template.title,
|
||||
summary: summaryRaw.length > 0 ? summaryRaw : null,
|
||||
document: doc as Record<string, unknown>,
|
||||
});
|
||||
router.push("/create/completed");
|
||||
}, [router, templateReviewSlug]);
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
currentStep,
|
||||
@@ -149,9 +222,15 @@ function CreateFlowLayoutContent({
|
||||
|
||||
if (sessionUser === null) {
|
||||
if (saveDraft) return;
|
||||
const returnToTemplateReview =
|
||||
templateReviewSlug != null
|
||||
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
||||
: null;
|
||||
openLogin({
|
||||
variant: "saveProgress",
|
||||
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
|
||||
nextPath:
|
||||
returnToTemplateReview ??
|
||||
`${pathname ?? "/create"}?syncDraft=1`,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
@@ -161,19 +240,96 @@ function CreateFlowLayoutContent({
|
||||
await runAuthenticatedExit(opts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "community-save") {
|
||||
setCommunitySaveMagicLinkError(null);
|
||||
setCommunitySaveMagicLinkSuccess(false);
|
||||
setCommunitySaveMagicLinkSubmitting(false);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||
setCommunitySaveMagicLinkError(null);
|
||||
setCommunitySaveMagicLinkSuccess(false);
|
||||
const raw = state.communitySaveEmail;
|
||||
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||
if (!isValidCreateFlowSaveEmail(trimmed)) return;
|
||||
|
||||
setCommunitySaveMagicLinkSubmitting(true);
|
||||
try {
|
||||
const stepAfterSave = getNextStep("community-save");
|
||||
const segment = stepAfterSave ?? "review";
|
||||
const rawNext = `/create/${segment}?syncDraft=1`;
|
||||
const nextPath = safeInternalPath(rawNext);
|
||||
const result = await requestMagicLink(trimmed, nextPath);
|
||||
if (result.ok === false) {
|
||||
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
||||
setCommunitySaveMagicLinkError(
|
||||
tLogin("errors.rateLimited").replace("{seconds}", String(seconds)),
|
||||
);
|
||||
} else {
|
||||
setCommunitySaveMagicLinkError(
|
||||
result.error || tLogin("errors.generic"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTransferPendingFlag();
|
||||
updateState({ communitySaveEmail: trimmed });
|
||||
setCommunitySaveMagicLinkSuccess(true);
|
||||
} catch {
|
||||
setCommunitySaveMagicLinkError(tLogin("errors.network"));
|
||||
} finally {
|
||||
setCommunitySaveMagicLinkSubmitting(false);
|
||||
}
|
||||
}, [state.communitySaveEmail, tLogin, updateState]);
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||
const isFinalReviewStep = currentStep === "final-review";
|
||||
const isCardsStep = currentStep === "cards";
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
|
||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||
const mainContentClass = isCompletedStep
|
||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||
: isRightRailStep
|
||||
? "items-stretch overflow-hidden"
|
||||
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
|
||||
? "items-start justify-center overflow-y-auto"
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
|
||||
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
||||
const mainMaxMdJustify =
|
||||
isTextStep && !isCompletedStep && !isRightRailStep
|
||||
? "max-md:justify-center"
|
||||
: "max-md:justify-start";
|
||||
const mainMaxMdCross =
|
||||
isCompletedStep || isRightRailStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
|
||||
const hasErrorOverlays =
|
||||
Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
|
||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||
currentStep,
|
||||
);
|
||||
|
||||
const footerPrimaryButtonClass =
|
||||
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
||||
|
||||
const hasTopOverlays =
|
||||
Boolean(draftSaveBannerMessage) ||
|
||||
Boolean(publishBannerMessage) ||
|
||||
Boolean(templateReviewApplyError) ||
|
||||
Boolean(communitySaveMagicLinkError) ||
|
||||
Boolean(communitySaveMagicLinkSuccess);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||
{hasErrorOverlays ? (
|
||||
{hasTopOverlays ? (
|
||||
<div
|
||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
||||
aria-live="polite"
|
||||
@@ -202,6 +358,42 @@ function CreateFlowLayoutContent({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{templateReviewApplyError ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={messages.create.templateReview.errors.applyFailed}
|
||||
description={templateReviewApplyError}
|
||||
onClose={() => setTemplateReviewApplyError(null)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{communitySaveMagicLinkError ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={communitySaveMessages.magicLinkErrorTitle}
|
||||
description={communitySaveMagicLinkError}
|
||||
onClose={() => setCommunitySaveMagicLinkError(null)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{communitySaveMagicLinkSuccess ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="positive"
|
||||
title={communitySaveMessages.magicLinkSuccessTitle}
|
||||
description={communitySaveMessages.magicLinkSuccessDescription}
|
||||
onClose={() => setCommunitySaveMagicLinkSuccess(false)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
@@ -230,27 +422,144 @@ function CreateFlowLayoutContent({
|
||||
}`.trim()}
|
||||
/>
|
||||
<main
|
||||
className={`flex min-h-0 flex-1 justify-center ${
|
||||
useFullHeightMain
|
||||
? isCompletedStep
|
||||
? "items-stretch overflow-y-auto sm:overflow-hidden"
|
||||
: "items-stretch overflow-hidden"
|
||||
: "flex-row items-center justify-center overflow-y-auto"
|
||||
}`}
|
||||
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
|
||||
proportionBarProgress={proportionBarProgress}
|
||||
proportionBarVariant="segmented"
|
||||
secondButton={
|
||||
nextStep ? (
|
||||
isTemplateReviewRoute ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isApplyingTemplate}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
|
||||
onClick={() => void handleUseTemplateWithoutChanges()}
|
||||
>
|
||||
{messages.create.templateReview.footer.useWithoutChanges}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isApplyingTemplate}
|
||||
title={
|
||||
messages.create.templateReview.footer.customizeAriaHint
|
||||
}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
onClick={() => {
|
||||
if (!templateReviewSlug) return;
|
||||
// Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
|
||||
router.push(
|
||||
`/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{messages.create.templateReview.footer.customize}
|
||||
</Button>
|
||||
</div>
|
||||
) : currentStep === "community-name" && nextStep ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.next}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmName}
|
||||
</Button>
|
||||
</div>
|
||||
) : currentStep === "community-save" && nextStep ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.saveLater}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
communitySaveMagicLinkSubmitting ||
|
||||
communitySaveMagicLinkSuccess ||
|
||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
void handleCommunitySaveMagicLinkSubmit();
|
||||
}}
|
||||
>
|
||||
{communitySaveMagicLinkSubmitting
|
||||
? footer.submitEmailSending
|
||||
: footer.submitEmail}
|
||||
</Button>
|
||||
</div>
|
||||
) : currentStep === "review" && nextStep ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.createCustom}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
router.push("/templates");
|
||||
}}
|
||||
>
|
||||
{footer.createFromTemplate}
|
||||
</Button>
|
||||
</div>
|
||||
) : nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
if (currentStep === "final-review") {
|
||||
void handleFinalize();
|
||||
@@ -262,14 +571,26 @@ function CreateFlowLayoutContent({
|
||||
{currentStep === "final-review"
|
||||
? isPublishing
|
||||
? messages.create.publish.finalizeButtonPublishing
|
||||
: "Finalize CommunityRule"
|
||||
: footer.finalizeCommunityRule
|
||||
: currentStep === "confirm-stakeholders"
|
||||
? "Confirm Stakeholders"
|
||||
: "Next"}
|
||||
? footer.confirmStakeholders
|
||||
: currentStep === "community-context"
|
||||
? footer.confirmDescription
|
||||
: currentStep === "community-structure"
|
||||
? footer.confirmDetails
|
||||
: currentStep === "community-size"
|
||||
? footer.confirmMembers
|
||||
: footer.next}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||
onBackClick={
|
||||
isTemplateReviewRoute
|
||||
? () => router.push("/")
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
clearAnonymousCreateFlowStorage,
|
||||
hasTransferPendingFlag,
|
||||
readAnonymousCreateFlowState,
|
||||
} from "./anonymousDraftStorage";
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { isValidStep } from "./utils/flowSteps";
|
||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||
import { saveDraftToServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
@@ -56,8 +56,8 @@ export function PostLoginDraftTransfer({
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = pathname?.split("/").pop() ?? "";
|
||||
const step = isValidStep(segment) ? segment : undefined;
|
||||
const step =
|
||||
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
|
||||
const payload = {
|
||||
...local,
|
||||
...(step ? { currentStep: step } : {}),
|
||||
@@ -100,8 +100,8 @@ export function PostLoginDraftTransfer({
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = pathname?.split("/").pop() ?? "";
|
||||
const step = isValidStep(segment) ? segment : undefined;
|
||||
const step =
|
||||
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
|
||||
const payload = {
|
||||
...local,
|
||||
...(step ? { currentStep: step } : {}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
clearAnonymousCreateFlowStorage,
|
||||
hasTransferPendingFlag,
|
||||
readAnonymousCreateFlowState,
|
||||
} from "./anonymousDraftStorage";
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { fetchDraftFromServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { notFound, useRouter } from "next/navigation";
|
||||
import { use, useEffect } from "react";
|
||||
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||
import { isValidStep } from "../utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ screenId: string }>;
|
||||
}
|
||||
|
||||
export default function CreateFlowScreenPage({ params }: PageProps) {
|
||||
const { screenId: raw } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (raw === "community-reflection") {
|
||||
router.replace("/create/community-save");
|
||||
}
|
||||
}, [raw, router]);
|
||||
|
||||
if (raw === "community-reflection") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidStep(raw)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const screenId = raw as CreateFlowStep;
|
||||
return <CreateFlowScreenView screenId={screenId} />;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { use } from "react";
|
||||
import { VALID_STEPS } from "../utils/flowSteps";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ step: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic route handler for create flow steps
|
||||
*
|
||||
* Handles all flow steps via dynamic routing: /create/[step]
|
||||
* Validates step exists and renders appropriate template (placeholder for now)
|
||||
*/
|
||||
export default function CreateFlowStepPage({ params }: PageProps) {
|
||||
const { step } = use(params);
|
||||
|
||||
// Validate step exists
|
||||
if (!(VALID_STEPS as readonly string[]).includes(step)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Placeholder content - templates will be implemented in CR-51-55
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-white text-2xl font-bold mb-4">
|
||||
Create Flow Step: {step}
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
Template implementation coming in CR-51 through CR-55
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import Create from "../../components/modals/Create";
|
||||
import TextArea from "../../components/controls/TextArea";
|
||||
|
||||
const COMPACT_TITLE = "How should this community communicate with each-other?";
|
||||
const COMPACT_DESCRIPTION =
|
||||
"You can select multiple methods for different needs or add your own";
|
||||
const EXPANDED_TITLE =
|
||||
"What method should this community use to communicate with eachother?";
|
||||
const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION;
|
||||
|
||||
/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */
|
||||
|
||||
/** Card ids for "Add platform" Create modal variants. */
|
||||
const IN_PERSON_CARD_ID = "in-person-meetings";
|
||||
const SIGNAL_CARD_ID = "signal";
|
||||
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
|
||||
|
||||
/** Copy for the default confirm modal (non–add-platform cards). */
|
||||
const CONFIRM_MODAL = {
|
||||
title: "Confirm selection",
|
||||
description: "Confirm to select this option.",
|
||||
nextButtonText: "Confirm",
|
||||
showBackButton: false,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* "Add platform" variants share the same header pattern and "Add Platform" button.
|
||||
* Each has its own title, description, and body (three TextArea sections).
|
||||
*/
|
||||
const ADD_PLATFORM_MODALS: Record<
|
||||
string,
|
||||
{ title: string; description: string; nextButtonText: string }
|
||||
> = {
|
||||
[IN_PERSON_CARD_ID]: {
|
||||
title: "In-Person Meetings",
|
||||
description:
|
||||
"Physical gatherings for high-bandwidth communication and relationship building.",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
[SIGNAL_CARD_ID]: {
|
||||
title: "Signal",
|
||||
description:
|
||||
"End-to-end encrypted messaging ideal for small, security-minded groups",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
[VIDEO_MEETINGS_CARD_ID]: {
|
||||
title: "Video Meetings",
|
||||
description: "Synchronous video calls for remote face-to-face interaction.",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
};
|
||||
|
||||
const SECTION_KEYS = [
|
||||
"Core Principle & Scope",
|
||||
"Logistics, Admin & Norms",
|
||||
"Code of Conduct",
|
||||
] as const;
|
||||
type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */
|
||||
const ADD_PLATFORM_SECTION_DEFAULTS: Record<
|
||||
string,
|
||||
Record<SectionKey, string>
|
||||
> = {
|
||||
[IN_PERSON_CARD_ID]: {
|
||||
"Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`,
|
||||
"Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`,
|
||||
"Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`,
|
||||
},
|
||||
[SIGNAL_CARD_ID]: {
|
||||
"Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`,
|
||||
"Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`,
|
||||
"Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`,
|
||||
},
|
||||
[VIDEO_MEETINGS_CARD_ID]: {
|
||||
"Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`,
|
||||
"Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`,
|
||||
"Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Section with heading + info icon and an editable TextArea.
|
||||
* This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings".
|
||||
*/
|
||||
function CreateModalSection({
|
||||
title,
|
||||
value: _value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (_value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
|
||||
aria-hidden
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
<TextArea
|
||||
formHeader={false}
|
||||
value={_value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="large"
|
||||
rows={6}
|
||||
appearance="embedded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Body for any "Add platform" modal: three editable sections (TextArea only). */
|
||||
function AddPlatformModalContent({
|
||||
platformCardId,
|
||||
}: {
|
||||
platformCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionKey, string>
|
||||
>(
|
||||
defaults ?? {
|
||||
"Core Principle & Scope": "",
|
||||
"Logistics, Admin & Norms": "",
|
||||
"Code of Conduct": "",
|
||||
},
|
||||
);
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionKey, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
if (!defaults) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_KEYS.map((key) => (
|
||||
<CreateModalSection
|
||||
key={key}
|
||||
title={key}
|
||||
value={sectionValues[key]}
|
||||
onChange={(v) => updateSection(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Communication method cards (Figma 20246-15828). First three are recommended. */
|
||||
const SAMPLE_CARDS = [
|
||||
{
|
||||
id: IN_PERSON_CARD_ID,
|
||||
label: "In-Person Meetings",
|
||||
supportText:
|
||||
"Physical gatherings for high-bandwidth communication and relationship building.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: SIGNAL_CARD_ID,
|
||||
label: "Signal",
|
||||
supportText: "Encrypted messaging for high-security, private coordination.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: VIDEO_MEETINGS_CARD_ID,
|
||||
label: "Video Meetings",
|
||||
supportText: "Synchronous video calls for remote face-to-face interaction.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
label: "Label",
|
||||
supportText:
|
||||
"Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
label: "Label",
|
||||
supportText:
|
||||
"Structured sessions where parties collaboratively resolve disputes.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
label: "Label",
|
||||
supportText: "Members vote to resolve a dispute democratically.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
label: "Label",
|
||||
supportText: "Invite-only",
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Whether this card id uses the "Add platform" modal (shared header, platform-specific body). */
|
||||
function isAddPlatformCard(cardId: string | null): cardId is string {
|
||||
return cardId !== null && cardId in ADD_PLATFORM_MODALS;
|
||||
}
|
||||
|
||||
/** Resolve Create modal header/buttons: Add platform variant or default confirm. */
|
||||
function getCreateModalConfig(pendingCardId: string | null) {
|
||||
if (isAddPlatformCard(pendingCardId)) {
|
||||
return {
|
||||
...ADD_PLATFORM_MODALS[pendingCardId],
|
||||
showBackButton: false,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
return CONFIRM_MODAL;
|
||||
}
|
||||
|
||||
/** Create flow card stack step: compact grid with optional expand to full list. */
|
||||
export default function CardsPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const title = expanded ? EXPANDED_TITLE : COMPACT_TITLE;
|
||||
const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION;
|
||||
const modalConfig = getCreateModalConfig(pendingCardId);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
<div className="flex w-full flex-col gap-6 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
>
|
||||
{isAddPlatformCard(pendingCardId) ? (
|
||||
<AddPlatformModalContent platformCardId={pendingCardId} />
|
||||
) : null}
|
||||
</Create>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
|
||||
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
|
||||
/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
|
||||
const FALLBACK_TITLE = "Mutual Aid Mondays";
|
||||
const FALLBACK_DESCRIPTION =
|
||||
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
|
||||
|
||||
const TOAST_TITLE = "This is what folks see when you share your CommunityRule";
|
||||
const TOAST_DESCRIPTION =
|
||||
"Your group can use this document as an operating manual.";
|
||||
|
||||
const SOLIDARITY_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.";
|
||||
|
||||
/** Static sections for the completed Community Rule document (placeholder data). */
|
||||
const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Solidarity Forever", body: SOLIDARITY_BODY },
|
||||
{
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Completed create flow page.
|
||||
* Figma: 20907-213286 (main), 18002-28017 (toast).
|
||||
*/
|
||||
export default function CompletedPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
string | undefined
|
||||
>(FALLBACK_DESCRIPTION);
|
||||
const [documentSections, setDocumentSections] =
|
||||
useState<CommunityRuleDocumentSection[]>(COMPLETED_RULE_SECTIONS);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readLastPublishedRule();
|
||||
if (!stored) return;
|
||||
const parsed = parseDocumentSectionsForDisplay(stored.document);
|
||||
if (parsed.length === 0) return;
|
||||
setDocumentSections(parsed);
|
||||
setHeaderTitle(stored.title);
|
||||
const sum =
|
||||
typeof stored.summary === "string" ? stored.summary.trim() : "";
|
||||
setHeaderDescription(sum.length > 0 ? sum : undefined);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] px-5 md:px-12">
|
||||
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-[var(--measures-spacing-1200,48px)] min-h-0 min-w-0 w-full">
|
||||
{/* Left column: community title + header, centered, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<HeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
</div>
|
||||
{/* Right column: Community Rule document — this column scrolls independently; padding inside scroll so content isn't clipped */}
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{/* Soft fade at top: gradient wash only (no blur) so no sharp cutoff line */}
|
||||
<div
|
||||
className="sticky top-0 z-10 h-5 shrink-0 pointer-events-none bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="py-8 min-w-0">
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={TOAST_TITLE}
|
||||
description={TOAST_DESCRIPTION}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col items-center px-5 min-w-0 bg-[var(--color-teal-teal50,#c9fef9)] py-8">
|
||||
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||
<HeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="M"
|
||||
palette="inverse"
|
||||
/>
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
useCardStyle
|
||||
className="w-full p-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={TOAST_TITLE}
|
||||
description={TOAST_DESCRIPTION}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
|
||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
|
||||
size?: HeaderLockupProps["size"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
|
||||
*/
|
||||
export function CreateFlowHeaderLockup({
|
||||
size: sizeProp,
|
||||
...rest
|
||||
}: CreateFlowHeaderLockupProps) {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const size = sizeProp ?? (mdUp ? "L" : "M");
|
||||
return <HeaderLockup {...rest} size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "./createFlowLayoutTokens";
|
||||
|
||||
/** Shared `RuleCard` / template card chrome: width + radius; padding comes from `RuleCard` (L+expanded = 24px). */
|
||||
export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS =
|
||||
"w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]";
|
||||
|
||||
type CreateFlowLockupCardStepShellProps = {
|
||||
lockupTitle: string;
|
||||
lockupDescription?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
|
||||
export function CreateFlowLockupCardStepShell({
|
||||
lockupTitle,
|
||||
lockupDescription,
|
||||
children,
|
||||
}: CreateFlowLockupCardStepShellProps) {
|
||||
return (
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={lockupTitle}
|
||||
description={lockupDescription}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type CreateFlowStepShellVariant =
|
||||
| "centeredNarrow"
|
||||
| "centeredNarrowBottomPad"
|
||||
| "wideGrid"
|
||||
| "wideGridLoosePadding"
|
||||
| "bare";
|
||||
|
||||
/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */
|
||||
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||
|
||||
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||
centeredNarrow:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
||||
centeredNarrowBottomPad:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
||||
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
|
||||
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
|
||||
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
|
||||
wideGridLoosePadding:
|
||||
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
|
||||
bare: "w-full min-w-0",
|
||||
};
|
||||
|
||||
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
|
||||
none: "",
|
||||
"space-1400": "pt-[var(--space-1400)]",
|
||||
"space-800": "pt-[var(--space-800)]",
|
||||
};
|
||||
|
||||
interface CreateFlowStepShellProps {
|
||||
children: ReactNode;
|
||||
variant?: CreateFlowStepShellVariant;
|
||||
/** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
||||
*/
|
||||
export function CreateFlowStepShell({
|
||||
children,
|
||||
variant = "centeredNarrow",
|
||||
contentTopBelowMd = "none",
|
||||
className = "",
|
||||
}: CreateFlowStepShellProps) {
|
||||
const topClass = contentTopBelowMdClass[contentTopBelowMd];
|
||||
return (
|
||||
<div
|
||||
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
|
||||
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
||||
"w-full min-w-0 md:max-w-[640px]";
|
||||
|
||||
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
||||
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
|
||||
|
||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
clearLegacyCreateFlowKeysOnce,
|
||||
readAnonymousCreateFlowState,
|
||||
writeAnonymousCreateFlowState,
|
||||
} from "../anonymousDraftStorage";
|
||||
} from "../utils/anonymousDraftStorage";
|
||||
|
||||
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import RuleCard from "../../components/cards/RuleCard";
|
||||
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const TITLE = "Review your CommunityRule";
|
||||
const DESCRIPTION =
|
||||
"Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.";
|
||||
|
||||
const RULE_CARD_TITLE_FALLBACK = "Your community";
|
||||
const RULE_CARD_DESCRIPTION_FALLBACK =
|
||||
"Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.";
|
||||
|
||||
/** Static categories for final review (read-only display). */
|
||||
const FINAL_REVIEW_CATEGORIES: Category[] = [
|
||||
{
|
||||
name: "Values",
|
||||
chipOptions: [
|
||||
{ id: "v1", label: "Consciousness", state: "unselected" },
|
||||
{ id: "v2", label: "Ecology", state: "unselected" },
|
||||
{ id: "v3", label: "Abundance", state: "unselected" },
|
||||
{ id: "v4", label: "Art", state: "unselected" },
|
||||
{ id: "v5", label: "Decisiveness", state: "unselected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Communication",
|
||||
chipOptions: [{ id: "c1", label: "Signal", state: "unselected" }],
|
||||
},
|
||||
{
|
||||
name: "Membership",
|
||||
chipOptions: [{ id: "m1", label: "Open Admission", state: "unselected" }],
|
||||
},
|
||||
{
|
||||
name: "Decision-making",
|
||||
chipOptions: [
|
||||
{ id: "d1", label: "Lazy Consensus", state: "unselected" },
|
||||
{ id: "d2", label: "Modified Consensus", state: "unselected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Conflict management",
|
||||
chipOptions: [
|
||||
{ id: "cf1", label: "Code of Conduct", state: "unselected" },
|
||||
{ id: "cf2", label: "Restorative Justice", state: "unselected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Final review step (right before completed).
|
||||
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
|
||||
*/
|
||||
export default function FinalReviewPage() {
|
||||
const { state } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const t = typeof state.title === "string" ? state.title.trim() : "";
|
||||
return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK;
|
||||
}, [state.title]);
|
||||
|
||||
const ruleCardDescription = useMemo(() => {
|
||||
const s = typeof state.summary === "string" ? state.summary.trim() : "";
|
||||
return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK;
|
||||
}, [state.summary]);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-12">
|
||||
<div className="flex w-full flex-col gap-4 min-w-0 sm:grid sm:grid-cols-2 sm:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full flex flex-col items-stretch">
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="rounded-[24px] !max-w-full !w-full min-w-0"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-5 min-w-0">
|
||||
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
justification="left"
|
||||
size="M"
|
||||
/>
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="w-full rounded-[12px] p-4"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
|
||||
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
||||
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
||||
|
||||
/** True at viewport ≥1024px (e.g. review grid column split with Tailwind `lg:`). */
|
||||
export function useCreateFlowLgUp(): boolean {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isLgOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_LG);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return !isMounted || isLgOrLarger;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
|
||||
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
||||
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
||||
|
||||
/** True at viewport ≥640px (pairs with Tailwind `md:` on create-flow screens). */
|
||||
export function useCreateFlowMdUp(): boolean {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return !isMounted || isMdOrLarger;
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps";
|
||||
import {
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
parseCreateFlowScreenFromPathname,
|
||||
} from "../utils/flowSteps";
|
||||
|
||||
/**
|
||||
* Options passed to navigation handlers (e.g. for blur before navigate)
|
||||
@@ -20,8 +24,7 @@ const blurActiveElement = (): void => {
|
||||
/**
|
||||
* Hook for Create Rule Flow navigation.
|
||||
*
|
||||
* Must be used within the create flow (pathname like /create/[step]).
|
||||
* Uses the current step from the URL and provides type-safe navigation.
|
||||
* Resolves the active step from `/create/{screenId}` via {@link parseCreateFlowScreenFromPathname} (flowSteps).
|
||||
*/
|
||||
export function useCreateFlowNavigation(): {
|
||||
currentStep: CreateFlowStep | null;
|
||||
@@ -36,9 +39,7 @@ export function useCreateFlowNavigation(): {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const currentStep = (pathname?.split("/").pop() ??
|
||||
null) as CreateFlowStep | null;
|
||||
const validStep = isValidStep(currentStep) ? currentStep : null;
|
||||
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||
|
||||
const nextStep = getNextStep(validStep);
|
||||
const previousStep = getPreviousStep(validStep);
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import NumberedList from "../../components/type/NumberedList";
|
||||
|
||||
/**
|
||||
* Informational page for the create flow
|
||||
*
|
||||
* Displays information about the create flow process using HeaderLockup and NumberedList components.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
|
||||
*/
|
||||
export default function InformationalPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Tell us about your organization",
|
||||
description:
|
||||
"Start by providing your group's name, description, and profile image.",
|
||||
},
|
||||
{
|
||||
title: "Define your group's CommunityRule.",
|
||||
description:
|
||||
"Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations.",
|
||||
},
|
||||
{
|
||||
title: "Share and evolve over time",
|
||||
description:
|
||||
"Review and refine your community framework before putting it into action and adapting it over time.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[48px] items-center w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */}
|
||||
<HeaderLockup
|
||||
title="How CommunityRule helps groups like yours"
|
||||
description="This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together."
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* NumberedList: M size at 640px+, S size below 640px */}
|
||||
<NumberedList items={items} size={effectiveMdOrLarger ? "M" : "S"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { FIRST_STEP } from "./utils/flowSteps";
|
||||
|
||||
/** `/create` redirects to the first wizard step (Figma frame 1). */
|
||||
export default function CreateIndexPage() {
|
||||
redirect(`/create/${FIRST_STEP}`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
isTemplatesFetchAborted,
|
||||
type RuleTemplateDto,
|
||||
} from "../../../../lib/create/fetchTemplates";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../../components/CreateFlowLockupCardStepShell";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */
|
||||
export default function ReviewTemplatePage({ params }: PageProps) {
|
||||
const { slug: rawSlug } = use(params);
|
||||
const slug = decodeURIComponent(rawSlug);
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.templateReview");
|
||||
|
||||
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
if (!cancelled) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
const result = await fetchTemplateBySlug(slug, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (result === null) {
|
||||
setError(messages.create.templateReview.errors.notFound);
|
||||
setTemplate(null);
|
||||
} else if ("error" in result) {
|
||||
setError(result.error);
|
||||
setTemplate(null);
|
||||
} else {
|
||||
setTemplate(result);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled || isTemplatesFetchAborted(e)) return;
|
||||
setError(messages.create.templateReview.errors.loadFailed);
|
||||
setTemplate(null);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ac.abort();
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div
|
||||
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
|
||||
{t("loading")}
|
||||
</p>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
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}`}
|
||||
>
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={t("errors.loadFailed")}
|
||||
description={error ?? t("errors.notFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateFlowLockupCardStepShell
|
||||
lockupTitle={t("intro.title")}
|
||||
lockupDescription={t("intro.description")}
|
||||
>
|
||||
<TemplateReviewCard
|
||||
template={template}
|
||||
ruleCardClassName={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||
size={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import RuleCard from "../../components/cards/RuleCard";
|
||||
|
||||
/** Mid-flow review step (after upload, before cards). */
|
||||
export default function ReviewPage() {
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
<div className="flex w-full flex-col gap-4 min-w-0 sm:grid sm:grid-cols-2 sm:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title="Your community is added - congrats!"
|
||||
description="In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full">
|
||||
<RuleCard
|
||||
title="Mutual Aid Mondays"
|
||||
description="Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness."
|
||||
size="L"
|
||||
expanded={false}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt="Mutual Aid Mondays"
|
||||
className="rounded-[16px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import DecisionMakingSidebar from "../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const SIDEBAR_TITLE = "How should conflicts be resolved?";
|
||||
|
||||
const SIDEBAR_DESCRIPTION = (
|
||||
<>
|
||||
You can also combine or <span className="underline">add</span> new
|
||||
approaches to the list
|
||||
</>
|
||||
);
|
||||
|
||||
const MESSAGE_BOX_TITLE =
|
||||
"Consider defining approaches to steward key resources:";
|
||||
|
||||
const MESSAGE_BOX_ITEMS: InfoMessageBoxItem[] = [
|
||||
{ id: "amend", label: "Amend your CommunityRule" },
|
||||
{ id: "finances", label: "Steward finances" },
|
||||
{ id: "project", label: "Project level decisions" },
|
||||
{ id: "discipline", label: "Discipline and member termination" },
|
||||
];
|
||||
|
||||
const SAMPLE_CARDS: CardStackItem[] = [
|
||||
{
|
||||
id: "mediation",
|
||||
label: "Mediation",
|
||||
supportText:
|
||||
"Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "facilitation",
|
||||
label: "Facilitated dialogue",
|
||||
supportText:
|
||||
"Structured sessions where parties collaboratively resolve disputes.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "invite-only",
|
||||
label: "Invite-only",
|
||||
supportText: "Private discussions with selected participants.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "arbitration",
|
||||
label: "Arbitration",
|
||||
supportText: "Arbitrators are chosen specifically for a particular case.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "direct-dialogue",
|
||||
label: "Direct dialogue",
|
||||
supportText:
|
||||
"Encouraging direct, respectful dialogue between those involved.",
|
||||
recommended: true,
|
||||
},
|
||||
// Extra cards to test scrolling (only visible when "See all" is expanded)
|
||||
{ id: "label-1", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-2", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-3", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-4", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-5", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-6", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-7", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-8", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-9", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-10", label: "Label", supportText: "", recommended: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Right Rail step of the create flow.
|
||||
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
|
||||
*/
|
||||
export default function RightRailPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
markCreateFlowInteraction();
|
||||
setMessageBoxCheckedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
|
||||
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-12 min-h-0 min-w-0 w-full">
|
||||
{/* Left column: sidebar stays put, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
{/* Right column: card stack — this column scrolls independently */}
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
<div className="py-8 flex flex-col gap-6 items-center min-w-0">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel="See all decision approaches"
|
||||
showLessLabel="Show less"
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full min-h-0 overflow-y-auto flex flex-col items-center px-5">
|
||||
<div className="flex flex-col gap-6 w-full min-w-0 py-8">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="M"
|
||||
justification="center"
|
||||
/>
|
||||
<div className="flex flex-col gap-6 items-center w-full">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel="See all decision approaches"
|
||||
showLessLabel="Show less"
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CardsScreen } from "./card/CardsScreen";
|
||||
import { RightRailScreen } from "./right-rail/RightRailScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
/**
|
||||
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
|
||||
*/
|
||||
export function CreateFlowScreenView({
|
||||
screenId,
|
||||
}: {
|
||||
screenId: CreateFlowStep;
|
||||
}): ReactNode {
|
||||
switch (screenId) {
|
||||
case "informational":
|
||||
return <InformationalScreen />;
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
);
|
||||
case "community-structure":
|
||||
return <CommunityStructureSelectScreen />;
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={48}
|
||||
mainAlign="center"
|
||||
/>
|
||||
);
|
||||
case "community-size":
|
||||
return <CommunitySizeSelectScreen />;
|
||||
case "community-upload":
|
||||
return <CommunityUploadScreen />;
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
inputType="email"
|
||||
showCharacterCount={false}
|
||||
headerJustification="center"
|
||||
/>
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "cards":
|
||||
return <CardsScreen />;
|
||||
case "right-rail":
|
||||
return <RightRailScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
case "final-review":
|
||||
return <FinalReviewScreen />;
|
||||
case "completed":
|
||||
return <CompletedScreen />;
|
||||
default: {
|
||||
const _exhaustive: never = screenId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import TextArea from "../../../components/controls/TextArea";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
const IN_PERSON_CARD_ID = "in-person-meetings";
|
||||
const SIGNAL_CARD_ID = "signal";
|
||||
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
|
||||
|
||||
const ADD_PLATFORM_CARD_IDS = [
|
||||
IN_PERSON_CARD_ID,
|
||||
SIGNAL_CARD_ID,
|
||||
VIDEO_MEETINGS_CARD_ID,
|
||||
] as const;
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
"codeOfConduct",
|
||||
] as const;
|
||||
type SectionField = (typeof SECTION_FIELDS)[number];
|
||||
|
||||
const COMMUNICATION_CARD_ORDER = [
|
||||
IN_PERSON_CARD_ID,
|
||||
SIGNAL_CARD_ID,
|
||||
VIDEO_MEETINGS_CARD_ID,
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
] as const;
|
||||
|
||||
function CreateModalSection({
|
||||
title,
|
||||
value: _value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (_value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
|
||||
aria-hidden
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
<TextArea
|
||||
formHeader={false}
|
||||
value={_value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="large"
|
||||
rows={6}
|
||||
appearance="embedded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPlatformModalContent({
|
||||
platformCardId,
|
||||
}: {
|
||||
platformCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
|
||||
const defaults =
|
||||
modal && "sections" in modal
|
||||
? modal.sections
|
||||
: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
};
|
||||
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionField, string>
|
||||
>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
logisticsAdmin: defaults.logisticsAdmin,
|
||||
codeOfConduct: defaults.codeOfConduct,
|
||||
}));
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionField, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
if (!modal || !("sections" in modal)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<CreateModalSection
|
||||
key={field}
|
||||
title={comm.sectionHeadings[field]}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isAddPlatformCard(cardId: string | null): boolean {
|
||||
return (
|
||||
cardId !== null &&
|
||||
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
|
||||
);
|
||||
}
|
||||
|
||||
export function CardsScreen() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
COMMUNICATION_CARD_ORDER.map((id) => {
|
||||
const row = comm.cards[id as keyof typeof comm.cards];
|
||||
return {
|
||||
id,
|
||||
label: row.label,
|
||||
supportText: row.supportText,
|
||||
recommended: true,
|
||||
};
|
||||
}),
|
||||
[comm],
|
||||
);
|
||||
|
||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||
const description = expanded
|
||||
? comm.page.expandedDescription
|
||||
: comm.page.compactDescription;
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in comm.modals
|
||||
? (() => {
|
||||
const modal =
|
||||
comm.modals[pendingCardId as keyof typeof comm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId]);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||
<CreateFlowHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
/>
|
||||
</div>
|
||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
onToggleExpand={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
toggleLabel={comm.page.seeAllLink}
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
>
|
||||
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
|
||||
<AddPlatformModalContent
|
||||
key={pendingCardId}
|
||||
platformCardId={pendingCardId}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
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 { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
export function CompletedScreen() {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const m = useMessages();
|
||||
const completed = m.create.completed;
|
||||
|
||||
const fallbackSections = useMemo(
|
||||
() =>
|
||||
[...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
|
||||
[completed.fallbackDocumentSections],
|
||||
);
|
||||
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(
|
||||
() => completed.fallbackTitle,
|
||||
);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
string | undefined
|
||||
>(() => completed.fallbackDescription);
|
||||
const [documentSections, setDocumentSections] =
|
||||
useState<CommunityRuleDocumentSection[]>(fallbackSections);
|
||||
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toast = !toastDismissed ? (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={completed.toastTitle}
|
||||
description={completed.toastDescription}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
||||
<div
|
||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col justify-start overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="w-full min-w-0 py-0 md:pb-8">
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
useCardStyle={!mdUp}
|
||||
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toast}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import NumberedList from "../../../components/type/NumberedList";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
/**
|
||||
* Create Community — frame 1 (Figma [20094-16005](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20094-16005)).
|
||||
* URL: /create/informational
|
||||
*/
|
||||
export function InformationalScreen() {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const copy = useMessages().create.informational;
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: copy.steps["0"].title,
|
||||
description: copy.steps["0"].description,
|
||||
},
|
||||
{
|
||||
title: copy.steps["1"].title,
|
||||
description: copy.steps["1"].description,
|
||||
},
|
||||
{
|
||||
title: copy.steps["2"].title,
|
||||
description: copy.steps["2"].description,
|
||||
},
|
||||
];
|
||||
|
||||
const description: ReactNode = (
|
||||
<>
|
||||
{copy.descriptionLead}{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="font-inter font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{copy.workshopLabel}
|
||||
</a>{" "}
|
||||
{copy.descriptionTrail}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={copy.title}
|
||||
description={description}
|
||||
justification="left"
|
||||
/>
|
||||
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import RuleCard from "../../../components/cards/RuleCard";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||
export function CommunityReviewScreen() {
|
||||
const lgUp = useCreateFlowLgUp();
|
||||
const t = useTranslation("create.review");
|
||||
const { state } = useCreateFlow();
|
||||
|
||||
const cardTitle =
|
||||
typeof state.title === "string" && state.title.trim().length > 0
|
||||
? state.title.trim()
|
||||
: t("ruleCard.title");
|
||||
const cardDescription =
|
||||
typeof state.communityContext === "string" &&
|
||||
state.communityContext.trim().length > 0
|
||||
? state.communityContext.trim()
|
||||
: t("ruleCard.description");
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex w-full min-w-0 flex-col items-center gap-6 lg:mx-auto lg:w-full lg:grid lg:grid-cols-2 lg:items-center lg:justify-items-center lg:gap-x-[var(--measures-spacing-1200,48px)] lg:gap-y-6 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col justify-center lg:min-h-[212px] ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
/>
|
||||
</div>
|
||||
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
|
||||
<RuleCard
|
||||
title={cardTitle}
|
||||
description={cardDescription}
|
||||
size={lgUp ? "L" : "M"}
|
||||
expanded={false}
|
||||
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={cardTitle}
|
||||
className="rounded-[24px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import RuleCard from "../../../components/cards/RuleCard";
|
||||
import type { Category } from "../../../components/cards/RuleCard/RuleCard.types";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../../components/CreateFlowLockupCardStepShell";
|
||||
|
||||
function buildFinalReviewCategories(
|
||||
rows: { name: string; chips: string[] }[],
|
||||
): Category[] {
|
||||
return rows.map((cat) => ({
|
||||
name: cat.name,
|
||||
chipOptions: cat.chips.map((label, idx) => ({
|
||||
id: `${cat.name}-${idx}`,
|
||||
label,
|
||||
state: "unselected" as const,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function FinalReviewScreen() {
|
||||
const { state } = useCreateFlow();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.finalReview");
|
||||
const m = useMessages();
|
||||
|
||||
const finalReviewCategories = useMemo(
|
||||
() => buildFinalReviewCategories(m.create.finalReview.categories),
|
||||
[m.create.finalReview.categories],
|
||||
);
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
|
||||
}, [state.title, t]);
|
||||
|
||||
const ruleCardDescription = useMemo(() => {
|
||||
const raw =
|
||||
typeof state.summary === "string" ? state.summary.trim() : "";
|
||||
return raw.length > 0 ? raw : t("ruleCardDescriptionFallback");
|
||||
}, [state.summary, t]);
|
||||
|
||||
return (
|
||||
<CreateFlowLockupCardStepShell
|
||||
lockupTitle={t("title")}
|
||||
lockupDescription={t("description")}
|
||||
>
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size={mdUp ? "L" : "M"}
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={finalReviewCategories}
|
||||
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
|
||||
export function RightRailScreen() {
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
rr.messageBox.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})),
|
||||
[rr.messageBox.items],
|
||||
);
|
||||
|
||||
const sampleCards: CardStackItem[] = useMemo(
|
||||
() =>
|
||||
rr.cards.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
supportText: c.supportText,
|
||||
recommended: c.recommended,
|
||||
})),
|
||||
[rr.cards],
|
||||
);
|
||||
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{rr.sidebar.descriptionBefore}
|
||||
<span className="underline">{rr.sidebar.descriptionLink}</span>
|
||||
{rr.sidebar.descriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
markCreateFlowInteraction();
|
||||
setMessageBoxCheckedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
|
||||
<div
|
||||
className={`mx-auto grid h-auto min-h-0 w-full shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-12 md:pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-stretch justify-start overflow-hidden md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<DecisionMakingSidebar
|
||||
title={rr.sidebar.title}
|
||||
description={sidebarDescription}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title={rr.cardStack.emptyTitle}
|
||||
description={rr.cardStack.emptyDescription}
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
function chipRowsFromLabels(
|
||||
rows: readonly { label: string }[],
|
||||
): ChipOption[] {
|
||||
return rows.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "Selected")
|
||||
.map((o) => o.id);
|
||||
}
|
||||
|
||||
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
|
||||
export function CommunitySizeSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.communitySize;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
ChipOption[]
|
||||
>(() => {
|
||||
const base = chipRowsFromLabels(cs.communitySizes);
|
||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||
return base.map((opt) => ({
|
||||
...opt,
|
||||
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
|
||||
}));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||
setCommunitySizeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.state === "Custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state: selected.has(opt.id)
|
||||
? ("Selected" as const)
|
||||
: ("Unselected" as const),
|
||||
},
|
||||
),
|
||||
);
|
||||
}, [state.selectedCommunitySizeIds]);
|
||||
|
||||
const persistSelection = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setCommunitySizeOptions(next);
|
||||
updateState({
|
||||
selectedCommunitySizeIds: selectedIdsFromOptions(next),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommunitySizeClick = (chipId: string) => {
|
||||
const next: ChipOption[] = communitySizeOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
}
|
||||
: opt,
|
||||
);
|
||||
persistSelection(next);
|
||||
};
|
||||
|
||||
const multiSelectBlock = (
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
size="M"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
addButton={false}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={cs.header.title}
|
||||
description={cs.header.description}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
{multiSelectBlock}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
function createListCustomHandlers(
|
||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||
confirmState: "Unselected" | "Selected",
|
||||
onInteraction?: () => void,
|
||||
) {
|
||||
const touch = () => onInteraction?.();
|
||||
return {
|
||||
onAddClick: () => {
|
||||
touch();
|
||||
setList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
]);
|
||||
},
|
||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||
touch();
|
||||
setList((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: confirmState }
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCustomChipClose: (chipId: string) => {
|
||||
touch();
|
||||
setList((prev) => prev.filter((o) => o.id !== chipId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chipRowsFromLabels(
|
||||
rows: readonly { label: string }[],
|
||||
): ChipOption[] {
|
||||
return rows.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function applySavedSelection(
|
||||
options: ChipOption[],
|
||||
saved: string[] | undefined,
|
||||
): ChipOption[] {
|
||||
const selected = new Set(saved ?? []);
|
||||
return options.map((opt) =>
|
||||
opt.state === "Custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state: selected.has(opt.id)
|
||||
? ("Selected" as const)
|
||||
: ("Unselected" as const),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "Selected")
|
||||
.map((o) => o.id);
|
||||
}
|
||||
|
||||
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
|
||||
export function CommunityStructureSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.communityStructure;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
|
||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||
ChipOption[]
|
||||
>(() =>
|
||||
applySavedSelection(
|
||||
chipRowsFromLabels(cs.organizationTypes),
|
||||
state.selectedOrganizationTypeIds,
|
||||
),
|
||||
);
|
||||
|
||||
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() =>
|
||||
applySavedSelection(
|
||||
chipRowsFromLabels(cs.scaleOptions),
|
||||
state.selectedScaleIds,
|
||||
),
|
||||
);
|
||||
|
||||
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() =>
|
||||
applySavedSelection(
|
||||
chipRowsFromLabels(cs.maturityOptions),
|
||||
state.selectedMaturityIds,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOrganizationTypeOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedOrganizationTypeIds),
|
||||
);
|
||||
}, [state.selectedOrganizationTypeIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds));
|
||||
}, [state.selectedScaleIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaturityOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedMaturityIds),
|
||||
);
|
||||
}, [state.selectedMaturityIds]);
|
||||
|
||||
const organizationCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setOrganizationTypeOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const scaleCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setScaleOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const maturityCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setMaturityOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const persistOrg = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setOrganizationTypeOptions(next);
|
||||
updateState({ selectedOrganizationTypeIds: selectedIdsFromOptions(next) });
|
||||
};
|
||||
|
||||
const persistScale = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setScaleOptions(next);
|
||||
updateState({ selectedScaleIds: selectedIdsFromOptions(next) });
|
||||
};
|
||||
|
||||
const persistMaturity = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setMaturityOptions(next);
|
||||
updateState({ selectedMaturityIds: selectedIdsFromOptions(next) });
|
||||
};
|
||||
|
||||
const handleOrganizationTypeClick = (chipId: string) => {
|
||||
persistOrg(
|
||||
organizationTypeOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleScaleClick = (chipId: string) => {
|
||||
persistScale(
|
||||
scaleOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleMaturityClick = (chipId: string) => {
|
||||
persistMaturity(
|
||||
maturityOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const multiSelectBlock = (
|
||||
<>
|
||||
<MultiSelect
|
||||
label={cs.organizationMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.organizationMultiSelect.addButtonText}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={cs.scaleMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
options={scaleOptions}
|
||||
onChipClick={handleScaleClick}
|
||||
{...scaleCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.scaleMultiSelect.addButtonText}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={cs.maturityMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
options={maturityOptions}
|
||||
onChipClick={handleMaturityClick}
|
||||
{...maturityCustomHandlers}
|
||||
addButton
|
||||
addButtonText={cs.maturityMultiSelect.addButtonText}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<CreateFlowHeaderLockup
|
||||
title={cs.header.title}
|
||||
description={cs.header.description}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
{multiSelectBlock}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
+25
-39
@@ -1,40 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { useState } from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
const TITLE =
|
||||
"Do other stakeholders need to be involved in creating your community?";
|
||||
|
||||
const DESCRIPTION =
|
||||
"Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.";
|
||||
|
||||
const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!";
|
||||
|
||||
/**
|
||||
* Confirm stakeholders step — stacked lockup + MultiSelect (not split columns).
|
||||
* Figma: 21104-46594.
|
||||
*/
|
||||
export default function ConfirmStakeholdersPage() {
|
||||
export function ConfirmStakeholdersScreen() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const t = useTranslation("create.confirmStakeholders");
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
[],
|
||||
);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleAddStakeholder = () => {
|
||||
markCreateFlowInteraction();
|
||||
@@ -65,14 +47,18 @@ export default function ConfirmStakeholdersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--measures-spacing-1800,64px)] pb-28 md:pb-32">
|
||||
<div className="flex w-full max-w-[640px] flex-col gap-[var(--measures-spacing-300,12px)] items-start">
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrowBottomPad"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
@@ -85,21 +71,21 @@ export default function ConfirmStakeholdersPage() {
|
||||
onCustomChipConfirm={handleCustomChipConfirm}
|
||||
onCustomChipClose={handleCustomChipClose}
|
||||
addButton
|
||||
addButtonText="Add stakeholder"
|
||||
addButtonText={t("addStakeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 bottom-[5.25rem] md:bottom-[5.5rem]"
|
||||
className="fixed bottom-[5.25rem] left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 md:bottom-[5.5rem]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="banner"
|
||||
status="positive"
|
||||
title={DRAFT_TOAST_TITLE}
|
||||
title={t("draftToastTitle")}
|
||||
hasLeadingIcon={false}
|
||||
hasBodyText={false}
|
||||
onClose={() => setToastDismissed(true)}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import {
|
||||
CreateFlowStepShell,
|
||||
type CreateFlowContentTopBelowMd,
|
||||
} from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import type { CreateFlowTextStateField } from "../../types";
|
||||
|
||||
type Props = {
|
||||
messageNamespace: string;
|
||||
stateField: CreateFlowTextStateField;
|
||||
maxLength: number;
|
||||
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
||||
mainAlign?: "start" | "center";
|
||||
inputType?: HTMLInputTypeAttribute;
|
||||
showCharacterCount?: boolean;
|
||||
headerJustification?: HeaderLockupJustificationValue;
|
||||
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared narrow-column + TextInput pattern for Create Community text frames.
|
||||
*/
|
||||
export function CreateFlowTextFieldScreen({
|
||||
messageNamespace,
|
||||
stateField,
|
||||
maxLength,
|
||||
mainAlign = "start",
|
||||
inputType = "text",
|
||||
showCharacterCount = true,
|
||||
headerJustification = "left",
|
||||
contentTopBelowMd = "space-1400",
|
||||
}: Props) {
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation(messageNamespace);
|
||||
|
||||
const readFromState = (): string => {
|
||||
const raw = state[stateField];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
};
|
||||
|
||||
const [value, setValue] = useState(() => readFromState());
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = readFromState();
|
||||
if (incoming.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync when context hydrates from server/local
|
||||
setValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [state, stateField]);
|
||||
|
||||
const characterCount = value.length;
|
||||
const hint =
|
||||
showCharacterCount === false
|
||||
? false
|
||||
: t("characterCountTemplate")
|
||||
.replace("{current}", String(characterCount))
|
||||
.replace("{max}", String(maxLength));
|
||||
|
||||
const mainItems =
|
||||
mainAlign === "center" ? "items-center" : "items-start";
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd={contentTopBelowMd}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col gap-[18px] ${mainItems} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification={headerJustification}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<TextInput
|
||||
className="!transition-none"
|
||||
type={inputType}
|
||||
placeholder={t("placeholder")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setValue(v);
|
||||
markCreateFlowInteraction();
|
||||
updateState({ [stateField]: v } as Record<string, string>);
|
||||
}}
|
||||
inputSize={mdUp ? "medium" : "small"}
|
||||
formHeader={false}
|
||||
textHint={hint}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import Upload from "../../../components/controls/Upload";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
|
||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
const u = m.create.communityUpload;
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
|
||||
const handleUploadClick = () => {
|
||||
markCreateFlowInteraction();
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-[18px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<CreateFlowHeaderLockup
|
||||
title={u.title}
|
||||
description={u.description}
|
||||
justification="center"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Upload
|
||||
active={true}
|
||||
showHelpIcon={false}
|
||||
hintText={u.hintText}
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
function createListCustomHandlers(
|
||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||
confirmState: "Unselected" | "Selected",
|
||||
onInteraction?: () => void,
|
||||
) {
|
||||
const touch = () => onInteraction?.();
|
||||
return {
|
||||
onAddClick: () => {
|
||||
touch();
|
||||
setList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
]);
|
||||
},
|
||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||
touch();
|
||||
setList((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: confirmState }
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
},
|
||||
onCustomChipClose: (chipId: string) => {
|
||||
touch();
|
||||
setList((prev) => prev.filter((o) => o.id !== chipId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select page for the create flow
|
||||
*
|
||||
* Displays selection options using HeaderLockup and MultiSelect components.
|
||||
* Responsive layout: two-column at 640px+, single column below 640px.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
|
||||
*/
|
||||
export default function SelectPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "1 member", state: "Unselected" },
|
||||
{ id: "2", label: "2-10 members", state: "Unselected" },
|
||||
{ id: "3", label: "10-24 members", state: "Unselected" },
|
||||
{ id: "4", label: "24-64 members", state: "Unselected" },
|
||||
{ id: "5", label: "64-128 members", state: "Unselected" },
|
||||
{ id: "6", label: "125-1000 members", state: "Unselected" },
|
||||
{ id: "7", label: "1000+ members", state: "Unselected" },
|
||||
]);
|
||||
|
||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "Non-profit", state: "Unselected" },
|
||||
{ id: "2", label: "For-profit", state: "Unselected" },
|
||||
{ id: "3", label: "Community", state: "Unselected" },
|
||||
{ id: "4", label: "Educational", state: "Unselected" },
|
||||
]);
|
||||
|
||||
const [governanceStyleOptions, setGovernanceStyleOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "Democratic", state: "Unselected" },
|
||||
{ id: "2", label: "Consensus", state: "Unselected" },
|
||||
{ id: "3", label: "Hierarchical", state: "Unselected" },
|
||||
{ id: "4", label: "Flat", state: "Unselected" },
|
||||
]);
|
||||
|
||||
const communityCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setCommunitySizeOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const organizationCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setOrganizationTypeOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
const governanceCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setGovernanceStyleOptions,
|
||||
"Unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCommunitySizeClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setCommunitySizeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleOrganizationTypeClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setOrganizationTypeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleGovernanceStyleClick = (chipId: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setGovernanceStyleOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
{effectiveMdOrLarger ? (
|
||||
// Two-column layout for 640px+
|
||||
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
|
||||
{/* Left column: HeaderLockup */}
|
||||
<div className="flex flex-[1_0_0] flex-col gap-[var(--measures-spacing-200,8px)] items-start justify-center max-w-[640px] min-h-px min-w-px py-[12px]">
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Three MultiSelect components */}
|
||||
<div className="flex flex-[1_0_0] flex-col gap-[var(--measures-spacing-800,32px)] items-start max-w-[640px] min-h-px min-w-px">
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
{...communityCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={governanceStyleOptions}
|
||||
onChipClick={handleGovernanceStyleClick}
|
||||
{...governanceCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Single column layout below 640px
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-400,16px)] items-start w-full max-w-[640px]">
|
||||
{/* HeaderLockup */}
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
justification="left"
|
||||
size="M"
|
||||
/>
|
||||
|
||||
{/* Three MultiSelect components */}
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
{...communityCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={governanceStyleOptions}
|
||||
onChipClick={handleGovernanceStyleClick}
|
||||
{...governanceCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import TextInput from "../../components/controls/TextInput";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Text page for the create flow
|
||||
*
|
||||
* Displays a text input field for user input using HeaderLockup and TextInput components.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
|
||||
*/
|
||||
export default function TextPage() {
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const [value, setValue] = useState(() =>
|
||||
typeof state.title === "string" ? state.title : "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = state.title;
|
||||
if (typeof incoming !== "string" || incoming.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local
|
||||
setValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [state.title]);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const maxLength = 48;
|
||||
const characterCount = value.length;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[18px] items-start w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */}
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* TextInput: medium size at 640px+, small size below 640px */}
|
||||
<div className="w-full">
|
||||
<TextInput
|
||||
placeholder="Enter your community name"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setValue(v);
|
||||
markCreateFlowInteraction();
|
||||
updateState({ title: v });
|
||||
}}
|
||||
inputSize={effectiveMdOrLarger ? "medium" : "small"}
|
||||
formHeader={false}
|
||||
textHint={`${characterCount}/${maxLength}`}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
-5
@@ -6,13 +6,17 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create rule flow
|
||||
* Valid step IDs for the create rule flow (URL segment after `/create/`).
|
||||
* Create Community order matches Figma; `review` closes that stage per design.
|
||||
*/
|
||||
export type CreateFlowStep =
|
||||
| "informational"
|
||||
| "text"
|
||||
| "select"
|
||||
| "upload"
|
||||
| "community-name"
|
||||
| "community-size"
|
||||
| "community-context"
|
||||
| "community-structure"
|
||||
| "community-upload"
|
||||
| "community-save"
|
||||
| "review"
|
||||
| "cards"
|
||||
| "right-rail"
|
||||
@@ -20,6 +24,13 @@ export type CreateFlowStep =
|
||||
| "final-review"
|
||||
| "completed";
|
||||
|
||||
/** String keys used by generic text-field steps for `CreateFlowState`. */
|
||||
export type CreateFlowTextStateField =
|
||||
| "title"
|
||||
| "summary"
|
||||
| "communityContext"
|
||||
| "communitySaveEmail";
|
||||
|
||||
/**
|
||||
* Flow state for inputs across create-flow steps.
|
||||
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||
@@ -28,6 +39,18 @@ export type CreateFlowStep =
|
||||
export interface CreateFlowState {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
/** Additional copy fields for multi-step Create Community text frames (Figma). */
|
||||
communityContext?: string;
|
||||
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
|
||||
communitySaveEmail?: string;
|
||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||
selectedCommunitySizeIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (organization types). */
|
||||
selectedOrganizationTypeIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (scale). */
|
||||
selectedScaleIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (maturity). */
|
||||
selectedMaturityIds?: string[];
|
||||
currentStep?: CreateFlowStep;
|
||||
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||
sections?: Record<string, unknown>[];
|
||||
@@ -51,7 +74,7 @@ export interface CreateFlowContextValue {
|
||||
clearState: () => void;
|
||||
/**
|
||||
* True after the user edits any template control (pages use local state until wired to `state`).
|
||||
* Drives Save & Exit visibility together with `hasCreateFlowUserInput(state)`.
|
||||
* Drives Save & Exit visibility together with hasCreateFlowUserInput (utils/hasCreateFlowUserInput.ts).
|
||||
*/
|
||||
interactionTouched: boolean;
|
||||
markCreateFlowInteraction: () => void;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import Upload from "../../components/controls/Upload";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
/**
|
||||
* Upload page for the create flow
|
||||
*
|
||||
* Displays upload functionality using HeaderLockup and Upload components.
|
||||
* Responsive layout: centered at 640px+, left-aligned below 640px.
|
||||
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleUploadClick = () => {
|
||||
markCreateFlowInteraction();
|
||||
// TODO: Handle upload button click (e.g. open file picker)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[18px] items-center w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Center justification at 640px+, left below 640px */}
|
||||
<HeaderLockup
|
||||
title="How should conflicts be resolved?"
|
||||
description="This will be the name of your community"
|
||||
justification={effectiveMdOrLarger ? "center" : "left"}
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* Upload component: no label in create flow, max width 474px */}
|
||||
<div className="w-full max-w-[474px]">
|
||||
<Upload
|
||||
active={true}
|
||||
showHelpIcon={true}
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreateFlowState } from "./types";
|
||||
import type { CreateFlowState } from "../types";
|
||||
import { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState";
|
||||
|
||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||
@@ -23,8 +24,10 @@ export function readAnonymousCreateFlowState(): CreateFlowState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as CreateFlowState;
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? migrateLegacyCreateFlowState(parsed)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ProportionBarState } from "../../components/progress/ProportionBar/ProportionBar.types";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
||||
|
||||
/**
|
||||
* One `ProportionBarState` per index in `FLOW_STEP_ORDER` (same length).
|
||||
* Third Create Community step (`community-structure`) uses `1-2` per Figma.
|
||||
*/
|
||||
const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
||||
"1-0", // informational
|
||||
"1-1", // community-name
|
||||
"1-2", // community-structure
|
||||
"1-3", // community-context
|
||||
"1-4", // community-size
|
||||
"1-5", // community-upload
|
||||
"2-0", // community-save
|
||||
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
||||
"2-2", // cards
|
||||
"3-0", // right-rail
|
||||
"3-1", // confirm-stakeholders
|
||||
"3-2", // final-review
|
||||
"3-2", // completed
|
||||
] as const;
|
||||
|
||||
if (PROPORTION_BY_STEP_INDEX.length !== FLOW_STEP_ORDER.length) {
|
||||
throw new Error(
|
||||
"createFlowProportionProgress: PROPORTION_BY_STEP_INDEX length must match FLOW_STEP_ORDER",
|
||||
);
|
||||
}
|
||||
|
||||
export function getProportionBarProgressForCreateFlowStep(
|
||||
step: CreateFlowStep | null | undefined,
|
||||
): ProportionBarState {
|
||||
const idx = getStepIndex(step);
|
||||
if (idx < 0) return "1-0";
|
||||
return PROPORTION_BY_STEP_INDEX[idx] ?? "1-0";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Figma layout families for the create flow (not encoded in the URL).
|
||||
* Registry and `app/create/screens/` are organized by these kinds.
|
||||
*/
|
||||
export type CreateFlowLayoutKind =
|
||||
| "informational"
|
||||
| "text"
|
||||
| "select"
|
||||
| "upload"
|
||||
| "review"
|
||||
| "card"
|
||||
| "right-rail"
|
||||
| "completed";
|
||||
|
||||
export interface CreateFlowScreenDefinition {
|
||||
layoutKind: CreateFlowLayoutKind;
|
||||
/** Figma node id (file Community-Rule-System), dev mode. */
|
||||
figmaNodeId: string;
|
||||
/**
|
||||
* Namespace for `useTranslation`, e.g. `create.communityName`.
|
||||
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
|
||||
*/
|
||||
messageNamespace: string;
|
||||
/** Match legacy `text` step: main area vertically centered below `md`. */
|
||||
centeredBodyBelowMd: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry: **distinct URL (`CreateFlowStep`) → Figma + layout**.
|
||||
* Source of truth for product order remains `FLOW_STEP_ORDER` in `flowSteps.ts`.
|
||||
*/
|
||||
export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
CreateFlowStep,
|
||||
CreateFlowScreenDefinition
|
||||
> = {
|
||||
/** Figma: Flow — Informational (node 20094-16005). */
|
||||
informational: {
|
||||
layoutKind: "informational",
|
||||
figmaNodeId: "20094-16005",
|
||||
messageNamespace: "create.informational",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-name": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-18187",
|
||||
messageNamespace: "create.communityName",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-size": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-41317",
|
||||
messageNamespace: "create.communitySize",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-context": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-41243",
|
||||
messageNamespace: "create.communityContext",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-structure": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-18244",
|
||||
messageNamespace: "create.communityStructure",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-upload": {
|
||||
layoutKind: "upload",
|
||||
figmaNodeId: "20094-41524",
|
||||
messageNamespace: "create.communityUpload",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-save": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20097-14948",
|
||||
messageNamespace: "create.communitySave",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
review: {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "19706-12135",
|
||||
messageNamespace: "create.review",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
cards: {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "TBD-cards",
|
||||
messageNamespace: "create.communication",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"right-rail": {
|
||||
layoutKind: "right-rail",
|
||||
figmaNodeId: "TBD-right-rail",
|
||||
messageNamespace: "create.rightRail",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"confirm-stakeholders": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "21104-46594",
|
||||
messageNamespace: "create.confirmStakeholders",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"final-review": {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "20907-212767",
|
||||
messageNamespace: "create.finalReview",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
completed: {
|
||||
layoutKind: "completed",
|
||||
figmaNodeId: "20907-213286",
|
||||
messageNamespace: "create.completed",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function createFlowStepUsesCenteredTextLayout(
|
||||
step: CreateFlowStep | null,
|
||||
): boolean {
|
||||
if (!step) return false;
|
||||
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* Step definitions and helpers for the Create Rule Flow
|
||||
*
|
||||
* Single source of truth for step order and navigation helpers.
|
||||
* Order matches Figma Create Community (frames 1–8) then later stages.
|
||||
* `community-structure` precedes `community-context` and `community-size` (Figma frame 3 vs 5 swap).
|
||||
*/
|
||||
|
||||
import type { CreateFlowStep } from "../types";
|
||||
@@ -11,9 +13,12 @@ import type { CreateFlowStep } from "../types";
|
||||
*/
|
||||
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
||||
"informational",
|
||||
"text",
|
||||
"select",
|
||||
"upload",
|
||||
"community-name",
|
||||
"community-structure",
|
||||
"community-context",
|
||||
"community-size",
|
||||
"community-upload",
|
||||
"community-save",
|
||||
"review",
|
||||
"cards",
|
||||
"right-rail",
|
||||
@@ -75,3 +80,23 @@ export function isValidStep(
|
||||
(VALID_STEPS as readonly string[]).includes(step)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `/create/{screenId}` (and optional trailing segments) from pathname.
|
||||
* Returns null for non-wizard paths (e.g. `/create/review-template/...`).
|
||||
*/
|
||||
export function parseCreateFlowScreenFromPathname(
|
||||
pathname: string | null,
|
||||
): CreateFlowStep | null {
|
||||
if (!pathname || pathname.length === 0) return null;
|
||||
if (pathname.includes("/create/review-template/")) return null;
|
||||
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const createIdx = parts.indexOf("create");
|
||||
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
||||
|
||||
const segment = parts[createIdx + 1];
|
||||
if (segment === "review-template") return null;
|
||||
|
||||
return isValidStep(segment) ? segment : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateFlowState } from "./types";
|
||||
import type { CreateFlowState } from "../types";
|
||||
|
||||
const IGNORED_KEYS = new Set<string>(["currentStep"]);
|
||||
|
||||
@@ -190,7 +190,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
**Implementation:**
|
||||
|
||||
1. **Hydration:** **Done:** [SignedInDraftHydration](app/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/create/layout.tsx).
|
||||
2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional.
|
||||
2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/utils/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional.
|
||||
3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx).
|
||||
4. **Save failures (UX):** **Done (CR-76):** Dismissible banner with server `message` (no second confirm to leave); post-login transfer shows reason; unit tests in `tests/unit/saveDraftToServer.test.ts`. Retry/backoff remains optional.
|
||||
5. **Tests:** `saveDraftToServer` unit tests; [draftHydrationUtils](lib/create/draftHydrationUtils.ts) unit tests. Playwright against Next standalone + route mocks for `/api/auth/session` was flaky here; cover hydration with **manual QA** (signed in + sync on + server draft) or add a future E2E with a dedicated auth fixture.
|
||||
@@ -210,7 +210,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Completing the flow persists a **PublishedRule** via existing [publishRule](lib/create/api.ts).
|
||||
|
||||
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on [app/create/final-review/page.tsx](app/create/final-review/page.tsx) or [completed/page.tsx](app/create/completed/page.tsx) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||
**Context:** [lib/create/api.ts](lib/create/api.ts) already wraps `POST /api/rules`. UI on the `final-review` / `completed` steps (see [app/create/screens/CreateFlowScreenView.tsx](app/create/screens/CreateFlowScreenView.tsx) and `app/create/screens/`) must call it with `{ title, summary?, document }` derived from `CreateFlowState`.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
@@ -258,7 +258,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
**Goal:** Home or create entry surfaces use live template data instead of only static i18n JSON.
|
||||
|
||||
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and [app/create/[step]/page.tsx](app/create/[step]/page.tsx) placeholders reference future template work (CR-51–55).
|
||||
**Context:** [RuleStack.view.tsx](app/components/sections/RuleStack/RuleStack.view.tsx) and create entry surfaces reference future template work. Wizard URLs are static segments under `app/create/`; see [`docs/create-flow.md`](create-flow.md) and **Ticket 17** for the canonical custom flow.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
@@ -271,7 +271,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
- [ ] Changing a template row in Prisma Studio reflects after refresh (or revalidate).
|
||||
- [ ] No layout shift regression on LCP-critical pages (use skeletons).
|
||||
|
||||
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), [app/create/[step]/page.tsx](app/create/[step]/page.tsx) or related, possibly new `lib/templates/fetchTemplates.ts`.
|
||||
**Files:** [app/components/sections/RuleStack/](app/components/sections/RuleStack/), create-flow entry routes under [app/create/](app/create/), possibly new `lib/templates/fetchTemplates.ts`.
|
||||
|
||||
**Follow-up:** **Ticket 16** — dynamic recommendations from authoring spreadsheets and create-flow answers.
|
||||
|
||||
@@ -305,6 +305,37 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
|
||||
---
|
||||
|
||||
## Ticket 17 — Canon custom create-rule wizard (routes, resume, progress) + docs
|
||||
|
||||
**Depends on:** none for documentation; soft optional **CR-73**, **CR-76**, **CR-77** for payload/resume/publish alignment.
|
||||
|
||||
**Goal:** Establish the **official custom** create-rule flow (ordered steps, URLs, persistence, entry points, **Figma three-stage framing**) in repo docs and close gaps between that spec and the implementation (routing clutter, progress UI, step source of truth, resume vs URL).
|
||||
|
||||
**Context:** Step order lives in [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts). Wizard screens render from [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) plus [`CREATE_FLOW_SCREEN_REGISTRY`](app/create/utils/createFlowScreenRegistry.ts) (Figma node + layout family per slug). [`docs/create-flow.md`](create-flow.md) is the **canonical** URL/persistence summary: **Create Community** (eight semantic steps ending at `review`) → **Create Custom CommunityRule** → **Review and complete**. **Full create-from-template** will likely use **additional route(s)** when product defines it; **`/create/review-template/[slug]`** remains auxiliary preview only. **Template → `final-review` or mid-wizard prefill** is **out of scope** here (future ticket); `/create/informational?template=` is a **no-op** until then.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Keep [`docs/create-flow.md`](create-flow.md) in sync with product/Figma (stage ↔ step mapping, future template routes).
|
||||
2. ~~Remove legacy [`app/create/[step]/page.tsx`](app/create/[step]/page.tsx)~~ — replaced by [`app/create/[screenId]/page.tsx`](app/create/[screenId]/page.tsx) with real screens; unknown slugs `notFound()`.
|
||||
3. Unify **step source of truth**: URL via [`useCreateFlowNavigation`](app/create/hooks/useCreateFlowNavigation.ts) vs unused [`CreateFlowContext`](app/create/context/CreateFlowContext.tsx) `currentStep` — pick one model; align [`useCreateFlowExit`](app/create/hooks/useCreateFlowExit.ts) / draft payload if needed.
|
||||
4. **Resume:** After [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx), decide redirect to `/create/${state.currentStep}` vs stay on current URL; test or document.
|
||||
5. Wire [`CreateFlowFooter`](app/components/utility/CreateFlowFooter/) `ProportionBar` to step progress from `FLOW_STEP_ORDER` (and `review-template` / `completed` exceptions per design); optional **two-level progress** (stage + step within stage) when design specifies.
|
||||
6. When Figma hands off, surface **stage labels** in create shell (top nav, footer, or step chrome) using the mapping in `create-flow.md`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] [`docs/create-flow.md`](create-flow.md) matches shipped behavior or lists known gaps, including **Figma three-stage** mapping and **future template route** note.
|
||||
- [ ] No misleading dynamic step placeholder for valid wizard URLs.
|
||||
- [ ] Footer progress reflects step index **or** doc/issue records a deliberate deferral with design sign-off.
|
||||
- [ ] Hydration + `currentStep` behavior is verified (redirect vs stay).
|
||||
- [ ] `?template=` documented as deferred; no implied “template customize → full wizard” parity.
|
||||
|
||||
**Files:** [`docs/create-flow.md`](create-flow.md), [`app/create/`](app/create/), [`app/components/utility/CreateFlowFooter/`](app/components/utility/CreateFlowFooter/), optionally [`docs/backend-roadmap.md`](backend-roadmap.md) §12 cross-links.
|
||||
|
||||
**Linear:** [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) (**Backlog**). **Parallel** to templates (7–8) and publish (6); not part of **CR-72 → CR-83**.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
|
||||
|
||||
**Depends on:** none (orthogonal).
|
||||
@@ -509,14 +540,15 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
||||
| 14 | 14 | Session lifecycle + cleanup |
|
||||
| 15 | 15 | Profile + account (Figma profile) |
|
||||
| 16 | 16 | Template matrix + xlsx ingestion |
|
||||
| 17 | 17 | Canon create-flow (custom path) |
|
||||
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**.
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Ticket 17** (**[CR-89](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) and tracks UX/code alignment (progress bar, resume URL, `[step]` cleanup); **parallel** to publish and templates. **Tickets 13–14** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done** — **CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**.
|
||||
|
||||
---
|
||||
|
||||
## Linear (Community-rule team)
|
||||
|
||||
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), not in the CR-72–83 sequence.
|
||||
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), **CR-89** / Ticket 17 (canon create-flow + implementation gaps), not in the CR-72–83 sequence.
|
||||
|
||||
| Doc ticket | Linear | Title (short) |
|
||||
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
@@ -536,6 +568,7 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts +
|
||||
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
|
||||
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
|
||||
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion |
|
||||
| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Temporary working notes for building the backend. Safe to delete once the stack
|
||||
- **Next.js 16** single repo ([`package.json`](package.json)).
|
||||
- **PostgreSQL + Prisma**: schema and migrations under `prisma/`; product APIs under `app/api/*` (health, auth/magic-link, session, drafts, rules, templates, web-vitals).
|
||||
- **Server modules** in `lib/server/` (db, session, mail, rate limiting, etc.).
|
||||
- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users start a **fresh** in-memory session per “Create rule”; **Save & Exit** (from `select` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links.
|
||||
- **Create flow:** **Anonymous** users mirror in-progress state to **`create-flow-anonymous`** in `localStorage`; **Exit** opens the save-progress magic-link modal; after verify, [`PostLoginDraftTransfer`](app/create/PostLoginDraftTransfer.tsx) can **PUT** `/api/drafts/me` when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**. **Signed-in** users get a **fresh** in-memory session per “Create rule” entry, but with sync on the layout may **hydrate** from **`GET /api/drafts/me`** via [`SignedInDraftHydration`](app/create/SignedInDraftHydration.tsx); **Save & Exit** (from `community-structure` onward) **PUT**s when sync is on. **Log in** from the marketing header uses the global modal ([`AuthModalProvider`](app/contexts/AuthModalContext.tsx)); **`/login`** remains for verify errors and deep links. **Step order and URLs:** [`docs/create-flow.md`](docs/create-flow.md) and [`app/create/utils/flowSteps.ts`](app/create/utils/flowSteps.ts).
|
||||
- **Web vitals** [`app/api/web-vitals/route.ts`](app/api/web-vitals/route.ts) still use **file-based** storage under `.next` (not suitable for multi-instance production).
|
||||
- **CI:** [`.gitea/workflows/ci.yaml`](.gitea/workflows/ci.yaml) (build, test, lint, `prisma validate`); no in-repo production deploy definition.
|
||||
|
||||
@@ -138,7 +138,7 @@ Match the current API behavior; tighten as product evolves:
|
||||
|
||||
**Backend behavior already in the repo:** Steps **5–10** match implemented Route Handlers and middleware (`lib/server/*`). **Step 11** (web vitals) is **not** production-ready (files under `.next`); treat as follow-up work aligned with §7.
|
||||
|
||||
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
**Product / frontend still open (not only “backend exists”):** Sign-in UI, wiring publish from the create flow, template seed + UI consumption (flat list first), **canon create-flow alignment** (Ticket 17 / [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) — progress bar, resume URL, `[step]` cleanup; spec in [`docs/create-flow.md`](create-flow.md)), **spreadsheet-driven template recommendations** (Ticket 16 / [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) — after v1 templates), **profile / my rules dashboard** (Ticket 15)—see §12 and [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -218,7 +218,7 @@ npm run dev
|
||||
|
||||
## 12. Frontend hook-up
|
||||
|
||||
**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** “Create rule” does **not** auto-load server drafts yet (profile “open draft” is future).
|
||||
**Step 1.** **Anonymous** create flow: in-progress state is stored in **`create-flow-anonymous`** (`localStorage`). **Signed-in** users: when **`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`**, the create layout may **hydrate** in-memory flow state from **`GET /api/drafts/me`** once per session ([`SignedInDraftHydration`](../app/create/SignedInDraftHydration.tsx)), including conflict handling if anonymous storage also has data. Without sync, signed-in progress stays **in memory** until **Save & Exit** (no automatic server read on entry). **Canonical wizard step order, URLs, and Figma product stages** (**Create Community** → **Create Custom CommunityRule** → **Review and complete**) are documented in [`docs/create-flow.md`](create-flow.md). The route **`/create/review-template/[slug]`** is an **auxiliary** template preview (not a numbered wizard step); a **full create-from-template** path will likely be **separate route(s)** when defined. **Prefilling the wizard or landing on `final-review` from a template** is **not** shipped yet — see **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** / Ticket 17 in [docs/backend-linear-tickets.md](backend-linear-tickets.md).
|
||||
|
||||
**Step 2.** Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` to enable **PUT** on **Save & Exit** and after **magic-link transfer** from the save-progress exit modal.
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Create rule flow (custom wizard) — canonical reference
|
||||
|
||||
Product/engineering reference for the **custom** “Create rule” experience: URL order, persistence, and entry points. **Implementation work** to align code with this doc (progress bar, resume redirects, etc.) is tracked in Linear **[CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)** and [docs/backend-linear-tickets.md](backend-linear-tickets.md) **Ticket 17**.
|
||||
|
||||
---
|
||||
|
||||
## Product stages (Figma)
|
||||
|
||||
The Figma **Create Community** sequence is the **source of truth** for the first segment of the wizard (eight frames). After **`review`**, the flow continues with **Create Custom CommunityRule** and **Review and complete** stages. The shipped URL sequence in [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) **follows that trajectory**; stages are a **product** slice of that linear order, not separate routers today.
|
||||
|
||||
| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) |
|
||||
| --- | --- | --- |
|
||||
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `community-upload` → `community-save` → `review` |
|
||||
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `cards` → `right-rail` |
|
||||
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
|
||||
|
||||
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
|
||||
|
||||
**Create from template (future):** A full **template-driven** create path is **not** finalized; it will likely live on **additional route(s)** (and may reuse these stages where it overlaps the custom trajectory). Today, **`/create/review-template/[slug]`** is only an auxiliary **preview** in the create shell; it is **not** a Figma stage and not the final template-create entry. See **Out of scope** in [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo).
|
||||
|
||||
---
|
||||
|
||||
## Step order and URLs
|
||||
|
||||
Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts) and the [`CreateFlowStep`](../app/create/types.ts) type. Wizard steps use a **single dynamic route**: [`app/create/[screenId]/page.tsx`](../app/create/[screenId]/page.tsx), which validates `screenId` and renders [`CreateFlowScreenView`](../app/create/screens/CreateFlowScreenView.tsx). Implementation files are grouped under [`app/create/screens/`](../app/create/screens/) by Figma **layout kind** (subfolders: informational, text, select, upload, review, card, right-rail, completed). **`/create`** redirects to the first step.
|
||||
|
||||
| Order | Figma stage | Step ID (`screenId`) | Path |
|
||||
| ----: | ----------- | -------------------- | ---- |
|
||||
| 1 | Create Community | `informational` | `/create/informational` |
|
||||
| 2 | Create Community | `community-name` | `/create/community-name` |
|
||||
| 3 | Create Community | `community-structure` | `/create/community-structure` |
|
||||
| 4 | Create Community | `community-context` | `/create/community-context` |
|
||||
| 5 | Create Community | `community-size` | `/create/community-size` |
|
||||
| 6 | Create Community | `community-upload` | `/create/community-upload` |
|
||||
| 7 | Create Community | `community-save` | `/create/community-save` |
|
||||
| 8 | Create Community (review frame) | `review` | `/create/review` |
|
||||
| 9 | Create Custom CommunityRule | `cards` | `/create/cards` |
|
||||
| 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
|
||||
| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
|
||||
| 12 | Review and complete | `final-review` | `/create/final-review` |
|
||||
| 13 | Review and complete | `completed` | `/create/completed` |
|
||||
|
||||
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
|
||||
|
||||
Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/create/hooks/useCreateFlowNavigation.ts).
|
||||
|
||||
---
|
||||
|
||||
## Auxiliary route (not a wizard step or Figma stage)
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
|
||||
|
||||
From that page, **Customize** currently navigates to `/create/informational?template=<slug>`. The **`template` query parameter is reserved**; the informational step **does not** yet read it to prefill `CreateFlowState`. **Starting the wizard from a template at `final-review` or any mid-flow step** is **out of scope** until a dedicated product ticket ships. A **full create-from-template** experience will **likely use separate route(s)** when product and eng define it (may still align conceptually with the same three stages where behavior overlaps the custom path).
|
||||
|
||||
---
|
||||
|
||||
## Persistence and exit
|
||||
|
||||
| Mode | Where progress lives | Save & Exit / server draft |
|
||||
| --- | --- | --- |
|
||||
| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [backend-linear-tickets.md](backend-linear-tickets.md)). |
|
||||
| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Sign out** is on profile, not in the create top nav. |
|
||||
|
||||
Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/backend-roadmap.md`](backend-roadmap.md) §12.
|
||||
|
||||
---
|
||||
|
||||
## Known implementation gaps (tracked on CR-89)
|
||||
|
||||
- **URL vs `currentStep` in saved draft:** hydration may merge server JSON without redirecting to `state.currentStep`; confirm product behavior and fix or document.
|
||||
- **Inner “text/select shells”:** deferred until Create Community is stable; screens use **`CreateFlowStepShell`** only for Stage 1.
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [docs/backend-roadmap.md](backend-roadmap.md) §12 — Frontend hook-up
|
||||
- [docs/backend-linear-tickets.md](backend-linear-tickets.md) — Tickets 4, 5, 6, 17
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
import { migrateLegacyCreateFlowState } from "./migrateLegacyCreateFlowState";
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
@@ -77,7 +78,9 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
|
||||
if (!data.draft?.payload || typeof data.draft.payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
return data.draft.payload as CreateFlowState;
|
||||
return migrateLegacyCreateFlowState(
|
||||
data.draft.payload as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const DRAFT_SAVE_NETWORK_ERROR =
|
||||
|
||||
@@ -50,11 +50,16 @@ export function buildPublishPayload(
|
||||
return { ok: false, error: "missingCommunityName" };
|
||||
}
|
||||
|
||||
let summary: string | undefined;
|
||||
if (typeof state.summary === "string") {
|
||||
const t = state.summary.trim();
|
||||
if (t.length > 0) summary = t;
|
||||
}
|
||||
const firstNonEmpty = (...candidates: unknown[]): string | undefined => {
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== "string") continue;
|
||||
const t = c.trim();
|
||||
if (t.length > 0) return t;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let summary = firstNonEmpty(state.summary, state.communityContext);
|
||||
|
||||
let sections = parseSectionsFromCreateFlowState(state);
|
||||
if (sections.length === 0) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Client fetch for curated rule templates (GET /api/templates).
|
||||
*/
|
||||
|
||||
export type RuleTemplateDto = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
body: unknown;
|
||||
sortOrder: number;
|
||||
featured: boolean;
|
||||
};
|
||||
|
||||
type TemplatesResponse = { templates?: RuleTemplateDto[] };
|
||||
|
||||
export type FetchTemplatesOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function isAbortError(e: unknown): boolean {
|
||||
return (
|
||||
(e instanceof DOMException && e.name === "AbortError") ||
|
||||
(e instanceof Error && e.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
/** For callers that `catch` around `fetchTemplates` / `fetchTemplateBySlug`. */
|
||||
export function isTemplatesFetchAborted(e: unknown): boolean {
|
||||
return isAbortError(e);
|
||||
}
|
||||
|
||||
export async function fetchTemplates(
|
||||
options?: FetchTemplatesOptions,
|
||||
): Promise<RuleTemplateDto[] | { error: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/templates", {
|
||||
credentials: "include",
|
||||
signal: options?.signal,
|
||||
});
|
||||
const data = (await res.json()) as TemplatesResponse & { error?: string };
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? data.error
|
||||
: "Could not load templates",
|
||||
};
|
||||
}
|
||||
return Array.isArray(data.templates) ? data.templates : [];
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
throw e;
|
||||
}
|
||||
return { error: "Could not load templates" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTemplateBySlug(
|
||||
slug: string,
|
||||
options?: FetchTemplatesOptions,
|
||||
): Promise<RuleTemplateDto | null | { error: string }> {
|
||||
const result = await fetchTemplates(options);
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return result.find((t) => t.slug === slug) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const EMAIL_MAX_LEN = 254;
|
||||
|
||||
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
||||
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const t = value.trim();
|
||||
if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
|
||||
/**
|
||||
* Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`).
|
||||
* Safe to run on any parsed draft payload before merging into context.
|
||||
*/
|
||||
export function migrateLegacyCreateFlowState(
|
||||
raw: Record<string, unknown> | null | undefined,
|
||||
): CreateFlowState {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
const next: Record<string, unknown> = { ...raw };
|
||||
if (typeof next.communityReflection === "string") {
|
||||
if (
|
||||
next.communitySaveEmail === undefined ||
|
||||
next.communitySaveEmail === ""
|
||||
) {
|
||||
next.communitySaveEmail = next.communityReflection;
|
||||
}
|
||||
}
|
||||
delete next.communityReflection;
|
||||
if (next.currentStep === "community-reflection") {
|
||||
next.currentStep = "community-save";
|
||||
}
|
||||
return next as CreateFlowState;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Category } from "../../app/components/cards/RuleCard/RuleCard.types";
|
||||
import type { ChipOption } from "../../app/components/controls/MultiSelect/MultiSelect.types";
|
||||
|
||||
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return typeof o.title === "string" && typeof o.body === "string";
|
||||
}
|
||||
|
||||
function isDocumentSection(
|
||||
x: unknown,
|
||||
): x is {
|
||||
categoryName: string;
|
||||
entries: { title: string; body: string }[];
|
||||
} {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
if (typeof o.categoryName !== "string") return false;
|
||||
if (!Array.isArray(o.entries)) return false;
|
||||
return o.entries.every(isDocumentEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API template `body` (published-rule document shape) to RuleCard category rows.
|
||||
*/
|
||||
export function templateBodyToCategories(body: unknown): Category[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const sections = (body as Record<string, unknown>).sections;
|
||||
if (!Array.isArray(sections)) return [];
|
||||
|
||||
const out: Category[] = [];
|
||||
for (const raw of sections) {
|
||||
if (!isDocumentSection(raw)) continue;
|
||||
const chipOptions: ChipOption[] = raw.entries.map((e, i) => ({
|
||||
id: `${raw.categoryName}-${i}`,
|
||||
label: e.title,
|
||||
state: "unselected",
|
||||
}));
|
||||
out.push({
|
||||
name: raw.categoryName,
|
||||
chipOptions,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary line under tag rows: prefer API description; else first entry bodies (short).
|
||||
*/
|
||||
export function templateSummaryFromBody(
|
||||
description: string | null | undefined,
|
||||
body: unknown,
|
||||
): string {
|
||||
const d = typeof description === "string" ? description.trim() : "";
|
||||
if (d.length > 0) return d;
|
||||
|
||||
if (!body || typeof body !== "object") return "";
|
||||
const sections = (body as Record<string, unknown>).sections;
|
||||
if (!Array.isArray(sections)) return "";
|
||||
for (const s of sections) {
|
||||
if (!isDocumentSection(s)) continue;
|
||||
const first = s.entries[0];
|
||||
if (isDocumentEntry(first) && first.body.trim()) {
|
||||
return first.body.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -852,3 +852,27 @@ export type ButtonStateValue =
|
||||
| "Active"
|
||||
| "Hover"
|
||||
| "Disabled";
|
||||
|
||||
/**
|
||||
* ProportionBar layout variant (Figma uses a segmented track in the create-flow footer).
|
||||
*/
|
||||
export type ProportionBarVariantValue =
|
||||
| "default"
|
||||
| "segmented"
|
||||
| "Default"
|
||||
| "Segmented";
|
||||
|
||||
/**
|
||||
* Normalize ProportionBar variant (Figma PascalCase vs codebase lowercase).
|
||||
*/
|
||||
export function normalizeProportionBarVariant(
|
||||
value: string | undefined,
|
||||
defaultValue: "default" | "segmented" = "default",
|
||||
): "default" | "segmented" {
|
||||
if (!value) return defaultValue;
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === "default" || normalized === "segmented") {
|
||||
return normalized;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
|
||||
/**
|
||||
* Curated templates for public list UIs (same query as GET /api/templates).
|
||||
* Returns [] when the database is not configured or on query failure.
|
||||
*/
|
||||
export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await prisma.ruleTemplate.findMany({
|
||||
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
sortOrder: true,
|
||||
featured: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ export const createFlowStateSchema = z
|
||||
.object({
|
||||
title: z.string().max(500).optional(),
|
||||
summary: z.string().max(8000).optional(),
|
||||
communityContext: z.string().max(48).optional(),
|
||||
communitySaveEmail: z.string().max(320).optional(),
|
||||
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
||||
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
||||
selectedScaleIds: z.array(z.string()).optional(),
|
||||
selectedMaturityIds: z.array(z.string()).optional(),
|
||||
currentStep: createFlowStepSchema.optional(),
|
||||
sections: z.array(z.unknown()).optional(),
|
||||
stakeholders: z.array(z.unknown()).optional(),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Governance template cards aligned with Figma Community-Rule-System node 21764-16435
|
||||
* (Card / Rule variants: icon + title + short description + surface color).
|
||||
*
|
||||
* Each slug is seeded in Prisma and links to `/create/review-template/[slug]`.
|
||||
*/
|
||||
|
||||
export type GovernanceTemplateCatalogEntry = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Tailwind background class — colors from Figma invert/brand surfaces */
|
||||
backgroundColor: string;
|
||||
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
|
||||
iconPath: string;
|
||||
};
|
||||
|
||||
/** SVGs in `public/assets/template-mark/<slug>.svg` (kebab-case slug). */
|
||||
export function governanceTemplateIconPath(slug: string): string {
|
||||
return `assets/template-mark/${slug}.svg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order matches the Figma stack (top → bottom).
|
||||
*/
|
||||
export const GOVERNANCE_TEMPLATE_CATALOG: GovernanceTemplateCatalogEntry[] = [
|
||||
{
|
||||
slug: "consensus",
|
||||
title: "Consensus",
|
||||
description:
|
||||
"Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-positive-secondary)]",
|
||||
iconPath: governanceTemplateIconPath("consensus"),
|
||||
},
|
||||
{
|
||||
slug: "consensus-clusters",
|
||||
title: "Circles",
|
||||
description:
|
||||
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
iconPath: governanceTemplateIconPath("consensus-clusters"),
|
||||
},
|
||||
{
|
||||
slug: "solidarity-network",
|
||||
title: "Solidarity Network",
|
||||
description:
|
||||
"Power is held by autonomous \"cells.\" A central hub acts as a switchboard for resources but cannot dictate cell activities.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-positive-primary)]",
|
||||
iconPath: governanceTemplateIconPath("solidarity-network"),
|
||||
},
|
||||
{
|
||||
slug: "sortition-jury",
|
||||
title: "Sortition (Jury)",
|
||||
description:
|
||||
"A representative sample of the community is chosen by lottery to form a temporary council.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||
iconPath: governanceTemplateIconPath("sortition-jury"),
|
||||
},
|
||||
{
|
||||
slug: "liquid-democracy",
|
||||
title: "Liquid Democracy",
|
||||
description:
|
||||
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-kiwi)]",
|
||||
iconPath: governanceTemplateIconPath("liquid-democracy"),
|
||||
},
|
||||
{
|
||||
slug: "do-ocracy",
|
||||
title: "Do-ocracy",
|
||||
description:
|
||||
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-royal)]",
|
||||
iconPath: governanceTemplateIconPath("do-ocracy"),
|
||||
},
|
||||
{
|
||||
slug: "quadratic-governance",
|
||||
title: "Quadratic Governance",
|
||||
description:
|
||||
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-secondary)]",
|
||||
iconPath: governanceTemplateIconPath("quadratic-governance"),
|
||||
},
|
||||
{
|
||||
slug: "federated-clusters",
|
||||
title: "Federated Clusters",
|
||||
description:
|
||||
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-primary)]",
|
||||
iconPath: governanceTemplateIconPath("federated-clusters"),
|
||||
},
|
||||
{
|
||||
slug: "devolution",
|
||||
title: "Devolution",
|
||||
description:
|
||||
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-negative-secondary)]",
|
||||
iconPath: governanceTemplateIconPath("devolution"),
|
||||
},
|
||||
{
|
||||
slug: "benevolent-dictator",
|
||||
title: "Benevolent Dictator",
|
||||
description:
|
||||
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-negative-primary)]",
|
||||
iconPath: governanceTemplateIconPath("benevolent-dictator"),
|
||||
},
|
||||
{
|
||||
slug: "petition",
|
||||
title: "Petition",
|
||||
description:
|
||||
"Any participant can propose a rule change. If enough sign it, it goes to a general vote.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
iconPath: governanceTemplateIconPath("petition"),
|
||||
},
|
||||
{
|
||||
slug: "self-appointed-board",
|
||||
title: "Self-Appointed Board",
|
||||
description:
|
||||
"An existing board selects its own successors to preserve a specific mission over time.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
|
||||
iconPath: governanceTemplateIconPath("self-appointed-board"),
|
||||
},
|
||||
{
|
||||
slug: "elected-board",
|
||||
title: "Elected Board",
|
||||
description:
|
||||
"An elected board determines policies and organizes their implementation.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-warning-secondary)]",
|
||||
iconPath: governanceTemplateIconPath("elected-board"),
|
||||
},
|
||||
];
|
||||
|
||||
const bySlug = new Map(
|
||||
GOVERNANCE_TEMPLATE_CATALOG.map((e) => [e.slug, e] as const),
|
||||
);
|
||||
|
||||
/**
|
||||
* Order for the home “Popular templates” row (four cards). Must match catalog slugs.
|
||||
*/
|
||||
export const GOVERNANCE_TEMPLATE_HOME_SLUGS: readonly string[] = [
|
||||
"consensus-clusters",
|
||||
"consensus",
|
||||
"elected-board",
|
||||
"petition",
|
||||
];
|
||||
|
||||
export function getGovernanceTemplatesForHome(): GovernanceTemplateCatalogEntry[] {
|
||||
return GOVERNANCE_TEMPLATE_HOME_SLUGS.map((slug) => {
|
||||
const e = bySlug.get(slug);
|
||||
if (!e) {
|
||||
throw new Error(`governanceTemplateCatalog: missing slug "${slug}"`);
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
export function getGovernanceTemplateCatalogEntry(
|
||||
slug: string,
|
||||
): GovernanceTemplateCatalogEntry | undefined {
|
||||
return bySlug.get(slug);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||
import { templateSummaryFromBody } from "../create/templateReviewMapping";
|
||||
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
|
||||
import {
|
||||
GOVERNANCE_TEMPLATE_CATALOG,
|
||||
getGovernanceTemplateCatalogEntry,
|
||||
governanceTemplateIconPath,
|
||||
} from "./governanceTemplateCatalog";
|
||||
|
||||
/** Matches TemplateReviewCard when slug is absent from the Figma catalog. */
|
||||
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
|
||||
iconPath: governanceTemplateIconPath("consensus"),
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
} as const;
|
||||
|
||||
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
|
||||
|
||||
function presentationForSlug(slug: string): Pick<
|
||||
GovernanceTemplateCatalogEntry,
|
||||
"iconPath" | "backgroundColor"
|
||||
> {
|
||||
const catalog = getGovernanceTemplateCatalogEntry(slug);
|
||||
return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* One grid card: API copy + Figma icon/surface from catalog (or fallback).
|
||||
*/
|
||||
export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry {
|
||||
const pres = presentationForSlug(template.slug);
|
||||
const description = templateSummaryFromBody(template.description, template.body);
|
||||
return {
|
||||
slug: template.slug,
|
||||
title: template.title,
|
||||
description,
|
||||
iconPath: pres.iconPath,
|
||||
backgroundColor: pres.backgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
const bySlug = (templates: RuleTemplateDto[]) =>
|
||||
new Map(templates.map((t) => [t.slug, t] as const));
|
||||
|
||||
/**
|
||||
* Ordered subset for home: follow `slugOrder`; skip missing slugs.
|
||||
*/
|
||||
export function gridEntriesForSlugOrder(
|
||||
templates: RuleTemplateDto[],
|
||||
slugOrder: readonly string[],
|
||||
): TemplateGridCardEntry[] {
|
||||
const map = bySlug(templates);
|
||||
const out: TemplateGridCardEntry[] = [];
|
||||
for (const slug of slugOrder) {
|
||||
const t = map.get(slug);
|
||||
if (t) out.push(ruleTemplateToGridEntry(t));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home row: prefer API row per slug; if missing, use static Figma catalog entry.
|
||||
*/
|
||||
export function gridEntriesForSlugOrderWithCatalogFallback(
|
||||
templates: RuleTemplateDto[],
|
||||
slugOrder: readonly string[],
|
||||
): TemplateGridCardEntry[] {
|
||||
const map = bySlug(templates);
|
||||
const out: TemplateGridCardEntry[] = [];
|
||||
for (const slug of slugOrder) {
|
||||
const t = map.get(slug);
|
||||
if (t) {
|
||||
out.push(ruleTemplateToGridEntry(t));
|
||||
continue;
|
||||
}
|
||||
const cat = getGovernanceTemplateCatalogEntry(slug);
|
||||
if (cat) out.push(cat);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full templates index: `featured` first, then `sortOrder`, then title.
|
||||
*/
|
||||
export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] {
|
||||
const withSort = [...templates].sort((a, b) => {
|
||||
if (a.featured !== b.featured) return a.featured ? -1 : 1;
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return withSort.map(ruleTemplateToGridEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketing `/templates`: use API order when rows exist; otherwise static catalog.
|
||||
*/
|
||||
export function gridEntriesForFullCatalogWithFallback(
|
||||
templates: RuleTemplateDto[],
|
||||
): TemplateGridCardEntry[] {
|
||||
if (templates.length === 0) {
|
||||
return [...GOVERNANCE_TEMPLATE_CATALOG];
|
||||
}
|
||||
return gridEntriesForFullCatalog(templates);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "Why does your community exist?",
|
||||
"description": "Edit or change the description to match how you’d like the organization to be described to other users. Try and describe your mission, goals, and scope.",
|
||||
"placeholder": "Describe your community",
|
||||
"characterCountTemplate": "{current}/{max}"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "What is your community called?",
|
||||
"description": "This will be the name of your community",
|
||||
"placeholder": "Enter community name",
|
||||
"characterCountTemplate": "{current}/{max}"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Save your progress",
|
||||
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
|
||||
"placeholder": "email@domain.com",
|
||||
"characterCountTemplate": "{current}/{max}",
|
||||
"magicLinkSuccessTitle": "Check your email to log in!",
|
||||
"magicLinkSuccessDescription": "Your account is created, now just check your email for a magic link",
|
||||
"magicLinkErrorTitle": "Could not send link"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "How many people will be in your community in the near term?",
|
||||
"description": "Choose how many people you think will be in your community in the next year or two. Your selection here will determine what governance patterns are recommended later in the process."
|
||||
},
|
||||
"communitySizes": [
|
||||
{ "label": "1 member" },
|
||||
{ "label": "2-5 members" },
|
||||
{ "label": "6-12 members" },
|
||||
{ "label": "13-100 members" },
|
||||
{ "label": "100-100,000 members" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "What kind of community would you like to improve?",
|
||||
"description": "Choose tags the describe your community. You can also combine or add new values to the list."
|
||||
},
|
||||
"organizationMultiSelect": {
|
||||
"label": "Organization Type",
|
||||
"addButtonText": "Add organization type"
|
||||
},
|
||||
"scaleMultiSelect": {
|
||||
"label": "Scale",
|
||||
"addButtonText": "Add scale"
|
||||
},
|
||||
"maturityMultiSelect": {
|
||||
"label": "Maturity",
|
||||
"addButtonText": "Add maturity"
|
||||
},
|
||||
"organizationTypes": [
|
||||
{ "label": "Worker’s coop" },
|
||||
{ "label": "Mutual aid" },
|
||||
{ "label": "Open source project" },
|
||||
{ "label": "Nonprofit" },
|
||||
{ "label": "For profit business" },
|
||||
{ "label": "DAO" }
|
||||
],
|
||||
"scaleOptions": [
|
||||
{ "label": "Local" },
|
||||
{ "label": "Regional" },
|
||||
{ "label": "National" },
|
||||
{ "label": "Global" }
|
||||
],
|
||||
"maturityOptions": [
|
||||
{ "label": "Early stage" },
|
||||
{ "label": "Growth stage" },
|
||||
{ "label": "Established" },
|
||||
{ "label": "Enterprise" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Add a photo to identify your group",
|
||||
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
||||
"hintText": "Add image from your device"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "Do other stakeholders need to be involved in creating your community?",
|
||||
"description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.",
|
||||
"addStakeholder": "Add stakeholder",
|
||||
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Review your CommunityRule",
|
||||
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
|
||||
"ruleCardTitleFallback": "Your community",
|
||||
"ruleCardDescriptionFallback": "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Values",
|
||||
"chips": ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"]
|
||||
},
|
||||
{
|
||||
"name": "Communication",
|
||||
"chips": ["Signal"]
|
||||
},
|
||||
{
|
||||
"name": "Membership",
|
||||
"chips": ["Open Admission"]
|
||||
},
|
||||
{
|
||||
"name": "Decision-making",
|
||||
"chips": ["Lazy Consensus", "Modified Consensus"]
|
||||
},
|
||||
{
|
||||
"name": "Conflict management",
|
||||
"chips": ["Code of Conduct", "Restorative Justice"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user