diff --git a/src/components/campaigns/steps/BasicInfoStep.jsx b/src/components/campaigns/steps/BasicInfoStep.jsx index a20d18b..de54ac5 100644 --- a/src/components/campaigns/steps/BasicInfoStep.jsx +++ b/src/components/campaigns/steps/BasicInfoStep.jsx @@ -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 = [ @@ -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 || "", @@ -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, diff --git a/src/components/common/GlobalLoadingWrapper.jsx b/src/components/common/GlobalLoadingWrapper.jsx index ef3bcf8..240d850 100644 --- a/src/components/common/GlobalLoadingWrapper.jsx +++ b/src/components/common/GlobalLoadingWrapper.jsx @@ -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 ); diff --git a/src/components/common/GlobalLoadingWrapper.test.jsx b/src/components/common/GlobalLoadingWrapper.test.jsx index b3b258b..0496f7a 100644 --- a/src/components/common/GlobalLoadingWrapper.test.jsx +++ b/src/components/common/GlobalLoadingWrapper.test.jsx @@ -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 }), }, diff --git a/src/components/dashboard/RecentCampaigns.jsx b/src/components/dashboard/RecentCampaigns.jsx index 9f6dcab..ae85f2b 100644 --- a/src/components/dashboard/RecentCampaigns.jsx +++ b/src/components/dashboard/RecentCampaigns.jsx @@ -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'; diff --git a/src/features/campaigns/campaignsSelectors.js b/src/features/campaigns/campaignsSelectors.js index 3c7c9d9..3532983 100644 --- a/src/features/campaigns/campaignsSelectors.js +++ b/src/features/campaigns/campaignsSelectors.js @@ -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; diff --git a/src/features/campaigns/campaignsSlice.js b/src/features/campaigns/campaignsSlice.js index 9bec630..59d896e 100644 --- a/src/features/campaigns/campaignsSlice.js +++ b/src/features/campaigns/campaignsSlice.js @@ -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({ @@ -26,27 +58,142 @@ 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'; + }); }, }); @@ -54,8 +201,10 @@ export const { setFormStep, nextStep, prevStep, - updateDraftCampaign, - resetCampaignForm, + updateDraft, + clearDraft, + clearCurrentCampaign, + clearCampaignsError, } = campaignsSlice.actions; export const selectFormStep = (state) => state.campaigns.formStep; diff --git a/src/features/campaigns/campaignsSlice.test.js b/src/features/campaigns/campaignsSlice.test.js new file mode 100644 index 0000000..fb96adc --- /dev/null +++ b/src/features/campaigns/campaignsSlice.test.js @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; + +vi.mock('../../services/api', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('../../utils/toast', () => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), + toastInfo: vi.fn(), + toastLoading: vi.fn(), +})); + +import api from '../../services/api'; +import campaignsReducer, { + setFormStep, + nextStep, + prevStep, + updateDraft, + clearDraft, + clearCurrentCampaign, + clearCampaignsError, + selectFormStep, + selectDraftCampaign, + CAMPAIGN_STEPS, +} from './campaignsSlice'; +import { + createCampaign, + updateCampaign, + deleteCampaign, + submitCampaign, + fetchMyCampaigns, + fetchCampaignById, +} from './campaignsThunks'; +import { toastSuccess, toastError } from '../../utils/toast'; + +// Real store so thunks are dispatched through middleware and execute their +// payload creators (awaiting the action creator directly does not run them). +const makeStore = () => configureStore({ reducer: { campaigns: campaignsReducer } }); + +describe('campaignsSlice', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('matches the Issue #85 spec shape', () => { + const state = campaignsReducer(undefined, { type: '@@INIT' }); + expect(state).toEqual({ + campaigns: [], + currentCampaign: null, + draftCampaign: { + title: '', + category: '', + description: '', + fullStory: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', + }, + formStep: 1, + isLoading: false, + error: null, + }); + }); + + it('exposes CAMPAIGN_STEPS in the expected visual order', () => { + expect(CAMPAIGN_STEPS).toEqual(['details', 'funding', 'media', 'review']); + }); + }); + + describe('synchronous reducers', () => { + const seeded = { + campaigns: [], + currentCampaign: null, + draftCampaign: { + title: 'Old', + category: 'Old', + description: '', + fullStory: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', + }, + formStep: 2, + isLoading: false, + error: null, + }; + + it('setFormStep updates to a valid 1-indexed step', () => { + const result = campaignsReducer(seeded, setFormStep(3)); + expect(result.formStep).toBe(3); + }); + + it('setFormStep ignores values below 1 or above the max step', () => { + expect(campaignsReducer(seeded, setFormStep(0)).formStep).toBe(2); + expect(campaignsReducer(seeded, setFormStep(-1)).formStep).toBe(2); + expect( + campaignsReducer(seeded, setFormStep(CAMPAIGN_STEPS.length + 1)).formStep + ).toBe(2); + expect( + campaignsReducer(seeded, setFormStep('not-a-number')).formStep + ).toBe(2); + }); + + it('nextStep increments up to the last step and then stops', () => { + let state = campaignsReducer(seeded, nextStep()); + expect(state.formStep).toBe(3); + + state = campaignsReducer(state, nextStep()); + expect(state.formStep).toBe(CAMPAIGN_STEPS.length); + + state = campaignsReducer(state, nextStep()); + expect(state.formStep).toBe(CAMPAIGN_STEPS.length); + }); + + it('prevStep decrements down to 1 and then stops', () => { + let state = campaignsReducer({ ...seeded, formStep: CAMPAIGN_STEPS.length }, prevStep()); + expect(state.formStep).toBe(CAMPAIGN_STEPS.length - 1); + + while (state.formStep > 1) state = campaignsReducer(state, prevStep()); + expect(state.formStep).toBe(1); + + state = campaignsReducer(state, prevStep()); + expect(state.formStep).toBe(1); + }); + + it('updateDraft merges payload into draftCampaign without losing other fields', () => { + const result = campaignsReducer( + seeded, + updateDraft({ title: 'New Title', goalAmount: '5000' }) + ); + expect(result.draftCampaign.title).toBe('New Title'); + expect(result.draftCampaign.goalAmount).toBe('5000'); + // Untouched fields are preserved + expect(result.draftCampaign.category).toBe('Old'); + expect(result.draftCampaign.coverImageUrl).toBe(''); + }); + + it('clearDraft resets draft to empty and formStep to 1', () => { + const dirty = { + ...seeded, + draftCampaign: { + title: 'a', + category: 'b', + description: 'c', + fullStory: 'd', + goalAmount: 'e', + deadline: 'f', + coverImageUrl: 'g', + }, + formStep: 4, + }; + const result = campaignsReducer(dirty, clearDraft()); + expect(result.draftCampaign).toEqual({ + title: '', + category: '', + description: '', + fullStory: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', + }); + expect(result.formStep).toBe(1); + }); + + it('clearCurrentCampaign nulls out currentCampaign', () => { + const result = campaignsReducer( + { ...seeded, currentCampaign: { id: 1, title: 'x' } }, + clearCurrentCampaign() + ); + expect(result.currentCampaign).toBeNull(); + }); + + it('clearCampaignsError nulls out error', () => { + const result = campaignsReducer({ ...seeded, error: 'boom' }, clearCampaignsError()); + expect(result.error).toBeNull(); + }); + }); + + describe('inline selectors', () => { + it('selects formStep and draftCampaign from state', () => { + const fakeState = { + campaigns: { + formStep: 2, + draftCampaign: { title: 'Draft Title' }, + }, + }; + expect(selectFormStep(fakeState)).toBe(2); + expect(selectDraftCampaign(fakeState).title).toBe('Draft Title'); + }); + }); + + describe('extraReducers (reducer transitions via dispatched actions)', () => { + // We dispatch the thunks through a real store and assert on the + // resulting fulfilled/rejected/pending outcomes and state. + + it('createCampaign: pending → loading=true/error cleared; fulfilled → campaign stored', async () => { + api.post.mockResolvedValueOnce({ data: { id: 7, title: 'New' } }); + const store = makeStore(); + await store.dispatch(createCampaign({ title: 'New' })); + + const state = store.getState().campaigns; + expect(state.isLoading).toBe(false); + expect(state.campaigns).toContainEqual({ id: 7, title: 'New' }); + expect(state.currentCampaign).toEqual({ id: 7, title: 'New' }); + expect(api.post).toHaveBeenCalledWith('/campaigns', { title: 'New' }); + expect(toastSuccess).toHaveBeenCalledWith('Campaign created'); + }); + + it('createCampaign: rejected → error recorded and toast fired', async () => { + api.post.mockRejectedValueOnce({ + response: { data: { message: 'Bad payload' } }, + }); + const store = makeStore(); + const action = await store.dispatch(createCampaign({})); + + expect(action.meta.requestStatus).toBe('rejected'); + const state = store.getState().campaigns; + expect(state.isLoading).toBe(false); + expect(state.error).toEqual({ message: 'Bad payload' }); + expect(toastError).toHaveBeenCalled(); + }); + + it('updateCampaign: fulfilled replaces the matching campaign in the list', async () => { + api.post.mockResolvedValueOnce({ data: { id: 2, title: 'Old' } }); + const store = makeStore(); + await store.dispatch(createCampaign({ title: 'Old' })); + + api.put.mockResolvedValueOnce({ data: { id: 2, title: 'Updated' } }); + await store.dispatch(updateCampaign({ id: 2, title: 'Updated' })); + + const state = store.getState().campaigns; + expect(state.campaigns).toContainEqual({ id: 2, title: 'Updated' }); + expect(state.currentCampaign).toEqual({ id: 2, title: 'Updated' }); + expect(api.put).toHaveBeenCalledWith('/campaigns/2', { title: 'Updated' }); + }); + + it('updateCampaign: rejected when id is missing sets a fallback error', async () => { + const store = makeStore(); + const action = await store.dispatch(updateCampaign({ title: 'oops' })); + + expect(action.meta.requestStatus).toBe('rejected'); + expect(action.payload).toMatch(/id is required/); + }); + + it('deleteCampaign: fulfilled removes the campaign and clears current when matched', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1, title: 'A' } }); + const store = makeStore(); + await store.dispatch(createCampaign({ title: 'A' })); + expect(store.getState().campaigns.campaigns).toHaveLength(1); + + api.delete.mockResolvedValueOnce({}); + await store.dispatch(deleteCampaign(1)); + + const state = store.getState().campaigns; + expect(state.campaigns).toEqual([]); + expect(state.currentCampaign).toBeNull(); + expect(toastSuccess).toHaveBeenCalledWith('Campaign deleted'); + }); + + it('submitCampaign: fulfilled clears draft and resets formStep; creates then submits when given a draft', async () => { + api.post + .mockResolvedValueOnce({ data: { id: 50 } }) + .mockResolvedValueOnce({ data: { id: 50, status: 'submitted' } }); + + const store = makeStore(); + const action = await store.dispatch(submitCampaign({ title: 'Hey', category: 'Education' })); + + expect(api.post).toHaveBeenNthCalledWith( + 1, + '/campaigns', + { title: 'Hey', category: 'Education' }, + ); + expect(api.post).toHaveBeenNthCalledWith(2, '/campaigns/50/submit'); + expect(action.payload).toEqual({ id: 50, status: 'submitted' }); + + const state = store.getState().campaigns; + expect(state.draftCampaign).toEqual({ + title: '', + category: '', + description: '', + fullStory: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', + }); + expect(state.formStep).toBe(1); + expect(state.campaigns).toContainEqual({ id: 50, status: 'submitted' }); + }); + + it('submitCampaign: rejected surfaces the error message', async () => { + api.post.mockRejectedValueOnce({ + response: { data: { message: 'Cannot submit' } }, + }); + const store = makeStore(); + const action = await store.dispatch(submitCampaign(7)); + + expect(action.meta.requestStatus).toBe('rejected'); + expect(store.getState().campaigns.error).toEqual({ message: 'Cannot submit' }); + expect(toastError).toHaveBeenCalled(); + }); + + it('fetchMyCampaigns: fulfilled replaces the campaigns list', async () => { + api.get.mockResolvedValueOnce({ data: [{ id: 10 }, { id: 20 }] }); + const store = makeStore(); + await store.dispatch(fetchMyCampaigns()); + + const state = store.getState().campaigns; + expect(state.campaigns).toEqual([{ id: 10 }, { id: 20 }]); + expect(state.isLoading).toBe(false); + }); + + it('fetchMyCampaigns: rejected surfaces the error and does not mutate campaigns', async () => { + api.get.mockRejectedValueOnce({ + response: { data: { message: 'Server down' } }, + }); + const store = makeStore(); + const action = await store.dispatch(fetchMyCampaigns()); + + expect(action.meta.requestStatus).toBe('rejected'); + const state = store.getState().campaigns; + expect(state.campaigns).toEqual([]); + expect(state.error).toEqual({ message: 'Server down' }); + expect(toastError).toHaveBeenCalled(); + }); + + it('fetchCampaignById: fulfilled sets currentCampaign and merges into the list', async () => { + api.get.mockResolvedValueOnce({ data: { id: 5, title: 'Fetched' } }); + const store = makeStore(); + await store.dispatch(fetchCampaignById(5)); + + const state = store.getState().campaigns; + expect(state.currentCampaign).toEqual({ id: 5, title: 'Fetched' }); + expect(state.campaigns).toContainEqual({ id: 5, title: 'Fetched' }); + }); + + it('fetchCampaignById: rejected when id is missing sets a fallback error', async () => { + const store = makeStore(); + const action = await store.dispatch(fetchCampaignById()); + + expect(action.meta.requestStatus).toBe('rejected'); + expect(action.payload).toMatch(/id is required/); + }); + + it('fetchCampaignById: rejected with server error surfaces the message', async () => { + api.get.mockRejectedValueOnce({ + response: { data: { message: 'Not found' } }, + }); + const store = makeStore(); + await store.dispatch(fetchCampaignById(99)); + + expect(store.getState().campaigns.error).toEqual({ message: 'Not found' }); + expect(toastError).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/campaigns/campaignsThunks.js b/src/features/campaigns/campaignsThunks.js index 281c8ca..df74c81 100644 --- a/src/features/campaigns/campaignsThunks.js +++ b/src/features/campaigns/campaignsThunks.js @@ -1,14 +1,107 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; +import api from '../../services/api'; import { toastSuccess, toastError } from '../../utils/toast'; -export const fetchCampaigns = createAsyncThunk( - 'campaigns/fetchAll', +export const createCampaign = createAsyncThunk( + 'campaigns/create', + async (campaignData, { rejectWithValue }) => { + try { + const response = await api.post('/campaigns', campaignData); + toastSuccess('Campaign created'); + return response.data; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to create campaign'); + } + } +); + +export const updateCampaign = createAsyncThunk( + 'campaigns/update', + async ({ id, ...updates }, { rejectWithValue }) => { + if (!id) { + return rejectWithValue('Campaign id is required to update'); + } + try { + const response = await api.put(`/campaigns/${id}`, updates); + toastSuccess('Campaign updated'); + return response.data; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to update campaign'); + } + } +); + +export const deleteCampaign = createAsyncThunk( + 'campaigns/delete', + async (id, { rejectWithValue }) => { + if (!id) { + return rejectWithValue('Campaign id is required to delete'); + } + try { + await api.delete(`/campaigns/${id}`); + toastSuccess('Campaign deleted'); + return id; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to delete campaign'); + } + } +); + +export const submitCampaign = createAsyncThunk( + 'campaigns/submit', + async (campaignInput, { rejectWithValue }) => { + try { + // If passed a full draft object (no id), create then submit in one shot. + if ( + campaignInput && + typeof campaignInput === 'object' && + campaignInput.id == null + ) { + const created = await api.post('/campaigns', campaignInput); + const response = await api.post( + `/campaigns/${created.data.id}/submit`, + ); + toastSuccess('Campaign submitted'); + return response.data; + } + const response = await api.post(`/campaigns/${campaignInput}/submit`); + toastSuccess('Campaign submitted'); + return response.data; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to submit campaign'); + } + } +); + +export const fetchMyCampaigns = createAsyncThunk( + 'campaigns/fetchMyCampaigns', async (_, { rejectWithValue }) => { try { - toastSuccess('Campaigns loaded'); - } catch (error) { - toastError(error); - return rejectWithValue(error.message); + const response = await api.get('/campaigns/mine'); + return response.data; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to load your campaigns'); + } + } +); + +export const fetchCampaignById = createAsyncThunk( + 'campaigns/fetchById', + async (id, { rejectWithValue }) => { + if (!id) { + return rejectWithValue('Campaign id is required'); + } + try { + const response = await api.get(`/campaigns/${id}`); + return response.data; + } catch (err) { + toastError(err); + return rejectWithValue(err.response?.data ?? err.message ?? 'Failed to load campaign'); } } ); diff --git a/src/pages/campaigns/CreateCampaignPage.jsx b/src/pages/campaigns/CreateCampaignPage.jsx index 056b683..7098059 100644 --- a/src/pages/campaigns/CreateCampaignPage.jsx +++ b/src/pages/campaigns/CreateCampaignPage.jsx @@ -5,10 +5,11 @@ import { CAMPAIGN_STEPS, nextStep, prevStep, - resetCampaignForm, + clearDraft, selectDraftCampaign, selectFormStep, } from "../../features/campaigns/campaignsSlice"; +import { submitCampaign } from "../../features/campaigns/campaignsThunks"; import BasicInfoStep from "../../components/campaigns/steps/BasicInfoStep"; import FundingStep from "./steps/FundingStep"; import MediaStep from "./steps/MediaStep"; @@ -44,9 +45,10 @@ const CreateCampaignPage = () => { ), ); - const isFirstStep = formStep === 0; - const isLastStep = formStep === CAMPAIGN_STEPS.length - 1; - const { title, Component } = STEP_META[formStep]; + // formStep is 1-indexed: 1 == first step, CAMPAIGN_STEPS.length == last. + const isFirstStep = formStep === 1; + const isLastStep = formStep === CAMPAIGN_STEPS.length; + const { title, Component } = STEP_META[formStep - 1]; const validationRef = useRef(null); @@ -65,9 +67,13 @@ const CreateCampaignPage = () => { const handleBack = () => dispatch(prevStep()); const handleSubmit = () => { - // Placeholder submit; integrates with campaign service in a later issue. - console.log("Submitting campaign draft:", draft); - dispatch(resetCampaignForm()); + // Submit the draft for review. The slice clears draftCampaign and + // resets formStep on fulfillment (see submitCampaign.extraReducers). + dispatch(submitCampaign(draft)); + }; + + const handleCancel = () => { + dispatch(clearDraft()); }; return ( @@ -80,8 +86,9 @@ const CreateCampaignPage = () => { {/* Step progress indicator */}