Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/components/campaigns/steps/BasicInfoStep.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import {
selectDraftCampaign,
updateDraftCampaign,
updateDraft,
} from "../../../features/campaigns/campaignsSlice";

const CATEGORIES = [
Expand Down Expand Up @@ -61,7 +61,7 @@ const BasicInfoStep = ({ validationRef }) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
dispatch(
updateDraftCampaign({
updateDraft({
title: watched.title || "",
category: watched.category || "",
description: watched.shortDescription || "",
Expand All @@ -82,7 +82,7 @@ const BasicInfoStep = ({ validationRef }) => {
// ensure final save before proceeding
const values = await handleSubmit((vals) => vals)();
dispatch(
updateDraftCampaign({
updateDraft({
title: values.title,
category: values.category,
description: values.shortDescription,
Expand Down
4 changes: 2 additions & 2 deletions src/components/common/GlobalLoadingWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ const GlobalLoadingWrapper = ({ children }) => {

const isReduxLoading = useSelector((state) => {
if (onDashboard) {
return state.campaigns.loading || state.donations.loading;
return state.campaigns.isLoading || state.donations.loading;
}

return (
state.auth.isLoading ||
state.campaigns.loading ||
state.campaigns.isLoading ||
state.donations.loading ||
state.dashboard.isLoading
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/GlobalLoadingWrapper.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import GlobalLoadingWrapper from './GlobalLoadingWrapper';
const createStore = (overrides = {}) => configureStore({
reducer: {
auth: () => ({ isLoading: false, ...overrides.auth }),
campaigns: () => ({ loading: false, ...overrides.campaigns }),
campaigns: () => ({ isLoading: false, ...overrides.campaigns }),
donations: () => ({ loading: false, ...overrides.donations }),
dashboard: () => ({ isLoading: false, ...overrides.dashboard }),
},
Expand Down
1 change: 0 additions & 1 deletion src/components/dashboard/RecentCampaigns.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ArrowRight, PlusCircle, LayoutGrid } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { fetchRecentCampaigns } from '../../features/dashboard/dashboardThunks';
import {
import {
selectRecentCampaigns,
selectDashboardLoading,
} from '../../features/dashboard/dashboardSelectors';
Expand Down
5 changes: 3 additions & 2 deletions src/features/campaigns/campaignsSelectors.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const selectAllCampaigns = (state) => state.campaigns.items;
export const selectCampaignsLoading = (state) => state.campaigns.loading;
export const selectAllCampaigns = (state) => state.campaigns.campaigns;
export const selectCampaignsLoading = (state) => state.campaigns.isLoading;
export const selectCampaignsError = (state) => state.campaigns.error;
export const selectCurrentCampaign = (state) => state.campaigns.currentCampaign;
175 changes: 162 additions & 13 deletions src/features/campaigns/campaignsSlice.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,55 @@
import { createSlice } from '@reduxjs/toolkit';
import {
createCampaign,
updateCampaign,
deleteCampaign,
submitCampaign,
fetchMyCampaigns,
fetchCampaignById,
} from './campaignsThunks';

// Visual order of the multi-step "Create Campaign" form.
// Indices are 0-based; the public slice state uses 1-based `formStep`
// (1 == details, 2 == funding, 3 == media, 4 == review) to match the
// issue's spec and the user-facing "Step N" indicator.
export const CAMPAIGN_STEPS = ['details', 'funding', 'media', 'review'];

const emptyDraft = {
title: '',
category: '',
description: '',
fullStory: '',
goalAmount: '',
deadline: '',
coverImageUrl: '',
};

const initialState = {
items: [],
loading: false,
error: null,
// Multi-step "Create Campaign" form state
formStep: 0,
campaigns: [],
currentCampaign: null,
draftCampaign: { ...emptyDraft },
formStep: 1,
isLoading: false,
error: null,
};

const findCampaignIndex = (state, id) =>
state.campaigns.findIndex((c) => c?.id === id);

const replaceCampaign = (state, incoming) => {
if (!incoming || incoming.id == null) return;
const idx = findCampaignIndex(state, incoming.id);
if (idx !== -1) {
state.campaigns[idx] = incoming;
} else {
state.campaigns.push(incoming);
}
};

const setCurrentIfPresent = (state, incoming) => {
if (incoming?.id != null) {
state.currentCampaign = incoming;
}
};

const campaignsSlice = createSlice({
Expand All @@ -26,36 +58,153 @@ const campaignsSlice = createSlice({
reducers: {
setFormStep(state, action) {
const step = action.payload;
if (step >= 0 && step < CAMPAIGN_STEPS.length) {
if (typeof step === 'number' && step >= 1 && step <= CAMPAIGN_STEPS.length) {
state.formStep = step;
}
},
nextStep(state) {
if (state.formStep < CAMPAIGN_STEPS.length - 1) {
if (state.formStep < CAMPAIGN_STEPS.length) {
state.formStep += 1;
}
},
prevStep(state) {
if (state.formStep > 0) {
if (state.formStep > 1) {
state.formStep -= 1;
}
},
updateDraftCampaign(state, action) {
updateDraft(state, action) {
state.draftCampaign = { ...state.draftCampaign, ...action.payload };
},
resetCampaignForm(state) {
state.formStep = 0;
clearDraft(state) {
state.draftCampaign = { ...emptyDraft };
state.formStep = 1;
},
clearCurrentCampaign(state) {
state.currentCampaign = null;
},
clearCampaignsError(state) {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// createCampaign
.addCase(createCampaign.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(createCampaign.fulfilled, (state, action) => {
state.isLoading = false;
replaceCampaign(state, action.payload);
// The newly created campaign becomes "current" so the next
// submit/update/delete targets it without extra wiring.
setCurrentIfPresent(state, action.payload);
})
.addCase(createCampaign.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to create campaign';
})
// updateCampaign
.addCase(updateCampaign.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(updateCampaign.fulfilled, (state, action) => {
state.isLoading = false;
replaceCampaign(state, action.payload);
// Explicitly promote the updated campaign to "current" so
// viewers/editors land on it without extra wiring — same as
// createCampaign.fulfilled.
setCurrentIfPresent(state, action.payload);
})
.addCase(updateCampaign.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to update campaign';
})
// deleteCampaign
.addCase(deleteCampaign.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(deleteCampaign.fulfilled, (state, action) => {
state.isLoading = false;
const removedId = action.payload;
state.campaigns = state.campaigns.filter((c) => c?.id !== removedId);
if (state.currentCampaign?.id === removedId) {
state.currentCampaign = null;
}
})
.addCase(deleteCampaign.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to delete campaign';
})
// submitCampaign
.addCase(submitCampaign.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(submitCampaign.fulfilled, (state, action) => {
state.isLoading = false;
replaceCampaign(state, action.payload);
if (action.payload?.id != null) {
state.currentCampaign = action.payload;
}
// Submission succeeded — clear the draft so the wizard returns
// to a clean Step 1 for the next campaign.
state.draftCampaign = { ...emptyDraft };
state.formStep = 1;
})
.addCase(submitCampaign.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to submit campaign';
})
// fetchMyCampaigns
.addCase(fetchMyCampaigns.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchMyCampaigns.fulfilled, (state, action) => {
state.isLoading = false;
// The /campaigns/mine endpoint is contractually expected to
// return a flat array of campaigns. If we receive anything
// else (e.g. a wrapped object), surface it as an error so the
// UI doesn't silently render an empty list.
if (Array.isArray(action.payload)) {
state.campaigns = action.payload;
} else {
state.campaigns = [];
state.error = 'Unexpected response shape from /campaigns/mine';
}
})
.addCase(fetchMyCampaigns.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to load your campaigns';
})
// fetchCampaignById
.addCase(fetchCampaignById.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchCampaignById.fulfilled, (state, action) => {
state.isLoading = false;
replaceCampaign(state, action.payload);
setCurrentIfPresent(state, action.payload);
})
.addCase(fetchCampaignById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload || 'Failed to load campaign';
});
},
});

export const {
setFormStep,
nextStep,
prevStep,
updateDraftCampaign,
resetCampaignForm,
updateDraft,
clearDraft,
clearCurrentCampaign,
clearCampaignsError,
} = campaignsSlice.actions;

export const selectFormStep = (state) => state.campaigns.formStep;
Expand Down
Loading
Loading