Skip to content
Closed
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
48 changes: 47 additions & 1 deletion src/features/campaigns/campaignsSlice.js
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 0 additions & 10 deletions src/pages/CreateCampaign.jsx

This file was deleted.

137 changes: 137 additions & 0 deletions src/pages/campaigns/CreateCampaignPage.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Create Campaign</h1>
<p className="text-slate-500">Start a new social impact project.</p>
</div>

{/* Step progress indicator */}
<ol className="flex items-center gap-2">
{STEP_META.map((step, index) => {
const isComplete = index < formStep;
const isCurrent = index === formStep;
return (
<li key={step.id} className="flex flex-1 items-center gap-2">
<span
className={[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold',
isCurrent
? 'bg-accent text-white'
: isComplete
? 'bg-green-500 text-white'
: 'bg-slate-200 text-slate-500',
].join(' ')}
>
{isComplete ? '✓' : index + 1}
</span>
{index < STEP_META.length - 1 && (
<span
className={`hidden h-0.5 flex-1 rounded sm:block ${
isComplete ? 'bg-green-500' : 'bg-slate-200'
}`}
/>
)}
</li>
);
})}
</ol>

<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-primary">
Step {formStep + 1}: {title}
</h2>
<Component />
</div>

<div className="flex items-center justify-between">
<button
type="button"
onClick={handleBack}
disabled={isFirstStep}
className="rounded-lg border border-slate-300 px-5 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
Back
</button>

{isLastStep ? (
<button
type="button"
onClick={handleSubmit}
className="rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
Submit Campaign
</button>
) : (
<button
type="button"
onClick={handleNext}
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white transition-colors hover:opacity-90"
>
Next
</button>
)}
</div>
</div>
);
};

export default CreateCampaignPage;
66 changes: 66 additions & 0 deletions src/pages/campaigns/steps/DetailsStep.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium text-slate-700">
Campaign Title
</label>
<input
id="title"
type="text"
value={draft.title}
onChange={handleChange('title')}
placeholder="e.g. Clean Water Initiative"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
</div>

<div>
<label htmlFor="category" className="mb-1 block text-sm font-medium text-slate-700">
Category
</label>
<select
id="category"
value={draft.category}
onChange={handleChange('category')}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
>
<option value="">Select a category</option>
{CATEGORIES.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>

<div>
<label htmlFor="description" className="mb-1 block text-sm font-medium text-slate-700">
Description
</label>
<textarea
id="description"
rows={5}
value={draft.description}
onChange={handleChange('description')}
placeholder="Tell backers about your campaign..."
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
</div>
</div>
);
};

export default DetailsStep;
45 changes: 45 additions & 0 deletions src/pages/campaigns/steps/FundingStep.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="goalAmount" className="mb-1 block text-sm font-medium text-slate-700">
Funding Goal (XLM)
</label>
<input
id="goalAmount"
type="number"
min="0"
value={draft.goalAmount}
onChange={handleChange('goalAmount')}
placeholder="e.g. 10000"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
</div>

<div>
<label htmlFor="deadline" className="mb-1 block text-sm font-medium text-slate-700">
Campaign Deadline
</label>
<input
id="deadline"
type="date"
value={draft.deadline}
onChange={handleChange('deadline')}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
</div>
</div>
);
};

export default FundingStep;
45 changes: 45 additions & 0 deletions src/pages/campaigns/steps/MediaStep.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div>
<label htmlFor="coverImageUrl" className="mb-1 block text-sm font-medium text-slate-700">
Cover Image URL
</label>
<input
id="coverImageUrl"
type="url"
value={draft.coverImageUrl}
onChange={handleChange('coverImageUrl')}
placeholder="https://example.com/image.jpg"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
</div>

{draft.coverImageUrl ? (
<div className="overflow-hidden rounded-lg border border-slate-200">
<img
src={draft.coverImageUrl}
alt="Campaign cover preview"
className="h-48 w-full object-cover"
/>
</div>
) : (
<div className="flex h-48 items-center justify-center rounded-lg border border-dashed border-slate-300 text-sm text-slate-400">
Image preview will appear here
</div>
)}
</div>
);
};

export default MediaStep;
Loading
Loading