Navigation, state management, create rule button integration
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { getAllBlogPosts } from "../../../lib/content";
|
import { getAllBlogPosts } from "../../../lib/content";
|
||||||
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
|
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import MenuBarItem from "../MenuBarItem";
|
import MenuBarItem from "../MenuBarItem";
|
||||||
import Button from "../../buttons/Button";
|
import Button from "../../buttons/Button";
|
||||||
@@ -20,6 +20,7 @@ export const avatarImages = [
|
|||||||
const TopNavContainer = memo<TopNavProps>(
|
const TopNavContainer = memo<TopNavProps>(
|
||||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const t = useTranslation("header");
|
const t = useTranslation("header");
|
||||||
|
|
||||||
// Schema markup for site navigation
|
// Schema markup for site navigation
|
||||||
@@ -164,7 +165,7 @@ const TopNavContainer = memo<TopNavProps>(
|
|||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
buttonType={buttonType}
|
buttonType={buttonType}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
href="/create/informational"
|
onClick={() => router.push("/create/informational")}
|
||||||
ariaLabel={t("ariaLabels.createNewRule")}
|
ariaLabel={t("ariaLabels.createNewRule")}
|
||||||
>
|
>
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
{renderAvatarGroup(containerSize, avatarSize)}
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleExit = () => {
|
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||||
if (onExit) {
|
if (onExit) {
|
||||||
onExit();
|
onExit(options);
|
||||||
} else {
|
} else {
|
||||||
// Default behavior: navigate to home
|
// Default behavior: navigate to home
|
||||||
router.push("/");
|
router.push("/");
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ export interface CreateFlowTopNavProps {
|
|||||||
*/
|
*/
|
||||||
onEdit?: () => void;
|
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)
|
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
|
||||||
* @default "default"
|
* @default "default"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function CreateFlowTopNavView({
|
|||||||
buttonType="outline"
|
buttonType="outline"
|
||||||
palette={buttonPalette}
|
palette={buttonPalette}
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
onClick={onExit}
|
onClick={() => onExit?.({ saveDraft: loggedIn })}
|
||||||
ariaLabel={exitButtonText}
|
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]"
|
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 { notFound } from "next/navigation";
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
import type { CreateFlowStep } from "../types";
|
import { VALID_STEPS } from "../utils/flowSteps";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ step: string }>;
|
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
|
* Dynamic route handler for create flow steps
|
||||||
*
|
*
|
||||||
@@ -33,7 +18,7 @@ export default function CreateFlowStepPage({ params }: PageProps) {
|
|||||||
const { step } = use(params);
|
const { step } = use(params);
|
||||||
|
|
||||||
// Validate step exists
|
// Validate step exists
|
||||||
if (!VALID_STEPS.includes(step as CreateFlowStep)) {
|
if (!(VALID_STEPS as readonly string[]).includes(step)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
CreateFlowState,
|
CreateFlowState,
|
||||||
CreateFlowContextValue,
|
CreateFlowContextValue,
|
||||||
@@ -9,6 +16,39 @@ import type {
|
|||||||
|
|
||||||
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
|
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 {
|
interface CreateFlowProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
initialStep?: CreateFlowStep | null;
|
initialStep?: CreateFlowStep | null;
|
||||||
@@ -17,27 +57,39 @@ interface CreateFlowProviderProps {
|
|||||||
/**
|
/**
|
||||||
* Provider component for Create Flow state management
|
* Provider component for Create Flow state management
|
||||||
*
|
*
|
||||||
* This is a basic implementation that will be expanded in CR-56
|
* Manages flow state with optional localStorage persistence and draft support.
|
||||||
* with full navigation logic, state persistence, and validation.
|
|
||||||
*/
|
*/
|
||||||
export function CreateFlowProvider({
|
export function CreateFlowProvider({
|
||||||
children,
|
children,
|
||||||
initialStep = null,
|
initialStep = null,
|
||||||
}: CreateFlowProviderProps) {
|
}: CreateFlowProviderProps) {
|
||||||
const [state, setState] = useState<CreateFlowState>({});
|
const [state, setState] = useState<CreateFlowState>(() =>
|
||||||
|
readStateFromStorage(STORAGE_KEY),
|
||||||
|
);
|
||||||
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
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) => ({
|
setState((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
...updates,
|
...updates,
|
||||||
}));
|
}));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
setState({});
|
||||||
|
removeFromStorage(STORAGE_KEY);
|
||||||
|
removeFromStorage(DRAFT_STORAGE_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const contextValue: CreateFlowContextValue = {
|
const contextValue: CreateFlowContextValue = {
|
||||||
state,
|
state,
|
||||||
currentStep,
|
currentStep,
|
||||||
updateState,
|
updateState,
|
||||||
|
clearState,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
* 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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import NumberedList from "../../components/type/NumberedList";
|
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.
|
* Responsive sizing: uses L/M for HeaderLockup and M/S for NumberedList based on 640px breakpoint.
|
||||||
*/
|
*/
|
||||||
export default function InformationalPage() {
|
export default function InformationalPage() {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
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 = [
|
const items = [
|
||||||
{
|
{
|
||||||
title: "Tell us about your organization",
|
title: "Tell us about your organization",
|
||||||
@@ -39,11 +49,11 @@ export default function InformationalPage() {
|
|||||||
title="How CommunityRule helps groups like yours"
|
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."
|
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"
|
justification="left"
|
||||||
size={isMdOrLarger ? "L" : "M"}
|
size={effectiveMdOrLarger ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* NumberedList: M size at 640px+, S size below 640px */}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+33
-67
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CreateFlowProvider } from "./context/CreateFlowContext";
|
import {
|
||||||
|
CreateFlowProvider,
|
||||||
|
useCreateFlow,
|
||||||
|
saveCreateFlowDraft,
|
||||||
|
} from "./context/CreateFlowContext";
|
||||||
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
||||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||||
import Button from "../components/buttons/Button";
|
import Button from "../components/buttons/Button";
|
||||||
import type { CreateFlowStep } from "./types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout for the Create Rule Flow
|
* Layout for the Create Rule Flow
|
||||||
@@ -16,77 +20,38 @@ import type { CreateFlowStep } from "./types";
|
|||||||
* Includes the create flow-specific TopNav and Footer components.
|
* Includes the create flow-specific TopNav and Footer components.
|
||||||
*/
|
*/
|
||||||
function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
} = useCreateFlowNavigation();
|
||||||
|
const { state, clearState } = useCreateFlow();
|
||||||
|
|
||||||
// Extract current step from pathname
|
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||||
const currentStep = pathname?.split("/").pop() as CreateFlowStep | undefined;
|
const saveDraft = options?.saveDraft ?? false;
|
||||||
|
if (!saveDraft && typeof window !== "undefined") {
|
||||||
// Define step order
|
const confirmed = window.confirm(
|
||||||
const stepOrder: CreateFlowStep[] = [
|
"Leave create flow? Your progress will be lost.",
|
||||||
"informational",
|
);
|
||||||
"text",
|
if (!confirmed) return;
|
||||||
"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;
|
|
||||||
}
|
}
|
||||||
return stepOrder[currentIndex + 1];
|
if (saveDraft) {
|
||||||
};
|
saveCreateFlowDraft(state);
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
}
|
}
|
||||||
|
clearState();
|
||||||
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCompletedStep = currentStep === "completed";
|
const isCompletedStep = currentStep === "completed";
|
||||||
|
const isRightRailStep = currentStep === "right-rail";
|
||||||
|
const useFullHeightMain = isCompletedStep || isRightRailStep;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
<CreateFlowTopNav
|
||||||
hasShare={isCompletedStep}
|
hasShare={isCompletedStep}
|
||||||
@@ -98,13 +63,14 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
|||||||
? () => router.push("/create/final-review")
|
? () => router.push("/create/final-review")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onExit={handleExit}
|
||||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||||
className={
|
className={
|
||||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<main
|
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
@@ -117,7 +83,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
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)]"
|
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"
|
{currentStep === "final-review"
|
||||||
? "Finalize CommunityRule"
|
? "Finalize CommunityRule"
|
||||||
@@ -125,7 +91,7 @@ function CreateFlowLayoutContent({ children }: { children: ReactNode }) {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
onBackClick={previousStep ? handleBack : undefined}
|
onBackClick={previousStep ? goToPreviousStep : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,9 +115,11 @@ export default function RightRailPage() {
|
|||||||
|
|
||||||
if (showDesktopLayout) {
|
if (showDesktopLayout) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center px-5 md:px-12">
|
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex gap-12 items-stretch w-full max-w-[1280px] min-w-0">
|
<div className="flex min-h-0 flex-1 overflow-hidden px-5 md:px-12">
|
||||||
<div className="flex flex-1 flex-col justify-center gap-3 min-w-0">
|
<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
|
<DecisionMakingSidebar
|
||||||
title={SIDEBAR_TITLE}
|
title={SIDEBAR_TITLE}
|
||||||
description={SIDEBAR_DESCRIPTION}
|
description={SIDEBAR_DESCRIPTION}
|
||||||
@@ -129,7 +131,9 @@ export default function RightRailPage() {
|
|||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-6 items-center min-w-0">
|
{/* 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
|
<CardStack
|
||||||
cards={SAMPLE_CARDS}
|
cards={SAMPLE_CARDS}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -147,12 +151,14 @@ export default function RightRailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center px-5">
|
<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">
|
<div className="flex flex-col gap-6 w-full min-w-0 py-8">
|
||||||
<DecisionMakingSidebar
|
<DecisionMakingSidebar
|
||||||
title={SIDEBAR_TITLE}
|
title={SIDEBAR_TITLE}
|
||||||
description={SIDEBAR_DESCRIPTION}
|
description={SIDEBAR_DESCRIPTION}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import MultiSelect from "../../components/controls/MultiSelect";
|
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.
|
* Responsive sizing: uses L/M for HeaderLockup and S for MultiSelect based on 640px breakpoint.
|
||||||
*/
|
*/
|
||||||
export default function SelectPage() {
|
export default function SelectPage() {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
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
|
// Sample options for MultiSelect components
|
||||||
const [communitySizeOptions, setCommunitySizeOptions] = useState([
|
const [communitySizeOptions, setCommunitySizeOptions] = useState([
|
||||||
{ id: "1", label: "1 member", state: "Unselected" as const },
|
{ id: "1", label: "1 member", state: "Unselected" as const },
|
||||||
@@ -81,7 +90,7 @@ export default function SelectPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center px-[var(--spacing-measures-spacing-500,20px)] md:px-[64px]">
|
<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+
|
// Two-column layout for 640px+
|
||||||
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
|
<div className="flex gap-[var(--measures-spacing-1200,48px)] items-center justify-center w-full max-w-[1280px]">
|
||||||
{/* Left column: HeaderLockup */}
|
{/* Left column: HeaderLockup */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import TextInput from "../../components/controls/TextInput";
|
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.
|
* Responsive sizing: uses L/M for HeaderLockup and medium/small for TextInput based on 640px breakpoint.
|
||||||
*/
|
*/
|
||||||
export default function TextPage() {
|
export default function TextPage() {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
||||||
const [value, setValue] = useState("");
|
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 maxLength = 48;
|
||||||
const characterCount = value.length;
|
const characterCount = value.length;
|
||||||
|
|
||||||
@@ -26,7 +35,7 @@ export default function TextPage() {
|
|||||||
title="What is your community called?"
|
title="What is your community called?"
|
||||||
description="This will be the name of your community"
|
description="This will be the name of your community"
|
||||||
justification="left"
|
justification="left"
|
||||||
size={isMdOrLarger ? "L" : "M"}
|
size={effectiveMdOrLarger ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TextInput: medium size at 640px+, small size below 640px */}
|
{/* TextInput: medium size at 640px+, small size below 640px */}
|
||||||
@@ -35,7 +44,7 @@ export default function TextPage() {
|
|||||||
placeholder="Enter your community name"
|
placeholder="Enter your community name"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
inputSize={isMdOrLarger ? "medium" : "small"}
|
inputSize={effectiveMdOrLarger ? "medium" : "small"}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
textHint={`${characterCount}/${maxLength}`}
|
textHint={`${characterCount}/${maxLength}`}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ export interface CreateFlowContextValue {
|
|||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
currentStep: CreateFlowStep | null;
|
currentStep: CreateFlowStep | null;
|
||||||
updateState: (_updates: Partial<CreateFlowState>) => void;
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import Upload from "../../components/controls/Upload";
|
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.
|
* Responsive sizing: uses L/M for HeaderLockup based on 640px breakpoint.
|
||||||
*/
|
*/
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
|
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 = () => {
|
const handleUploadClick = () => {
|
||||||
// TODO: Handle upload button click (e.g. open file picker)
|
// TODO: Handle upload button click (e.g. open file picker)
|
||||||
};
|
};
|
||||||
@@ -25,8 +35,8 @@ export default function UploadPage() {
|
|||||||
<HeaderLockup
|
<HeaderLockup
|
||||||
title="How should conflicts be resolved?"
|
title="How should conflicts be resolved?"
|
||||||
description="This will be the name of your community"
|
description="This will be the name of your community"
|
||||||
justification={isMdOrLarger ? "center" : "left"}
|
justification={effectiveMdOrLarger ? "center" : "left"}
|
||||||
size={isMdOrLarger ? "L" : "M"}
|
size={effectiveMdOrLarger ? "L" : "M"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Upload component: no label in create flow, max width 474px */}
|
{/* 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 React from "react";
|
||||||
|
import { vi } from "vitest";
|
||||||
import TopNav from "../../app/components/navigation/TopNav";
|
import TopNav from "../../app/components/navigation/TopNav";
|
||||||
import { componentTestSuite } from "../utils/componentTestSuite";
|
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>;
|
type TopNavProps = React.ComponentProps<typeof TopNav>;
|
||||||
|
|
||||||
// Test folderTop=false variant (standard header)
|
// Test folderTop=false variant (standard header)
|
||||||
|
|||||||
Reference in New Issue
Block a user