diff --git a/src/components/dashboard/CampaignStatsWidget.jsx b/src/components/dashboard/CampaignStatsWidget.jsx
index 0d5a969..c1f62e8 100644
--- a/src/components/dashboard/CampaignStatsWidget.jsx
+++ b/src/components/dashboard/CampaignStatsWidget.jsx
@@ -27,8 +27,10 @@ const AnimatedCounter = ({ value, duration = 1000 }) => {
: value;
if (isNaN(target)) {
- setCount(value);
- return;
+ // Non-numeric values are shown as-is. Defer the state update to the next
+ // frame so we don't call setState synchronously inside the effect body.
+ const rafId = window.requestAnimationFrame(() => setCount(value));
+ return () => window.cancelAnimationFrame(rafId);
}
let startTimestamp = null;
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 (
+ -
+
+ {isComplete ? '✓' : index + 1}
+
+ {index < STEP_META.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DetailsStep;
diff --git a/src/pages/campaigns/steps/FundingStep.jsx b/src/pages/campaigns/steps/FundingStep.jsx
new file mode 100644
index 0000000..d6af83c
--- /dev/null
+++ b/src/pages/campaigns/steps/FundingStep.jsx
@@ -0,0 +1,45 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { selectDraftCampaign, updateDraftCampaign } from '../../../features/campaigns/campaignsSlice';
+
+const FundingStep = () => {
+ const dispatch = useDispatch();
+ const draft = useSelector(selectDraftCampaign);
+
+ const handleChange = (field) => (event) => {
+ dispatch(updateDraftCampaign({ [field]: event.target.value }));
+ };
+
+ return (
+
+ );
+};
+
+export default FundingStep;
diff --git a/src/pages/campaigns/steps/MediaStep.jsx b/src/pages/campaigns/steps/MediaStep.jsx
new file mode 100644
index 0000000..41cc4aa
--- /dev/null
+++ b/src/pages/campaigns/steps/MediaStep.jsx
@@ -0,0 +1,45 @@
+import { useDispatch, useSelector } from 'react-redux';
+import { selectDraftCampaign, updateDraftCampaign } from '../../../features/campaigns/campaignsSlice';
+
+const MediaStep = () => {
+ const dispatch = useDispatch();
+ const draft = useSelector(selectDraftCampaign);
+
+ const handleChange = (field) => (event) => {
+ dispatch(updateDraftCampaign({ [field]: event.target.value }));
+ };
+
+ return (
+
+
+
+
+
+
+ {draft.coverImageUrl ? (
+
+

+
+ ) : (
+
+ Image preview will appear here
+
+ )}
+
+ );
+};
+
+export default MediaStep;
diff --git a/src/pages/campaigns/steps/ReviewStep.jsx b/src/pages/campaigns/steps/ReviewStep.jsx
new file mode 100644
index 0000000..c4e16ea
--- /dev/null
+++ b/src/pages/campaigns/steps/ReviewStep.jsx
@@ -0,0 +1,35 @@
+import { useSelector } from 'react-redux';
+import { selectDraftCampaign } from '../../../features/campaigns/campaignsSlice';
+
+const FIELDS = [
+ { key: 'title', label: 'Title' },
+ { key: 'category', label: 'Category' },
+ { key: 'description', label: 'Description' },
+ { key: 'goalAmount', label: 'Funding Goal (XLM)' },
+ { key: 'deadline', label: 'Deadline' },
+ { key: 'coverImageUrl', label: 'Cover Image URL' },
+];
+
+const ReviewStep = () => {
+ const draft = useSelector(selectDraftCampaign);
+
+ return (
+
+
+ Review your campaign details before submitting.
+
+
+ {FIELDS.map((field) => (
+
+
- {field.label}
+ -
+ {draft[field.key] || Not provided}
+
+
+ ))}
+
+
+ );
+};
+
+export default ReviewStep;
diff --git a/src/routes/AppRouter.jsx b/src/routes/AppRouter.jsx
index d26d6e3..8a74778 100644
--- a/src/routes/AppRouter.jsx
+++ b/src/routes/AppRouter.jsx
@@ -20,7 +20,7 @@ import VerifyEmailPage from '../pages/auth/VerifyEmailPage';
const Home = lazy(() => import('../pages/Home'));
const Explore = lazy(() => import('../pages/Explore'));
const CampaignDetails = lazy(() => import('../pages/CampaignDetails'));
-const CreateCampaign = lazy(() => import('../pages/CreateCampaign'));
+const CreateCampaignPage = lazy(() => import('../pages/campaigns/CreateCampaignPage'));
const Admin = lazy(() => import('../pages/Admin'));
// Dashboard sub-pages
@@ -50,7 +50,12 @@ const AppRouter = () => {
} />
-
+
+
+ } />
+
+
} />
} />
@@ -92,4 +97,4 @@ const AppRouter = () => {
);
};
-export default AppRouter;
\ No newline at end of file
+export default AppRouter;