Create flow UX updates
This commit is contained in:
@@ -21,6 +21,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title = "",
|
||||
description = "",
|
||||
layout = "default",
|
||||
headerLockupSize,
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -74,6 +75,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
title={title}
|
||||
description={description}
|
||||
layout={layout}
|
||||
headerLockupSize={headerLockupSize}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
|
||||
export interface CardStackItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,6 +21,8 @@ export interface CardStackProps {
|
||||
description?: string;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
|
||||
headerLockupSize?: HeaderLockupSizeValue;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -34,5 +38,6 @@ export interface CardStackViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
layout: "default" | "singleStack";
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ export function CardStackView({
|
||||
title,
|
||||
description,
|
||||
layout,
|
||||
headerLockupSize,
|
||||
className,
|
||||
}: CardStackViewProps) {
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: recommended only (up to 5). Expanded: all cards.
|
||||
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
|
||||
@@ -33,7 +35,7 @@ export function CardStackView({
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -73,7 +75,7 @@ export function CardStackView({
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -43,10 +43,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onShare}
|
||||
ariaLabel="Share"
|
||||
ariaLabel={t("shareAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
Share
|
||||
{t("share")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -56,10 +56,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onExport}
|
||||
ariaLabel="Export"
|
||||
ariaLabel={t("exportAriaLabel")}
|
||||
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
<span>Export</span>
|
||||
<span>{t("export")}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -83,10 +83,10 @@ export function CreateFlowTopNavView({
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onEdit}
|
||||
ariaLabel="Edit"
|
||||
ariaLabel={t("editAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
Edit
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../../lib/create/fetchTemplates";
|
||||
import messages from "../../messages/en/index";
|
||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import Alert from "../components/modals/Alert";
|
||||
@@ -76,6 +77,7 @@ function CreateFlowLayoutContent({
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const tFooter = useTranslation("create.footer");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { openLogin } = useAuthModal();
|
||||
@@ -99,12 +101,15 @@ function CreateFlowLayoutContent({
|
||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||
|
||||
const templateReviewMatch = pathname?.match(
|
||||
/^\/create\/review-template\/([^/]+)$/,
|
||||
/\/create\/review-template\/([^/?#]+)/,
|
||||
);
|
||||
const templateReviewSlug = templateReviewMatch?.[1]
|
||||
? decodeURIComponent(templateReviewMatch[1])
|
||||
: null;
|
||||
const isTemplateReviewRoute = Boolean(templateReviewSlug);
|
||||
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
|
||||
const isTemplateReviewRoute = Boolean(
|
||||
pathname?.includes("/create/review-template/"),
|
||||
);
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
@@ -218,8 +223,29 @@ function CreateFlowLayoutContent({
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||
const isFinalReviewStep = currentStep === "final-review";
|
||||
const isCardsStep = currentStep === "cards";
|
||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||
|
||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||
const mainContentClass = isCompletedStep
|
||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||
: isRightRailStep
|
||||
? "items-stretch overflow-hidden"
|
||||
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
|
||||
? "items-start justify-center overflow-y-auto"
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
|
||||
const isTextStep = currentStep === "text";
|
||||
const mainMaxMdJustify =
|
||||
isTextStep && !isCompletedStep && !isRightRailStep
|
||||
? "max-md:justify-center"
|
||||
: "max-md:justify-start";
|
||||
const mainMaxMdCross =
|
||||
isCompletedStep || isRightRailStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
|
||||
@@ -299,20 +325,14 @@ function CreateFlowLayoutContent({
|
||||
}`.trim()}
|
||||
/>
|
||||
<main
|
||||
className={`flex min-h-0 flex-1 justify-center ${
|
||||
useFullHeightMain
|
||||
? isCompletedStep
|
||||
? "items-stretch overflow-y-auto sm:overflow-hidden"
|
||||
: "items-stretch overflow-hidden"
|
||||
: "flex-row items-center justify-center overflow-y-auto"
|
||||
}`}
|
||||
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!isCompletedStep && (
|
||||
<CreateFlowFooter
|
||||
className="shrink-0"
|
||||
progressBar={!isTemplateReviewRoute}
|
||||
progressBar={!isTemplateReviewRoute && !isFinalReviewStep}
|
||||
secondButton={
|
||||
isTemplateReviewRoute ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
@@ -364,10 +384,10 @@ function CreateFlowLayoutContent({
|
||||
{currentStep === "final-review"
|
||||
? isPublishing
|
||||
? messages.create.publish.finalizeButtonPublishing
|
||||
: "Finalize CommunityRule"
|
||||
: tFooter("finalizeCommunityRule")
|
||||
: currentStep === "confirm-stakeholders"
|
||||
? "Confirm Stakeholders"
|
||||
: "Next"}
|
||||
? tFooter("confirmStakeholders")
|
||||
: tFooter("next")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function CreateFlowStepPage({ params }: PageProps) {
|
||||
|
||||
// Placeholder content - templates will be implemented in CR-51-55
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-1 max-md:items-start max-md:justify-start md:items-center md:justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-white text-2xl font-bold mb-4">
|
||||
Create Flow Step: {step}
|
||||
|
||||
+107
-168
@@ -1,91 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import Create from "../../components/modals/Create";
|
||||
import TextArea from "../../components/controls/TextArea";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
const COMPACT_TITLE = "How should this community communicate with each-other?";
|
||||
const COMPACT_DESCRIPTION =
|
||||
"You can select multiple methods for different needs or add your own";
|
||||
const EXPANDED_TITLE =
|
||||
"What method should this community use to communicate with eachother?";
|
||||
const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION;
|
||||
|
||||
/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */
|
||||
|
||||
/** Card ids for "Add platform" Create modal variants. */
|
||||
const IN_PERSON_CARD_ID = "in-person-meetings";
|
||||
const SIGNAL_CARD_ID = "signal";
|
||||
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
|
||||
|
||||
/** Copy for the default confirm modal (non–add-platform cards). */
|
||||
const CONFIRM_MODAL = {
|
||||
title: "Confirm selection",
|
||||
description: "Confirm to select this option.",
|
||||
nextButtonText: "Confirm",
|
||||
showBackButton: false,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* "Add platform" variants share the same header pattern and "Add Platform" button.
|
||||
* Each has its own title, description, and body (three TextArea sections).
|
||||
*/
|
||||
const ADD_PLATFORM_MODALS: Record<
|
||||
string,
|
||||
{ title: string; description: string; nextButtonText: string }
|
||||
> = {
|
||||
[IN_PERSON_CARD_ID]: {
|
||||
title: "In-Person Meetings",
|
||||
description:
|
||||
"Physical gatherings for high-bandwidth communication and relationship building.",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
[SIGNAL_CARD_ID]: {
|
||||
title: "Signal",
|
||||
description:
|
||||
"End-to-end encrypted messaging ideal for small, security-minded groups",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
[VIDEO_MEETINGS_CARD_ID]: {
|
||||
title: "Video Meetings",
|
||||
description: "Synchronous video calls for remote face-to-face interaction.",
|
||||
nextButtonText: "Add Platform",
|
||||
},
|
||||
};
|
||||
|
||||
const SECTION_KEYS = [
|
||||
"Core Principle & Scope",
|
||||
"Logistics, Admin & Norms",
|
||||
"Code of Conduct",
|
||||
const ADD_PLATFORM_CARD_IDS = [
|
||||
IN_PERSON_CARD_ID,
|
||||
SIGNAL_CARD_ID,
|
||||
VIDEO_MEETINGS_CARD_ID,
|
||||
] as const;
|
||||
type SectionKey = (typeof SECTION_KEYS)[number];
|
||||
|
||||
/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */
|
||||
const ADD_PLATFORM_SECTION_DEFAULTS: Record<
|
||||
string,
|
||||
Record<SectionKey, string>
|
||||
> = {
|
||||
[IN_PERSON_CARD_ID]: {
|
||||
"Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`,
|
||||
"Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`,
|
||||
"Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`,
|
||||
},
|
||||
[SIGNAL_CARD_ID]: {
|
||||
"Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`,
|
||||
"Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`,
|
||||
"Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`,
|
||||
},
|
||||
[VIDEO_MEETINGS_CARD_ID]: {
|
||||
"Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`,
|
||||
"Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`,
|
||||
"Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`,
|
||||
},
|
||||
};
|
||||
const SECTION_FIELDS = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
"codeOfConduct",
|
||||
] as const;
|
||||
type SectionField = (typeof SECTION_FIELDS)[number];
|
||||
|
||||
const COMMUNICATION_CARD_ORDER = [
|
||||
IN_PERSON_CARD_ID,
|
||||
SIGNAL_CARD_ID,
|
||||
VIDEO_MEETINGS_CARD_ID,
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Section with heading + info icon and an editable TextArea.
|
||||
@@ -132,119 +82,101 @@ function AddPlatformModalContent({
|
||||
platformCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const defaults = ADD_PLATFORM_SECTION_DEFAULTS[platformCardId];
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
|
||||
if (!modal || !("sections" in modal)) return null;
|
||||
|
||||
const defaults = modal.sections;
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionKey, string>
|
||||
>(
|
||||
defaults ?? {
|
||||
"Core Principle & Scope": "",
|
||||
"Logistics, Admin & Norms": "",
|
||||
"Code of Conduct": "",
|
||||
},
|
||||
);
|
||||
Record<SectionField, string>
|
||||
>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
logisticsAdmin: defaults.logisticsAdmin,
|
||||
codeOfConduct: defaults.codeOfConduct,
|
||||
}));
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionKey, value: string) => {
|
||||
(key: SectionField, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
if (!defaults) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_KEYS.map((key) => (
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<CreateModalSection
|
||||
key={key}
|
||||
title={key}
|
||||
value={sectionValues[key]}
|
||||
onChange={(v) => updateSection(key, v)}
|
||||
key={field}
|
||||
title={comm.sectionHeadings[field]}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Communication method cards (Figma 20246-15828). First three are recommended. */
|
||||
const SAMPLE_CARDS = [
|
||||
{
|
||||
id: IN_PERSON_CARD_ID,
|
||||
label: "In-Person Meetings",
|
||||
supportText:
|
||||
"Physical gatherings for high-bandwidth communication and relationship building.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: SIGNAL_CARD_ID,
|
||||
label: "Signal",
|
||||
supportText: "Encrypted messaging for high-security, private coordination.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: VIDEO_MEETINGS_CARD_ID,
|
||||
label: "Video Meetings",
|
||||
supportText: "Synchronous video calls for remote face-to-face interaction.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
label: "Label",
|
||||
supportText:
|
||||
"Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
label: "Label",
|
||||
supportText:
|
||||
"Structured sessions where parties collaboratively resolve disputes.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
label: "Label",
|
||||
supportText: "Members vote to resolve a dispute democratically.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
label: "Label",
|
||||
supportText: "Invite-only",
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Whether this card id uses the "Add platform" modal (shared header, platform-specific body). */
|
||||
function isAddPlatformCard(cardId: string | null): cardId is string {
|
||||
return cardId !== null && cardId in ADD_PLATFORM_MODALS;
|
||||
}
|
||||
|
||||
/** Resolve Create modal header/buttons: Add platform variant or default confirm. */
|
||||
function getCreateModalConfig(pendingCardId: string | null) {
|
||||
if (isAddPlatformCard(pendingCardId)) {
|
||||
return {
|
||||
...ADD_PLATFORM_MODALS[pendingCardId],
|
||||
showBackButton: false,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
return CONFIRM_MODAL;
|
||||
function isAddPlatformCard(cardId: string | null): boolean {
|
||||
return (
|
||||
cardId !== null &&
|
||||
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Create flow card stack step: compact grid with optional expand to full list. */
|
||||
export default function CardsPage() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const title = expanded ? EXPANDED_TITLE : COMPACT_TITLE;
|
||||
const description = expanded ? EXPANDED_DESCRIPTION : COMPACT_DESCRIPTION;
|
||||
const modalConfig = getCreateModalConfig(pendingCardId);
|
||||
const sampleCards = useMemo(
|
||||
() =>
|
||||
COMMUNICATION_CARD_ORDER.map((id) => {
|
||||
const row = comm.cards[id as keyof typeof comm.cards];
|
||||
return {
|
||||
id,
|
||||
label: row.label,
|
||||
supportText: row.supportText,
|
||||
recommended: true,
|
||||
};
|
||||
}),
|
||||
[comm.cards],
|
||||
);
|
||||
|
||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||
const description = expanded
|
||||
? comm.page.expandedDescription
|
||||
: comm.page.compactDescription;
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in comm.modals
|
||||
? (() => {
|
||||
const modal =
|
||||
comm.modals[pendingCardId as keyof typeof comm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
@@ -272,19 +204,21 @@ export default function CardsPage() {
|
||||
}, [markCreateFlowInteraction, pendingCardId]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
<div className="flex w-full flex-col gap-6 min-w-0">
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-800"
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col gap-6">
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
<CreateFlowHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardClick}
|
||||
expanded={expanded}
|
||||
@@ -293,6 +227,8 @@ export default function CardsPage() {
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
hasMore={true}
|
||||
toggleLabel={comm.page.seeAllLink}
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,10 +244,13 @@ export default function CardsPage() {
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
>
|
||||
{isAddPlatformCard(pendingCardId) ? (
|
||||
<AddPlatformModalContent platformCardId={pendingCardId} />
|
||||
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
|
||||
<AddPlatformModalContent
|
||||
key={pendingCardId}
|
||||
platformCardId={pendingCardId}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
+62
-179
@@ -1,111 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
|
||||
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import { useMessages } from "../../contexts/MessagesContext";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
|
||||
/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
|
||||
const FALLBACK_TITLE = "Mutual Aid Mondays";
|
||||
const FALLBACK_DESCRIPTION =
|
||||
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
|
||||
|
||||
const TOAST_TITLE = "This is what folks see when you share your CommunityRule";
|
||||
const TOAST_DESCRIPTION =
|
||||
"Your group can use this document as an operating manual.";
|
||||
|
||||
const SOLIDARITY_BODY =
|
||||
"Food Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth.";
|
||||
|
||||
/** Static sections for the completed Community Rule document (placeholder data). */
|
||||
const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{ title: "Solidarity Forever", body: SOLIDARITY_BODY },
|
||||
{
|
||||
title: "Shared Leadership",
|
||||
body: "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader.",
|
||||
},
|
||||
{
|
||||
title: "Organizing Offline",
|
||||
body: "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face connection and resist the pull of digital metrics.",
|
||||
},
|
||||
{
|
||||
title: "Circular Food Systems",
|
||||
body: "We intervene in the ecological crisis by addressing food waste and food recovery. We redirect surplus food to where it is needed and model a circular economy at the scale of our communities.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Signal",
|
||||
body: "We use Signal for sensitive coordination. Encrypted messaging helps protect our members and our plans from surveillance.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Membership",
|
||||
entries: [
|
||||
{
|
||||
title: "Open Admission",
|
||||
body: "Anyone who shares our values and is willing to contribute is welcome. We do not require applications or approval processes for general participation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{
|
||||
title: "Lazy Consensus",
|
||||
body: "We use lazy consensus for most decisions: proposals move forward unless someone raises a blocking concern. This keeps us moving without requiring everyone to approve every detail.",
|
||||
},
|
||||
{
|
||||
title: "Modified Consensus",
|
||||
body: "For larger or more consequential decisions we use modified consensus, with clear timelines and a fallback to a supermajority vote if needed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Conflict management",
|
||||
entries: [
|
||||
{
|
||||
title: "Code of Conduct",
|
||||
body: "We have a code of conduct that sets expectations for behavior and outlines how we address harm.",
|
||||
},
|
||||
{
|
||||
title: "Restorative Justice",
|
||||
body: "When conflict arises we prioritize restoration and learning over punishment. We use facilitated circles and other restorative practices where appropriate.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
|
||||
/**
|
||||
* Completed create flow page.
|
||||
* Figma: 20907-213286 (main), 18002-28017 (toast).
|
||||
*/
|
||||
export default function CompletedPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const m = useMessages();
|
||||
const completed = m.create.completed;
|
||||
|
||||
const fallbackSections = useMemo(
|
||||
() =>
|
||||
[...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
|
||||
[completed.fallbackDocumentSections],
|
||||
);
|
||||
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE);
|
||||
const [headerTitle, setHeaderTitle] = useState(
|
||||
() => completed.fallbackTitle,
|
||||
);
|
||||
const [headerDescription, setHeaderDescription] = useState<
|
||||
string | undefined
|
||||
>(FALLBACK_DESCRIPTION);
|
||||
>(() => completed.fallbackDescription);
|
||||
const [documentSections, setDocumentSections] =
|
||||
useState<CommunityRuleDocumentSection[]>(COMPLETED_RULE_SECTIONS);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
useState<CommunityRuleDocumentSection[]>(fallbackSections);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readLastPublishedRule();
|
||||
@@ -119,99 +47,54 @@ export default function CompletedPage() {
|
||||
setHeaderDescription(sum.length > 0 ? sum : undefined);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] px-5 md:px-12">
|
||||
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-[var(--measures-spacing-1200,48px)] min-h-0 min-w-0 w-full">
|
||||
{/* Left column: community title + header, centered, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<HeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
</div>
|
||||
{/* Right column: Community Rule document — this column scrolls independently; padding inside scroll so content isn't clipped */}
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{/* Soft fade at top: gradient wash only (no blur) so no sharp cutoff line */}
|
||||
<div
|
||||
className="sticky top-0 z-10 h-5 shrink-0 pointer-events-none bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="py-8 min-w-0">
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={TOAST_TITLE}
|
||||
description={TOAST_DESCRIPTION}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const toast = !toastDismissed ? (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={completed.toastTitle}
|
||||
description={completed.toastDescription}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col items-center px-5 min-w-0 bg-[var(--color-teal-teal50,#c9fef9)] py-8">
|
||||
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||
<HeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="M"
|
||||
palette="inverse"
|
||||
/>
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
useCardStyle
|
||||
className="w-full p-4"
|
||||
/>
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
||||
<div className="mx-auto grid min-h-0 w-full max-w-[1280px] grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0">
|
||||
<div className="flex min-w-0 flex-col justify-start overflow-hidden md:justify-center md:pb-8">
|
||||
<CreateFlowHeaderLockup
|
||||
title={headerTitle}
|
||||
description={headerDescription}
|
||||
justification="left"
|
||||
size="L"
|
||||
palette="inverse"
|
||||
/>
|
||||
</div>
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden md:overflow-y-auto">
|
||||
<div
|
||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 py-0 md:pb-8">
|
||||
<CommunityRuleDocument
|
||||
sections={documentSections}
|
||||
useCardStyle={!mdUp}
|
||||
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-10 w-full"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="toast"
|
||||
status="default"
|
||||
title={TOAST_TITLE}
|
||||
description={TOAST_DESCRIPTION}
|
||||
hasLeadingIcon
|
||||
hasBodyText
|
||||
onClose={() => setToastDismissed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{toast}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
|
||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
|
||||
size?: HeaderLockupProps["size"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
|
||||
*/
|
||||
export function CreateFlowHeaderLockup({
|
||||
size: sizeProp,
|
||||
...rest
|
||||
}: CreateFlowHeaderLockupProps) {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const size = sizeProp ?? (mdUp ? "L" : "M");
|
||||
return <HeaderLockup {...rest} size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||
|
||||
/** Shared `RuleCard` / template card chrome: matches final-review desktop + mobile padding and radius. */
|
||||
export const CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS =
|
||||
"w-full min-w-0 rounded-[12px] p-4 md:rounded-[24px] md:!max-w-full md:!w-full md:p-0";
|
||||
|
||||
type CreateFlowLockupCardStepShellProps = {
|
||||
lockupTitle: string;
|
||||
lockupDescription?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Final-review-style create-flow step: `wideGrid` shell, two-column grid at `md+`,
|
||||
* left `CreateFlowHeaderLockup` (vertically centered in column), right column for card content.
|
||||
*/
|
||||
export function CreateFlowLockupCardStepShell({
|
||||
lockupTitle,
|
||||
lockupDescription,
|
||||
children,
|
||||
}: CreateFlowLockupCardStepShellProps) {
|
||||
return (
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div className="flex w-full min-w-0 flex-col gap-4 md:grid md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="flex min-w-0 flex-col justify-start md:justify-center">
|
||||
<CreateFlowHeaderLockup
|
||||
title={lockupTitle}
|
||||
description={lockupDescription}
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 w-full flex-col items-stretch">{children}</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type CreateFlowStepShellVariant =
|
||||
| "centeredNarrow"
|
||||
| "centeredNarrowBottomPad"
|
||||
| "wideGrid"
|
||||
| "wideGridLoosePadding"
|
||||
| "bare";
|
||||
|
||||
/** Top padding below `md` between top nav and step content (semantic space tokens). */
|
||||
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||
|
||||
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||
centeredNarrow:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
||||
centeredNarrowBottomPad:
|
||||
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
||||
wideGrid: "w-full min-w-0 max-w-[1280px] shrink-0 px-5 md:px-12",
|
||||
wideGridLoosePadding:
|
||||
"w-full min-w-0 max-w-[1280px] shrink-0 px-5 md:px-16",
|
||||
bare: "w-full min-w-0",
|
||||
};
|
||||
|
||||
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
|
||||
none: "",
|
||||
"space-1400": "max-md:pt-[var(--space-1400)]",
|
||||
"space-800": "max-md:pt-[var(--space-800)]",
|
||||
};
|
||||
|
||||
interface CreateFlowStepShellProps {
|
||||
children: ReactNode;
|
||||
variant?: CreateFlowStepShellVariant;
|
||||
/** Padding-top below `md` only; `text` step uses `none`. */
|
||||
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
||||
*/
|
||||
export function CreateFlowStepShell({
|
||||
children,
|
||||
variant = "centeredNarrow",
|
||||
contentTopBelowMd = "none",
|
||||
className = "",
|
||||
}: CreateFlowStepShellProps) {
|
||||
const topClass = contentTopBelowMdClass[contentTopBelowMd];
|
||||
return (
|
||||
<div
|
||||
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useState } from "react";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const TITLE =
|
||||
"Do other stakeholders need to be involved in creating your community?";
|
||||
|
||||
const DESCRIPTION =
|
||||
"Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.";
|
||||
|
||||
const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
/**
|
||||
* Confirm stakeholders step — stacked lockup + MultiSelect (not split columns).
|
||||
@@ -22,19 +15,11 @@ const DRAFT_TOAST_TITLE = "Congratulations! You've drafted your CommunityRule!";
|
||||
*/
|
||||
export default function ConfirmStakeholdersPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const t = useTranslation("create.confirmStakeholders");
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
[],
|
||||
);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleAddStakeholder = () => {
|
||||
markCreateFlowInteraction();
|
||||
@@ -65,14 +50,16 @@ export default function ConfirmStakeholdersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--measures-spacing-1800,64px)] pb-28 md:pb-32">
|
||||
<div className="flex w-full max-w-[640px] flex-col gap-[var(--measures-spacing-300,12px)] items-start">
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrowBottomPad"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-300,12px)]">
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
@@ -85,21 +72,21 @@ export default function ConfirmStakeholdersPage() {
|
||||
onCustomChipConfirm={handleCustomChipConfirm}
|
||||
onCustomChipClose={handleCustomChipClose}
|
||||
addButton
|
||||
addButtonText="Add stakeholder"
|
||||
addButtonText={t("addStakeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
{!toastDismissed && (
|
||||
<div
|
||||
className="fixed left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 bottom-[5.25rem] md:bottom-[5.5rem]"
|
||||
className="fixed bottom-[5.25rem] left-1/2 z-10 w-[min(640px,calc(100%-2.5rem))] max-w-[640px] -translate-x-1/2 md:bottom-[5.5rem]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Alert
|
||||
type="banner"
|
||||
status="positive"
|
||||
title={DRAFT_TOAST_TITLE}
|
||||
title={t("draftToastTitle")}
|
||||
hasLeadingIcon={false}
|
||||
hasBodyText={false}
|
||||
onClose={() => setToastDismissed(true)}
|
||||
|
||||
@@ -1,136 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { useMemo } from "react";
|
||||
import RuleCard from "../../components/cards/RuleCard";
|
||||
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../components/CreateFlowLockupCardStepShell";
|
||||
|
||||
const TITLE = "Review your CommunityRule";
|
||||
const DESCRIPTION =
|
||||
"Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.";
|
||||
|
||||
const RULE_CARD_TITLE_FALLBACK = "Your community";
|
||||
const RULE_CARD_DESCRIPTION_FALLBACK =
|
||||
"Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.";
|
||||
|
||||
/** Static categories for final review (read-only display). */
|
||||
const FINAL_REVIEW_CATEGORIES: Category[] = [
|
||||
{
|
||||
name: "Values",
|
||||
chipOptions: [
|
||||
{ id: "v1", label: "Consciousness", state: "unselected" },
|
||||
{ id: "v2", label: "Ecology", state: "unselected" },
|
||||
{ id: "v3", label: "Abundance", state: "unselected" },
|
||||
{ id: "v4", label: "Art", state: "unselected" },
|
||||
{ id: "v5", label: "Decisiveness", state: "unselected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Communication",
|
||||
chipOptions: [{ id: "c1", label: "Signal", state: "unselected" }],
|
||||
},
|
||||
{
|
||||
name: "Membership",
|
||||
chipOptions: [{ id: "m1", label: "Open Admission", state: "unselected" }],
|
||||
},
|
||||
{
|
||||
name: "Decision-making",
|
||||
chipOptions: [
|
||||
{ id: "d1", label: "Lazy Consensus", state: "unselected" },
|
||||
{ id: "d2", label: "Modified Consensus", state: "unselected" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Conflict management",
|
||||
chipOptions: [
|
||||
{ id: "cf1", label: "Code of Conduct", state: "unselected" },
|
||||
{ id: "cf2", label: "Restorative Justice", state: "unselected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
function buildFinalReviewCategories(
|
||||
rows: { name: string; chips: string[] }[],
|
||||
): Category[] {
|
||||
return rows.map((cat) => ({
|
||||
name: cat.name,
|
||||
chipOptions: cat.chips.map((label, idx) => ({
|
||||
id: `${cat.name}-${idx}`,
|
||||
label,
|
||||
state: "unselected" as const,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Final review step (right before completed).
|
||||
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
|
||||
* Figma: 20907-212767 (full-size), 20976-220705 (below `md`).
|
||||
*/
|
||||
export default function FinalReviewPage() {
|
||||
const { state } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const t = useTranslation("create.finalReview");
|
||||
const m = useMessages();
|
||||
|
||||
const finalReviewCategories = useMemo(
|
||||
() => buildFinalReviewCategories(m.create.finalReview.categories),
|
||||
[m.create.finalReview.categories],
|
||||
);
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const t = typeof state.title === "string" ? state.title.trim() : "";
|
||||
return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK;
|
||||
}, [state.title]);
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
|
||||
}, [state.title, t]);
|
||||
|
||||
const ruleCardDescription = useMemo(() => {
|
||||
const s = typeof state.summary === "string" ? state.summary.trim() : "";
|
||||
return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK;
|
||||
}, [state.summary]);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-12">
|
||||
<div className="flex w-full flex-col gap-4 min-w-0 sm:grid sm:grid-cols-2 sm:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full flex flex-col items-stretch">
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="rounded-[24px] !max-w-full !w-full min-w-0"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const raw =
|
||||
typeof state.summary === "string" ? state.summary.trim() : "";
|
||||
return raw.length > 0 ? raw : t("ruleCardDescriptionFallback");
|
||||
}, [state.summary, t]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-5 min-w-0">
|
||||
<div className="flex flex-col gap-4 w-full max-w-[639px]">
|
||||
<HeaderLockup
|
||||
title={TITLE}
|
||||
description={DESCRIPTION}
|
||||
justification="left"
|
||||
size="M"
|
||||
/>
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={FINAL_REVIEW_CATEGORIES}
|
||||
className="w-full rounded-[12px] p-4"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CreateFlowLockupCardStepShell
|
||||
lockupTitle={t("title")}
|
||||
lockupDescription={t("description")}
|
||||
>
|
||||
<RuleCard
|
||||
title={ruleCardTitle}
|
||||
description={ruleCardDescription}
|
||||
size="L"
|
||||
expanded={true}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt={ruleCardTitle}
|
||||
categories={finalReviewCategories}
|
||||
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Matches design-system `md` (`--breakpoint-md`, 640px in `app/tailwind.css`).
|
||||
* Use with Tailwind `md:` / `max-md:` utilities in create-flow pages.
|
||||
*/
|
||||
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
||||
|
||||
/**
|
||||
* True at or above the create-flow `md` breakpoint (desktop-oriented layout).
|
||||
*
|
||||
* `useMediaQuery` initializes to `false` on the server and first client render
|
||||
* to avoid hydration mismatches. We combine it with a post-mount flag so the
|
||||
* first paint matches the intended desktop layout until `matchMedia` runs.
|
||||
*/
|
||||
export function useCreateFlowMdUp(): boolean {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return !isMounted || isMdOrLarger;
|
||||
}
|
||||
@@ -1,60 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import NumberedList from "../../components/type/NumberedList";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
/**
|
||||
* Informational page for the create flow
|
||||
*
|
||||
* Displays information about the create flow process using HeaderLockup and NumberedList components.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
|
||||
* Lockup sizing via `CreateFlowHeaderLockup`. NumberedList: S / M by breakpoint.
|
||||
*/
|
||||
export default function InformationalPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.informational");
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Tell us about your organization",
|
||||
description:
|
||||
"Start by providing your group's name, description, and profile image.",
|
||||
title: t("steps.0.title"),
|
||||
description: t("steps.0.description"),
|
||||
},
|
||||
{
|
||||
title: "Define your group's CommunityRule.",
|
||||
description:
|
||||
"Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations.",
|
||||
title: t("steps.1.title"),
|
||||
description: t("steps.1.description"),
|
||||
},
|
||||
{
|
||||
title: "Share and evolve over time",
|
||||
description:
|
||||
"Review and refine your community framework before putting it into action and adapting it over time.",
|
||||
title: t("steps.2.title"),
|
||||
description: t("steps.2.description"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[48px] items-center w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */}
|
||||
<HeaderLockup
|
||||
title="How CommunityRule helps groups like yours"
|
||||
description="This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together."
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full max-w-[640px] flex-col items-center gap-12">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* NumberedList: M size at 640px+, S size below 640px */}
|
||||
<NumberedList items={items} size={effectiveMdOrLarger ? "M" : "S"} />
|
||||
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"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";
|
||||
import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
} from "../../components/CreateFlowLockupCardStepShell";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -28,13 +31,6 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
||||
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;
|
||||
@@ -62,69 +58,43 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
||||
};
|
||||
}, [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>
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div className="flex w-full shrink-0 items-center justify-start pb-16">
|
||||
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
|
||||
{t("loading")}
|
||||
</p>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||
<div className="flex w-full max-w-[640px] shrink-0 flex-col gap-4 pb-8">
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={t("errors.loadFailed")}
|
||||
description={error ?? t("errors.notFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<CreateFlowLockupCardStepShell
|
||||
lockupTitle={t("intro.title")}
|
||||
lockupDescription={t("intro.description")}
|
||||
>
|
||||
<TemplateReviewCard
|
||||
template={template}
|
||||
ruleCardClassName={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||
/>
|
||||
</CreateFlowLockupCardStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-11
@@ -1,34 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import RuleCard from "../../components/cards/RuleCard";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
/** Mid-flow review step (after upload, before cards). */
|
||||
export default function ReviewPage() {
|
||||
const t = useTranslation("create.review");
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-16">
|
||||
<div className="flex w-full flex-col gap-4 min-w-0 sm:grid sm:grid-cols-2 sm:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col gap-4 md:grid md:grid-cols-2 md:gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title="Your community is added - congrats!"
|
||||
description="In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 w-full">
|
||||
<RuleCard
|
||||
title="Mutual Aid Mondays"
|
||||
description="Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness."
|
||||
title={t("ruleCard.title")}
|
||||
description={t("ruleCard.description")}
|
||||
size="L"
|
||||
expanded={false}
|
||||
backgroundColor="bg-[#c9fef9]"
|
||||
logoUrl="/assets/Vector_MutualAid.svg"
|
||||
logoAlt="Mutual Aid Mondays"
|
||||
logoAlt={t("ruleCard.logoAlt")}
|
||||
className="rounded-[16px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
+66
-144
@@ -1,100 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../components/utility/CardStack";
|
||||
import type { InfoMessageBoxItem } from "../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../components/utility/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
|
||||
const SIDEBAR_TITLE = "How should conflicts be resolved?";
|
||||
|
||||
const SIDEBAR_DESCRIPTION = (
|
||||
<>
|
||||
You can also combine or <span className="underline">add</span> new
|
||||
approaches to the list
|
||||
</>
|
||||
);
|
||||
|
||||
const MESSAGE_BOX_TITLE =
|
||||
"Consider defining approaches to steward key resources:";
|
||||
|
||||
const MESSAGE_BOX_ITEMS: InfoMessageBoxItem[] = [
|
||||
{ id: "amend", label: "Amend your CommunityRule" },
|
||||
{ id: "finances", label: "Steward finances" },
|
||||
{ id: "project", label: "Project level decisions" },
|
||||
{ id: "discipline", label: "Discipline and member termination" },
|
||||
];
|
||||
|
||||
const SAMPLE_CARDS: CardStackItem[] = [
|
||||
{
|
||||
id: "mediation",
|
||||
label: "Mediation",
|
||||
supportText:
|
||||
"Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "facilitation",
|
||||
label: "Facilitated dialogue",
|
||||
supportText:
|
||||
"Structured sessions where parties collaboratively resolve disputes.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "invite-only",
|
||||
label: "Invite-only",
|
||||
supportText: "Private discussions with selected participants.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "arbitration",
|
||||
label: "Arbitration",
|
||||
supportText: "Arbitrators are chosen specifically for a particular case.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "direct-dialogue",
|
||||
label: "Direct dialogue",
|
||||
supportText:
|
||||
"Encouraging direct, respectful dialogue between those involved.",
|
||||
recommended: true,
|
||||
},
|
||||
// Extra cards to test scrolling (only visible when "See all" is expanded)
|
||||
{ id: "label-1", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-2", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-3", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-4", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-5", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-6", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-7", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-8", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-9", label: "Label", supportText: "", recommended: false },
|
||||
{ id: "label-10", label: "Label", supportText: "", recommended: false },
|
||||
];
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
|
||||
/**
|
||||
* Right Rail step of the create flow.
|
||||
* Two-column layout (sidebar + card stack) at 640+, single column at 320-639.
|
||||
*/
|
||||
export default function RightRailPage() {
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
rr.messageBox.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})),
|
||||
[rr.messageBox.items],
|
||||
);
|
||||
|
||||
const showDesktopLayout = !isMounted || isMdOrLarger;
|
||||
const sampleCards: CardStackItem[] = useMemo(
|
||||
() =>
|
||||
rr.cards.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
supportText: c.supportText,
|
||||
recommended: c.recommended,
|
||||
})),
|
||||
[rr.cards],
|
||||
);
|
||||
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{rr.sidebar.descriptionBefore}
|
||||
<span className="underline">{rr.sidebar.descriptionLink}</span>
|
||||
{rr.sidebar.descriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
@@ -121,79 +77,45 @@ export default function RightRailPage() {
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
|
||||
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-12 min-h-0 min-w-0 w-full">
|
||||
{/* Left column: sidebar stays put, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="L"
|
||||
justification="left"
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
|
||||
<div className="mx-auto grid h-auto min-h-0 w-full max-w-[1280px] shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:gap-12 md:pb-8">
|
||||
<div
|
||||
className="flex min-w-0 flex-col items-stretch justify-start overflow-hidden md:justify-center"
|
||||
>
|
||||
<DecisionMakingSidebar
|
||||
title={rr.sidebar.title}
|
||||
description={sidebarDescription}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
</div>
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto">
|
||||
<div className="flex min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title={rr.cardStack.emptyTitle}
|
||||
description={rr.cardStack.emptyDescription}
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
{/* Right column: card stack — this column scrolls independently */}
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
<div className="py-8 flex flex-col gap-6 items-center min-w-0">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel="See all decision approaches"
|
||||
showLessLabel="Show less"
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full min-h-0 overflow-y-auto flex flex-col items-center px-5">
|
||||
<div className="flex flex-col gap-6 w-full min-w-0 py-8">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="M"
|
||||
justification="center"
|
||||
/>
|
||||
<div className="flex flex-col gap-6 items-center w-full">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel="See all decision approaches"
|
||||
showLessLabel="Show less"
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+75
-113
@@ -2,16 +2,17 @@
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
function createListCustomHandlers(
|
||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||
@@ -44,55 +45,40 @@ function createListCustomHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
function chipRowsFromLabels(
|
||||
rows: readonly { label: string }[],
|
||||
): ChipOption[] {
|
||||
return rows.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select page for the create flow
|
||||
*
|
||||
* Displays selection options using HeaderLockup and MultiSelect components.
|
||||
* Responsive layout: two-column at 640px+, single column below 640px.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
|
||||
* Responsive layout: two-column at `md` and up, single column below (see `--breakpoint-md` in `app/tailwind.css`).
|
||||
* Lockup sizing via `CreateFlowHeaderLockup`. MultiSelect stays `S`.
|
||||
*/
|
||||
export default function SelectPage() {
|
||||
const m = useMessages();
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.select");
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "1 member", state: "Unselected" },
|
||||
{ id: "2", label: "2-10 members", state: "Unselected" },
|
||||
{ id: "3", label: "10-24 members", state: "Unselected" },
|
||||
{ id: "4", label: "24-64 members", state: "Unselected" },
|
||||
{ id: "5", label: "64-128 members", state: "Unselected" },
|
||||
{ id: "6", label: "125-1000 members", state: "Unselected" },
|
||||
{ id: "7", label: "1000+ members", state: "Unselected" },
|
||||
]);
|
||||
>(() => chipRowsFromLabels(m.create.select.communitySizes));
|
||||
|
||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "Non-profit", state: "Unselected" },
|
||||
{ id: "2", label: "For-profit", state: "Unselected" },
|
||||
{ id: "3", label: "Community", state: "Unselected" },
|
||||
{ id: "4", label: "Educational", state: "Unselected" },
|
||||
]);
|
||||
>(() => chipRowsFromLabels(m.create.select.organizationTypes));
|
||||
|
||||
const [governanceStyleOptions, setGovernanceStyleOptions] = useState<
|
||||
ChipOption[]
|
||||
>([
|
||||
{ id: "1", label: "Democratic", state: "Unselected" },
|
||||
{ id: "2", label: "Consensus", state: "Unselected" },
|
||||
{ id: "3", label: "Hierarchical", state: "Unselected" },
|
||||
{ id: "4", label: "Flat", state: "Unselected" },
|
||||
]);
|
||||
>(() => chipRowsFromLabels(m.create.select.governanceStyles));
|
||||
|
||||
const communityCustomHandlers = useMemo(
|
||||
() =>
|
||||
@@ -164,93 +150,69 @@ export default function SelectPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const multiLabel = t("multiSelect.label");
|
||||
const addText = t("multiSelect.addButtonText");
|
||||
|
||||
const multiSelectBlock = (
|
||||
<>
|
||||
<MultiSelect
|
||||
label={multiLabel}
|
||||
size="S"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
{...communityCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText={addText}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={multiLabel}
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText={addText}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={multiLabel}
|
||||
size="S"
|
||||
options={governanceStyleOptions}
|
||||
onChipClick={handleGovernanceStyleClick}
|
||||
{...governanceCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText={addText}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
{effectiveMdOrLarger ? (
|
||||
// Two-column layout for 640px+
|
||||
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
|
||||
{/* Left column: HeaderLockup */}
|
||||
<div className="flex flex-[1_0_0] flex-col gap-[var(--measures-spacing-200,8px)] items-start justify-center max-w-[640px] min-h-px min-w-px py-[12px]">
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
{mdUp ? (
|
||||
<div className="flex w-full max-w-[1280px] items-center justify-center gap-[var(--measures-spacing-1200,48px)]">
|
||||
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start justify-center gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
justification="left"
|
||||
size="L"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Three MultiSelect components */}
|
||||
<div className="flex flex-[1_0_0] flex-col gap-[var(--measures-spacing-800,32px)] items-start max-w-[640px] min-h-px min-w-px">
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
{...communityCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={governanceStyleOptions}
|
||||
onChipClick={handleGovernanceStyleClick}
|
||||
{...governanceCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<div className="flex max-w-[640px] min-h-px min-w-px flex-[1_0_0] flex-col items-start gap-[var(--measures-spacing-800,32px)]">
|
||||
{multiSelectBlock}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Single column layout below 640px
|
||||
<div className="flex flex-col gap-[var(--measures-spacing-400,16px)] items-start w-full max-w-[640px]">
|
||||
{/* HeaderLockup */}
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
<div className="flex w-full max-w-[640px] flex-col items-start gap-[var(--measures-spacing-400,16px)]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
justification="left"
|
||||
size="M"
|
||||
/>
|
||||
|
||||
{/* Three MultiSelect components */}
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
{...communityCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
size="S"
|
||||
options={governanceStyleOptions}
|
||||
onChipClick={handleGovernanceStyleClick}
|
||||
{...governanceCustomHandlers}
|
||||
addButton={true}
|
||||
addButtonText="Add organization type"
|
||||
/>
|
||||
{multiSelectBlock}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-26
@@ -1,21 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import TextInput from "../../components/controls/TextInput";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
/**
|
||||
* Text page for the create flow
|
||||
*
|
||||
* Displays a text input field for user input using HeaderLockup and TextInput components.
|
||||
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
|
||||
* Lockup sizing via `CreateFlowHeaderLockup`. TextInput: small / medium by breakpoint.
|
||||
* Below `md`, this step stays vertically centered in the main area (see `CreateFlowLayoutClient`).
|
||||
*/
|
||||
export default function TextPage() {
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.text");
|
||||
const [value, setValue] = useState(() =>
|
||||
typeof state.title === "string" ? state.title : "",
|
||||
);
|
||||
@@ -27,32 +30,23 @@ export default function TextPage() {
|
||||
setValue((prev) => (prev === "" ? incoming : prev));
|
||||
}, [state.title]);
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const maxLength = 48;
|
||||
const characterCount = value.length;
|
||||
const hint = t("characterCountTemplate")
|
||||
.replace("{current}", String(characterCount))
|
||||
.replace("{max}", String(maxLength));
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[18px] items-start w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */}
|
||||
<HeaderLockup
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
<CreateFlowStepShell variant="centeredNarrow">
|
||||
<div className="flex w-full max-w-[640px] flex-col items-start gap-[18px]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification="left"
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* TextInput: medium size at 640px+, small size below 640px */}
|
||||
<div className="w-full">
|
||||
<TextInput
|
||||
placeholder="Enter your community name"
|
||||
placeholder={t("placeholder")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
@@ -60,13 +54,13 @@ export default function TextPage() {
|
||||
markCreateFlowInteraction();
|
||||
updateState({ title: v });
|
||||
}}
|
||||
inputSize={effectiveMdOrLarger ? "medium" : "small"}
|
||||
inputSize={mdUp ? "medium" : "small"}
|
||||
formHeader={false}
|
||||
textHint={`${characterCount}/${maxLength}`}
|
||||
textHint={hint}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-26
@@ -1,30 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import Upload from "../../components/controls/Upload";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||
|
||||
/**
|
||||
* Upload page for the create flow
|
||||
*
|
||||
* Displays upload functionality using HeaderLockup and Upload components.
|
||||
* Responsive layout: centered at 640px+, left-aligned below 640px.
|
||||
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
|
||||
* Responsive layout: centered at `md` and up, left-aligned below.
|
||||
* Lockup sizing via `CreateFlowHeaderLockup`.
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.upload");
|
||||
|
||||
const handleUploadClick = () => {
|
||||
markCreateFlowInteraction();
|
||||
@@ -32,17 +25,16 @@ export default function UploadPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
<div className="flex flex-col gap-[18px] items-center w-full max-w-[640px]">
|
||||
{/* HeaderLockup: Center justification at 640px+, left below 640px */}
|
||||
<HeaderLockup
|
||||
title="How should conflicts be resolved?"
|
||||
description="This will be the name of your community"
|
||||
justification={effectiveMdOrLarger ? "center" : "left"}
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
>
|
||||
<div className="flex w-full max-w-[640px] flex-col items-center gap-[18px]">
|
||||
<CreateFlowHeaderLockup
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
justification={mdUp ? "center" : "left"}
|
||||
/>
|
||||
|
||||
{/* Upload component: no label in create flow, max width 474px */}
|
||||
<div className="w-full max-w-[474px]">
|
||||
<Upload
|
||||
active={true}
|
||||
@@ -51,6 +43,6 @@ export default function UploadPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"fallbackTitle": "Mutual Aid Mondays",
|
||||
"fallbackDescription": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.",
|
||||
"toastTitle": "This is what folks see when you share your CommunityRule",
|
||||
"toastDescription": "Your group can use this document as an operating manual.",
|
||||
"fallbackDocumentSections": [
|
||||
{
|
||||
"categoryName": "Values",
|
||||
"entries": [
|
||||
{
|
||||
"title": "Solidarity Forever",
|
||||
"body": "Food Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."
|
||||
},
|
||||
{
|
||||
"title": "Shared Leadership",
|
||||
"body": "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader."
|
||||
},
|
||||
{
|
||||
"title": "Organizing Offline",
|
||||
"body": "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face connection and resist the pull of digital metrics."
|
||||
},
|
||||
{
|
||||
"title": "Circular Food Systems",
|
||||
"body": "We intervene in the ecological crisis by addressing food waste and food recovery. We redirect surplus food to where it is needed and model a circular economy at the scale of our communities."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categoryName": "Communication",
|
||||
"entries": [
|
||||
{
|
||||
"title": "Signal",
|
||||
"body": "We use Signal for sensitive coordination. Encrypted messaging helps protect our members and our plans from surveillance."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categoryName": "Membership",
|
||||
"entries": [
|
||||
{
|
||||
"title": "Open Admission",
|
||||
"body": "Anyone who shares our values and is willing to contribute is welcome. We do not require applications or approval processes for general participation."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categoryName": "Decision-making",
|
||||
"entries": [
|
||||
{
|
||||
"title": "Lazy Consensus",
|
||||
"body": "We use lazy consensus for most decisions: proposals move forward unless someone raises a blocking concern. This keeps us moving without requiring everyone to approve every detail."
|
||||
},
|
||||
{
|
||||
"title": "Modified Consensus",
|
||||
"body": "For larger or more consequential decisions we use modified consensus, with clear timelines and a fallback to a supermajority vote if needed."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categoryName": "Conflict management",
|
||||
"entries": [
|
||||
{
|
||||
"title": "Code of Conduct",
|
||||
"body": "We have a code of conduct that sets expectations for behavior and outlines how we address harm."
|
||||
},
|
||||
{
|
||||
"title": "Restorative Justice",
|
||||
"body": "When conflict arises we prioritize restoration and learning over punishment. We use facilitated circles and other restorative practices where appropriate."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "Do other stakeholders need to be involved in creating your community?",
|
||||
"description": "Adding people at this step will invite them to see your proposed CommunityRule and make their own proposals.",
|
||||
"addStakeholder": "Add stakeholder",
|
||||
"draftToastTitle": "Congratulations! You've drafted your CommunityRule!"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Review your CommunityRule",
|
||||
"description": "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.",
|
||||
"ruleCardTitleFallback": "Your community",
|
||||
"ruleCardDescriptionFallback": "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.",
|
||||
"categories": [
|
||||
{
|
||||
"name": "Values",
|
||||
"chips": ["Consciousness", "Ecology", "Abundance", "Art", "Decisiveness"]
|
||||
},
|
||||
{
|
||||
"name": "Communication",
|
||||
"chips": ["Signal"]
|
||||
},
|
||||
{
|
||||
"name": "Membership",
|
||||
"chips": ["Open Admission"]
|
||||
},
|
||||
{
|
||||
"name": "Decision-making",
|
||||
"chips": ["Lazy Consensus", "Modified Consensus"]
|
||||
},
|
||||
{
|
||||
"name": "Conflict management",
|
||||
"chips": ["Code of Conduct", "Restorative Justice"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"next": "Next",
|
||||
"finalizeCommunityRule": "Finalize CommunityRule",
|
||||
"confirmStakeholders": "Confirm Stakeholders"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "How CommunityRule helps groups like yours",
|
||||
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together.",
|
||||
"steps": {
|
||||
"0": {
|
||||
"title": "Tell us about your organization",
|
||||
"description": "Start by providing your group's name, description, and profile image."
|
||||
},
|
||||
"1": {
|
||||
"title": "Define your group's CommunityRule.",
|
||||
"description": "Outline decision-making processes, conflict resolution methods, and membership practices. Get recommendations."
|
||||
},
|
||||
"2": {
|
||||
"title": "Share and evolve over time",
|
||||
"description": "Review and refine your community framework before putting it into action and adapting it over time."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Your community is added - congrats!",
|
||||
"description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "Mutual Aid Mondays",
|
||||
"description": "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.",
|
||||
"logoAlt": "Mutual Aid Mondays"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"title": "How should conflicts be resolved?",
|
||||
"descriptionBefore": "You can also combine or ",
|
||||
"descriptionLink": "add",
|
||||
"descriptionAfter": " new approaches to the list"
|
||||
},
|
||||
"messageBox": {
|
||||
"title": "Consider defining approaches to steward key resources:",
|
||||
"items": [
|
||||
{ "id": "amend", "label": "Amend your CommunityRule" },
|
||||
{ "id": "finances", "label": "Steward finances" },
|
||||
{ "id": "project", "label": "Project level decisions" },
|
||||
{ "id": "discipline", "label": "Discipline and member termination" }
|
||||
]
|
||||
},
|
||||
"cardStack": {
|
||||
"toggleSeeAll": "See all decision approaches",
|
||||
"toggleShowLess": "Show less",
|
||||
"emptyTitle": "",
|
||||
"emptyDescription": ""
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"id": "mediation",
|
||||
"label": "Mediation",
|
||||
"supportText": "Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "facilitation",
|
||||
"label": "Facilitated dialogue",
|
||||
"supportText": "Structured sessions where parties collaboratively resolve disputes.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "invite-only",
|
||||
"label": "Invite-only",
|
||||
"supportText": "Private discussions with selected participants.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "arbitration",
|
||||
"label": "Arbitration",
|
||||
"supportText": "Arbitrators are chosen specifically for a particular case.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "direct-dialogue",
|
||||
"label": "Direct dialogue",
|
||||
"supportText": "Encouraging direct, respectful dialogue between those involved.",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "label-1",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-2",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-3",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-4",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-5",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-6",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-7",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-8",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-9",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "label-10",
|
||||
"label": "Label",
|
||||
"supportText": "",
|
||||
"recommended": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "What is your community called?",
|
||||
"description": "This will be the name of your community"
|
||||
},
|
||||
"multiSelect": {
|
||||
"label": "Label",
|
||||
"addButtonText": "Add organization type"
|
||||
},
|
||||
"communitySizes": [
|
||||
{ "label": "1 member" },
|
||||
{ "label": "2-10 members" },
|
||||
{ "label": "10-24 members" },
|
||||
{ "label": "24-64 members" },
|
||||
{ "label": "64-128 members" },
|
||||
{ "label": "125-1000 members" },
|
||||
{ "label": "1000+ members" }
|
||||
],
|
||||
"organizationTypes": [
|
||||
{ "label": "Non-profit" },
|
||||
{ "label": "For-profit" },
|
||||
{ "label": "Community" },
|
||||
{ "label": "Educational" }
|
||||
],
|
||||
"governanceStyles": [
|
||||
{ "label": "Democratic" },
|
||||
{ "label": "Consensus" },
|
||||
{ "label": "Hierarchical" },
|
||||
{ "label": "Flat" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "What is your community called?",
|
||||
"description": "This will be the name of your community",
|
||||
"placeholder": "Enter your community name",
|
||||
"characterCountTemplate": "{current}/{max}"
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"saveAndExit": "Save & Exit",
|
||||
"exit": "Exit",
|
||||
"share": "Share",
|
||||
"export": "Export",
|
||||
"edit": "Edit",
|
||||
"shareAriaLabel": "Share",
|
||||
"exportAriaLabel": "Export",
|
||||
"editAriaLabel": "Edit",
|
||||
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
|
||||
"draftSaveBannerTitle": "Couldn't save draft",
|
||||
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "How should conflicts be resolved?",
|
||||
"description": "Upload supporting materials or examples that help describe how your community handles conflict."
|
||||
}
|
||||
@@ -19,6 +19,16 @@ import profile from "./pages/profile.json";
|
||||
import navigation from "./navigation.json";
|
||||
import metadata from "./metadata.json";
|
||||
import communication from "./create/communication.json";
|
||||
import createInformational from "./create/informational.json";
|
||||
import createText from "./create/text.json";
|
||||
import createSelect from "./create/select.json";
|
||||
import createUpload from "./create/upload.json";
|
||||
import createReview from "./create/review.json";
|
||||
import createConfirmStakeholders from "./create/confirmStakeholders.json";
|
||||
import createFinalReview from "./create/finalReview.json";
|
||||
import createCompleted from "./create/completed.json";
|
||||
import createRightRail from "./create/rightRail.json";
|
||||
import createFooter from "./create/footer.json";
|
||||
import createTopNav from "./create/topNav.json";
|
||||
import createDraftHydration from "./create/draftHydration.json";
|
||||
import createPublish from "./create/publish.json";
|
||||
@@ -47,6 +57,16 @@ export default {
|
||||
},
|
||||
create: {
|
||||
communication,
|
||||
informational: createInformational,
|
||||
text: createText,
|
||||
select: createSelect,
|
||||
upload: createUpload,
|
||||
review: createReview,
|
||||
confirmStakeholders: createConfirmStakeholders,
|
||||
finalReview: createFinalReview,
|
||||
completed: createCompleted,
|
||||
rightRail: createRightRail,
|
||||
footer: createFooter,
|
||||
topNav: createTopNav,
|
||||
draftHydration: createDraftHydration,
|
||||
publish: createPublish,
|
||||
|
||||
Reference in New Issue
Block a user