diff --git a/README.md b/README.md index deacd33f..b01b6456 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Collaborators: * Eleanor Elkus; Github: @eelkus01 * Jason Jiang; Github: @jasonjiang9142 * Remus Harris; Github: @remus287 +* Celine Boudaie; Github: @celineboudaie # Description The Boston Voter App addresses the lack of accessible information about voting logistics and candidates in municipal elections in Boston. This progressive web application centralizes all vital voting information to increase voter turnout, particularly focusing on BIPOC (Black, Indigenous, People of color) voters who face significant barriers to voting in local elections. @@ -78,17 +79,21 @@ Note: Using the keywork local uses the local Express or Strapi server. Leaving t This API key is for enabling Mailchimp, a service that allows a site admin to collect email addresses input by site users +## Candidate Info + +Code is linked to strapi so when a candidate is updated on strapi the website is updated + # Deployment Access our deployed website [here](https://bostonvoter.com/) or https://bostonvoter.com/. -[![Watch the video](https://img.youtube.com/vi/AIte6hS3cCc/0.jpg)](https://www.youtube.com/watch?v=AIte6hS3cCc&feature=youtu.be) # Collaborators * Arshnoor Kaur Chadha - Github: @arshnoorKC13 * Elenaor Elkus - Github: @eelkus01 * Jason Jiang - Github: @jasonjiang9142 * Remus Harris - Github: @remus287 +* Celine Boudaie - Github: @celineboudaie # For more detailed technical documentation visit https://github.com/BU-Spark/pitne-voter-app/wiki diff --git a/client/src/pages/candidateInfo/[candidate].tsx b/client/src/pages/candidateInfo/[candidate].tsx new file mode 100644 index 00000000..de9b99f6 --- /dev/null +++ b/client/src/pages/candidateInfo/[candidate].tsx @@ -0,0 +1,250 @@ +/* candidate profiles that appear when their icon is clicked on candidate info page. + Pulls data from strapi "Candidates" content. +*/ + +import React, { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { CandidateAPI } from '@/common'; +import { all } from 'axios'; +import ButtonFillEx from '@/components/button/ButtonFillEx'; +import { Accordion, AccordionDetails, AccordionSummary, Link, Typography } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +interface CandidateAttributes { + CampaignSiteLink: string | null; + District: string; + ElectionName: string; + LinkedinLink: string | null; + Name: string; + Party: string; + Role: string; + createdAt: string; + publishedAt: string; + updatedAt: string; + Question1: string | null; + Answer1: string | null; + Question2: string | null; + Answer2: string | null; + Question3: string | null; + Answer3: string | null; + Question4: string | null; + Answer4: string | null; + Question5: string | null; + Answer5: string | null; + Headshot: { + data: { + attributes: { + url: string + } + } + + } +} + +interface CandidateDataObject { + id: number; + attributes: CandidateAttributes; +} + +interface Candidate { + attributes: CandidateAttributes; +} + +interface QuestionsAndAnswers { + [key: string]: { question: string | null, answer: string | null }; +} + +export default function Candidate() { + const router = useRouter(); + const [candidateName, setCandidateName] = useState(''); + const [allCandidateData, setAllCandidateData] = useState([]) + const [candidateData, setCandidateData] = useState(null); + const [questionsAndAnswers, setQuestionsAndAnswers] = useState({}); + + + // Get candidate name from URL + useEffect(() => { + if (!router.isReady) return; + + const { candidate } = router.query; + candidate && setCandidateName(candidate as string); + + }, [router.isReady, router.query]); + + + // Get candidate data from strapi + useEffect(() => { + const getData = async () => { + try { + const response = await fetch(CandidateAPI + '?populate=*', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + const data = (await response.json()).data; + setAllCandidateData(data) + console.log(data) + } + + + } catch (e) { + console.log(e); + } + }; + + getData(); + + }, []); + + + // Set the candidate data + useEffect(() => { + if (candidateName && allCandidateData) { + const normalizedInput = (input: string) => input.replace(/\s+/g, '').toLowerCase(); + const foundCandidateData = allCandidateData.find((candidateData: any) => + normalizedInput(candidateData.attributes.Name) === normalizedInput(candidateName) + ); + if (foundCandidateData) { + setCandidateData(foundCandidateData.attributes); + } else { + setCandidateData(null); + } + } + }, [allCandidateData, candidateName]); + + useEffect(() => { + console.log(candidateData); + console.log(candidateData?.Headshot.data.attributes.url) + }, [candidateData]) + + + // Get filled out questions and answers from strapi and populate map + useEffect(() => { + if (candidateData) { + const qaMap = Object.entries(candidateData) + .filter(([key, value]) => key.startsWith('Question') || key.startsWith('Answer')) + .reduce((acc, [key, value]) => { + const questionIndex = key.match(/\d+/)?.[0]; + if (questionIndex) { + if (!acc[questionIndex]) { + acc[questionIndex] = { question: null, answer: null }; + } + acc[questionIndex][key.includes('Question') ? 'question' : 'answer'] = value; + } + return acc; + }, {}); + setQuestionsAndAnswers(qaMap); + } + + + + }, [candidateData]); + + + return ( + <> +
+ {/* Actual candidate data */} +
+
+ {/* Go Back button */} + +
+ {candidateData ? ( +
+
+
+
+ +
+
+ {/* Candidate image */} +
+
+
+ {/* Name, role, party */} +
+

+ {candidateData?.Name} +

+

+ {candidateData?.Role} +

+

+ {candidateData?.Party} +

+ + + {/* Links */} +
+ {candidateData.CampaignSiteLink && ( + + )} + {candidateData.LinkedinLink && ( + + )} +
+ +
+
+
+
+
+ + + {/* Questions and Answers if filled out */} + {Object.entries(questionsAndAnswers) && +
+ + {Object.entries(questionsAndAnswers).map(([index, qa]) => ( + qa.question && qa.answer ? ( + + + {/* Question */} + } aria-controls={`panel${index}-content`} id={`panel${index}-header`}> + {qa.question} + + + {/* Answer */} + + {qa.answer} + + + + ) : null + ))} +

Questions curated by the founder, journalist Yawu Miller.

+
+ + } +
+ +
+ ) : (null)} +
+ + ); +} \ No newline at end of file diff --git a/client/src/pages/candidateInfo/index.tsx b/client/src/pages/candidateInfo/index.tsx index 5dc9bc09..f1f222d0 100644 --- a/client/src/pages/candidateInfo/index.tsx +++ b/client/src/pages/candidateInfo/index.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useEffect, useState } from 'react'; import SubscribePopup from '../../components/subscribePopup/SubscribePopup'; - +import { CandidateAPI } from '@/common'; // use this instead of hardcoded link +import { useRouter } from 'next/router'; interface Candidate { id: number; @@ -10,6 +11,7 @@ interface Candidate { District: string; Party: string; ElectionName: string; + Office: string; Bio?: string; CampaignSiteLink?: string; LinkedInLink?: string; @@ -18,51 +20,53 @@ interface Candidate { }; } -// Component for Candidate Information Page +const parties = ['Democrat', 'Republican', 'Independent', 'Non Partisan', 'Other']; +const electionTypes = ['Federal Election', 'State Election', 'Municipal Election', 'Special Election', 'Primary Election', 'Ballot Questions/Referendum']; +const districts = ['District 1', 'District 2', 'District 3', 'District 4']; // Example districts, replace with actual + export default function CandidateInfo() { const [candidates, setCandidates] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [expandedCandidateId, setExpandedCandidateId] = useState(null); // State for managing expanded candidate + const [filteredCandidates, setFilteredCandidates] = useState([]); + const [filters, setFilters] = useState({ + party: '', + electionType: '', + district: '', + }); const [showPopup, setShowPopup] = useState(false); + const router = useRouter(); + useEffect(() => { const fetchCandidateData = async () => { - console.log("Fetching candidate data..."); try { const response = await fetch('https://pitne-voter-app-production.up.railway.app/api/candidates?populate=Headshot'); - console.log("API Response Status:", response.status); if (response.ok) { const data = await response.json(); - console.log("Fetched data:", data); - if (data.data && data.data.length > 0) { - // Map through fetched candidates to add the Headshot URL - const fetchedCandidates: Candidate[] = data.data.map((candidate: any) => { - const headshotUrl = candidate.attributes.Headshot?.data?.attributes?.url - ? `https://pitne-voter-app-production.up.railway.app${candidate.attributes.Headshot.data.attributes.url}` - : undefined; - return { - ...candidate, - attributes: { - ...candidate.attributes, - PhotoURL: headshotUrl, // Add headshot URL to PhotoURL attribute - }, - }; - }); - console.log("Fetched Candidates with Headshot URL:", fetchedCandidates); - setCandidates(fetchedCandidates); - } else { - console.warn("No candidate data available."); + const fetchedCandidates: Candidate[] = data.data.map((candidate: any) => { + const headshotUrl = candidate.attributes.Headshot?.data?.attributes?.url + ? `https://pitne-voter-app-production.up.railway.app${candidate.attributes.Headshot.data.attributes.url}` + : undefined; + return { + ...candidate, + attributes: { + ...candidate.attributes, + PhotoURL: headshotUrl, + }, + }; + }); + setCandidates(fetchedCandidates); + setFilteredCandidates(fetchedCandidates); + } else { setError("No candidate data available."); } } else { - console.error('Failed to fetch candidate data', response.statusText); setError('Failed to fetch candidate data'); } - } catch (fetchError) { - console.error('Error:', fetchError); + } catch { setError('An error occurred while fetching candidate data'); } finally { setIsLoading(false); @@ -78,102 +82,168 @@ export default function CandidateInfo() { // Cleanup timer when component unmounts return () => clearTimeout(popupTimer); + }, []); const handleClosePopup = () => { setShowPopup(false); }; - console.log("Loading state:", isLoading); - console.log("Candidates data:", candidates); + const handleCandidateClick = (name: string) => { + const formattedName = name.replace(/\s+/g, '').toLowerCase(); // Ensure it matches profile URL structure + router.push(`/candidateInfo/${formattedName}`); // Navigate to the candidate's profile page + }; + + const handleFilterChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFilters(prevFilters => ({ + ...prevFilters, + [name]: value, + })); + }; - const toggleExpand = (candidateId: number) => { - setExpandedCandidateId(prevId => (prevId === candidateId ? null : candidateId)); // Toggle expand/collapse + const handleResetFilters = () => { + setFilters({ + party: '', + electionType: '', + district: '', + }); }; + useEffect(() => { + const filtered = candidates.filter(candidate => { + const matchesParty = filters.party ? candidate.attributes.Party === filters.party : true; + const matchesElection = filters.electionType ? candidate.attributes.ElectionName === filters.electionType : true; + const matchesDistrict = filters.district ? candidate.attributes.District === filters.district : true; + return matchesParty && matchesElection && matchesDistrict; + }); + setFilteredCandidates(filtered); + }, [filters, candidates]); + if (isLoading) return

