Skip to content
Open
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
101 changes: 101 additions & 0 deletions client/app/create/components/CandidateCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { TrashIcon } from "@heroicons/react/24/solid";
import { Candidate } from "../../helpers/candidateValidation";

interface CandidateCardProps {
candidate: Candidate;
index: number;
isDuplicate: boolean;
emptyFields: Set<"name" | "description"> | undefined;
onRemove: () => void;
onUpdate: (field: "name" | "description", value: string) => void;
onBlur: (field: "name" | "description") => void;
}

const inputBaseClasses =
"block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none sm:text-base min-h-[44px]";
const inputNormalClasses =
"border-gray-300 focus:ring-indigo-500 focus:border-indigo-500";
const inputErrorClasses =
"border-red-500 bg-red-50 focus:ring-red-500 focus:border-red-500";

/**
* Renders an individual candidate entry with validation states.
* Includes name input, description textarea, and delete button.
*/
const CandidateCard: React.FC<CandidateCardProps> = ({
candidate,
index,
isDuplicate,
emptyFields,
onRemove,
onUpdate,
onBlur,
}) => {
const hasNameError = isDuplicate || emptyFields?.has("name");
const hasDescriptionError = emptyFields?.has("description");

return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="p-4 border border-gray-200 rounded-lg space-y-3"
>
<div className="flex justify-between items-center">
<h4 className="text-md font-medium text-gray-700">
Candidate {index + 1}
</h4>
<motion.button
type="button"
onClick={onRemove}
className="text-red-600 hover:text-red-700 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label={`Remove candidate ${index + 1}`}
>
<TrashIcon className="h-5 w-5" />
</motion.button>
</div>

<div className="space-y-3">
<div>
<input
type="text"
value={candidate.name}
onChange={(e) => onUpdate("name", e.target.value)}
onBlur={() => onBlur("name")}
placeholder="Candidate Name"
className={`${inputBaseClasses} ${
hasNameError ? inputErrorClasses : inputNormalClasses
}`}
aria-invalid={hasNameError}
/>
{isDuplicate && (
<p className="mt-1 text-sm text-red-600">
Duplicate name - must be unique
</p>
)}
</div>

<textarea
value={candidate.description}
onChange={(e) => onUpdate("description", e.target.value)}
onBlur={() => onBlur("description")}
placeholder="Candidate Description"
rows={2}
className={`${inputBaseClasses} ${
hasDescriptionError ? inputErrorClasses : inputNormalClasses
}`}
aria-invalid={hasDescriptionError}
/>
</div>
</motion.div>
);
};

export default CandidateCard;
28 changes: 28 additions & 0 deletions client/app/create/components/CandidateCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from "react";

interface CandidateCounterProps {
count: number;
minimum: number;
}

/**
* Displays the current candidate count with visual feedback.
* Shows warning color (amber) when below minimum, success color (green) when at/above minimum.
*/
const CandidateCounter: React.FC<CandidateCounterProps> = ({ count, minimum }) => {
const isBelowMinimum = count < minimum;

return (
<span
className={`text-sm font-medium px-2 py-1 rounded-full ${
isBelowMinimum
? "bg-amber-100 text-amber-700"
: "bg-green-100 text-green-700"
}`}
>
{count}/{minimum} minimum
</span>
);
};

export default CandidateCounter;
91 changes: 91 additions & 0 deletions client/app/create/components/CandidateSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"use client";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { Candidate, CandidateValidationErrors, MIN_CANDIDATES } from "../../helpers/candidateValidation";
import CandidateCounter from "./CandidateCounter";
import CandidateCard from "./CandidateCard";

interface CandidateSectionProps {
candidates: Candidate[];
validationErrors: CandidateValidationErrors;
onAddCandidate: () => void;
onRemoveCandidate: (id: string) => void;
onUpdateCandidate: (id: string, field: "name" | "description", value: string) => void;
onFieldBlur: (id: string, field: "name" | "description") => void;
}

/**
* Main component for managing candidates in the election creation form.
* Includes counter, add button, and list of candidate cards with animations.
*/
const CandidateSection: React.FC<CandidateSectionProps> = ({
candidates,
validationErrors,
onAddCandidate,
onRemoveCandidate,
onUpdateCandidate,
onFieldBlur,
}) => {
const isBelowMinimum = candidates.length < MIN_CANDIDATES;

return (
<div className="space-y-4">
{/* Header with title, counter, and add button */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
<div className="flex items-center gap-3">
<h3 className="text-lg font-medium text-gray-900">Candidates</h3>
<CandidateCounter count={candidates.length} minimum={MIN_CANDIDATES} />
</div>
<motion.button
type="button"
onClick={onAddCandidate}
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 min-h-[44px]"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<PlusIcon className="h-4 w-4 mr-1" />
Add Candidate
</motion.button>
</div>

{/* Warning indicator when below minimum */}
{isBelowMinimum && candidates.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 text-amber-700 bg-amber-50 px-3 py-2 rounded-md"
>
<ExclamationTriangleIcon className="h-5 w-5" />
<span className="text-sm">Add at least {MIN_CANDIDATES} candidates to create an election</span>
</motion.div>
)}

{/* Candidate list or placeholder */}
{candidates.length === 0 ? (
<p className="text-gray-500 text-sm italic text-center py-4">
No candidates added yet. Click &quot;Add Candidate&quot; to begin adding candidates.
</p>
) : (
<div className="space-y-3">
<AnimatePresence mode="popLayout" initial={false}>
{candidates.map((candidate, index) => (
<CandidateCard
key={candidate.id}
candidate={candidate}
index={index}
isDuplicate={validationErrors.duplicateIds.has(candidate.id)}
emptyFields={validationErrors.emptyFields.get(candidate.id)}
onRemove={() => onRemoveCandidate(candidate.id)}
onUpdate={(field, value) => onUpdateCandidate(candidate.id, field, value)}
onBlur={(field) => onFieldBlur(candidate.id, field)}
/>
))}
</AnimatePresence>
</div>
)}
</div>
);
};

export default CandidateSection;
Loading