RuleTemplate seed and create flow
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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;
|
||||
featured: boolean;
|
||||
};
|
||||
|
||||
type TemplatesResponse = { templates?: RuleTemplateDto[] };
|
||||
|
||||
export async function fetchTemplates(): Promise<
|
||||
RuleTemplateDto[] | { error: string }
|
||||
> {
|
||||
try {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
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 {
|
||||
return { error: "Could not load templates" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTemplateBySlug(
|
||||
slug: string,
|
||||
): Promise<RuleTemplateDto | null | { error: string }> {
|
||||
const result = await fetchTemplates();
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return result.find((t) => t.slug === slug) ?? null;
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user