- {progressSummary}
+ {i18n.stepsCompletedLabel(completedCount, sortedSteps.length)}{" "}
+ {i18n.percentCompleteLabel(progressPercent)}
- {/* Screen reader live announcement area — #811 */}
+ {/* Assertive live region for step-change announcements */}
- {/* Pending indicator for screen readers — #812 */}
+ {/* Polite pending indicator */}
{state.isPending && (
- {t("onboarding.updating") || "Updating step…"}
+ {i18n.updating}
)}
- {/* Container */}
+ {/* ── Card ──────────────────────────────────────────────────────────── */}
- {/* Header */}
-
-
-
- {t("onboarding.title") || "Onboarding Progress"}
+ {/* ── Header ──────────────────────────────────────────────────────── */}
+
+
+
+ {i18n.title}
- {progressPercentage}%
+ {i18n.percentCompleteLabel(progressPercent)}
-
- {t("onboarding.subtitle") || "Complete all required steps to finish setup"}
+
+
+ {i18n.subtitle}
- {/* Overall progress bar — animated with framer-motion — #809 */}
+ {/* Progress bar */}
- {/* Status text */}
-
- {completedStepsCount} of{" "}
- {sortedSteps.length} steps completed
- {isOnboardingComplete && (
-
-
- {/* Steps list — staggered entrance animation — #809 */}
+ {/* ── Steps list ───────────────────────────────────────────────────── */}
>
{sortedSteps.map((step, index) => {
- const isCurrentStep = effectiveCurrentStep === step.id;
- const isPendingStep = state.isPending && isCurrentStep;
- const stepDescriptionId = `${progressSummaryId}-step-${index + 1}-description`;
+ const isCurrent = effectiveCurrentStep === step.id;
+ const isPending = state.isPending && isCurrent;
+ const stepDescId = `${progressSummaryId}-desc-${index}`;
+
+ const indicatorColorClass = step.completed
+ ? "border-pluto-500 bg-pluto-100 text-pluto-800 shadow-[0_4px_12px_rgba(74,111,165,0.18)] dark:border-pluto-400 dark:bg-pluto-800/60 dark:text-pluto-100"
+ : isCurrent
+ ? "border-pluto-600 bg-pluto-50 text-pluto-700 shadow-[0_4px_12px_rgba(74,111,165,0.14)] dark:border-pluto-400 dark:bg-pluto-900/60 dark:text-pluto-200"
+ : "border-pluto-200 bg-white text-pluto-600 dark:border-pluto-700 dark:bg-pluto-900/40 dark:text-pluto-400 group-hover:border-pluto-400 group-hover:bg-pluto-50 dark:group-hover:border-pluto-500 dark:group-hover:bg-pluto-800/50";
return (
- {/* Step content */}
-
+ {/* Step text content */}
+
{step.title}
{step.required && (
*
)}
+
{step.description}
- {/* Status badge — animated scale-in — #809 */}
-
-
- {step.completed
- ? t("onboarding.completed") || "Completed"
- : isCurrentStep
- ? t("onboarding.inProgress") || "In Progress"
- : t("onboarding.pending") || "Pending"}
-
-
-
-
- {/* Connector line (vertical orientation only) */}
+
+
+
+ {/* Vertical connector line */}
{orientation === "vertical" && index < sortedSteps.length - 1 && (
)}
@@ -524,11 +537,11 @@ export const OnboardingProgressTracker: React.FC
- {/* Completion banner — animated entrance — #809 */}
+ {/* ── Completion banner ─────────────────────────────────────────────── */}
- {isOnboardingComplete && sortedSteps.length > 0 && (
+ {isComplete && sortedSteps.length > 0 && (
>
/>
-
- {t("onboarding.successTitle") || "Onboarding Complete!"}
+
+ {i18n.successTitle}
-
- {t("onboarding.successMessage") ||
- "You have successfully completed all required onboarding steps."}
+
+ {i18n.successMessage}
@@ -569,6 +581,6 @@ export const OnboardingProgressTracker: React.FC
);
-};
+});
export default OnboardingProgressTracker;
diff --git a/frontend/src/hooks/useOnboardingI18n.ts b/frontend/src/hooks/useOnboardingI18n.ts
new file mode 100644
index 00000000..65b6e658
--- /dev/null
+++ b/frontend/src/hooks/useOnboardingI18n.ts
@@ -0,0 +1,149 @@
+/**
+ * useOnboardingI18n
+ *
+ * Centralises every translated string used by the Onboarding Progress Tracker.
+ * Consuming components call this hook once and receive a stable object of
+ * typed helpers — no duplicated useTranslations("onboarding") calls scattered
+ * across files.
+ */
+
+"use client";
+
+import { useCallback } from "react";
+import { useTranslations } from "next-intl";
+
+// ── Step data shape (minimal — component owns the full type) ──────────────────
+
+interface StepMeta {
+ order: number;
+ title: string;
+ description: string;
+ completed: boolean;
+ required: boolean;
+}
+
+// ── Return type ───────────────────────────────────────────────────────────────
+
+export interface OnboardingI18n {
+ // Static strings
+ title: string;
+ subtitle: string;
+ progressBar: string;
+ stepsList: string;
+ progressTracker: string;
+ allCompleted: string;
+ successTitle: string;
+ successMessage: string;
+ completed: string;
+ inProgress: string;
+ pending: string;
+ required: string;
+ optional: string;
+ updating: string;
+ stepChangeFailed: string;
+
+ // Parameterised helpers
+ /** "X of Y steps completed" */
+ stepsCompletedLabel: (completed: number, total: number) => string;
+ /** "N% complete" */
+ percentCompleteLabel: (percent: number) => string;
+ /** Progress announcement for aria-live: "Progress: N% complete" */
+ progressAnnouncement: (percent: number) => string;
+ /**
+ * Full step button aria-label.
+ * Appends ". Completed" and/or ". Required" as appropriate.
+ */
+ stepAriaLabel: (
+ number: number,
+ title: string,
+ completed: boolean,
+ required: boolean,
+ ) => string;
+ /** Step click announcement for the aria-live region. */
+ stepAnnouncement: (step: StepMeta, total: number, statusLabel: string) => string;
+ /** Status label for a given step state. */
+ statusLabel: (completed: boolean, isCurrent: boolean) => string;
+}
+
+// ── Hook ──────────────────────────────────────────────────────────────────────
+
+export function useOnboardingI18n(): OnboardingI18n {
+ const t = useTranslations("onboarding");
+
+ const stepsCompletedLabel = useCallback(
+ (completed: number, total: number) =>
+ t("stepsCompleted", { completed, total }),
+ [t],
+ );
+
+ const percentCompleteLabel = useCallback(
+ (percent: number) => t("percentComplete", { percent }),
+ [t],
+ );
+
+ const progressAnnouncement = useCallback(
+ (percent: number) => t("progressAnnouncement", { percent }),
+ [t],
+ );
+
+ const stepAriaLabel = useCallback(
+ (
+ number: number,
+ title: string,
+ completed: boolean,
+ required: boolean,
+ ): string => {
+ if (completed && required)
+ return t("stepLabelCompletedRequired", { number, title });
+ if (completed) return t("stepLabelCompleted", { number, title });
+ if (required) return t("stepLabelRequired", { number, title });
+ return t("stepLabel", { number, title });
+ },
+ [t],
+ );
+
+ const stepAnnouncement = useCallback(
+ (step: StepMeta, total: number, statusLabel: string): string =>
+ t("stepAnnouncement", {
+ number: step.order,
+ total,
+ title: step.title,
+ description: step.description,
+ status: statusLabel,
+ }),
+ [t],
+ );
+
+ const statusLabel = useCallback(
+ (completed: boolean, isCurrent: boolean): string => {
+ if (completed) return t("completed");
+ if (isCurrent) return t("inProgress");
+ return t("pending");
+ },
+ [t],
+ );
+
+ return {
+ title: t("title"),
+ subtitle: t("subtitle"),
+ progressBar: t("progressBar"),
+ stepsList: t("stepsList"),
+ progressTracker: t("progressTracker"),
+ allCompleted: t("allCompleted"),
+ successTitle: t("successTitle"),
+ successMessage: t("successMessage"),
+ completed: t("completed"),
+ inProgress: t("inProgress"),
+ pending: t("pending"),
+ required: t("required"),
+ optional: t("optional"),
+ updating: t("updating"),
+ stepChangeFailed: t("stepChangeFailed"),
+ stepsCompletedLabel,
+ percentCompleteLabel,
+ progressAnnouncement,
+ stepAriaLabel,
+ stepAnnouncement,
+ statusLabel,
+ };
+}
diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts
new file mode 100644
index 00000000..7a9b1f46
--- /dev/null
+++ b/frontend/src/hooks/useOnboardingProgress.ts
@@ -0,0 +1,195 @@
+/**
+ * useOnboardingProgress
+ *
+ * Encapsulates all stateful logic for the Onboarding Progress Tracker so the
+ * component stays a pure presentation layer.
+ *
+ * Responsibilities:
+ * - Owns the useReducer instance and exposes read-only derived values.
+ * - Syncs external prop changes (steps array) into the reducer via SYNC_STEPS.
+ * - Syncs external currentStep prop changes via SET_CURRENT_STEP.
+ * - Handles optimistic step navigation with async callback + rollback.
+ * - Produces translated screen-reader announcement strings via useOnboardingI18n.
+ * - Fires onComplete when all required steps are done.
+ */
+
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useReducer,
+ useRef,
+} from "react";
+import {
+ onboardingReducer,
+ createInitialOnboardingState,
+ selectEffectiveStep,
+ selectProgressPercent,
+ type OnboardingState,
+} from "@/components/onboarding-reducer";
+import { useOnboardingI18n } from "@/hooks/useOnboardingI18n";
+
+// ── Shared step type (re-exported for consumers) ──────────────────────────────
+
+export interface OnboardingStep {
+ id: string;
+ title: string;
+ description: string;
+ completed: boolean;
+ required: boolean;
+ order: number;
+}
+
+// ── Options / return types ────────────────────────────────────────────────────
+
+export interface UseOnboardingProgressOptions {
+ steps: OnboardingStep[];
+ currentStep?: string;
+ onStepChange?: (stepId: string) => void | Promise
;
+ onComplete?: () => void;
+}
+
+export interface UseOnboardingProgressReturn {
+ sortedSteps: OnboardingStep[];
+ effectiveCurrentStep: string | undefined;
+ state: OnboardingState;
+ progressPercent: number;
+ completedCount: number;
+ isComplete: boolean;
+ progressSummaryId: string;
+ handleStepClick: (stepId: string) => Promise;
+}
+
+// ── Hook ──────────────────────────────────────────────────────────────────────
+
+export function useOnboardingProgress({
+ steps,
+ currentStep: currentStepProp,
+ onStepChange,
+ onComplete,
+}: UseOnboardingProgressOptions): UseOnboardingProgressReturn {
+ const i18n = useOnboardingI18n();
+ const progressSummaryId = useId();
+
+ // ── Derived step data ────────────────────────────────────────────────────
+
+ const sortedSteps = useMemo(
+ () => [...steps].sort((a, b) => a.order - b.order),
+ [steps],
+ );
+
+ const completedCount = useMemo(
+ () => sortedSteps.filter((s) => s.completed).length,
+ [sortedSteps],
+ );
+
+ const isComplete = useMemo(() => {
+ const required = sortedSteps.filter((s) => s.required);
+ return required.length > 0 && required.every((s) => s.completed);
+ }, [sortedSteps]);
+
+ // ── Reducer ──────────────────────────────────────────────────────────────
+
+ const [state, dispatch] = useReducer(
+ onboardingReducer,
+ createInitialOnboardingState(
+ currentStepProp ?? sortedSteps[0]?.id,
+ sortedSteps.length,
+ completedCount,
+ ),
+ );
+
+ // Sync step counts when the steps prop changes
+ useEffect(() => {
+ dispatch({
+ type: "SYNC_STEPS",
+ payload: { total: sortedSteps.length, completed: completedCount },
+ });
+ }, [sortedSteps.length, completedCount]);
+
+ // Sync external currentStep prop
+ const prevCurrentStepPropRef = useRef(currentStepProp);
+ useEffect(() => {
+ if (
+ currentStepProp !== undefined &&
+ currentStepProp !== prevCurrentStepPropRef.current
+ ) {
+ dispatch({ type: "SET_CURRENT_STEP", payload: currentStepProp });
+ prevCurrentStepPropRef.current = currentStepProp;
+ }
+ }, [currentStepProp]);
+
+ // ── Derived values ────────────────────────────────────────────────────────
+
+ const effectiveCurrentStep = selectEffectiveStep(state);
+ const progressPercent = selectProgressPercent(state);
+
+ // ── Completion side-effect ────────────────────────────────────────────────
+
+ const onCompleteRef = useRef(onComplete);
+ onCompleteRef.current = onComplete;
+
+ useEffect(() => {
+ if (isComplete && sortedSteps.length > 0) {
+ dispatch({ type: "SET_ANNOUNCEMENT", payload: i18n.successTitle });
+ onCompleteRef.current?.();
+ }
+ }, [isComplete, sortedSteps.length, i18n.successTitle]);
+
+ // ── Progress announcements ────────────────────────────────────────────────
+
+ useEffect(() => {
+ dispatch({
+ type: "SET_ANNOUNCEMENT",
+ payload: i18n.progressAnnouncement(progressPercent),
+ });
+ }, [progressPercent, i18n]);
+
+ // ── Step click handler ────────────────────────────────────────────────────
+
+ const handleStepClick = useCallback(
+ async (stepId: string) => {
+ if (state.isPending) return;
+
+ const step = sortedSteps.find((s) => s.id === stepId);
+ if (!step) return;
+
+ dispatch({ type: "OPTIMISTIC_STEP", payload: stepId });
+
+ const status = i18n.statusLabel(
+ step.completed,
+ effectiveCurrentStep === stepId,
+ );
+ dispatch({
+ type: "SET_ANNOUNCEMENT",
+ payload: i18n.stepAnnouncement(step, sortedSteps.length, status),
+ });
+
+ try {
+ await onStepChange?.(stepId);
+ dispatch({ type: "CONFIRM_STEP", payload: stepId });
+ } catch {
+ dispatch({ type: "ROLLBACK_STEP" });
+ dispatch({
+ type: "SET_ANNOUNCEMENT",
+ payload: i18n.stepChangeFailed,
+ });
+ }
+ },
+ [sortedSteps, effectiveCurrentStep, onStepChange, state.isPending, i18n],
+ );
+
+ return {
+ sortedSteps,
+ effectiveCurrentStep,
+ state,
+ progressPercent,
+ completedCount,
+ isComplete,
+ progressSummaryId,
+ handleStepClick,
+ };
+}