From 040f702f77e3915cd21d91befb187f0e4bc6514c Mon Sep 17 00:00:00 2001 From: josephchimebuka Date: Sun, 21 Jun 2026 09:03:46 +0000 Subject: [PATCH] feat: responsive dashboard layout and multi-step campaign form Closes #83: Make the dashboard fully responsive across screen sizes. - Sidebar navigation collapses to a fixed bottom nav bar on mobile (< 768px) - Stats widgets stack 1 column on mobile, 2 on tablet, 4 on desktop - Recent donations table is wrapped for horizontal scroll on small screens - Layout uses Tailwind breakpoints so there is no horizontal overflow Closes #86: Build the multi-step campaign creation form container. - Add src/pages/campaigns/CreateCampaignPage.jsx managing step navigation - Track current step via formStep and persist inputs in draftCampaign Redux state - Render the correct step component based on formStep with Next/Back buttons - Warn on unsaved changes via useBeforeUnload when leaving the page Co-authored-by: Cursor --- src/components/layout/MainLayout.jsx | 100 +++++++++++---- src/features/campaigns/campaignSlice.js | 57 +++++++++ src/pages/CreateCampaign.jsx | 10 -- src/pages/Dashboard.jsx | 80 +++++++++++- 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 | 5 +- src/store/index.js | 10 +- 11 files changed, 548 insertions(+), 42 deletions(-) create mode 100644 src/features/campaigns/campaignSlice.js 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/components/layout/MainLayout.jsx b/src/components/layout/MainLayout.jsx index 04a05d5..4515e66 100644 --- a/src/components/layout/MainLayout.jsx +++ b/src/components/layout/MainLayout.jsx @@ -1,28 +1,86 @@ -import { Outlet, Link } from 'react-router-dom'; +import { Outlet, NavLink } from 'react-router-dom'; + +const NAV_ITEMS = [ + { to: '/', label: 'Home', icon: '🏠', end: true }, + { to: '/explore', label: 'Explore', icon: '🔍' }, + { to: '/dashboard', label: 'Dashboard', icon: '📊' }, + { to: '/campaigns/create', label: 'Create', icon: '➕' }, + { to: '/admin', label: 'Admin', icon: '🛡️' }, + { to: '/login', label: 'Login', icon: '🔐' }, + { to: '/register', label: 'Register', icon: '📝' }, +]; + +const sidebarLinkClasses = ({ isActive }) => + [ + 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors', + isActive + ? 'bg-accent text-white' + : 'text-slate-300 hover:bg-white/10 hover:text-white', + ].join(' '); + +const bottomLinkClasses = ({ isActive }) => + [ + 'flex min-w-[64px] flex-col items-center justify-center gap-0.5 px-2 py-1 text-[11px] font-medium transition-colors', + isActive ? 'text-accent' : 'text-slate-500 hover:text-primary', + ].join(' '); const MainLayout = () => { return ( -
-
-
- -
- {/* Outlet renders the matched child route */} - -
- -
-

© {new Date().getFullYear()} StellarAid. All rights reserved.

-
+

+ © {new Date().getFullYear()} StellarAid +

+ + + {/* Main content area */} +
+ {/* Mobile top bar (< 768px) */} +
+ StellarAid +
+ + {/* Add bottom padding on mobile so content is not hidden behind bottom nav */} +
+ +
+ +
+

© {new Date().getFullYear()} StellarAid. All rights reserved.

+
+
+ + {/* Bottom navigation: visible only on mobile (< 768px) */} +
); }; diff --git a/src/features/campaigns/campaignSlice.js b/src/features/campaigns/campaignSlice.js new file mode 100644 index 0000000..bc20c1f --- /dev/null +++ b/src/features/campaigns/campaignSlice.js @@ -0,0 +1,57 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const CAMPAIGN_STEPS = ['details', 'funding', 'media', 'review']; + +const initialState = { + formStep: 0, + draftCampaign: { + title: '', + category: '', + description: '', + goalAmount: '', + deadline: '', + coverImageUrl: '', + }, +}; + +const campaignSlice = createSlice({ + name: 'campaigns', + initialState, + reducers: { + 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() { + return initialState; + }, + }, +}); + +export const { + setFormStep, + nextStep, + prevStep, + updateDraftCampaign, + resetCampaignForm, +} = campaignSlice.actions; + +export const selectFormStep = (state) => state.campaigns.formStep; +export const selectDraftCampaign = (state) => state.campaigns.draftCampaign; + +export default campaignSlice.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/Dashboard.jsx b/src/pages/Dashboard.jsx index 577059a..1a61a3e 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,8 +1,82 @@ +const STATS = [ + { label: 'Total Raised', value: '12,480 XLM', change: '+8.2%' }, + { label: 'Active Campaigns', value: '6', change: '+2' }, + { label: 'Total Backers', value: '1,204', change: '+114' }, + { label: 'Avg. Donation', value: '42 XLM', change: '+3.1%' }, +]; + +const RECENT_DONATIONS = [ + { id: 1, donor: 'GA3D...K9PL', campaign: 'Clean Water Initiative', amount: '250 XLM', date: '2026-06-20', status: 'Confirmed' }, + { id: 2, donor: 'GB7C...2XQW', campaign: 'School Rebuild Fund', amount: '120 XLM', date: '2026-06-19', status: 'Confirmed' }, + { id: 3, donor: 'GD1M...8RTY', campaign: 'Medical Supplies Drive', amount: '500 XLM', date: '2026-06-19', status: 'Pending' }, + { id: 4, donor: 'GC9F...4ZAA', campaign: 'Clean Water Initiative', amount: '75 XLM', date: '2026-06-18', status: 'Confirmed' }, + { id: 5, donor: 'GE2B...7HJK', campaign: 'Reforestation Project', amount: '300 XLM', date: '2026-06-17', status: 'Failed' }, +]; + +const statusStyles = { + Confirmed: 'bg-green-100 text-green-700', + Pending: 'bg-amber-100 text-amber-700', + Failed: 'bg-red-100 text-red-700', +}; + const Dashboard = () => { return ( -
-

Dashboard

-

Manage your account, donations, and projects.

+
+
+

Dashboard

+

Manage your account, donations, and projects.

+
+ + {/* Stats widgets: 1 column on mobile, 2 on tablet, 4 on desktop */} +
+ {STATS.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+

{stat.change}

+
+ ))} +
+ + {/* Recent donations table: horizontally scrollable on small screens */} +
+
+

Recent Donations

+
+
+ + + + + + + + + + + + {RECENT_DONATIONS.map((row) => ( + + + + + + + + ))} + +
DonorCampaignAmountDateStatus
{row.donor}{row.campaign}{row.amount}{row.date} + + {row.status} + +
+
+
); }; diff --git a/src/pages/campaigns/CreateCampaignPage.jsx b/src/pages/campaigns/CreateCampaignPage.jsx new file mode 100644 index 0000000..c0b6d20 --- /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/campaignSlice'; +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..8aad456 --- /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/campaignSlice'; + +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 ( +
+
+ + +
+ +
+ + +
+ +
+ +