Navigation, state management, create rule button integration
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { getAllBlogPosts } from "../../../lib/content";
|
||||
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
|
||||
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MenuBarItem from "../MenuBarItem";
|
||||
import Button from "../../buttons/Button";
|
||||
@@ -20,6 +20,7 @@ export const avatarImages = [
|
||||
const TopNavContainer = memo<TopNavProps>(
|
||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const t = useTranslation("header");
|
||||
|
||||
// Schema markup for site navigation
|
||||
@@ -164,7 +165,7 @@ const TopNavContainer = memo<TopNavProps>(
|
||||
size={buttonSize}
|
||||
buttonType={buttonType}
|
||||
palette={palette}
|
||||
href="/create/informational"
|
||||
onClick={() => router.push("/create/informational")}
|
||||
ariaLabel={t("ariaLabels.createNewRule")}
|
||||
>
|
||||
{renderAvatarGroup(containerSize, avatarSize)}
|
||||
|
||||
@@ -20,9 +20,9 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleExit = () => {
|
||||
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||
if (onExit) {
|
||||
onExit();
|
||||
onExit(options);
|
||||
} else {
|
||||
// Default behavior: navigate to home
|
||||
router.push("/");
|
||||
|
||||
@@ -39,9 +39,10 @@ export interface CreateFlowTopNavProps {
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* Callback when Exit/Save & Exit button is clicked
|
||||
* Callback when Exit/Save & Exit button is clicked.
|
||||
* When user is logged in, called with { saveDraft: true } to stub "Save & Exit".
|
||||
*/
|
||||
onExit?: () => void;
|
||||
onExit?: (options?: { saveDraft?: boolean }) => void;
|
||||
/**
|
||||
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
|
||||
* @default "default"
|
||||
|
||||
@@ -89,7 +89,7 @@ export function CreateFlowTopNavView({
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onExit}
|
||||
onClick={() => onExit?.({ saveDraft: loggedIn })}
|
||||
ariaLabel={exitButtonText}
|
||||
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]"
|
||||
>
|
||||
|
||||
@@ -2,27 +2,12 @@
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { use } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { VALID_STEPS } from "../utils/flowSteps";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ step: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create rule flow
|
||||
*/
|
||||
const VALID_STEPS: CreateFlowStep[] = [
|
||||
"informational",
|
||||
"text",
|
||||
"select",
|
||||
"upload",
|
||||
"review",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"final-review",
|
||||
"completed",
|
||||
];
|
||||
|
||||
/**
|
||||
* Dynamic route handler for create flow steps
|
||||
*
|
||||
@@ -33,7 +18,7 @@ export default function CreateFlowStepPage({ params }: PageProps) {
|
||||
const { step } = use(params);
|
||||
|
||||
// Validate step exists
|
||||
if (!VALID_STEPS.includes(step as CreateFlowStep)) {
|
||||
if (!(VALID_STEPS as readonly string[]).includes(step)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type {
|
||||
CreateFlowState,
|
||||
CreateFlowContextValue,
|
||||
@@ -9,6 +16,39 @@ import type {
|
||||
|
||||
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "create-flow-state";
|
||||
const DRAFT_STORAGE_KEY = "create-flow-draft";
|
||||
|
||||
function readStateFromStorage(key: string): CreateFlowState {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as CreateFlowState;
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStateToStorage(key: string, value: CreateFlowState): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. quota, private mode)
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromStorage(key: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateFlowProviderProps {
|
||||
children: ReactNode;
|
||||
initialStep?: CreateFlowStep | null;
|
||||
@@ -17,27 +57,39 @@ interface CreateFlowProviderProps {
|
||||
/**
|
||||
* Provider component for Create Flow state management
|
||||
*
|
||||
* This is a basic implementation that will be expanded in CR-56
|
||||
* with full navigation logic, state persistence, and validation.
|
||||
* Manages flow state with optional localStorage persistence and draft support.
|
||||
*/
|
||||
export function CreateFlowProvider({
|
||||
children,
|
||||
initialStep = null,
|
||||
}: CreateFlowProviderProps) {
|
||||
const [state, setState] = useState<CreateFlowState>({});
|
||||
const [state, setState] = useState<CreateFlowState>(() =>
|
||||
readStateFromStorage(STORAGE_KEY),
|
||||
);
|
||||
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
||||
|
||||
const updateState = (updates: Partial<CreateFlowState>) => {
|
||||
useEffect(() => {
|
||||
writeStateToStorage(STORAGE_KEY, state);
|
||||
}, [state]);
|
||||
|
||||
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
...updates,
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState({});
|
||||
removeFromStorage(STORAGE_KEY);
|
||||
removeFromStorage(DRAFT_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const contextValue: CreateFlowContextValue = {
|
||||
state,
|
||||
currentStep,
|
||||
updateState,
|
||||
clearState,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -47,6 +99,16 @@ export function CreateFlowProvider({
|
||||
);
|
||||
}
|
||||
|
||||
/** Save current state as draft (e.g. on "Save & Exit"). Stub for CR-57. */
|
||||
export function saveCreateFlowDraft(state: CreateFlowState): void {
|
||||
writeStateToStorage(DRAFT_STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
/** Load draft state if present. Caller can merge into initial state when entering flow. */
|
||||
export function loadCreateFlowDraft(): CreateFlowState {
|
||||
return readStateFromStorage(DRAFT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access Create Flow context
|
||||
*
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { getNextStep, getPreviousStep, isValidStep } from "../utils/flowSteps";
|
||||
|
||||
/**
|
||||
* Options passed to navigation handlers (e.g. for blur before navigate)
|
||||
*/
|
||||
const blurActiveElement = (): void => {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for Create Rule Flow navigation.
|
||||
*
|
||||
* Must be used within the create flow (pathname like /create/[step]).
|
||||
* Uses the current step from the URL and provides type-safe navigation.
|
||||
*/
|
||||
export function useCreateFlowNavigation(): {
|
||||
currentStep: CreateFlowStep | null;
|
||||
goToNextStep: () => void;
|
||||
goToPreviousStep: () => void;
|
||||
goToStep: (_step: CreateFlowStep) => void;
|
||||
canGoNext: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
nextStep: CreateFlowStep | null;
|
||||
previousStep: CreateFlowStep | null;
|
||||
} {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const currentStep = (pathname?.split("/").pop() ??
|
||||
null) as CreateFlowStep | null;
|
||||
const validStep = isValidStep(currentStep) ? currentStep : null;
|
||||
|
||||
const nextStep = getNextStep(validStep);
|
||||
const previousStep = getPreviousStep(validStep);
|
||||
|
||||
const goToNextStep = useCallback(() => {
|
||||
blurActiveElement();
|
||||
if (nextStep) {
|
||||
router.push(`/create/${nextStep}`);
|
||||
}
|
||||
}, [router, nextStep]);
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
blurActiveElement();
|
||||
if (previousStep) {
|
||||
router.push(`/create/${previousStep}`);
|
||||
}
|
||||
}, [router, previousStep]);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: CreateFlowStep) => {
|
||||
blurActiveElement();
|
||||
router.push(`/create/${step}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||
const canGoBack = useCallback(() => previousStep !== null, [previousStep]);
|
||||
|
||||
return {
|
||||
currentStep: validStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
goToStep,
|
||||
canGoNext,
|
||||
canGoBack,
|
||||
nextStep,
|
||||
previousStep,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import NumberedList from "../../components/type/NumberedList";
|
||||
@@ -11,8 +12,17 @@ import NumberedList from "../../components/type/NumberedList";
|
||||
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
|
||||
*/
|
||||
export default function InformationalPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Tell us about your organization",
|
||||
@@ -39,11 +49,11 @@ export default function InformationalPage() {
|
||||
title="How CommunityRule helps groups like yours"
|
||||
description="This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a workshop that your group can use to go through the process it together."
|
||||
justification="left"
|
||||
size={isMdOrLarger ? "L" : "M"}
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* NumberedList: M size at 640px+, S size below 640px */}
|
||||
<NumberedList items={items} size={isMdOrLarger ? "M" : "S"} />
|
||||
<NumberedList items={items} size={effectiveMdOrLarger ? "M" : "S"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+33
-67
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { CreateFlowProvider } from "./context/CreateFlowContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
CreateFlowProvider,
|
||||
useCreateFlow,
|
||||
saveCreateFlowDraft,
|
||||
} from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import type { CreateFlowStep } from "./types";
|
||||
|
||||
/**
|
||||
* Layout for the Create Rule Flow
|
||||
@@ -16,77 +20,38 @@ import type { CreateFlowStep } from "./types";
|
||||
* Includes the create flow-specific TopNav and Footer components.
|
||||
*/
|
||||
function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const {
|
||||
currentStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
} = useCreateFlowNavigation();
|
||||
const { state, clearState } = useCreateFlow();
|
||||
|
||||
// Extract current step from pathname
|
||||
const currentStep = pathname?.split("/").pop() as CreateFlowStep | undefined;
|
||||
|
||||
// Define step order
|
||||
const stepOrder: CreateFlowStep[] = [
|
||||
"informational",
|
||||
"text",
|
||||
"select",
|
||||
"upload",
|
||||
"review",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"final-review",
|
||||
"completed",
|
||||
];
|
||||
|
||||
// Get next step
|
||||
const getNextStep = (): CreateFlowStep | null => {
|
||||
if (!currentStep) return null;
|
||||
const currentIndex = stepOrder.indexOf(currentStep);
|
||||
if (currentIndex === -1 || currentIndex === stepOrder.length - 1) {
|
||||
return null;
|
||||
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||
const saveDraft = options?.saveDraft ?? false;
|
||||
if (!saveDraft && typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
"Leave create flow? Your progress will be lost.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
return stepOrder[currentIndex + 1];
|
||||
};
|
||||
|
||||
// Get previous step
|
||||
const getPreviousStep = (): CreateFlowStep | null => {
|
||||
if (!currentStep) return null;
|
||||
const currentIndex = stepOrder.indexOf(currentStep);
|
||||
if (currentIndex === -1 || currentIndex === 0) {
|
||||
return null;
|
||||
}
|
||||
return stepOrder[currentIndex - 1];
|
||||
};
|
||||
|
||||
const nextStep = getNextStep();
|
||||
const previousStep = getPreviousStep();
|
||||
|
||||
const handleNext = () => {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (nextStep) {
|
||||
router.push(`/create/${nextStep}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (previousStep) {
|
||||
router.push(`/create/${previousStep}`);
|
||||
if (saveDraft) {
|
||||
saveCreateFlowDraft(state);
|
||||
}
|
||||
clearState();
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-black flex flex-col ${isCompletedStep ? "h-screen overflow-hidden" : "min-h-screen"}`}
|
||||
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
|
||||
>
|
||||
<CreateFlowTopNav
|
||||
hasShare={isCompletedStep}
|
||||
@@ -98,13 +63,14 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||
? () => router.push("/create/final-review")
|
||||
: undefined
|
||||
}
|
||||
onExit={handleExit}
|
||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||
className={
|
||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={`flex-1 flex min-h-0 justify-center ${isCompletedStep ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
|
||||
className={`flex-1 flex min-h-0 justify-center ${useFullHeightMain ? "items-stretch overflow-hidden" : "items-center overflow-auto"}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
@@ -117,7 +83,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
||||
onClick={handleNext}
|
||||
onClick={goToNextStep}
|
||||
>
|
||||
{currentStep === "final-review"
|
||||
? "Finalize CommunityRule"
|
||||
@@ -125,7 +91,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={previousStep ? handleBack : undefined}
|
||||
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -115,35 +115,41 @@ export default function RightRailPage() {
|
||||
|
||||
if (showDesktopLayout) {
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-5 md:px-12">
|
||||
<div className="flex gap-12 items-stretch w-full max-w-[1280px] min-w-0">
|
||||
<div className="flex flex-1 flex-col justify-center gap-3 min-w-0">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 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 className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
|
||||
<div className="grid h-full max-w-[1280px] grid-cols-2 shrink-0 gap-12 min-h-0 min-w-0 w-full">
|
||||
{/* Left column: sidebar stays put, does not scroll */}
|
||||
<div className="flex min-w-0 flex-col justify-center overflow-hidden py-8">
|
||||
<DecisionMakingSidebar
|
||||
title={SIDEBAR_TITLE}
|
||||
description={SIDEBAR_DESCRIPTION}
|
||||
messageBoxTitle={MESSAGE_BOX_TITLE}
|
||||
messageBoxItems={MESSAGE_BOX_ITEMS}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size="L"
|
||||
justification="left"
|
||||
/>
|
||||
</div>
|
||||
{/* Right column: card stack — this column scrolls independently */}
|
||||
<div className="scrollbar-hide relative flex min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
<div className="py-8 flex flex-col gap-6 items-center min-w-0">
|
||||
<CardStack
|
||||
cards={SAMPLE_CARDS}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel="See all decision approaches"
|
||||
showLessLabel="Show less"
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,8 +157,8 @@ export default function RightRailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-5">
|
||||
<div className="flex flex-col gap-6 w-full min-w-0">
|
||||
<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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import MultiSelect from "../../components/controls/MultiSelect";
|
||||
@@ -13,8 +13,17 @@ import MultiSelect from "../../components/controls/MultiSelect";
|
||||
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
|
||||
*/
|
||||
export default function SelectPage() {
|
||||
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;
|
||||
|
||||
// Sample options for MultiSelect components
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState([
|
||||
{ id: "1", label: "1 member", state: "Unselected" as const },
|
||||
@@ -81,7 +90,7 @@ export default function SelectPage() {
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
||||
{isMdOrLarger ? (
|
||||
{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 */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import TextInput from "../../components/controls/TextInput";
|
||||
@@ -12,9 +12,18 @@ import TextInput from "../../components/controls/TextInput";
|
||||
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
|
||||
*/
|
||||
export default function TextPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -26,7 +35,7 @@ export default function TextPage() {
|
||||
title="What is your community called?"
|
||||
description="This will be the name of your community"
|
||||
justification="left"
|
||||
size={isMdOrLarger ? "L" : "M"}
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* TextInput: medium size at 640px+, small size below 640px */}
|
||||
@@ -35,7 +44,7 @@ export default function TextPage() {
|
||||
placeholder="Enter your community name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
inputSize={isMdOrLarger ? "medium" : "small"}
|
||||
inputSize={effectiveMdOrLarger ? "medium" : "small"}
|
||||
formHeader={false}
|
||||
textHint={`${characterCount}/${maxLength}`}
|
||||
maxLength={maxLength}
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ export interface CreateFlowContextValue {
|
||||
state: CreateFlowState;
|
||||
currentStep: CreateFlowStep | null;
|
||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||
// Navigation handlers will be added in CR-56
|
||||
/** Clear all flow state (e.g. on exit). Also clears persisted draft. */
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import Upload from "../../components/controls/Upload";
|
||||
@@ -12,8 +13,17 @@ import Upload from "../../components/controls/Upload";
|
||||
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
|
||||
*/
|
||||
export default function UploadPage() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const effectiveMdOrLarger = !isMounted || isMdOrLarger;
|
||||
|
||||
const handleUploadClick = () => {
|
||||
// TODO: Handle upload button click (e.g. open file picker)
|
||||
};
|
||||
@@ -25,8 +35,8 @@ export default function UploadPage() {
|
||||
<HeaderLockup
|
||||
title="How should conflicts be resolved?"
|
||||
description="This will be the name of your community"
|
||||
justification={isMdOrLarger ? "center" : "left"}
|
||||
size={isMdOrLarger ? "L" : "M"}
|
||||
justification={effectiveMdOrLarger ? "center" : "left"}
|
||||
size={effectiveMdOrLarger ? "L" : "M"}
|
||||
/>
|
||||
|
||||
{/* Upload component: no label in create flow, max width 474px */}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Step definitions and helpers for the Create Rule Flow
|
||||
*
|
||||
* Single source of truth for step order and navigation helpers.
|
||||
*/
|
||||
|
||||
import type { CreateFlowStep } from "../types";
|
||||
|
||||
/**
|
||||
* Ordered list of steps in the create rule flow
|
||||
*/
|
||||
export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
||||
"informational",
|
||||
"text",
|
||||
"select",
|
||||
"upload",
|
||||
"review",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"final-review",
|
||||
"completed",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid step IDs for the create flow (for validation)
|
||||
*/
|
||||
export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
||||
|
||||
/**
|
||||
* First step in the flow (entry point)
|
||||
*/
|
||||
export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
|
||||
|
||||
/**
|
||||
* Returns the next step in the flow, or null if current is last/invalid
|
||||
*/
|
||||
export function getNextStep(
|
||||
currentStep: CreateFlowStep | null | undefined,
|
||||
): CreateFlowStep | null {
|
||||
if (!currentStep) return null;
|
||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
|
||||
return FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous step in the flow, or null if current is first/invalid
|
||||
*/
|
||||
export function getPreviousStep(
|
||||
currentStep: CreateFlowStep | null | undefined,
|
||||
): CreateFlowStep | null {
|
||||
if (!currentStep) return null;
|
||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||
if (index <= 0) return null;
|
||||
return FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the step (0-based), or -1 if invalid
|
||||
*/
|
||||
export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
||||
if (!step) return -1;
|
||||
return FLOW_STEP_ORDER.indexOf(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given string is a valid create flow step
|
||||
*/
|
||||
export function isValidStep(
|
||||
step: string | null | undefined,
|
||||
): step is CreateFlowStep {
|
||||
return (
|
||||
typeof step === "string" &&
|
||||
(VALID_STEPS as readonly string[]).includes(step)
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import TopNav from "../../app/components/navigation/TopNav";
|
||||
import { componentTestSuite } from "../utils/componentTestSuite";
|
||||
|
||||
// Mock next/navigation (TopNav uses useRouter for Create Rule button and usePathname for nav state)
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
type TopNavProps = React.ComponentProps<typeof TopNav>;
|
||||
|
||||
// Test folderTop=false variant (standard header)
|
||||
|
||||
Reference in New Issue
Block a user