From b0769df10014bd434cd08658dbe6e289b12c3a30 Mon Sep 17 00:00:00 2001 From: josephchimebuka Date: Mon, 22 Jun 2026 14:36:54 +0000 Subject: [PATCH] feat: multi-step campaign creation form (#86) Build the multi-step form container that manages step navigation. - Add src/pages/campaigns/CreateCampaignPage.jsx as the step container - Manage the current step via formStep in the campaigns Redux slice - Render the correct step component based on formStep (Details -> Funding -> Media -> Review) - Add Next and Back buttons plus a progress indicator for navigation - Persist form inputs in draftCampaign Redux state so data is retained when going back - Warn on unsaved changes via useBeforeUnload when leaving the page - Route /create (and /campaigns/create) to the new page, replacing the placeholder Co-authored-by: Cursor --- src/features/campaigns/campaignsSlice.js | 48 +++++++- src/pages/CreateCampaign.jsx | 10 -- src/pages/campaigns/CreateCampaignPage.jsx | 137 +++++++++++++++++++++ src/pages/campaigns/steps/DetailsStep.jsx | 66 ++++++++++ src/pages/campaigns/steps/FundingStep.jsx | 45 +++++++ src/pages/campaigns/steps/MediaStep.jsx | 45 +++++++ src/pages/campaigns/steps/ReviewStep.jsx | 35 ++++++ src/routes/AppRouter.jsx | 11 +- 8 files changed, 383 insertions(+), 14 deletions(-) delete mode 100644 src/pages/CreateCampaign.jsx create mode 100644 src/pages/campaigns/CreateCampaignPage.jsx create mode 100644 src/pages/campaigns/steps/DetailsStep.jsx create mode 100644 src/pages/campaigns/steps/FundingStep.jsx create mode 100644 src/pages/campaigns/steps/MediaStep.jsx create mode 100644 src/pages/campaigns/steps/ReviewStep.jsx diff --git a/src/features/campaigns/campaignsSlice.js b/src/features/campaigns/campaignsSlice.js index 2929833..9bec630 100644 --- a/src/features/campaigns/campaignsSlice.js +++ b/src/features/campaigns/campaignsSlice.js @@ -1,18 +1,64 @@ import { createSlice } from '@reduxjs/toolkit'; +export const CAMPAIGN_STEPS = ['details', 'funding', 'media', 'review']; + +const emptyDraft = { + title: '', + category: '', + description: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', +}; + const initialState = { items: [], loading: false, error: null, + // Multi-step "Create Campaign" form state + formStep: 0, + draftCampaign: { ...emptyDraft }, }; const campaignsSlice = createSlice({ name: 'campaigns', initialState, reducers: { - // Reducer placeholders + setFormStep(state, action) { + const step = action.payload; + if (step >= 0 && step < CAMPAIGN_STEPS.length) { + state.formStep = step; + } + }, + nextStep(state) { + if (state.formStep < CAMPAIGN_STEPS.length - 1) { + state.formStep += 1; + } + }, + prevStep(state) { + if (state.formStep > 0) { + state.formStep -= 1; + } + }, + updateDraftCampaign(state, action) { + state.draftCampaign = { ...state.draftCampaign, ...action.payload }; + }, + resetCampaignForm(state) { + state.formStep = 0; + state.draftCampaign = { ...emptyDraft }; + }, }, }); +export const { + setFormStep, + nextStep, + prevStep, + updateDraftCampaign, + resetCampaignForm, +} = campaignsSlice.actions; + +export const selectFormStep = (state) => state.campaigns.formStep; +export const selectDraftCampaign = (state) => state.campaigns.draftCampaign; export default campaignsSlice.reducer; diff --git a/src/pages/CreateCampaign.jsx b/src/pages/CreateCampaign.jsx deleted file mode 100644 index 5f26b00..0000000 --- a/src/pages/CreateCampaign.jsx +++ /dev/null @@ -1,10 +0,0 @@ -const CreateCampaign = () => { - return ( -
-

Create Campaign

-

Start a new social impact project.

-
- ); -}; - -export default CreateCampaign; diff --git a/src/pages/campaigns/CreateCampaignPage.jsx b/src/pages/campaigns/CreateCampaignPage.jsx new file mode 100644 index 0000000..5eac9ec --- /dev/null +++ b/src/pages/campaigns/CreateCampaignPage.jsx @@ -0,0 +1,137 @@ +import { useCallback, useMemo } from 'react'; +import { useBeforeUnload } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { + CAMPAIGN_STEPS, + nextStep, + prevStep, + resetCampaignForm, + selectDraftCampaign, + selectFormStep, +} from '../../features/campaigns/campaignsSlice'; +import DetailsStep from './steps/DetailsStep'; +import FundingStep from './steps/FundingStep'; +import MediaStep from './steps/MediaStep'; +import ReviewStep from './steps/ReviewStep'; + +const STEP_META = [ + { id: 'details', title: 'Campaign Details', Component: DetailsStep }, + { id: 'funding', title: 'Funding Goal', Component: FundingStep }, + { id: 'media', title: 'Media', Component: MediaStep }, + { id: 'review', title: 'Review & Submit', Component: ReviewStep }, +]; + +const hasUnsavedData = (draft) => + Object.values(draft).some((value) => String(value).trim() !== ''); + +const CreateCampaignPage = () => { + const dispatch = useDispatch(); + const formStep = useSelector(selectFormStep); + const draft = useSelector(selectDraftCampaign); + + const isDirty = useMemo(() => hasUnsavedData(draft), [draft]); + + // Warn the user before leaving/refreshing the tab when there are unsaved changes. + useBeforeUnload( + useCallback( + (event) => { + if (isDirty) { + event.preventDefault(); + event.returnValue = ''; + } + }, + [isDirty] + ) + ); + + const isFirstStep = formStep === 0; + const isLastStep = formStep === CAMPAIGN_STEPS.length - 1; + const { title, Component } = STEP_META[formStep]; + + const handleNext = () => dispatch(nextStep()); + const handleBack = () => dispatch(prevStep()); + + const handleSubmit = () => { + // Placeholder submit; integrates with campaign service in a later issue. + console.log('Submitting campaign draft:', draft); + dispatch(resetCampaignForm()); + }; + + return ( +
+
+

Create Campaign

+

Start a new social impact project.

+
+ + {/* Step progress indicator */} +
    + {STEP_META.map((step, index) => { + const isComplete = index < formStep; + const isCurrent = index === formStep; + return ( +
  1. + + {isComplete ? '✓' : index + 1} + + {index < STEP_META.length - 1 && ( + + )} +
  2. + ); + })} +
+ +
+

+ Step {formStep + 1}: {title} +

+ +
+ +
+ + + {isLastStep ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default CreateCampaignPage; diff --git a/src/pages/campaigns/steps/DetailsStep.jsx b/src/pages/campaigns/steps/DetailsStep.jsx new file mode 100644 index 0000000..75ad09e --- /dev/null +++ b/src/pages/campaigns/steps/DetailsStep.jsx @@ -0,0 +1,66 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { selectDraftCampaign, updateDraftCampaign } from '../../../features/campaigns/campaignsSlice'; + +const CATEGORIES = ['Education', 'Health', 'Environment', 'Disaster Relief', 'Community']; + +const DetailsStep = () => { + const dispatch = useDispatch(); + const draft = useSelector(selectDraftCampaign); + + const handleChange = (field) => (event) => { + dispatch(updateDraftCampaign({ [field]: event.target.value })); + }; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ +