RuleTemplate seed and create flow
@@ -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.
|
||||
|
||||
|
||||
@@ -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,47 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../../lib/templates/governanceTemplateCatalog";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
|
||||
/**
|
||||
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
|
||||
*/
|
||||
export default function TemplatesPage() {
|
||||
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={GOVERNANCE_TEMPLATE_CATALOG}
|
||||
onTemplateClick={(slug) => {
|
||||
router.push(
|
||||
`/create/review-template/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,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 +196,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 +218,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 +232,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>
|
||||
@@ -279,8 +279,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 +292,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,66 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import RuleCard from "../RuleCard";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
|
||||
import {
|
||||
templateBodyToCategories,
|
||||
templateSummaryFromBody,
|
||||
} from "../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
getGovernanceTemplateCatalogEntry,
|
||||
governanceTemplateIconPath,
|
||||
} from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
const FALLBACK_PRESENTATION = {
|
||||
iconPath: governanceTemplateIconPath("consensus"),
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
};
|
||||
|
||||
export interface TemplateReviewCardProps {
|
||||
template: RuleTemplateDto;
|
||||
/** Merged onto RuleCard `className` (e.g. final-review desktop vs mobile radius/padding). */
|
||||
ruleCardClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
|
||||
* tag rows from API `body`.
|
||||
*/
|
||||
export function TemplateReviewCard({
|
||||
template,
|
||||
ruleCardClassName = "",
|
||||
}: TemplateReviewCardProps) {
|
||||
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
|
||||
const pres = catalog ?? FALLBACK_PRESENTATION;
|
||||
const categories = templateBodyToCategories(template.body);
|
||||
const summary = templateSummaryFromBody(template.description, template.body);
|
||||
|
||||
return (
|
||||
<RuleCard
|
||||
title={template.title}
|
||||
description={summary}
|
||||
expanded
|
||||
size="L"
|
||||
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";
|
||||
@@ -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,2 @@
|
||||
export { GovernanceTemplateGrid } from "./GovernanceTemplateGrid";
|
||||
export type { GovernanceTemplateGridProps } from "./GovernanceTemplateGrid";
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { RuleStackView } from "./RuleStack.view";
|
||||
import type { RuleStackProps } from "./RuleStack.types";
|
||||
@@ -19,21 +20,24 @@ declare global {
|
||||
}
|
||||
|
||||
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
|
||||
const handleTemplateClick = (templateName: string) => {
|
||||
const router = useRouter();
|
||||
|
||||
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 (
|
||||
|
||||
@@ -4,5 +4,5 @@ export interface RuleStackProps {
|
||||
|
||||
export interface RuleStackViewProps {
|
||||
className: string;
|
||||
onTemplateClick: (_templateName: string) => void;
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,21 @@
|
||||
"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 { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
import type { RuleStackViewProps } from "./RuleStack.types";
|
||||
|
||||
const homeFeaturedTemplates = getGovernanceTemplatesForHome();
|
||||
|
||||
export function RuleStackView({
|
||||
className,
|
||||
onTemplateClick,
|
||||
}: 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 +31,17 @@ 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>
|
||||
<GovernanceTemplateGrid
|
||||
entries={homeFeaturedTemplates}
|
||||
onTemplateClick={onTemplateClick}
|
||||
/>
|
||||
|
||||
{/* See all templates button */}
|
||||
<div
|
||||
className="
|
||||
flex justify-center w-full
|
||||
@@ -163,7 +50,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>
|
||||
|
||||
@@ -18,6 +18,10 @@ import Button from "../components/buttons/Button";
|
||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
||||
import { fetchAuthSession, publishRule } from "../../lib/create/api";
|
||||
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 { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
@@ -89,6 +93,18 @@ function CreateFlowLayoutContent({
|
||||
string | null
|
||||
>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||
|
||||
const templateReviewMatch = pathname?.match(
|
||||
/^\/create\/review-template\/([^/]+)$/,
|
||||
);
|
||||
const templateReviewSlug = templateReviewMatch?.[1]
|
||||
? decodeURIComponent(templateReviewMatch[1])
|
||||
: null;
|
||||
const isTemplateReviewRoute = Boolean(templateReviewSlug);
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
@@ -134,6 +150,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 +198,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/informational"}?syncDraft=1`,
|
||||
backdropVariant: "blurredYellow",
|
||||
});
|
||||
return;
|
||||
@@ -169,7 +224,9 @@ function CreateFlowLayoutContent({
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
|
||||
const hasErrorOverlays =
|
||||
Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
|
||||
Boolean(draftSaveBannerMessage) ||
|
||||
Boolean(publishBannerMessage) ||
|
||||
Boolean(templateReviewApplyError);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||
@@ -202,6 +259,18 @@ 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}
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
@@ -243,8 +312,41 @@ function CreateFlowLayoutContent({
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
progressBar={!isTemplateReviewRoute}
|
||||
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>
|
||||
) : nextStep ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
@@ -269,7 +371,13 @@ function CreateFlowLayoutContent({
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||
onBackClick={
|
||||
isTemplateReviewRoute
|
||||
? () => router.push("/")
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
type RuleTemplateDto,
|
||||
} from "../../../../lib/create/fetchTemplates";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template review: same responsive grid and RuleCard chrome as final-review;
|
||||
* copy from Figma 22142-898702 (intro + dynamic card from API).
|
||||
*/
|
||||
export default function ReviewTemplatePage({ params }: PageProps) {
|
||||
const { slug: rawSlug } = use(params);
|
||||
const slug = decodeURIComponent(rawSlug);
|
||||
const t = useTranslation("create.templateReview");
|
||||
|
||||
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- match final-review: defer breakpoint until mount
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
if (!cancelled) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
const result = await fetchTemplateBySlug(slug);
|
||||
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);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[1280px] shrink-0 items-center justify-center px-5 py-16 md:px-12">
|
||||
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
|
||||
{t("loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[640px] shrink-0 flex-col gap-4 px-5 py-8 md:px-12">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={t("errors.loadFailed")}
|
||||
description={error ?? t("errors.notFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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={t("intro.title")}
|
||||
description={t("intro.description")}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full flex flex-col items-stretch">
|
||||
<TemplateReviewCard
|
||||
template={template}
|
||||
ruleCardClassName="rounded-[24px] !max-w-full !w-full min-w-0"
|
||||
/>
|
||||
</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={t("intro.title")}
|
||||
description={t("intro.description")}
|
||||
justification="left"
|
||||
size="M"
|
||||
/>
|
||||
<TemplateReviewCard
|
||||
template={template}
|
||||
ruleCardClassName="w-full rounded-[12px] p-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"intro": {
|
||||
"title": "CommunityRule Template",
|
||||
"description": "These templates can get you started with a proven approach to governance and decision-making. As useful as these templates are, they are not one size fit all solutions though. We recommend that you use them as a starting place for your community to adapt and customize according to your group's specific needs."
|
||||
},
|
||||
"footer": {
|
||||
"useWithoutChanges": "Use without changes",
|
||||
"customize": "Customize",
|
||||
"customizeAriaHint": "Customize flow coming soon; for now this continues to the create flow entry."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "We could not load this template. Try again or pick another template from the home page.",
|
||||
"notFound": "This template was not found.",
|
||||
"applyFailed": "Something went wrong. Please try again."
|
||||
},
|
||||
"loading": "Loading template…"
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import quoteBlock from "./components/quoteBlock.json";
|
||||
import ruleCard from "./components/ruleCard.json";
|
||||
import ruleStack from "./components/ruleStack.json";
|
||||
import home from "./pages/home.json";
|
||||
import templates from "./pages/templates.json";
|
||||
import learn from "./pages/learn.json";
|
||||
import login from "./pages/login.json";
|
||||
import profile from "./pages/profile.json";
|
||||
@@ -21,6 +22,7 @@ import communication from "./create/communication.json";
|
||||
import createTopNav from "./create/topNav.json";
|
||||
import createDraftHydration from "./create/draftHydration.json";
|
||||
import createPublish from "./create/publish.json";
|
||||
import createTemplateReview from "./create/templateReview.json";
|
||||
|
||||
export default {
|
||||
common,
|
||||
@@ -38,6 +40,7 @@ export default {
|
||||
ruleStack,
|
||||
pages: {
|
||||
home,
|
||||
templates,
|
||||
learn,
|
||||
login,
|
||||
profile,
|
||||
@@ -47,6 +50,7 @@ export default {
|
||||
topNav: createTopNav,
|
||||
draftHydration: createDraftHydration,
|
||||
publish: createPublish,
|
||||
templateReview: createTemplateReview,
|
||||
},
|
||||
navigation,
|
||||
metadata,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_comment": "Full templates index — Figma 22142-898446",
|
||||
"title": "Templates",
|
||||
"subtitle": "These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule."
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"start-server-and-test": "^2.0.13",
|
||||
"storybook": "^10.2.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vitest": "^3.2.4",
|
||||
@@ -21475,6 +21476,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tty-browserify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
@@ -93,6 +96,7 @@
|
||||
"start-server-and-test": "^2.0.13",
|
||||
"storybook": "^10.2.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vitest": "^3.2.4",
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Curated rule templates for GET /api/templates.
|
||||
* Home “Popular templates” list uses `lib/templates/governanceTemplateCatalog.ts` (Figma 21764-16435);
|
||||
* DB titles/descriptions should stay aligned with `governanceTemplateCatalog.ts`.
|
||||
* `body.sections` use the same category row labels as the final-review RuleCard
|
||||
* (Values, Communication, Membership, Decision-making, Conflict management) so
|
||||
* template review matches that layout; `entries[].title` = chip labels, `body` = long text for documents.
|
||||
*/
|
||||
|
||||
/** Starter `body` for catalog templates — five category rows match template review / final-review layout. */
|
||||
function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
|
||||
return {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Core stance", body: coreValues }],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Transparency",
|
||||
body: "Updates and decisions are shared through agreed channels so members stay aligned.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Participation",
|
||||
body: "Roles and eligibility are explicit so people know how to take part.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Process",
|
||||
body: "Steps are documented so legitimacy stays high as you scale.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Resolution",
|
||||
body: "Disputes use fair, documented paths before they harden into splits.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const TEMPLATES: {
|
||||
slug: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
featured: boolean;
|
||||
body: Prisma.InputJsonValue;
|
||||
}[] = [
|
||||
{
|
||||
slug: "consensus-clusters",
|
||||
title: "Circles",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
|
||||
sortOrder: 0,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Distributed authority",
|
||||
body: "Authority lives in Circles close to the work. Domains are explicit so power is visible and negotiable.",
|
||||
},
|
||||
{
|
||||
title: "Transparency",
|
||||
body: "Decisions, roles, and metrics that affect members are easy to find and updated regularly.",
|
||||
},
|
||||
{
|
||||
title: "Equivalence",
|
||||
body: "Policy affects people in proportion to their stake; no silent vetoes from outside a domain.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Circle channels",
|
||||
body: "Each Circle maintains channels for async updates and synchronous sense-making.",
|
||||
},
|
||||
{
|
||||
title: "Council cadence",
|
||||
body: "The Council meets on a fixed rhythm to align strategy, resolve overlaps, and hear escalations.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Circle membership",
|
||||
body: "People join Circles by agreement of that Circle and clarity on domain contribution.",
|
||||
},
|
||||
{
|
||||
title: "Link roles",
|
||||
body: "Members link Circles as delegates or representatives when decisions span domains.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Consent within Circles",
|
||||
body: "Circles act when there is no reasoned objection from anyone in the Circle with a stake.",
|
||||
},
|
||||
{
|
||||
title: "Cross-domain consent",
|
||||
body: "When work spans Circles, proposals include impacted domains and integrate their concerns.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Objection testing",
|
||||
body: "Objections must show how a proposal fails the aim or creates harm; the group integrates or adapts.",
|
||||
},
|
||||
{
|
||||
title: "Mediation",
|
||||
body: "Facilitators help parties hear each other before escalating to Council or broader processes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "consensus",
|
||||
title: "Consensus",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Important decisions require broad agreement. Proposals move forward when serious objections are resolved and the group can stand behind the outcome.",
|
||||
sortOrder: 1,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Consciousness",
|
||||
body: "We make decisions with awareness of impact on people, ecosystems, and future generations.",
|
||||
},
|
||||
{
|
||||
title: "Ecology",
|
||||
body: "We design governance to reduce harm and regenerate the systems we depend on.",
|
||||
},
|
||||
{
|
||||
title: "Abundance",
|
||||
body: "We assume enough for needs when resources are shared fairly and waste is reduced.",
|
||||
},
|
||||
{
|
||||
title: "Art",
|
||||
body: "We leave room for creativity, culture, and expression in how we organize.",
|
||||
},
|
||||
{
|
||||
title: "Decisiveness",
|
||||
body: "We balance care with forward motion—clear timelines and roles prevent endless loops.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Signal",
|
||||
body: "We use Signal (or equivalent) for sensitive coordination and timely member updates.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Open Admission",
|
||||
body: "People who share our values and agree to practices can participate without unnecessary gatekeeping.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Lazy Consensus",
|
||||
body: "Proposals advance if no blocking concern is raised within the discussion window.",
|
||||
},
|
||||
{
|
||||
title: "Modified Consensus",
|
||||
body: "For larger decisions we use structured consensus with documented objections and integration steps.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Code of Conduct",
|
||||
body: "We uphold a code of conduct that sets expectations and pathways for accountability.",
|
||||
},
|
||||
{
|
||||
title: "Restorative Justice",
|
||||
body: "When harm occurs we prioritize repair, learning, and changed conditions over punishment.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "elected-board",
|
||||
title: "Elected Board",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Members elect a board to steward policy and operations. The board coordinates implementation and remains accountable through transparent reporting and elections.",
|
||||
sortOrder: 2,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Accountability",
|
||||
body: "The board answers to the membership through elections, published decisions, and recall where applicable.",
|
||||
},
|
||||
{
|
||||
title: "Service",
|
||||
body: "Board service is a temporary duty with term limits and clarity on scope of authority.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Board minutes",
|
||||
body: "Minutes summarize decisions, rationales, and next steps; members can access them on a regular cadence.",
|
||||
},
|
||||
{
|
||||
title: "Member forums",
|
||||
body: "The board hosts open sessions for questions, priorities, and feedback from the membership.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Eligibility to vote",
|
||||
body: "Voting members are defined clearly; associate or advisory roles are distinguished from full votes.",
|
||||
},
|
||||
{
|
||||
title: "Board terms",
|
||||
body: "Staggered terms keep continuity while refreshing leadership on a predictable schedule.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Board vote",
|
||||
body: "The board decides matters in its charter by majority or supermajority as specified.",
|
||||
},
|
||||
{
|
||||
title: "Member ratification",
|
||||
body: "Major structural changes require member approval according to your bylaws.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Recusal",
|
||||
body: "Directors recuse themselves when personal or financial conflicts appear.",
|
||||
},
|
||||
{
|
||||
title: "Appeals",
|
||||
body: "Members can appeal board decisions through a defined, fair process.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "petition",
|
||||
title: "Petition",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Any participant can propose a rule change. If enough sign it, it goes to a general vote.",
|
||||
sortOrder: 3,
|
||||
featured: true,
|
||||
body: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Open participation",
|
||||
body: "Legitimate voices can bring proposals without needing informal gatekeepers.",
|
||||
},
|
||||
{
|
||||
title: "Legitimacy",
|
||||
body: "Outcomes are trusted when process, quorum, and notice rules are followed consistently.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Discussion period",
|
||||
body: "Every proposal has a visible discussion window before voting closes.",
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
body: "Calls to vote and results are posted where all participants can see them.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Voting pool",
|
||||
body: "Who may vote is explicit (e.g. members in good standing for 30 days).",
|
||||
},
|
||||
{
|
||||
title: "Quorum",
|
||||
body: "Votes count only when quorum is met so decisions reflect an engaged subset.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Petition threshold",
|
||||
body: "Sponsors or seconders may be required so proposals show a minimal base of support.",
|
||||
},
|
||||
{
|
||||
title: "Majority rules",
|
||||
body: "Adoption thresholds (simple majority, supermajority) are defined per decision type.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Good faith",
|
||||
body: "Debate focuses on substance; harassment or bad-faith tactics are addressed under conduct policies.",
|
||||
},
|
||||
{
|
||||
title: "Ombuds",
|
||||
body: "A neutral contact helps people navigate disputes about process or interpretation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "solidarity-network",
|
||||
title: "Solidarity Network",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
|
||||
sortOrder: 4,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "sortition-jury",
|
||||
title: "Sortition (Jury)",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"A representative sample of the community is chosen by lottery to form a temporary council.",
|
||||
sortOrder: 5,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"A representative sample of the community is chosen by lottery to form a temporary council.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "liquid-democracy",
|
||||
title: "Liquid Democracy",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
|
||||
sortOrder: 6,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "do-ocracy",
|
||||
title: "Do-ocracy",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||
sortOrder: 7,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "quadratic-governance",
|
||||
title: "Quadratic Governance",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
|
||||
sortOrder: 8,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "federated-clusters",
|
||||
title: "Federated Clusters",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
|
||||
sortOrder: 9,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "devolution",
|
||||
title: "Devolution",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
|
||||
sortOrder: 10,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "benevolent-dictator",
|
||||
title: "Benevolent Dictator",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
|
||||
sortOrder: 11,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
|
||||
),
|
||||
},
|
||||
{
|
||||
slug: "self-appointed-board",
|
||||
title: "Self-Appointed Board",
|
||||
category: "Governance pattern",
|
||||
description:
|
||||
"An existing board selects its own successors to preserve a specific mission over time.",
|
||||
sortOrder: 12,
|
||||
featured: false,
|
||||
body: governancePatternBody(
|
||||
"An existing board selects its own successors to preserve a specific mission over time.",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
for (const row of TEMPLATES) {
|
||||
const { slug, title, category, description, sortOrder, featured, body } =
|
||||
row;
|
||||
await prisma.ruleTemplate.upsert({
|
||||
where: { slug },
|
||||
create: {
|
||||
slug,
|
||||
title,
|
||||
category,
|
||||
description,
|
||||
sortOrder,
|
||||
featured,
|
||||
body,
|
||||
},
|
||||
update: {
|
||||
title,
|
||||
category,
|
||||
description,
|
||||
sortOrder,
|
||||
featured,
|
||||
body,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
// eslint-disable-next-line no-console -- seed CLI feedback
|
||||
console.log(`Seeded ${TEMPLATES.length} rule template(s).`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
<svg width="91" height="90" viewBox="0 0 91 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.9511 24.94C50.2744 24.94 54.5897 20.4763 54.5897 14.97C54.5897 9.46372 50.2744 5 44.9511 5C39.6279 5 35.3125 9.46372 35.3125 14.97C35.3125 20.4763 39.6279 24.94 44.9511 24.94Z" fill="black"/>
|
||||
<path d="M16.0449 54.8404C21.3681 54.8404 25.6835 50.3767 25.6835 44.8704C25.6835 39.3641 21.3681 34.9004 16.0449 34.9004C10.7216 34.9004 6.40625 39.3641 6.40625 44.8704C6.40625 50.3767 10.7216 54.8404 16.0449 54.8404Z" fill="black"/>
|
||||
<path d="M73.873 54.8404C79.1962 54.8404 83.5116 50.3767 83.5116 44.8704C83.5116 39.3641 79.1962 34.9004 73.873 34.9004C68.5497 34.9004 64.2344 39.3641 64.2344 44.8704C64.2344 50.3767 68.5497 54.8404 73.873 54.8404Z" fill="black"/>
|
||||
<path d="M18.9667 27.94C24.29 27.94 28.6054 23.4763 28.6054 17.97C28.6054 12.4637 24.29 8 18.9667 8C13.6435 8 9.32812 12.4637 9.32812 17.97C9.32812 23.4763 13.6435 27.94 18.9667 27.94Z" fill="black"/>
|
||||
<path d="M71.0449 27.8599C76.3681 27.8599 80.6835 23.3962 80.6835 17.8899C80.6835 12.3836 76.3681 7.91992 71.0449 7.91992C65.7216 7.91992 61.4062 12.3836 61.4062 17.8899C61.4062 23.3962 65.7216 27.8599 71.0449 27.8599Z" fill="black"/>
|
||||
<path d="M44.9511 84.7505C50.2744 84.7505 54.5897 80.2868 54.5897 74.7805C54.5897 69.2743 50.2744 64.8105 44.9511 64.8105C39.6279 64.8105 35.3125 69.2743 35.3125 74.7805C35.3125 80.2868 39.6279 84.7505 44.9511 84.7505Z" fill="black"/>
|
||||
<path d="M18.873 81.8297C24.1962 81.8297 28.5116 77.3659 28.5116 71.8596C28.5116 66.3534 24.1962 61.8896 18.873 61.8896C13.5497 61.8896 9.23438 66.3534 9.23438 71.8596C9.23438 77.3659 13.5497 81.8297 18.873 81.8297Z" fill="black"/>
|
||||
<path d="M71.0449 81.8297C76.3681 81.8297 80.6835 77.3659 80.6835 71.8596C80.6835 66.3534 76.3681 61.8896 71.0449 61.8896C65.7216 61.8896 61.4062 66.3534 61.4062 71.8596C61.4062 77.3659 65.7216 81.8297 71.0449 81.8297Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg width="91" height="90" viewBox="0 0 91 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M45.4518 25.2C50.8445 25.2 55.2161 20.6781 55.2161 15.1C55.2161 9.52192 50.8445 5 45.4518 5C40.0591 5 35.6875 9.52192 35.6875 15.1C35.6875 20.6781 40.0591 25.2 45.4518 25.2Z" fill="black"/>
|
||||
<path d="M19.0299 28.16C24.4226 28.16 28.7942 23.638 28.7942 18.06C28.7942 12.4819 24.4226 7.95996 19.0299 7.95996C13.6372 7.95996 9.26562 12.4819 9.26562 18.06C9.26562 23.638 13.6372 28.16 19.0299 28.16Z" fill="black"/>
|
||||
<path d="M71.8893 28.16C77.282 28.16 81.6536 23.638 81.6536 18.06C81.6536 12.4819 77.282 7.95996 71.8893 7.95996C66.4966 7.95996 62.125 12.4819 62.125 18.06C62.125 23.638 66.4966 28.16 71.8893 28.16Z" fill="black"/>
|
||||
<path d="M45.4518 85.7898C50.8445 85.7898 55.2161 81.2679 55.2161 75.6898C55.2161 70.1118 50.8445 65.5898 45.4518 65.5898C40.0591 65.5898 35.6875 70.1118 35.6875 75.6898C35.6875 81.2679 40.0591 85.7898 45.4518 85.7898Z" fill="black"/>
|
||||
<path d="M19.0299 82.8299C24.4226 82.8299 28.7942 78.308 28.7942 72.7299C28.7942 67.1518 24.4226 62.6299 19.0299 62.6299C13.6372 62.6299 9.26562 67.1518 9.26562 72.7299C9.26562 78.308 13.6372 82.8299 19.0299 82.8299Z" fill="black"/>
|
||||
<path d="M71.8893 82.8299C77.282 82.8299 81.6536 78.308 81.6536 72.7299C81.6536 67.1518 77.282 62.6299 71.8893 62.6299C66.4966 62.6299 62.125 67.1518 62.125 72.7299C62.125 78.308 66.4966 82.8299 71.8893 82.8299Z" fill="black"/>
|
||||
<path d="M6.40625 32.3398V58.4498H32.8375L45.4634 45.3998L58.0797 58.4498H84.5109V32.3398H6.40625Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,13 +0,0 @@
|
||||
<svg width="91" height="90" viewBox="0 0 91 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_17413_8953)">
|
||||
<path d="M45.4518 24.2C50.8445 24.2 55.2161 19.6781 55.2161 14.1C55.2161 8.52192 50.8445 4 45.4518 4C40.0591 4 35.6875 8.52192 35.6875 14.1C35.6875 19.6781 40.0591 24.2 45.4518 24.2Z" fill="black"/>
|
||||
<path d="M25.9362 44.4002C31.3289 44.4002 35.7005 39.8783 35.7005 34.3002C35.7005 28.7221 31.3289 24.2002 25.9362 24.2002C20.5435 24.2002 16.1719 28.7221 16.1719 34.3002C16.1719 39.8783 20.5435 44.4002 25.9362 44.4002Z" fill="black"/>
|
||||
<path d="M45.4538 36L6.40625 84.79H84.511L45.4538 36ZM45.4538 74.66C43.5226 74.66 41.6348 74.0676 40.029 72.9578C38.4233 71.848 37.1718 70.2706 36.4328 68.4251C35.6937 66.5796 35.5004 64.5488 35.8771 62.5896C36.2539 60.6304 37.1838 58.8307 38.5494 57.4182C39.915 56.0057 41.6548 55.0438 43.5489 54.6541C45.443 54.2644 47.4062 54.4644 49.1904 55.2288C50.9746 55.9933 52.4996 57.2878 53.5725 58.9487C54.6454 60.6097 55.2181 62.5624 55.2181 64.56C55.2219 65.8889 54.9722 67.2055 54.4832 68.4343C53.9942 69.6632 53.2756 70.7801 52.3685 71.7212C51.4614 72.6622 50.3837 73.4089 49.1972 73.9183C48.0106 74.4278 46.7385 74.69 45.4538 74.69V74.66Z" fill="black"/>
|
||||
<path d="M64.9831 44.4002C70.3757 44.4002 74.7474 39.8783 74.7474 34.3002C74.7474 28.7221 70.3757 24.2002 64.9831 24.2002C59.5904 24.2002 55.2188 28.7221 55.2188 34.3002C55.2188 39.8783 59.5904 44.4002 64.9831 44.4002Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17413_8953">
|
||||
<rect width="78.1048" height="80.79" fill="white" transform="translate(6.40625 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M85.395 65.1948V85.395H4.60498V65.1948H85.395ZM85.395 55.0952H4.60498V4.60498L24.8052 24.8052L45.0054 4.60498L65.1948 24.8052L85.395 4.60498V55.0952Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.5449 24.94C49.8681 24.94 54.1835 20.4763 54.1835 14.97C54.1835 9.46372 49.8681 5 44.5449 5C39.2216 5 34.9062 9.46372 34.9062 14.97C34.9062 20.4763 39.2216 24.94 44.5449 24.94Z" fill="black"/>
|
||||
<path d="M15.6386 54.8404C20.9619 54.8404 25.2772 50.3767 25.2772 44.8704C25.2772 39.3641 20.9619 34.9004 15.6386 34.9004C10.3154 34.9004 6 39.3641 6 44.8704C6 50.3767 10.3154 54.8404 15.6386 54.8404Z" fill="black"/>
|
||||
<path d="M73.4667 54.8404C78.79 54.8404 83.1054 50.3767 83.1054 44.8704C83.1054 39.3641 78.79 34.9004 73.4667 34.9004C68.1435 34.9004 63.8281 39.3641 63.8281 44.8704C63.8281 50.3767 68.1435 54.8404 73.4667 54.8404Z" fill="black"/>
|
||||
<path d="M18.5605 27.94C23.8837 27.94 28.1991 23.4763 28.1991 17.97C28.1991 12.4637 23.8837 8 18.5605 8C13.2372 8 8.92188 12.4637 8.92188 17.97C8.92188 23.4763 13.2372 27.94 18.5605 27.94Z" fill="black"/>
|
||||
<path d="M70.6386 27.8599C75.9619 27.8599 80.2772 23.3962 80.2772 17.8899C80.2772 12.3836 75.9619 7.91992 70.6386 7.91992C65.3154 7.91992 61 12.3836 61 17.8899C61 23.3962 65.3154 27.8599 70.6386 27.8599Z" fill="black"/>
|
||||
<path d="M44.5449 84.7505C49.8681 84.7505 54.1835 80.2868 54.1835 74.7805C54.1835 69.2743 49.8681 64.8105 44.5449 64.8105C39.2216 64.8105 34.9062 69.2743 34.9062 74.7805C34.9062 80.2868 39.2216 84.7505 44.5449 84.7505Z" fill="black"/>
|
||||
<path d="M18.4667 81.8297C23.79 81.8297 28.1054 77.3659 28.1054 71.8596C28.1054 66.3534 23.79 61.8896 18.4667 61.8896C13.1435 61.8896 8.82812 66.3534 8.82812 71.8596C8.82812 77.3659 13.1435 81.8297 18.4667 81.8297Z" fill="black"/>
|
||||
<path d="M70.6386 81.8297C75.9619 81.8297 80.2772 77.3659 80.2772 71.8596C80.2772 66.3534 75.9619 61.8896 70.6386 61.8896C65.3154 61.8896 61 66.3534 61 71.8596C61 77.3659 65.3154 81.8297 70.6386 81.8297Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23 7C24.6569 7 26 8.34315 26 10V80C26 81.6569 24.6569 83 23 83H9C7.34315 83 6 81.6569 6 80V10C6 8.34315 7.34315 7 9 7H23Z" fill="black"/>
|
||||
<path d="M68 63C71.3137 63 74 65.6863 74 69C74 72.3137 71.3137 75 68 75C64.6863 75 62 72.3137 62 69C62 65.6863 64.6863 63 68 63Z" fill="black"/>
|
||||
<path d="M51 17C52.6569 17 54 18.3431 54 20V70C54 71.6569 52.6569 73 51 73H37C35.3431 73 34 71.6569 34 70V20C34 18.3431 35.3431 17 37 17H51Z" fill="black"/>
|
||||
<path d="M82 59C83.1046 59 84 59.8954 84 61C84 62.1046 83.1046 63 82 63C80.8954 63 80 62.1046 80 61C80 59.8954 80.8954 59 82 59Z" fill="black"/>
|
||||
<path d="M68 47C71.3137 47 74 49.6863 74 53C74 56.3137 71.3137 59 68 59C64.6863 59 62 56.3137 62 53C62 49.6863 64.6863 47 68 47Z" fill="black"/>
|
||||
<path d="M82 43C83.1046 43 84 43.8954 84 45C84 46.1046 83.1046 47 82 47C80.8954 47 80 46.1046 80 45C80 43.8954 80.8954 43 82 43Z" fill="black"/>
|
||||
<path d="M68 31C71.3137 31 74 33.6863 74 37C74 40.3137 71.3137 43 68 43C64.6863 43 62 40.3137 62 37C62 33.6863 64.6863 31 68 31Z" fill="black"/>
|
||||
<path d="M82 27C83.1046 27 84 27.8954 84 29C84 30.1046 83.1046 31 82 31C80.8954 31 80 30.1046 80 29C80 27.8954 80.8954 27 82 27Z" fill="black"/>
|
||||
<path d="M68 15C71.3137 15 74 17.6863 74 21C74 24.3137 71.3137 27 68 27C64.6863 27 62 24.3137 62 21C62 17.6863 64.6863 15 68 15Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M85 45L55 85H5L35 45L5 5H55L85 45ZM49 37C44.5817 37 41 40.5817 41 45C41 49.4183 44.5817 53 49 53C53.4183 53 57 49.4183 57 45C57 40.5817 53.4183 37 49 37Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M45.0455 25.2C50.4382 25.2 54.8098 20.6781 54.8098 15.1C54.8098 9.52192 50.4382 5 45.0455 5C39.6529 5 35.2812 9.52192 35.2812 15.1C35.2812 20.6781 39.6529 25.2 45.0455 25.2Z" fill="black"/>
|
||||
<path d="M18.6237 28.16C24.0163 28.16 28.388 23.638 28.388 18.06C28.388 12.4819 24.0163 7.95996 18.6237 7.95996C13.231 7.95996 8.85938 12.4819 8.85938 18.06C8.85938 23.638 13.231 28.16 18.6237 28.16Z" fill="black"/>
|
||||
<path d="M71.483 28.16C76.8757 28.16 81.2473 23.638 81.2473 18.06C81.2473 12.4819 76.8757 7.95996 71.483 7.95996C66.0904 7.95996 61.7188 12.4819 61.7188 18.06C61.7188 23.638 66.0904 28.16 71.483 28.16Z" fill="black"/>
|
||||
<path d="M45.0455 85.7898C50.4382 85.7898 54.8098 81.2679 54.8098 75.6898C54.8098 70.1118 50.4382 65.5898 45.0455 65.5898C39.6529 65.5898 35.2812 70.1118 35.2812 75.6898C35.2812 81.2679 39.6529 85.7898 45.0455 85.7898Z" fill="black"/>
|
||||
<path d="M18.6237 82.8299C24.0163 82.8299 28.388 78.308 28.388 72.7299C28.388 67.1518 24.0163 62.6299 18.6237 62.6299C13.231 62.6299 8.85938 67.1518 8.85938 72.7299C8.85938 78.308 13.231 82.8299 18.6237 82.8299Z" fill="black"/>
|
||||
<path d="M71.483 82.8299C76.8757 82.8299 81.2473 78.308 81.2473 72.7299C81.2473 67.1518 76.8757 62.6299 71.483 62.6299C66.0904 62.6299 61.7188 67.1518 61.7188 72.7299C61.7188 78.308 66.0904 82.8299 71.483 82.8299Z" fill="black"/>
|
||||
<path d="M6 32.3398V58.4498H32.4313L45.0572 45.3998L57.6734 58.4498H84.1047V32.3398H6Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1429 7C74.7665 7 85 17.2335 85 29.8571C85 40.9329 77.122 50.1672 66.6641 52.2656C67.4374 54.5597 67.8571 57.0165 67.8571 59.5714C67.8571 72.1951 57.6237 82.4286 45 82.4286C32.3763 82.4286 22.1429 72.1951 22.1429 59.5714C22.1429 57.0166 22.5616 54.5596 23.3348 52.2656C12.8774 50.1667 5 40.9325 5 29.8571C5 17.2335 15.2335 7 27.8571 7C34.6844 7 40.8117 9.9941 45 14.74C49.1883 9.9941 55.3156 7 62.1429 7ZM45 50.4286C39.9505 50.4286 35.8571 54.522 35.8571 59.5714C35.8571 64.6209 39.9505 68.7143 45 68.7143C50.0495 68.7143 54.1429 64.6209 54.1429 59.5714C54.1429 54.522 50.0495 50.4286 45 50.4286ZM27.8571 20.7143C22.8077 20.7143 18.7143 24.8077 18.7143 29.8571C18.7143 34.9066 22.8077 39 27.8571 39C32.9066 39 37 34.9066 37 29.8571C37 24.8077 32.9066 20.7143 27.8571 20.7143ZM62.1429 20.7143C57.0934 20.7143 53 24.8077 53 29.8571C53 34.9066 57.0934 39 62.1429 39C67.1923 39 71.2857 34.9066 71.2857 29.8571C71.2857 24.8077 67.1923 20.7143 62.1429 20.7143Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 67C15.3137 67 18 69.6863 18 73C18 76.3137 15.3137 79 12 79C8.68629 79 6 76.3137 6 73C6 69.6863 8.68629 67 12 67Z" fill="black"/>
|
||||
<path d="M38 49C43.5229 49 48 53.4772 48 59C48 64.5229 43.5229 69 38 69C35.3971 69 33.0272 68.0047 31.248 66.375L18 73L29.5459 64.3398C28.5679 62.7947 28 60.964 28 59C28 53.4772 32.4772 49 38 49Z" fill="black"/>
|
||||
<path d="M70 31C77.732 31 84 37.268 84 45C84 52.732 77.732 59 70 59C65.2627 59 61.0753 56.6469 58.542 53.0459L48 57L57.4062 51.1201C56.506 49.2711 56 47.1948 56 45C56 42.8049 56.5058 40.7281 57.4062 38.8789L48 33L58.542 36.9531C61.0753 33.3525 65.263 31 70 31Z" fill="black"/>
|
||||
<path d="M12 39C15.3137 39 18 41.6863 18 45C18 48.3137 15.3137 51 12 51C8.68629 51 6 48.3137 6 45C6 41.6863 8.68629 39 12 39Z" fill="black"/>
|
||||
<path d="M31.248 23.624C33.0271 21.9946 35.3973 21 38 21C43.5229 21 48 25.4772 48 31C48 36.5228 43.5229 41 38 41C35.3971 41 33.0272 40.0047 31.248 38.375L18 45L29.5459 36.3398C28.5679 34.7947 28 32.964 28 31C28 29.0357 28.5676 27.2045 29.5459 25.6592L18 17L31.248 23.624Z" fill="black"/>
|
||||
<path d="M12 11C15.3137 11 18 13.6863 18 17C18 20.3137 15.3137 23 12 23C8.68629 23 6 20.3137 6 17C6 13.6863 8.68629 11 12 11Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_17413_8953)">
|
||||
<path d="M45.0475 24.2C50.4402 24.2 54.8118 19.6781 54.8118 14.1C54.8118 8.52192 50.4402 4 45.0475 4C39.6548 4 35.2832 8.52192 35.2832 14.1C35.2832 19.6781 39.6548 24.2 45.0475 24.2Z" fill="black"/>
|
||||
<path d="M25.5286 44.4002C30.9213 44.4002 35.2929 39.8783 35.2929 34.3002C35.2929 28.7221 30.9213 24.2002 25.5286 24.2002C20.1359 24.2002 15.7643 28.7221 15.7643 34.3002C15.7643 39.8783 20.1359 44.4002 25.5286 44.4002Z" fill="black"/>
|
||||
<path d="M45.0476 36L6 84.79H84.1048L45.0476 36ZM45.0476 74.66C43.1164 74.66 41.2285 74.0676 39.6228 72.9578C38.0171 71.848 36.7656 70.2706 36.0265 68.4251C35.2875 66.5796 35.0941 64.5488 35.4709 62.5896C35.8476 60.6304 36.7776 58.8307 38.1432 57.4182C39.5087 56.0057 41.2485 55.0438 43.1426 54.6541C45.0367 54.2644 47 54.4644 48.7842 55.2288C50.5684 55.9933 52.0934 57.2878 53.1663 58.9487C54.2392 60.6097 54.8119 62.5624 54.8119 64.56C54.8157 65.8889 54.5659 67.2055 54.0769 68.4343C53.5879 69.6632 52.8693 70.7801 51.9622 71.7212C51.0552 72.6622 49.9775 73.4089 48.7909 73.9183C47.6044 74.4278 46.3323 74.69 45.0476 74.69V74.66Z" fill="black"/>
|
||||
<path d="M64.5762 44.4002C69.9689 44.4002 74.3405 39.8783 74.3405 34.3002C74.3405 28.7221 69.9689 24.2002 64.5762 24.2002C59.1835 24.2002 54.8119 28.7221 54.8119 34.3002C54.8119 39.8783 59.1835 44.4002 64.5762 44.4002Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_17413_8953">
|
||||
<rect width="78.1048" height="80.79" fill="white" transform="translate(6 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M40 5H5V40H40V5Z" fill="black"/>
|
||||
<path d="M65 5H45V25H65V5Z" fill="black"/>
|
||||
<path d="M85 5H70V20H85V5Z" fill="black"/>
|
||||
<path d="M60 30H45V45H60V30Z" fill="black"/>
|
||||
<path d="M25 45H5V65H25V45Z" fill="black"/>
|
||||
<path d="M45 45H30V60H45V45Z" fill="black"/>
|
||||
<path d="M20 70H5V85H20V70Z" fill="black"/>
|
||||
<path d="M85 25H65V45H85V25Z" fill="black"/>
|
||||
<path d="M85 50H50V85H85V50Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.585 65.29H65.2852L44.9951 85.5801L24.7051 65.29H4.41504V32.9102H85.585V65.29ZM27.3555 65.29L44.9951 82.9297L62.6348 65.29L44.9951 47.6504L27.3555 65.29Z" fill="black"/>
|
||||
<path d="M17.5352 7.39062C23.1407 7.3907 27.6845 11.9344 27.6846 17.54C27.6846 23.1457 23.1408 27.6903 17.5352 27.6904C11.9295 27.6904 7.38477 23.1457 7.38477 17.54C7.38482 11.9344 11.9295 7.39062 17.5352 7.39062Z" fill="black"/>
|
||||
<path d="M72.4648 7.39062C78.0705 7.39062 82.6152 11.9344 82.6152 17.54C82.6152 23.1457 78.0705 27.6904 72.4648 27.6904C66.8593 27.6903 62.3154 23.1457 62.3154 17.54C62.3155 11.9345 66.8593 7.39074 72.4648 7.39062Z" fill="black"/>
|
||||
<path d="M44.9951 4.41992C50.6008 4.41997 55.1455 8.96465 55.1455 14.5703C55.1453 20.1758 50.6006 24.7197 44.9951 24.7197C39.3896 24.7197 34.8449 20.1758 34.8447 14.5703C34.8447 8.96462 39.3894 4.41992 44.9951 4.41992Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1021 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M45 50C47.7614 50 50 47.7614 50 45C50 42.2386 47.7614 40 45 40C42.2386 40 40 42.2386 40 45C40 47.7614 42.2386 50 45 50Z" fill="black"/>
|
||||
<path d="M45 23C49.4183 23 53 19.4183 53 15C53 10.5817 49.4183 7 45 7C40.5817 7 37 10.5817 37 15C37 19.4183 40.5817 23 45 23Z" fill="black"/>
|
||||
<path d="M71 33C75.4183 33 79 29.4183 79 25C79 20.5817 75.4183 17 71 17C66.5817 17 63 20.5817 63 25C63 29.4183 66.5817 33 71 33Z" fill="black"/>
|
||||
<path d="M71 73C75.4183 73 79 69.4183 79 65C79 60.5817 75.4183 57 71 57C66.5817 57 63 60.5817 63 65C63 69.4183 66.5817 73 71 73Z" fill="black"/>
|
||||
<path d="M45 83C49.4183 83 53 79.4183 53 75C53 70.5817 49.4183 67 45 67C40.5817 67 37 70.5817 37 75C37 79.4183 40.5817 83 45 83Z" fill="black"/>
|
||||
<path d="M19 73C23.4183 73 27 69.4183 27 65C27 60.5817 23.4183 57 19 57C14.5817 57 11 60.5817 11 65C11 69.4183 14.5817 73 19 73Z" fill="black"/>
|
||||
<path d="M19 33C23.4183 33 27 29.4183 27 25C27 20.5817 23.4183 17 19 17C14.5817 17 11 20.5817 11 25C11 29.4183 14.5817 33 19 33Z" fill="black"/>
|
||||
<path d="M45 45V23" stroke="black" stroke-width="2"/>
|
||||
<path d="M45 45L65 29" stroke="black" stroke-width="2"/>
|
||||
<path d="M45 45L65 61" stroke="black" stroke-width="2"/>
|
||||
<path d="M45 45V67" stroke="black" stroke-width="2"/>
|
||||
<path d="M45 45L25 61" stroke="black" stroke-width="2"/>
|
||||
<path d="M45 45L25 29" stroke="black" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M73.535 62.3159C75.4141 62.5407 77.1923 63.2887 78.6668 64.4751C80.1414 65.6615 81.253 67.2383 81.8748 69.0259C82.4966 70.8135 82.6034 72.74 82.1834 74.5854C81.7635 76.4308 80.8338 78.121 79.4998 79.4634C78.1658 80.8057 76.4811 81.7467 74.6385 82.1782C72.7957 82.6097 70.8684 82.5145 69.077 81.9038C67.2855 81.2931 65.7013 80.1913 64.5057 78.7241C63.3103 77.257 62.5507 75.4836 62.3143 73.606H73.535V62.3159ZM16.4344 73.606H27.6844C27.4475 75.4822 26.6885 77.2541 25.494 78.7202C24.2994 80.1863 22.7173 81.2879 20.9276 81.8989C19.1379 82.51 17.2126 82.6057 15.3709 82.1763C13.5291 81.7468 11.8445 80.8089 10.5096 79.4692C9.17483 78.1296 8.24302 76.4423 7.82013 74.5991C7.39722 72.7558 7.50059 70.831 8.11798 69.0435C8.7354 67.2559 9.84193 65.6772 11.3123 64.4878C12.7826 63.2985 14.5574 62.5462 16.4344 62.3159V73.606ZM62.3143 16.436C62.1273 17.9555 62.2887 19.4979 62.7859 20.9458C63.2832 22.3937 64.1043 23.7095 65.1854 24.7935C66.2664 25.8773 67.5797 26.7015 69.0262 27.2026C70.4728 27.7038 72.015 27.869 73.535 27.686V62.3159C72.0176 62.1344 70.4785 62.2995 69.034 62.7983C67.5896 63.2971 66.2771 64.1168 65.1951 65.1958C64.1061 66.2854 63.2802 67.6095 62.7811 69.0669C62.2819 70.5245 62.1218 72.0774 62.3143 73.606H27.6844C27.879 72.0805 27.7223 70.5303 27.2264 69.0747C26.7304 67.6193 25.9083 66.2967 24.8231 65.2075C23.7376 64.1182 22.4175 63.2907 20.9637 62.7896C19.5099 62.2885 17.9604 62.1268 16.4344 62.3159V27.686C17.9567 27.8739 19.5025 27.7123 20.9529 27.2134C22.4033 26.7144 23.7209 25.8907 24.8055 24.8062C25.89 23.7216 26.7128 22.404 27.2117 20.9536C27.7106 19.5032 27.8722 17.9583 27.6844 16.436H62.3143ZM15.3885 7.81885C17.2331 7.39329 19.1603 7.49497 20.95 8.11182C22.7398 8.72874 24.3208 9.83627 25.5115 11.3081C26.7021 12.7799 27.4549 14.5569 27.6844 16.436H16.4344V27.686C14.5553 27.4565 12.7782 26.7029 11.3065 25.5122C9.83478 24.3215 8.72705 22.7414 8.11017 20.9517C7.49329 19.1619 7.39172 17.2347 7.8172 15.3901C8.24278 13.5454 9.17873 11.8567 10.5174 10.5181C11.856 9.17958 13.544 8.24441 15.3885 7.81885ZM69.0535 8.10596C70.845 7.48943 72.7742 7.38933 74.6199 7.81689C76.4656 8.24447 78.1541 9.18247 79.492 10.5239C80.8298 11.8653 81.7636 13.5561 82.1863 15.4028C82.609 17.2497 82.5029 19.1784 81.8817 20.9683C81.2604 22.758 80.1486 24.3372 78.6727 25.5249C77.1967 26.7126 75.4162 27.462 73.535 27.686V16.436H62.3143C62.5433 14.5553 63.2974 12.7761 64.4891 11.3032C65.6807 9.83059 67.2623 8.72246 69.0535 8.10596Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -57,7 +57,7 @@ export const Default = {
|
||||
size: "L",
|
||||
icon: (
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -165,7 +165,7 @@ export const SizeLarge = {
|
||||
size: "L",
|
||||
icon: (
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={103}
|
||||
height={103}
|
||||
@@ -184,7 +184,7 @@ export const SizeMedium = {
|
||||
size: "M",
|
||||
icon: (
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={56}
|
||||
height={56}
|
||||
@@ -203,7 +203,7 @@ export const SizeSmall = {
|
||||
size: "S",
|
||||
icon: (
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={56}
|
||||
height={56}
|
||||
@@ -222,7 +222,7 @@ export const SizeExtraSmall = {
|
||||
size: "XS",
|
||||
icon: (
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={8}
|
||||
height={8}
|
||||
@@ -311,7 +311,7 @@ export const AllVariants = {
|
||||
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
||||
icon={
|
||||
<Image
|
||||
src="assets/Icon_Sociocracy.svg"
|
||||
src="assets/template-mark/consensus-clusters.svg"
|
||||
alt="Sociocracy"
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -326,7 +326,7 @@ export const AllVariants = {
|
||||
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
||||
icon={
|
||||
<Image
|
||||
src="assets/Icon_Consensus.svg"
|
||||
src="assets/template-mark/consensus.svg"
|
||||
alt="Consensus"
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -341,7 +341,7 @@ export const AllVariants = {
|
||||
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
||||
icon={
|
||||
<Image
|
||||
src="assets/Icon_ElectedBoard.svg"
|
||||
src="assets/template-mark/elected-board.svg"
|
||||
alt="Elected Board"
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -356,7 +356,7 @@ export const AllVariants = {
|
||||
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
||||
icon={
|
||||
<Image
|
||||
src="assets/Icon_Petition.svg"
|
||||
src="assets/template-mark/petition.svg"
|
||||
alt="Petition"
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -43,7 +43,7 @@ test.describe("Critical User Journeys", () => {
|
||||
).toBeVisible();
|
||||
|
||||
// 6. User explores rule templates
|
||||
await page.locator("text=Consensus clusters").first().click();
|
||||
await page.locator("text=Circles").first().click();
|
||||
await page.locator("text=Consensus").nth(1).click();
|
||||
await page.locator("text=Elected Board").first().click();
|
||||
await page.locator("text=Petition").first().click();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Shared Next.js navigation mock for tests that render components using useRouter
|
||||
* (e.g. home RuleStack) without a file-local vi.mock.
|
||||
*/
|
||||
export const testRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
export const testPathname = vi.fn(() => "/");
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => testRouter,
|
||||
usePathname: () => testPathname(),
|
||||
useSearchParams: vi.fn(() => new URLSearchParams()),
|
||||
}));
|
||||
@@ -94,9 +94,7 @@ describe("Page Flow Integration", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Rule Stack section
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Consensus clusters" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Circles" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Elected Board" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -177,17 +175,20 @@ describe("Page Flow Integration", () => {
|
||||
expect(sectionNumbers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("rule stack displays all four governance types", async () => {
|
||||
test("rule stack shows four featured templates and link to full catalog", async () => {
|
||||
render(<Page />);
|
||||
|
||||
// Wait for dynamically imported RuleStack component
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Consensus clusters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Solidarity Network")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Elected Board")).toBeInTheDocument();
|
||||
expect(screen.getByText("Consensus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Petition")).toBeInTheDocument();
|
||||
|
||||
const seeAll = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(seeAll).toHaveAttribute("href", "/templates");
|
||||
|
||||
// Check that create rule button is present
|
||||
const createButton = screen.getByRole("button", {
|
||||
name: "Create CommunityRule",
|
||||
@@ -253,7 +254,7 @@ describe("Page Flow Integration", () => {
|
||||
expect(screen.getByText("How CommunityRule works")).toBeInTheDocument();
|
||||
|
||||
// 3. Rule types show different governance options
|
||||
expect(screen.getByText("Consensus clusters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
|
||||
// 4. Features highlight benefits
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, test, expect, afterEach, beforeEach } from "vitest";
|
||||
import TemplatesPage from "../../app/(marketing)/templates/page";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
beforeEach(() => {
|
||||
testRouter.push.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Templates page (/templates)", () => {
|
||||
test("renders title, intro, and full catalog", () => {
|
||||
render(<TemplatesPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Templates", level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/mutual aid and open source communities/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
for (const entry of GOVERNANCE_TEMPLATE_CATALOG) {
|
||||
expect(screen.getByText(entry.title)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("each template card navigates to review flow for its slug", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TemplatesPage />);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
|
||||
testRouter.push.mockClear();
|
||||
const solidarity = screen.getByText("Solidarity Network").closest("div");
|
||||
await user.click(solidarity);
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/solidarity-network",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,7 @@ describe("User Journey Integration", () => {
|
||||
|
||||
// Wait for dynamically imported RuleStack component
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Consensus clusters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Elected Board")).toBeInTheDocument();
|
||||
expect(screen.getByText("Consensus")).toBeInTheDocument();
|
||||
@@ -250,7 +250,7 @@ describe("User Journey Integration", () => {
|
||||
() => {
|
||||
// Check for any of the governance card titles
|
||||
const hasGovernanceContent =
|
||||
screen.queryByText(/Consensus clusters/i) ||
|
||||
screen.queryByText(/Circles/i) ||
|
||||
screen.queryByText(/Elected Board/i) ||
|
||||
screen.queryByText(/Petition/i);
|
||||
expect(hasGovernanceContent).toBeTruthy();
|
||||
|
||||
@@ -4,22 +4,38 @@ import {
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||
import { vi, describe, test, expect, afterEach, beforeEach } from "vitest";
|
||||
import { logger } from "../../lib/logger";
|
||||
import RuleStack from "../../app/components/sections/RuleStack";
|
||||
import { testRouter } from "../mocks/navigation";
|
||||
import {
|
||||
GOVERNANCE_TEMPLATE_CATALOG,
|
||||
getGovernanceTemplatesForHome,
|
||||
} from "../../lib/templates/governanceTemplateCatalog";
|
||||
|
||||
const homeFeatured = getGovernanceTemplatesForHome();
|
||||
|
||||
beforeEach(() => {
|
||||
testRouter.push.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("RuleStack Component", () => {
|
||||
test("renders all four rule cards", () => {
|
||||
test("renders four featured governance template cards on the home row", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
expect(screen.getByText("Consensus clusters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Consensus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Elected Board")).toBeInTheDocument();
|
||||
expect(screen.getByText("Petition")).toBeInTheDocument();
|
||||
for (const entry of homeFeatured) {
|
||||
expect(screen.getByText(entry.title)).toBeInTheDocument();
|
||||
}
|
||||
expect(GOVERNANCE_TEMPLATE_CATALOG.length).toBeGreaterThan(
|
||||
homeFeatured.length,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Solidarity Network"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with custom className", () => {
|
||||
@@ -29,38 +45,57 @@ describe("RuleStack Component", () => {
|
||||
expect(section).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
test("renders rule card descriptions", () => {
|
||||
test("renders sample rule card descriptions from featured catalog", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Units called Circles have the ability to decide/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Decisions that affect the group collectively/),
|
||||
screen.getByText(
|
||||
/Important decisions require unanimous agreement\. Proposals pass only if no serious objections remain\./,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/An elected board determines policies/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/All participants can propose and vote/),
|
||||
screen.getByText(
|
||||
/Any participant can propose a rule change\. If enough sign it/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders rule card icons", () => {
|
||||
render(<RuleStack />);
|
||||
test("renders rule card icons with image assets", () => {
|
||||
const { container } = render(<RuleStack />);
|
||||
|
||||
expect(screen.getByAltText("Sociocracy")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Consensus")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Elected Board")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Petition")).toBeInTheDocument();
|
||||
const imgs = container.querySelectorAll("img");
|
||||
const circles = [...imgs].find((el) => {
|
||||
const s = el.getAttribute("src") ?? "";
|
||||
return (
|
||||
s.includes("template-mark/consensus-clusters") ||
|
||||
s.includes("template-mark%2Fconsensus-clusters")
|
||||
);
|
||||
});
|
||||
const consensus = [...imgs].find((el) => {
|
||||
const s = el.getAttribute("src") ?? "";
|
||||
return (
|
||||
s.includes("consensus") &&
|
||||
!s.includes("consensus-clusters") &&
|
||||
!s.includes("elected") &&
|
||||
!s.includes("petition")
|
||||
);
|
||||
});
|
||||
expect(circles).toBeTruthy();
|
||||
expect(consensus).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders call-to-action button", () => {
|
||||
test("renders see-all-templates link to full templates page", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "See all templates" }),
|
||||
).toBeInTheDocument();
|
||||
const link = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/templates");
|
||||
});
|
||||
|
||||
test("applies correct CSS classes", () => {
|
||||
@@ -74,7 +109,6 @@ describe("RuleStack Component", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
const section = document.querySelector("section");
|
||||
// Check for responsive padding classes
|
||||
expect(section).toHaveClass("px-[20px]", "py-[32px]");
|
||||
expect(section?.className).toMatch(/min-\[640px\]:px-\[32px\]/);
|
||||
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
|
||||
@@ -87,22 +121,21 @@ describe("RuleStack Component", () => {
|
||||
expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2");
|
||||
});
|
||||
|
||||
test("renders RuleCard components with correct props", () => {
|
||||
test("renders RuleCard components with catalog surface colors", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
// Check that RuleCard components receive correct props
|
||||
const consensusClustersCard = screen
|
||||
.getByText("Consensus clusters")
|
||||
.closest('[class*="bg-[var(--color-surface-default-brand-lime)]"]');
|
||||
expect(consensusClustersCard).toBeInTheDocument();
|
||||
const circlesCard = screen
|
||||
.getByText("Circles")
|
||||
.closest('[class*="bg-[var(--color-surface-invert-brand-teal)]"]');
|
||||
expect(circlesCard).toBeInTheDocument();
|
||||
|
||||
const consensusCard = screen
|
||||
.getByText("Consensus")
|
||||
.closest('[class*="bg-[var(--color-surface-default-brand-rust)]"]');
|
||||
.closest('[class*="bg-[var(--color-surface-invert-positive-secondary)]"]');
|
||||
expect(consensusCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles template click events", async () => {
|
||||
test("handles template click events for featured templates", async () => {
|
||||
const user = userEvent.setup();
|
||||
const debugSpy = vi
|
||||
.spyOn(logger, "debug")
|
||||
@@ -113,7 +146,10 @@ describe("RuleStack Component", () => {
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith("Consensus template clicked");
|
||||
expect(debugSpy).toHaveBeenCalledWith("consensus template clicked");
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
@@ -124,70 +160,68 @@ describe("RuleStack Component", () => {
|
||||
const section = document.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
|
||||
// Check for proper heading structure: 1 from SectionHeader + 4 from RuleCards
|
||||
const headings = screen.getAllByRole("heading");
|
||||
expect(headings).toHaveLength(5); // One section header + four rule cards
|
||||
expect(headings).toHaveLength(1 + homeFeatured.length);
|
||||
});
|
||||
|
||||
test("applies responsive spacing", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
const section = document.querySelector("section");
|
||||
// Check for responsive padding classes
|
||||
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
|
||||
expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/);
|
||||
});
|
||||
|
||||
test("renders icons with correct attributes", () => {
|
||||
render(<RuleStack />);
|
||||
const { container } = render(<RuleStack />);
|
||||
|
||||
const sociocracyIcon = screen.getByAltText("Sociocracy");
|
||||
expect(sociocracyIcon).toHaveAttribute(
|
||||
"src",
|
||||
"/assets/Icon_Sociocracy.svg",
|
||||
);
|
||||
// Check for responsive icon size classes
|
||||
expect(sociocracyIcon?.className).toMatch(
|
||||
/min-\[640px\]:max-\[1023px\]:w-\[56px\]/,
|
||||
);
|
||||
expect(sociocracyIcon?.className).toMatch(
|
||||
/min-\[640px\]:max-\[1023px\]:h-\[56px\]/,
|
||||
);
|
||||
expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/);
|
||||
expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
|
||||
});
|
||||
|
||||
test("applies different background colors to cards", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
// Look for RuleCard elements with background color classes
|
||||
const cards = document.querySelectorAll('[role="button"]');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify that cards have background color classes
|
||||
cards.forEach((card) => {
|
||||
expect(card.className).toMatch(
|
||||
/bg-\[var\(--color-surface-default-brand-/,
|
||||
const imgs = container.querySelectorAll("img");
|
||||
const circlesIcon = [...imgs].find((el) => {
|
||||
const s = el.getAttribute("src") ?? "";
|
||||
return (
|
||||
s.includes("template-mark/consensus-clusters") ||
|
||||
s.includes("template-mark%2Fconsensus-clusters")
|
||||
);
|
||||
});
|
||||
expect(circlesIcon).toBeTruthy();
|
||||
expect(circlesIcon?.getAttribute("src")).toMatch(
|
||||
/template-mark(?:%2F|\/)consensus-clusters/,
|
||||
);
|
||||
expect(circlesIcon?.className).toMatch(
|
||||
/min-\[640px\]:max-\[1023px\]:w-\[56px\]/,
|
||||
);
|
||||
expect(circlesIcon?.className).toMatch(
|
||||
/min-\[640px\]:max-\[1023px\]:h-\[56px\]/,
|
||||
);
|
||||
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/);
|
||||
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
|
||||
});
|
||||
|
||||
test("renders with proper button styling", () => {
|
||||
test("applies different background colors to featured cards", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "See all templates" });
|
||||
// Button component uses outline variant which has bg-transparent and border
|
||||
expect(button?.className).toMatch(/bg-transparent/);
|
||||
expect(button?.className).toMatch(/border/);
|
||||
const buttons = document.querySelectorAll('[role="button"]');
|
||||
const templateSurfaces = [...buttons].filter((el) =>
|
||||
el.className.includes("--color-surface-invert"),
|
||||
);
|
||||
expect(templateSurfaces.length).toBe(homeFeatured.length);
|
||||
});
|
||||
|
||||
test("applies flex layout for button container", () => {
|
||||
test("renders with proper see-all link styling", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
const buttonContainer = screen
|
||||
.getByRole("button", { name: "See all templates" })
|
||||
const link = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(link?.className).toMatch(/bg-transparent/);
|
||||
expect(link?.className).toMatch(/border/);
|
||||
});
|
||||
|
||||
test("applies flex layout for see-all link container", () => {
|
||||
render(<RuleStack />);
|
||||
|
||||
const linkContainer = screen
|
||||
.getByRole("link", { name: "See all templates" })
|
||||
.closest("div");
|
||||
expect(buttonContainer).toHaveClass("flex", "justify-center");
|
||||
expect(linkContainer).toHaveClass("flex", "justify-center");
|
||||
});
|
||||
|
||||
test("handles analytics tracking", async () => {
|
||||
@@ -195,7 +229,6 @@ describe("RuleStack Component", () => {
|
||||
const gtagSpy = vi.fn();
|
||||
const analyticsSpy = vi.fn();
|
||||
|
||||
// Mock window.gtag and window.analytics
|
||||
Object.defineProperty(window, "gtag", {
|
||||
value: gtagSpy,
|
||||
writable: true,
|
||||
@@ -211,10 +244,10 @@ describe("RuleStack Component", () => {
|
||||
await user.click(electedBoardCard);
|
||||
|
||||
expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", {
|
||||
template_name: "Elected Board",
|
||||
template_slug: "elected-board",
|
||||
});
|
||||
expect(analyticsSpy).toHaveBeenCalledWith("Template Clicked", {
|
||||
templateName: "Elected Board",
|
||||
templateSlug: "elected-board",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
templateBodyToCategories,
|
||||
templateSummaryFromBody,
|
||||
} from "../../lib/create/templateReviewMapping";
|
||||
|
||||
describe("templateReviewMapping", () => {
|
||||
it("maps body sections to RuleCard categories", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Solidarity", body: "Long body" },
|
||||
{ title: "Ecology", body: "More" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const cats = templateBodyToCategories(body);
|
||||
expect(cats).toHaveLength(1);
|
||||
expect(cats[0].name).toBe("Values");
|
||||
expect(cats[0].chipOptions.map((c) => c.label)).toEqual([
|
||||
"Solidarity",
|
||||
"Ecology",
|
||||
]);
|
||||
expect(cats[0].chipOptions.every((c) => c.state === "unselected")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses description for summary when present", () => {
|
||||
expect(
|
||||
templateSummaryFromBody("Short API description", { sections: [] }),
|
||||
).toBe("Short API description");
|
||||
});
|
||||
|
||||
it("falls back to first entry body when description empty", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "X",
|
||||
entries: [{ title: "T", body: " First paragraph. " }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(templateSummaryFromBody("", body)).toBe("First paragraph.");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "./tests/mocks/navigation";
|
||||
import React from "react";
|
||||
import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||