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
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
Expand Down
202 changes: 202 additions & 0 deletions src/components/campaigns/steps/BasicInfoStep.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import {
selectDraftCampaign,
updateDraftCampaign,
} from "../../../features/campaigns/campaignsSlice";

const CATEGORIES = [
"Education",
"Health",
"Environment",
"Disaster Relief",
"Community",
];

const schema = yup.object({
title: yup
.string()
.required("Title is required")
.max(100, "Must be 100 characters or less"),
category: yup.string().required("Category is required"),
shortDescription: yup
.string()
.required("Short description is required")
.max(200, "Must be 200 characters or less"),
fullStory: yup.string().required("Full story is required"),
});

const BasicInfoStep = ({ validationRef }) => {
const dispatch = useDispatch();
const draft = useSelector(selectDraftCampaign);

const { register, handleSubmit, watch, trigger, formState, setValue } =
useForm({
resolver: yupResolver(schema),
defaultValues: {
title: draft.title || "",
category: draft.category || "",
shortDescription: draft.description || "",
fullStory: draft.fullStory || draft.description || "",
},
mode: "onChange",
});

// Keep local form values in sync if draft changes externally (e.g., from Redux store updates)
useEffect(() => {
setValue("title", draft.title || "");

setValue("category", draft.category || "");
setValue("shortDescription", draft.description || "");
setValue("fullStory", draft.fullStory || draft.description || "");
}, [draft, setValue]);

// Debounced autosave to Redux when form values change
const timeoutRef = useRef(null);
const watched = watch();
useEffect(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
dispatch(
updateDraftCampaign({
title: watched.title || "",
category: watched.category || "",
description: watched.shortDescription || "",
fullStory: watched.fullStory || "",
}),
);
}, 550);
return () => clearTimeout(timeoutRef.current);
}, [watched, dispatch]);

// Expose a validate function to parent via validationRef
useEffect(() => {
if (!validationRef) return;
validationRef.current = {
validate: async () => {
const valid = await trigger();
if (valid) {
// ensure final save before proceeding
const values = await handleSubmit((vals) => vals)();
dispatch(
updateDraftCampaign({
title: values.title,
category: values.category,
description: values.shortDescription,
fullStory: values.fullStory,
}),
);
}
return valid;
},
};
return () => {
if (validationRef.current && validationRef.current.validate) {
validationRef.current = null;
}
};
}, [validationRef, trigger, handleSubmit, dispatch]);

const title = watch("title") || "";
const shortDescription = watch("shortDescription") || "";

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"
maxLength={100}
{...register("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 className="mt-1 flex items-center justify-between text-xs text-slate-500">
<span>
{formState.errors.title ? formState.errors.title.message : ""}
</span>
<span>{title.length}/100</span>
</div>
</div>

<div>
<label
htmlFor="category"
className="mb-1 block text-sm font-medium text-slate-700"
>
Category
</label>
<select
id="category"
{...register("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 className="mt-1 text-xs text-slate-500">
{formState.errors.category?.message || ""}
</div>
</div>

<div>
<label
htmlFor="shortDescription"
className="mb-1 block text-sm font-medium text-slate-700"
>
Short Description
</label>
<textarea
id="shortDescription"
rows={3}
maxLength={200}
{...register("shortDescription")}
placeholder="A short summary for listings..."
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
<div className="mt-1 flex items-center justify-between text-xs text-slate-500">
<span>
{formState.errors.shortDescription
? formState.errors.shortDescription.message
: ""}
</span>
<span>{shortDescription.length}/200</span>
</div>
</div>

<div>
<label
htmlFor="fullStory"
className="mb-1 block text-sm font-medium text-slate-700"
>
Full Story
</label>
<textarea
id="fullStory"
rows={8}
{...register("fullStory")}
placeholder="Write the full story for your campaign. You can include formatting in a later iteration."
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-accent focus:ring-accent"
/>
<div className="mt-1 text-xs text-slate-500">
{formState.errors.fullStory?.message || ""}
</div>
</div>
</div>
);
};

export default BasicInfoStep;
65 changes: 39 additions & 26 deletions src/pages/campaigns/CreateCampaignPage.jsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import { useCallback, useMemo } from 'react';
import { useBeforeUnload } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useMemo, useRef } 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';
} from "../../features/campaigns/campaignsSlice";
import BasicInfoStep from "../../components/campaigns/steps/BasicInfoStep";
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 },
{ id: "details", title: "Campaign Details", Component: BasicInfoStep },
{ 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() !== '');
Object.values(draft).some((value) => String(value).trim() !== "");

const CreateCampaignPage = () => {
const dispatch = useDispatch();
Expand All @@ -37,23 +37,36 @@ const CreateCampaignPage = () => {
(event) => {
if (isDirty) {
event.preventDefault();
event.returnValue = '';
event.returnValue = "";
}
},
[isDirty]
)
[isDirty],
),
);

const isFirstStep = formStep === 0;
const isLastStep = formStep === CAMPAIGN_STEPS.length - 1;
const { title, Component } = STEP_META[formStep];

const handleNext = () => dispatch(nextStep());
const validationRef = useRef(null);

const handleNext = async () => {
// If the step exposes a validator, run it and only advance when valid.
if (
validationRef.current &&
typeof validationRef.current.validate === "function"
) {
const ok = await validationRef.current.validate();
if (ok) dispatch(nextStep());
} else {
dispatch(nextStep());
}
};
const handleBack = () => dispatch(prevStep());

const handleSubmit = () => {
// Placeholder submit; integrates with campaign service in a later issue.
console.log('Submitting campaign draft:', draft);
console.log("Submitting campaign draft:", draft);
dispatch(resetCampaignForm());
};

Expand All @@ -73,20 +86,20 @@ const CreateCampaignPage = () => {
<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',
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
isCurrent
? 'bg-accent text-white'
? "bg-accent text-white"
: isComplete
? 'bg-green-500 text-white'
: 'bg-slate-200 text-slate-500',
].join(' ')}
? "bg-green-500 text-white"
: "bg-slate-200 text-slate-500",
].join(" ")}
>
{isComplete ? '✓' : index + 1}
{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'
isComplete ? "bg-green-500" : "bg-slate-200"
}`}
/>
)}
Expand All @@ -99,7 +112,7 @@ const CreateCampaignPage = () => {
<h2 className="mb-4 text-lg font-semibold text-primary">
Step {formStep + 1}: {title}
</h2>
<Component />
<Component validationRef={validationRef} />
</div>

<div className="flex items-center justify-between">
Expand Down
Loading