From 8cd3aec8155f558a3cde1dfcfb1d7d470a51da48 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:26:13 +0600 Subject: [PATCH 01/13] feat(onboarding): add pure step-navigation helpers --- .../onboarding/onboarding-navigation.test.ts | 29 +++++++++++++++++++ .../onboarding/onboarding-navigation.ts | 26 +++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/components/onboarding/onboarding-navigation.test.ts create mode 100644 src/components/onboarding/onboarding-navigation.ts diff --git a/src/components/onboarding/onboarding-navigation.test.ts b/src/components/onboarding/onboarding-navigation.test.ts new file mode 100644 index 0000000..922db32 --- /dev/null +++ b/src/components/onboarding/onboarding-navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from '@jest/globals'; +import { stepIndexOf, nextStepId, prevStepId, isFirstStep, isLastStep } from './onboarding-navigation'; + +const steps = [ { id: 'basic' }, { id: 'commission' }, { id: 'withdraw' }, { id: 'appearance' } ]; + +describe( 'onboarding-navigation', () => { + it( 'resolves index of a step id', () => { + expect( stepIndexOf( steps, 'withdraw' ) ).toBe( 2 ); + expect( stepIndexOf( steps, 'missing' ) ).toBe( -1 ); + } ); + + it( 'advances and retreats within bounds', () => { + expect( nextStepId( steps, 'basic' ) ).toBe( 'commission' ); + expect( nextStepId( steps, 'appearance' ) ).toBeNull(); // already last + expect( prevStepId( steps, 'commission' ) ).toBe( 'basic' ); + expect( prevStepId( steps, 'basic' ) ).toBeNull(); // already first + } ); + + it( 'reports boundaries', () => { + expect( isFirstStep( steps, 'basic' ) ).toBe( true ); + expect( isLastStep( steps, 'appearance' ) ).toBe( true ); + expect( isLastStep( steps, 'withdraw' ) ).toBe( false ); + } ); + + it( 'treats an unknown active id as no movement', () => { + expect( nextStepId( steps, 'missing' ) ).toBeNull(); + expect( prevStepId( steps, 'missing' ) ).toBeNull(); + } ); +} ); diff --git a/src/components/onboarding/onboarding-navigation.ts b/src/components/onboarding/onboarding-navigation.ts new file mode 100644 index 0000000..5ecfdaf --- /dev/null +++ b/src/components/onboarding/onboarding-navigation.ts @@ -0,0 +1,26 @@ +export type StepRef = { id: string }; + +export function stepIndexOf( steps: StepRef[], id: string ): number { + return steps.findIndex( ( s ) => s.id === id ); +} + +export function nextStepId( steps: StepRef[], current: string ): string | null { + const i = stepIndexOf( steps, current ); + if ( i < 0 || i >= steps.length - 1 ) return null; + return steps[ i + 1 ].id; +} + +export function prevStepId( steps: StepRef[], current: string ): string | null { + const i = stepIndexOf( steps, current ); + if ( i <= 0 ) return null; + return steps[ i - 1 ].id; +} + +export function isFirstStep( steps: StepRef[], id: string ): boolean { + return stepIndexOf( steps, id ) === 0; +} + +export function isLastStep( steps: StepRef[], id: string ): boolean { + const i = stepIndexOf( steps, id ); + return i >= 0 && i === steps.length - 1; +} From 46eb8d75c3173ddb0ea2be9dd6d726cbe99c0e46 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:29:15 +0600 Subject: [PATCH 02/13] feat(onboarding): add Onboarding component types --- src/components/onboarding/onboarding-types.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/components/onboarding/onboarding-types.ts diff --git a/src/components/onboarding/onboarding-types.ts b/src/components/onboarding/onboarding-types.ts new file mode 100644 index 0000000..1a5f0b7 --- /dev/null +++ b/src/components/onboarding/onboarding-types.ts @@ -0,0 +1,55 @@ +// ============================================ +// Onboarding Component Types +// ============================================ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { ReactNode } from 'react'; +import type { SettingsElement } from '../settings/settings-types'; +import type { ApplyFiltersFunction } from '../settings/settings-context'; + +export interface OnboardingStep { + id: string; // also the page id inside schema + label?: string; + description?: string; + icon?: string; + schema: SettingsElement[]; // page subtree: [{id, type:'page'}, sections…, fields…] + skippable?: boolean; + completed?: boolean; +} + +export interface StepIndicatorRenderProps { + steps: Array<{ id: string; label?: string; completed?: boolean; active: boolean; index: number; }>; + orientation: 'horizontal' | 'vertical'; + onStepClick: (stepId: string) => void; +} + +export interface StepFooterRenderProps { + activeStepId: string; + isFirst: boolean; + isLast: boolean; + skippable: boolean; + dirty: boolean; + hasErrors: boolean; + onBack: () => void; + onSkip: () => void; + onNext: () => void; // saves current step, then advances + onFinish: () => void; // saves last step, then completes +} + +export interface OnboardingProps { + steps: OnboardingStep[]; + values?: Record; + activeStepId?: string; // controlled; falls back to first step + orientation?: 'horizontal' | 'vertical'; // default 'horizontal' + hookPrefix?: string; // default 'plugin_ui' + applyFilters?: ApplyFiltersFunction; + loading?: boolean; + className?: string; + onChange?: (stepId: string, key: string, value: any) => void; + onStepSave?: (stepId: string, treeValues: Record, flatValues: Record) => void | Promise; + onStepChange?: (stepId: string) => void; + onSkip?: (stepId: string) => void; + onComplete?: () => void; + renderStepIndicator?: (props: StepIndicatorRenderProps) => ReactNode; + renderFooter?: (props: StepFooterRenderProps) => ReactNode; +} From bc9c0175e88e622ad485e57580455f7449cfe57c Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:31:18 +0600 Subject: [PATCH 03/13] feat(onboarding): add default step indicator (horizontal/vertical) --- src/components/onboarding/step-indicator.tsx | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/components/onboarding/step-indicator.tsx diff --git a/src/components/onboarding/step-indicator.tsx b/src/components/onboarding/step-indicator.tsx new file mode 100644 index 0000000..db73bbd --- /dev/null +++ b/src/components/onboarding/step-indicator.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils'; +import { Check } from 'lucide-react'; +import type { StepIndicatorRenderProps } from './onboarding-types'; + +export function StepIndicator({ steps, orientation, onStepClick }: StepIndicatorRenderProps) { + const horizontal = orientation === 'horizontal'; + return ( +
    + {steps.map((step) => ( +
  1. + +
  2. + ))} +
+ ); +} From 952029e890b6c1ef0b85a047c41aa7fcd4a45292 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:33:13 +0600 Subject: [PATCH 04/13] feat(onboarding): add default step footer navigation --- src/components/onboarding/step-footer.tsx | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/components/onboarding/step-footer.tsx diff --git a/src/components/onboarding/step-footer.tsx b/src/components/onboarding/step-footer.tsx new file mode 100644 index 0000000..3a430ba --- /dev/null +++ b/src/components/onboarding/step-footer.tsx @@ -0,0 +1,37 @@ +import { Button } from '../ui/button'; +import type { StepFooterRenderProps } from './onboarding-types'; + +export function StepFooter({ + isFirst, isLast, skippable, dirty, hasErrors, onBack, onSkip, onNext, onFinish, +}: StepFooterRenderProps) { + return ( +
+
+ {!isFirst && ( + + )} +
+
+ {skippable && !isLast && ( + + )} + {isLast ? ( + + ) : ( + + )} +
+
+ ); +} From be8b302cf5a74e55cbe5b4f061cd10c031513ac9 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:34:07 +0600 Subject: [PATCH 05/13] refactor(onboarding): drop unused dirty from default footer destructure --- src/components/onboarding/step-footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/onboarding/step-footer.tsx b/src/components/onboarding/step-footer.tsx index 3a430ba..27a4850 100644 --- a/src/components/onboarding/step-footer.tsx +++ b/src/components/onboarding/step-footer.tsx @@ -2,7 +2,7 @@ import { Button } from '../ui/button'; import type { StepFooterRenderProps } from './onboarding-types'; export function StepFooter({ - isFirst, isLast, skippable, dirty, hasErrors, onBack, onSkip, onNext, onFinish, + isFirst, isLast, skippable, hasErrors, onBack, onSkip, onNext, onFinish, }: StepFooterRenderProps) { return (
Date: Wed, 10 Jun 2026 10:35:28 +0600 Subject: [PATCH 06/13] feat(onboarding): add step body reusing SettingsContent --- src/components/onboarding/step-body.tsx | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/components/onboarding/step-body.tsx diff --git a/src/components/onboarding/step-body.tsx b/src/components/onboarding/step-body.tsx new file mode 100644 index 0000000..3206b04 --- /dev/null +++ b/src/components/onboarding/step-body.tsx @@ -0,0 +1,6 @@ +import { SettingsContent } from '../settings/settings-content'; +import { cn } from '@/lib/utils'; + +export function StepBody({ className }: { className?: string }) { + return ; +} From d443495b65d52cd944512781cadb403c006c3216 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:38:09 +0600 Subject: [PATCH 07/13] feat(onboarding): add Onboarding root wiring provider + stepper chrome --- src/components/onboarding/index.tsx | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/components/onboarding/index.tsx diff --git a/src/components/onboarding/index.tsx b/src/components/onboarding/index.tsx new file mode 100644 index 0000000..425af09 --- /dev/null +++ b/src/components/onboarding/index.tsx @@ -0,0 +1,145 @@ +import { useMemo, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { SettingsProvider, useSettings } from '../settings/settings-context'; +import type { SettingsElement } from '../settings/settings-types'; +import { StepIndicator } from './step-indicator'; +import { StepFooter } from './step-footer'; +import { StepBody } from './step-body'; +import { isFirstStep, isLastStep, nextStepId, prevStepId } from './onboarding-navigation'; +import type { OnboardingProps } from './onboarding-types'; + +export function Onboarding({ + steps, + values, + activeStepId, + orientation = 'horizontal', + hookPrefix = 'plugin_ui', + applyFilters, + loading = false, + className, + onChange, + onStepSave, + onStepChange, + onSkip, + onComplete, + renderStepIndicator, + renderFooter, +}: OnboardingProps) { + const [internalActive, setInternalActive] = useState(activeStepId ?? steps[0]?.id ?? ''); + const active = activeStepId ?? internalActive; + + // Merge every step's page subtree into one schema; force hide_save on each + // page so SettingsContent suppresses its own save button (footer owns it). + const schema = useMemo(() => { + const out: SettingsElement[] = []; + for (const step of steps) { + for (const el of step.schema) { + if (el.type === 'page' && el.id === step.id) { + out.push({ ...el, hide_save: true }); + } else { + out.push(el); + } + } + } + return out; + }, [steps]); + + const goTo = (id: string) => { + if (activeStepId === undefined) setInternalActive(id); + onStepChange?.(id); + }; + + return ( + onChange(scopeId, key, value) : undefined} + onSave={onStepSave} + loading={loading} + hookPrefix={hookPrefix} + applyFilters={applyFilters} + initialPage={active} + > + + + ); +} + +function OnboardingInner({ + steps, active, orientation, className, goTo, onSkip, onComplete, renderStepIndicator, renderFooter, +}: { + steps: OnboardingProps['steps']; + active: string; + orientation: 'horizontal' | 'vertical'; + className?: string; + goTo: (id: string) => void; + onSkip?: OnboardingProps['onSkip']; + onComplete?: OnboardingProps['onComplete']; + renderStepIndicator?: OnboardingProps['renderStepIndicator']; + renderFooter?: OnboardingProps['renderFooter']; +}) { + const { setActivePage, getPageValues, isPageDirty, hasScopeErrors, save } = useSettings(); + + const activeStep = steps.find((s) => s.id === active) ?? steps[0]; + const isFirst = isFirstStep(steps, active); + const isLast = isLastStep(steps, active); + + const navigate = (id: string | null) => { + if (!id) return; + setActivePage(id); + goTo(id); + }; + + const persist = () => { + if (hasScopeErrors(active) || !save) return; + save(active, getPageValues(active)); // routes to onStepSave(stepId, tree, flat) + }; + + const indicatorProps = { + steps: steps.map((s, index) => ({ + id: s.id, label: s.label, completed: Boolean(s.completed), active: s.id === active, index, + })), + orientation, + onStepClick: (id: string) => navigate(id), + }; + + const footerProps = { + activeStepId: active, + isFirst, + isLast, + skippable: Boolean(activeStep?.skippable), + dirty: isPageDirty(active), + hasErrors: hasScopeErrors(active), + onBack: () => navigate(prevStepId(steps, active)), + onSkip: () => { onSkip?.(active); navigate(nextStepId(steps, active)); }, + onNext: () => { persist(); navigate(nextStepId(steps, active)); }, + onFinish: () => { persist(); onComplete?.(); }, + }; + + const horizontal = orientation === 'horizontal'; + + return ( +
+ {renderStepIndicator ? renderStepIndicator(indicatorProps) : } +
+ + {renderFooter ? renderFooter(footerProps) : } +
+
+ ); +} + +export type { OnboardingProps, OnboardingStep } from './onboarding-types'; From c22c34e4961e31d117feeb6fdfa319de339770a1 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:40:40 +0600 Subject: [PATCH 08/13] fix(onboarding): await step save before navigating/completing --- src/components/onboarding/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/onboarding/index.tsx b/src/components/onboarding/index.tsx index 425af09..c2a0917 100644 --- a/src/components/onboarding/index.tsx +++ b/src/components/onboarding/index.tsx @@ -100,9 +100,9 @@ function OnboardingInner({ goTo(id); }; - const persist = () => { + const persist = async () => { if (hasScopeErrors(active) || !save) return; - save(active, getPageValues(active)); // routes to onStepSave(stepId, tree, flat) + await save(active, getPageValues(active)); // routes to onStepSave(stepId, tree, flat) }; const indicatorProps = { @@ -122,8 +122,8 @@ function OnboardingInner({ hasErrors: hasScopeErrors(active), onBack: () => navigate(prevStepId(steps, active)), onSkip: () => { onSkip?.(active); navigate(nextStepId(steps, active)); }, - onNext: () => { persist(); navigate(nextStepId(steps, active)); }, - onFinish: () => { persist(); onComplete?.(); }, + onNext: async () => { await persist(); navigate(nextStepId(steps, active)); }, + onFinish: async () => { await persist(); onComplete?.(); }, }; const horizontal = orientation === 'horizontal'; From 7ccfe90fbd33561bae671d6fed36593936f69b7f Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:43:24 +0600 Subject: [PATCH 09/13] refactor(onboarding): honest async handler types, drop no-op onChange wrapper --- src/components/onboarding/index.tsx | 4 +++- src/components/onboarding/onboarding-types.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/onboarding/index.tsx b/src/components/onboarding/index.tsx index c2a0917..361c219 100644 --- a/src/components/onboarding/index.tsx +++ b/src/components/onboarding/index.tsx @@ -30,6 +30,8 @@ export function Onboarding({ // Merge every step's page subtree into one schema; force hide_save on each // page so SettingsContent suppresses its own save button (footer owns it). + // NOTE: callers should pass a stable `steps` reference (useMemo or a + // module-level constant) to avoid rebuilding the merged schema each render. const schema = useMemo(() => { const out: SettingsElement[] = []; for (const step of steps) { @@ -53,7 +55,7 @@ export function Onboarding({ onChange(scopeId, key, value) : undefined} + onChange={onChange} onSave={onStepSave} loading={loading} hookPrefix={hookPrefix} diff --git a/src/components/onboarding/onboarding-types.ts b/src/components/onboarding/onboarding-types.ts index 1a5f0b7..78db488 100644 --- a/src/components/onboarding/onboarding-types.ts +++ b/src/components/onboarding/onboarding-types.ts @@ -32,8 +32,8 @@ export interface StepFooterRenderProps { hasErrors: boolean; onBack: () => void; onSkip: () => void; - onNext: () => void; // saves current step, then advances - onFinish: () => void; // saves last step, then completes + onNext: () => void | Promise; // saves current step, then advances + onFinish: () => void | Promise; // saves last step, then completes } export interface OnboardingProps { From 81485a881a35035bbede13874cfecece382e7c0a Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:44:33 +0600 Subject: [PATCH 10/13] feat(onboarding): export Onboarding from package entry --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index c0e94a7..1ca0353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -386,6 +386,15 @@ export { } from './components/settings'; export { SettingsSkeleton } from './components/settings/settings-skeleton'; +// Onboarding (schema-driven setup wizard) +export { Onboarding } from './components/onboarding'; +export type { + OnboardingProps, + OnboardingStep, + StepIndicatorRenderProps, + StepFooterRenderProps, +} from './components/onboarding/onboarding-types'; + // ============================================ // Theme Presets // ============================================ From 33ae439924b12e012137528b7d6e1f04fb84a2fa Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:47:28 +0600 Subject: [PATCH 11/13] feat(onboarding): add Storybook stories and test script Adds three Onboarding stories (Horizontal, Vertical, CustomIndicator) following repo conventions (satisfies Meta, fn() from storybook/test, fullscreen layout, autodocs tag) and a `test` npm script for wp-scripts. --- package.json | 1 + .../onboarding/Onboarding.stories.tsx | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/components/onboarding/Onboarding.stories.tsx diff --git a/package.json b/package.json index de04e5a..872a8a9 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build-storybook": "storybook build", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc --noEmit", + "test": "wp-scripts test-unit-js", "prepare": "npm run build" }, "peerDependencies": { diff --git a/src/components/onboarding/Onboarding.stories.tsx b/src/components/onboarding/Onboarding.stories.tsx new file mode 100644 index 0000000..837799d --- /dev/null +++ b/src/components/onboarding/Onboarding.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; +import { Onboarding } from './index'; +import type { SettingsElement } from '../settings/settings-types'; + +const stepSchema = (pageId: string, fieldId: string, label: string): SettingsElement[] => [ + { id: pageId, type: 'page', label }, + { id: `${pageId}_section`, type: 'section', page_id: pageId, label }, + { + id: fieldId, type: 'field', variant: 'switch', section_id: `${pageId}_section`, + label: `${label} toggle`, default: 'on', + enable_state: { value: 'on', title: 'Enabled' }, disable_state: { value: 'off', title: 'Disabled' }, + } as SettingsElement, +]; + +const steps = [ + { id: 'basic', label: 'Basic', schema: stepSchema('basic', 'basic_toggle', 'Basic'), skippable: false, completed: true }, + { id: 'commission', label: 'Commission', schema: stepSchema('commission', 'commission_toggle', 'Commission'), skippable: true }, + { id: 'withdraw', label: 'Withdraw', schema: stepSchema('withdraw', 'withdraw_toggle', 'Withdraw'), skippable: true }, +]; + +const meta = { + title: 'Components/Onboarding', + component: Onboarding, + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], + args: { + onStepSave: fn(), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Horizontal: Story = { + args: { steps, orientation: 'horizontal' }, +}; + +export const Vertical: Story = { + args: { steps, orientation: 'vertical' }, +}; + +export const CustomIndicator: Story = { + args: { + steps, + orientation: 'horizontal', + renderStepIndicator: ({ steps: s, onStepClick }) => ( +
+ {s.map((step) => ( + + ))} +
+ ), + }, +}; From 94fe03e7e740b1aedcb0f9dc2899f55d87f89d06 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 10:51:45 +0600 Subject: [PATCH 12/13] docs(onboarding): document globally-unique field id requirement --- src/components/onboarding/onboarding-types.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/onboarding/onboarding-types.ts b/src/components/onboarding/onboarding-types.ts index 78db488..d583ecc 100644 --- a/src/components/onboarding/onboarding-types.ts +++ b/src/components/onboarding/onboarding-types.ts @@ -12,7 +12,15 @@ export interface OnboardingStep { label?: string; description?: string; icon?: string; - schema: SettingsElement[]; // page subtree: [{id, type:'page'}, sections…, fields…] + /** + * The step's settings elements as a page subtree: + * `[{ id, type: 'page' }, ...sections, ...fields]`. + * + * NOTE: all steps are merged into ONE flat provider schema, so field ids + * must be globally unique across the ENTIRE wizard (not just within a step). + * Duplicate field ids across steps collide silently in the shared values map. + */ + schema: SettingsElement[]; skippable?: boolean; completed?: boolean; } From 76aa151dc3f177fa0bd3248863acf334e57d0890 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 10 Jun 2026 11:19:31 +0600 Subject: [PATCH 13/13] feat(onboarding): add NoIndicator story demonstrating suppressed step indicator --- src/components/onboarding/Onboarding.stories.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/onboarding/Onboarding.stories.tsx b/src/components/onboarding/Onboarding.stories.tsx index 837799d..39c6a52 100644 --- a/src/components/onboarding/Onboarding.stories.tsx +++ b/src/components/onboarding/Onboarding.stories.tsx @@ -41,6 +41,14 @@ export const Vertical: Story = { args: { steps, orientation: 'vertical' }, }; +export const NoIndicator: Story = { + args: { + steps, + orientation: 'horizontal', + renderStepIndicator: () => null, + }, +}; + export const CustomIndicator: Story = { args: { steps,