diff --git a/app/components/utility/CardStack/CardStack.container.tsx b/app/components/utility/CardStack/CardStack.container.tsx index 48bc071..d684867 100644 --- a/app/components/utility/CardStack/CardStack.container.tsx +++ b/app/components/utility/CardStack/CardStack.container.tsx @@ -21,6 +21,7 @@ const CardStackContainer = memo( title = "", description = "", layout = "default", + headerLockupSize, className = "", }) => { const [internalExpanded, setInternalExpanded] = useState(false); @@ -74,6 +75,7 @@ const CardStackContainer = memo( title={title} description={description} layout={layout} + headerLockupSize={headerLockupSize} className={className} /> ); diff --git a/app/components/utility/CardStack/CardStack.types.ts b/app/components/utility/CardStack/CardStack.types.ts index 2c23324..6109af5 100644 --- a/app/components/utility/CardStack/CardStack.types.ts +++ b/app/components/utility/CardStack/CardStack.types.ts @@ -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; } diff --git a/app/components/utility/CardStack/CardStack.view.tsx b/app/components/utility/CardStack/CardStack.view.tsx index 34af000..ad74746 100644 --- a/app/components/utility/CardStack/CardStack.view.tsx +++ b/app/components/utility/CardStack/CardStack.view.tsx @@ -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} /> ) : null} @@ -73,7 +75,7 @@ export function CardStackView({ title={title} description={description} justification="center" - size="L" + size={lockupSize} /> ) : null} diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx index bf2420d..3148eee 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -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")} )} @@ -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]" > - Export + {t("export")} - Edit + {t("edit")} )} diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx index 2145a73..54cdeba 100644 --- a/app/create/CreateFlowLayoutClient.tsx +++ b/app/create/CreateFlowLayoutClient.tsx @@ -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()} />
{children}
{!isCompletedStep && ( @@ -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")} ) : null } diff --git a/app/create/[step]/page.tsx b/app/create/[step]/page.tsx index 94517c2..0d05913 100644 --- a/app/create/[step]/page.tsx +++ b/app/create/[step]/page.tsx @@ -24,7 +24,7 @@ export default function CreateFlowStepPage({ params }: PageProps) { // Placeholder content - templates will be implemented in CR-51-55 return ( -
+

Create Flow Step: {step} diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx index ef87873..00fd170 100644 --- a/app/create/cards/page.tsx +++ b/app/create/cards/page.tsx @@ -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 -> = { - [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 - >( - defaults ?? { - "Core Principle & Scope": "", - "Logistics, Admin & Norms": "", - "Code of Conduct": "", - }, - ); + Record + >(() => ({ + 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 (
- {SECTION_KEYS.map((key) => ( + {SECTION_FIELDS.map((field) => ( updateSection(key, v)} + key={field} + title={comm.sectionHeadings[field]} + value={sectionValues[field]} + onChange={(v) => updateSection(field, v)} /> ))}
); } -/** 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([]); const [createModalOpen, setCreateModalOpen] = useState(false); const [pendingCardId, setPendingCardId] = useState(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 ( -
-
+ +
-
!prev); }} hasMore={true} + toggleLabel={comm.page.seeAllLink} + headerLockupSize={mdUp ? "L" : "M"} />
@@ -308,10 +244,13 @@ export default function CardsPage() { currentStep={modalConfig.currentStep} totalSteps={modalConfig.totalSteps} > - {isAddPlatformCard(pendingCardId) ? ( - + {isAddPlatformCard(pendingCardId) && pendingCardId ? ( + ) : null} -
+ ); } diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx index 5b86299..1c2e955 100644 --- a/app/create/completed/page.tsx +++ b/app/create/completed/page.tsx @@ -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(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(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 ( -
-
-
- {/* Left column: community title + header, centered, does not scroll */} -
- -
- {/* Right column: Community Rule document — this column scrolls independently; padding inside scroll so content isn't clipped */} -
- {/* Soft fade at top: gradient wash only (no blur) so no sharp cutoff line */} -
-
- -
-
-
-
- - {!toastDismissed && ( -
- setToastDismissed(true)} - className="w-full" - /> -
- )} -
- ); - } + const toast = !toastDismissed ? ( +
+ setToastDismissed(true)} + className="w-full" + /> +
+ ) : null; return ( <> -
-
- - +
+
+
+ +
+
+
+
+ +
+
- - {!toastDismissed && ( -
- setToastDismissed(true)} - className="w-full" - /> -
- )} + {toast} ); } diff --git a/app/create/components/CreateFlowHeaderLockup.tsx b/app/create/components/CreateFlowHeaderLockup.tsx new file mode 100644 index 0000000..301cb94 --- /dev/null +++ b/app/create/components/CreateFlowHeaderLockup.tsx @@ -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 & { + /** 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 ; +} diff --git a/app/create/components/CreateFlowLockupCardStepShell.tsx b/app/create/components/CreateFlowLockupCardStepShell.tsx new file mode 100644 index 0000000..e75a001 --- /dev/null +++ b/app/create/components/CreateFlowLockupCardStepShell.tsx @@ -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 ( + +
+
+ +
+
{children}
+
+
+ ); +} diff --git a/app/create/components/CreateFlowStepShell.tsx b/app/create/components/CreateFlowStepShell.tsx new file mode 100644 index 0000000..0bf3903 --- /dev/null +++ b/app/create/components/CreateFlowStepShell.tsx @@ -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 = { + 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 = { + 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 ( +
+ {children} +
+ ); +} diff --git a/app/create/confirm-stakeholders/page.tsx b/app/create/confirm-stakeholders/page.tsx index 92eb7e7..5fdcd2d 100644 --- a/app/create/confirm-stakeholders/page.tsx +++ b/app/create/confirm-stakeholders/page.tsx @@ -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( [], ); - 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 ( <> -
-
+ +
-
-
+ {!toastDismissed && (
setToastDismissed(true)} diff --git a/app/create/final-review/page.tsx b/app/create/final-review/page.tsx index f00fe84..1e32aea 100644 --- a/app/create/final-review/page.tsx +++ b/app/create/final-review/page.tsx @@ -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 ( -
-
-
- -
-
- {}} - /> -
-
-
- ); - } + const raw = + typeof state.summary === "string" ? state.summary.trim() : ""; + return raw.length > 0 ? raw : t("ruleCardDescriptionFallback"); + }, [state.summary, t]); return ( -
-
- - {}} - /> -
-
+ + {}} + /> + ); } diff --git a/app/create/hooks/useCreateFlowMdUp.ts b/app/create/hooks/useCreateFlowMdUp.ts new file mode 100644 index 0000000..04843ea --- /dev/null +++ b/app/create/hooks/useCreateFlowMdUp.ts @@ -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; +} diff --git a/app/create/informational/page.tsx b/app/create/informational/page.tsx index 280a41a..baca2ce 100644 --- a/app/create/informational/page.tsx +++ b/app/create/informational/page.tsx @@ -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 ( -
-
- {/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */} - +
+ - - {/* NumberedList: M size at 640px+, S size below 640px */} - +
-
+ ); } diff --git a/app/create/review-template/[slug]/page.tsx b/app/create/review-template/[slug]/page.tsx index 543b5d8..6c3f004 100644 --- a/app/create/review-template/[slug]/page.tsx +++ b/app/create/review-template/[slug]/page.tsx @@ -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(null); const [error, setError] = useState(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 ( -
-

- {t("loading")} -

-
+ +
+

+ {t("loading")} +

+
+
); } if (error || !template) { return ( -
- -
- ); - } - - if (showDesktopLayout) { - return ( -
-
-
- -
-
- -
+ +
+
-
+ ); } return ( -
-
- - -
-
+ + + ); } diff --git a/app/create/review/page.tsx b/app/create/review/page.tsx index 6407d98..2ed87e7 100644 --- a/app/create/review/page.tsx +++ b/app/create/review/page.tsx @@ -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 ( -
-
+ +
-
-
+ ); } diff --git a/app/create/right-rail/page.tsx b/app/create/right-rail/page.tsx index 8c9b95e..b5da495 100644 --- a/app/create/right-rail/page.tsx +++ b/app/create/right-rail/page.tsx @@ -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 add 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( [], ); const [selectedIds, setSelectedIds] = useState([]); 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} + {rr.sidebar.descriptionLink} + {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 ( -
-
-
- {/* Left column: sidebar stays put, does not scroll */} -
- +
+
+
+ +
+
+
+
- {/* Right column: card stack — this column scrolls independently */} -
-
- -
-
- ); - } - - return ( -
-
- -
- -
-
); } diff --git a/app/create/select/page.tsx b/app/create/select/page.tsx index 3a230a5..316238f 100644 --- a/app/create/select/page.tsx +++ b/app/create/select/page.tsx @@ -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>, @@ -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 = ( + <> + + + + + ); + return ( -
- {effectiveMdOrLarger ? ( - // Two-column layout for 640px+ -
- {/* Left column: HeaderLockup */} -
- + {mdUp ? ( +
+
+
- - {/* Right column: Three MultiSelect components */} -
- - - +
+ {multiSelectBlock}
) : ( - // Single column layout below 640px -
- {/* HeaderLockup */} - + - - {/* Three MultiSelect components */} - - - + {multiSelectBlock}
)} -
+ ); } diff --git a/app/create/text/page.tsx b/app/create/text/page.tsx index 945a1d1..44b7ef0 100644 --- a/app/create/text/page.tsx +++ b/app/create/text/page.tsx @@ -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 ( -
-
- {/* HeaderLockup: Left justification, L size at 640px+, M size below 640px */} - +
+ - - {/* TextInput: medium size at 640px+, small size below 640px */}
{ 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} />
-
+ ); } diff --git a/app/create/upload/page.tsx b/app/create/upload/page.tsx index 981f46e..05ab2ed 100644 --- a/app/create/upload/page.tsx +++ b/app/create/upload/page.tsx @@ -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 ( -
-
- {/* HeaderLockup: Center justification at 640px+, left below 640px */} - +
+ - - {/* Upload component: no label in create flow, max width 474px */}
-
+ ); } diff --git a/messages/en/create/completed.json b/messages/en/create/completed.json new file mode 100644 index 0000000..32bc9a5 --- /dev/null +++ b/messages/en/create/completed.json @@ -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." + } + ] + } + ] +} diff --git a/messages/en/create/confirmStakeholders.json b/messages/en/create/confirmStakeholders.json new file mode 100644 index 0000000..c7bdb2e --- /dev/null +++ b/messages/en/create/confirmStakeholders.json @@ -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!" +} diff --git a/messages/en/create/finalReview.json b/messages/en/create/finalReview.json new file mode 100644 index 0000000..863d09d --- /dev/null +++ b/messages/en/create/finalReview.json @@ -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"] + } + ] +} diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json new file mode 100644 index 0000000..bfe3711 --- /dev/null +++ b/messages/en/create/footer.json @@ -0,0 +1,5 @@ +{ + "next": "Next", + "finalizeCommunityRule": "Finalize CommunityRule", + "confirmStakeholders": "Confirm Stakeholders" +} diff --git a/messages/en/create/informational.json b/messages/en/create/informational.json new file mode 100644 index 0000000..176b342 --- /dev/null +++ b/messages/en/create/informational.json @@ -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." + } + } +} diff --git a/messages/en/create/review.json b/messages/en/create/review.json new file mode 100644 index 0000000..efff3e7 --- /dev/null +++ b/messages/en/create/review.json @@ -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" + } +} diff --git a/messages/en/create/rightRail.json b/messages/en/create/rightRail.json new file mode 100644 index 0000000..a9a1753 --- /dev/null +++ b/messages/en/create/rightRail.json @@ -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 + } + ] +} diff --git a/messages/en/create/select.json b/messages/en/create/select.json new file mode 100644 index 0000000..74986c2 --- /dev/null +++ b/messages/en/create/select.json @@ -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" } + ] +} diff --git a/messages/en/create/text.json b/messages/en/create/text.json new file mode 100644 index 0000000..2e06ffe --- /dev/null +++ b/messages/en/create/text.json @@ -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}" +} diff --git a/messages/en/create/topNav.json b/messages/en/create/topNav.json index 16f61c5..7574805 100644 --- a/messages/en/create/topNav.json +++ b/messages/en/create/topNav.json @@ -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}" diff --git a/messages/en/create/upload.json b/messages/en/create/upload.json new file mode 100644 index 0000000..64fa00a --- /dev/null +++ b/messages/en/create/upload.json @@ -0,0 +1,4 @@ +{ + "title": "How should conflicts be resolved?", + "description": "Upload supporting materials or examples that help describe how your community handles conflict." +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 3fcf718..67e191c 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -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,