Loading...

; if (error) return

{error}

; return ( -
- {/* Header */} -
-
-
-

LEARN. PLAN.

-
-

- Explore the election, candidates, and crucial -
- issues personalized to your community! -

+
+ {/* Sidebar for Filters */} +
+

Filter Candidates

+ +
+ +
+ +
+ + +
+ +
+ + +
+ + {/* Reset Filters Button */} +
-
- {candidates.length > 0 ? ( - candidates.map(candidate => ( -
toggleExpand(candidate.id)} + {/* Main Content */} +
+ {filteredCandidates.length > 0 ? ( + filteredCandidates.map(candidate => ( +
-
-
-

{candidate.attributes.Name}

-

Party: {candidate.attributes.Party}

-
- {candidate.attributes.PhotoURL && ( - {candidate.attributes.Name} - )} -
- - {/* Expandable Details */} - {expandedCandidateId === candidate.id && ( -
-

District: {candidate.attributes.District}

-

Office Running For: {candidate.attributes.ElectionName}

-

Bio: {candidate.attributes.Bio}

- -
-

Questionnaire

- {Array.from({ length: 10 }).map((_, i) => { - const questionKey = `Question${i + 1}` as keyof Candidate['attributes']; - const answerKey = `Answer${i + 1}` as keyof Candidate['attributes']; - const question = candidate.attributes[questionKey]; - const answer = candidate.attributes[answerKey]; - return ( - question && answer ? ( -
-

{question}

-

{answer}

-
- ) : null - ); - })} +
+
+ {candidate.attributes.PhotoURL && ( + {candidate.attributes.Name} + )} +
+

{candidate.attributes.Name}

+
+

+ Party: {candidate.attributes.Party} +

+

+ Office: {candidate.attributes.Role} +

+

+ Election: {candidate.attributes.ElectionName} +

+

+ District: {candidate.attributes.District} +

+
- )} + +
)) ) : ( -

No candidates available.

+

No candidates match the selected filters.

)}
{/* Subscribe Popup */} {showPopup && } +
); -} +} \ No newline at end of file