Repository: thegoodparty/gp-webapp
Pull request #658: WEB-4039 : Link candidates on race pages
Author: login: oakinh
Created: 2025-05-06T16:01:11Z | Merged: 2025-05-08T16:39:14Z

![image](https://github.com/user-attachments/assets/9cf2c1f7-0f91-4e19-abaa-79615077259d)
![image](https://github.com/user-attachments/assets/d678b515-5fb7-4b34-9021-1badc8c45967)

Dependent on: https://github.com/thegoodparty/election-api/pull/30


Diff:
diff --git a/app/(landing)/elections/components/FeaturedCities.js b/app/(landing)/elections/components/FeaturedCities.js
index a1ffd48a1..11fed97a7 100644
--- a/app/(landing)/elections/components/FeaturedCities.js
+++ b/app/(landing)/elections/components/FeaturedCities.js
@@ -1,7 +1,7 @@
 import Button from '@shared/buttons/Button'
 import MaxWidth from '@shared/layouts/MaxWidth'
-import gpApi from 'gpApi'
-import gpFetch from 'gpApi/gpFetch'
+import { electionApiRoutes } from 'gpApi/routes'
+import { unAuthFetch } from 'gpApi/unAuthFetch'
 import { slugify } from 'helpers/articleHelper'
 import { numberFormatter } from 'helpers/numberHelper'
 import Image from 'next/image'
@@ -9,11 +9,11 @@ import map from 'public/images/elections/map.png'
 
 async function fetchFeatured() {
   try {
-    const api = gpApi.elections.featuredCities
+    const api = electionApiRoutes.places.featuredCities.path
     const payload = {
       count: 3,
     }
-    return await gpFetch(api, payload, 3600)
+    return await unAuthFetch(api, payload, 3600)
   } catch (e) {
     console.error('error', e)
     return false
diff --git a/app/(landing)/elections/position/[...loc]/components/PositionPage.js b/app/(landing)/elections/position/[...loc]/components/PositionPage.js
index 16cace232..8961e62d7 100644
--- a/app/(landing)/elections/position/[...loc]/components/PositionPage.js
+++ b/app/(landing)/elections/position/[...loc]/components/PositionPage.js
@@ -10,9 +10,12 @@ import Guides from 'app/(landing)/elections/shared/Guides'
 import Explore from './Explore'
 import VwoVariable from './VwoVariable'
 import { PositionLevel } from 'app/(landing)/elections/shared/PositionLevel'
+import CandidateCard from 'app/candidate/[name]/[office]/components/CandidateCard'
+import Link from 'next/link'
+import H3 from '@shared/typography/H3'
 
 export default function PositionPage(props) {
-  const { race, otherRaces, articles, county, city, positions } = props
+  const { race, otherRaces, articles, county, city, positions, candidates } = props
   const { positionLevel, state, Place } = race
   let loc = Place?.name || ''
   if (!positionLevel || positionLevel?.toUpperCase() === PositionLevel.LOCAL) {
@@ -29,6 +32,13 @@ export default function PositionPage(props) {
       city ? `${city}/` : ''
     }${race.slug}`
   }
+
+  const candidateLink = (c) => `/candidate/${c.slug}`
+  // const candidatesForLinks = candidates?.map((c) => ({
+  //    ...c,
+  //   name: `${c.firstName} ${c.lastName}`
+  // }))
+
   race.loc = loc
 
   return (
@@ -36,6 +46,24 @@ export default function PositionPage(props) {
       <MaxWidth>
         <Hero {...race} />
         <PositionDetails race={race} positions={positions} />
+        {/* ---------- Candidates running in this race ---------- */}
+        {candidates?.length > 0 && (
+          <section className="my-20">
+            <H3 className="mb-8">
+              Independent Candidates running for {race.normalizedPositionName}
+            </H3>
+
+            {/* grid → max 3 per row */}
+            <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
+              {candidates.map((c) => (
+                <Link key={c.slug} href={candidateLink(c)} className="block">
+                  <CandidateCard candidate={c} variant={'grid'} />
+                </Link>
+              ))}
+            </div>
+          </section>
+        )}
+        {/* ------------------------------------------------------ */}
       </MaxWidth>
 
       <CtaBanner race={race} />
diff --git a/app/(landing)/elections/position/[...loc]/page.js b/app/(landing)/elections/position/[...loc]/page.js
index c12359785..eb3f49088 100644
--- a/app/(landing)/elections/position/[...loc]/page.js
+++ b/app/(landing)/elections/position/[...loc]/page.js
@@ -1,26 +1,39 @@
 import pageMetaData from 'helpers/metadataHelper'
-import gpApi from 'gpApi'
-import gpFetch from 'gpApi/gpFetch'
 import { notFound, permanentRedirect } from 'next/navigation'
 import PositionPage from './components/PositionPage'
 import PositionSchema from './components/PositionSchema'
 import { fetchArticle } from 'app/blog/article/[slug]/page'
 import { PositionLevel } from '../../shared/PositionLevel'
+import { unAuthFetch } from 'gpApi/unAuthFetch'
+import { electionApiRoutes } from 'gpApi/routes'
 
 const fetchRace = async (raceSlug) => {
-  const api = gpApi.elections.races
+  const api = electionApiRoutes.races.find.path
   const payload = {
     raceSlug,
     includePlace: true,
   }
 
-  const res = await gpFetch(api, payload, 3600)
+  const res = await unAuthFetch(api, payload, 3600)
   if (Array.isArray(res) && res.length > 0) {
     return res[0]
   }
   return null
 }
 
+const fetchCandidates = async (raceSlug) => {
+  const api = electionApiRoutes.candidacies.find.path
+  const payload = {
+    raceSlug
+  }
+
+  const res = await unAuthFetch(api, payload, 3600)
+  if (Array.isArray(res) && res.length > 0) {
+    return res
+  }
+  return null
+}
+
 const parseLoc = (loc) => {
   const state = loc[0]
   const positionSlug = loc[loc.length - 1]
@@ -72,6 +85,7 @@ export default async function Page({ params }) {
   }
 
   const race = await fetchRace(loc.join('/'))
+  const candidates = await fetchCandidates(loc.join('/'))
   if (!race) {
     const newSlug = `${loc[0]}/${loc[1]}-county/${loc[2]}`
     const newRace = await fetchRace(newSlug)
@@ -103,6 +117,7 @@ export default async function Page({ params }) {
     state,
     county,
     city,
+    candidates
   }
   return (
     <>
diff --git a/app/(landing)/elections/shared/fetchPlace.js b/app/(landing)/elections/shared/fetchPlace.js
index 0dfc5fd0a..f2130b494 100644
--- a/app/(landing)/elections/shared/fetchPlace.js
+++ b/app/(landing)/elections/shared/fetchPlace.js
@@ -1,5 +1,5 @@
-import gpApi from 'gpApi'
-import gpFetch from 'gpApi/gpFetch'
+import { electionApiRoutes } from 'gpApi/routes'
+import { unAuthFetch } from 'gpApi/unAuthFetch'
 
 const fetchPlace = async ({
   slug,
@@ -7,7 +7,7 @@ const fetchPlace = async ({
   includeParent = false,
   categorizeChildren = false,
 }) => {
-  const api = gpApi.elections.places
+  const api = electionApiRoutes.places.find.path
   const payload = {
     slug: slug.toLowerCase(),
     includeChildren: true,
@@ -17,7 +17,7 @@ const fetchPlace = async ({
     includeParent,
     categorizeChildren,
   }
-  const res = await gpFetch(api, payload, 3600)
+  const res = await unAuthFetch(api, payload, 3600)
   if (Array.isArray(res)) {
     return res[0]
   }
diff --git a/app/candidate/[name]/[office]/components/AboutCard.js b/app/candidate/[name]/[office]/components/AboutCard.js
index 5764a077c..482f03fa7 100644
--- a/app/candidate/[name]/[office]/components/AboutCard.js
+++ b/app/candidate/[name]/[office]/components/AboutCard.js
@@ -1,7 +1,6 @@
 import Body1 from '@shared/typography/Body1'
 import MarketingH4 from '@shared/typography/MarketingH4'
 import Overline from '@shared/typography/Overline'
-import DescriptionLabel from './DescriptionLabel'
 
 export default function AboutCard(props) {
   const { candidate } = props
@@ -9,22 +8,8 @@ export default function AboutCard(props) {
     firstName,
     lastName,
     about,
-    jobHistory,
-    education,
-    militaryService,
-    previouslyInOffice,
-    priorRoles,
-    yearsInOffice,
   } = candidate
 
-  const descLabels = [
-    { title: 'Job history', description: jobHistory },
-    { title: 'Education', description: education },
-    { title: 'Military Service', description: militaryService },
-    { title: 'Previously In Office', description: previouslyInOffice },
-    { title: 'Total Years In Office', description: yearsInOffice },
-  ]
-
   return (
     <section className="bg-primary-dark border border-gray-700 p-6 rounded-2xl h-full">
       <Overline className="text-gray-400 mb-2">ABOUT</Overline>
@@ -36,25 +21,6 @@ export default function AboutCard(props) {
           <div dangerouslySetInnerHTML={{ __html: about }} />
         </Body1>
       )}
-      {descLabels.map((item) => (
-        <DescriptionLabel
-          key={item.title}
-          title={item.title}
-          description={item.description}
-        />
-      ))}
-      {priorRoles && priorRoles.length > 0 && (
-        <>
-          <Overline className="uppercase text-gray-400">PRIOR ROLES</Overline>
-          {priorRoles.map((role, index) => (
-            <Body1 className="my-2" key={index}>
-              {role.office}
-              <br />
-              {role.years}
-            </Body1>
-          ))}
-        </>
-      )}
     </section>
   )
 }
diff --git a/app/candidate/[name]/[office]/components/CandidateCard.js b/app/candidate/[name]/[office]/components/CandidateCard.js
index 21f3c35b9..d11b2e502 100644
--- a/app/candidate/[name]/[office]/components/CandidateCard.js
+++ b/app/candidate/[name]/[office]/components/CandidateCard.js
@@ -1,27 +1,30 @@
-import H1 from '@shared/typography/H1'
-import H5 from '@shared/typography/H5'
-import Overline from '@shared/typography/Overline'
-import { FaGlobeAmericas, FaInstagram, FaMapMarkerAlt } from 'react-icons/fa'
+import Image from 'next/image'
+import { IoPersonSharp } from 'react-icons/io5'
+import {
+  FaGlobeAmericas,
+  FaInstagram,
+  FaMapMarkerAlt,
+} from 'react-icons/fa'
 import {
   FaArrowRight,
   FaFacebookF,
   FaTiktok,
   FaXTwitter,
 } from 'react-icons/fa6'
-import { IoPersonSharp } from 'react-icons/io5'
 import { MdEmail, MdStars, MdVolunteerActivism } from 'react-icons/md'
+import { AiOutlineLinkedin } from 'react-icons/ai'
+
+import H1 from '@shared/typography/H1'
+import H5 from '@shared/typography/H5'
+import Overline from '@shared/typography/Overline'
+import Body2 from '@shared/typography/Body2'
 import TealButton from './TealButton'
 import StickyCard from './StickyCard'
 import CTA from './CTA'
-import Image from 'next/image'
-import { AiOutlineLinkedin } from 'react-icons/ai'
-import Body2 from '@shared/typography/Body2'
 
-function mapSocialIcon(type) {
-  //<MdEmail size={20} />
+/* helpers */
+const mapSocialIcon = (type) => {
   switch (type) {
-    case 'website':
-      return <FaGlobeAmericas size={20} />
     case 'twitter':
       return <FaXTwitter size={20} />
     case 'facebook':
@@ -37,34 +40,62 @@ function mapSocialIcon(type) {
   }
 }
 
-export default function CandidateCard(props) {
-  const { candidate } = props
+const detectSocialType = (url) => {
+  if (url.includes('twitter.com')) return 'twitter'
+  if (url.includes('facebook.com')) return 'facebook'
+  if (url.includes('instagram.com')) return 'instagram'
+  if (url.includes('tiktok.com')) return 'tiktok'
+  if (url.includes('linkedin.com')) return 'linkedin'
+  return 'website'
+}
+
+const prettyUrl = (url) => {
+  try {
+    return new URL(url).hostname.replace(/^www\./, '')
+  } catch {
+    return url
+  }
+}
+
+/* component */
+export default function CandidateCard({ candidate, variant = 'profile' }) {
   const {
     firstName,
     lastName,
     party,
-    office,
-    city,
+    positionName,
+    placeName,
     state,
-    claimed,
     image,
-    socialUrls,
+    urls = [],
     email,
   } = candidate
 
-  console.log('socialUrls', socialUrls)
+  const partyName =
+    party === 'Independent' || party === 'Nonpartisan'
+      ? 'Non-Partisan Candidate'
+      : ''
 
-  let partyName = ''
-  if (party === 'Independent') {
-    partyName = 'Non-Partisan Candidate'
-  }
+  const Wrapper =
+    variant === 'profile'
+      ? ({ children }) => <StickyCard>{children}</StickyCard>
+      : ({ children }) => <>{children}</>
 
   return (
-    <div className="mb-4 lg:w-[400px] lg:mr-4 pt-12 md:pt-0">
-      <div className="lg:w-[400px]">&nbsp;</div>
-      <StickyCard>
-        <div className="mb-4 lg:w-[400px] bg-primary-dark p-6 rounded-2xl border border-gray-700">
-          <div className="flex justify-between">
+    <div
+      className={`mb-6 ${
+        variant === 'profile' ? 'lg:w-[400px] pt-12 md:pt-0' : ''
+      }`}
+    >
+      {variant === 'profile' && <div className="lg:w-[400px]" />}
+
+      <Wrapper>
+        <div
+          className={`bg-primary-dark p-6 rounded-2xl border border-gray-700
+          ${variant === 'grid' ? 'text-white h-full flex flex-col' : ''}`}
+        >
+          {/* head-shot */}
+          <div className="flex justify-between mb-4">
             {image ? (
               <div className="bg-primary inline-block border border-white rounded-2xl relative w-28 h-28">
                 <Image
@@ -73,7 +104,7 @@ export default function CandidateCard(props) {
                   alt={`${firstName} ${lastName}`}
                   priority
                   unoptimized
-                  className="rounded-2xl w-28 h-28 object-cover object-center"
+                  className="rounded-2xl object-cover object-center"
                 />
               </div>
             ) : (
@@ -82,79 +113,101 @@ export default function CandidateCard(props) {
               </div>
             )}
           </div>
-          <Overline className="my-4">{partyName}</Overline>
+
+          {/* Non-partisan tag — profile only */}
+          {variant === 'profile' && (
+            <Overline className="my-4">{partyName}</Overline>
+          )}
+
           <H1 className="mb-4">
             {firstName} {lastName}
           </H1>
-          <div className="flex mb-3 items-center">
-            <MdStars className="text-secondary-light" size={20} />
-            <H5 className="ml-2">{party}</H5>
-          </div>
-          <div className="flex mb-3 items-center">
-            <IoPersonSharp className="text-secondary-light" size={20} />
-            <H5 className="ml-2">Running for {office}</H5>
-          </div>
-          <div className="flex  items-center mb-8">
-            <FaMapMarkerAlt className="text-secondary-light" size={20} />
-            <H5 className="ml-2">
-              {city ? `${city}, ` : ''}
-              {state}
-            </H5>
-          </div>
-          <div className="grid grid-cols-12 gap-4 mb-8">
-            {((socialUrls && socialUrls.length > 0) || email) && (
-              <>
-                {email && (
-                  <div className="col-span-6">
+
+          {/* Party line — profile only */}
+          {variant === 'profile' && (
+            <div className="flex mb-3 items-center">
+              <MdStars className="text-secondary-light" size={20} />
+              <H5 className="ml-2">{party}</H5>
+            </div>
+          )}
+
+          {/* Position line — profile only */}
+          {variant === 'profile' && (
+            <div className="flex mb-3 items-center">
+              <IoPersonSharp className="text-secondary-light" size={20} />
+              <H5 className="ml-2">Running for {positionName}</H5>
+            </div>
+          )}
+
+          {/* Location — profile only */}
+          {variant === 'profile' && (
+            <div className="flex items-center mb-8">
+              <FaMapMarkerAlt className="text-secondary-light" size={20} />
+              <H5 className="ml-2">
+                {placeName ? `${placeName}, ` : ''}
+                {state}
+              </H5>
+            </div>
+          )}
+
+          {/* Social / email — profile only */}
+          {variant === 'profile' && (email || urls.length > 0) && (
+            <ul className="mb-8 space-y-2">
+              {email && (
+                <li>
+                  <a
+                    href={`mailto:${email}`}
+                    rel="noopener noreferrer nofollow"
+                    className="flex items-center space-x-2 break-all"
+                  >
+                    <MdEmail size={20} />
+                    <span>{email}</span>
+                  </a>
+                </li>
+              )}
+
+              {urls.map((url) => {
+                const type = detectSocialType(url)
+                return (
+                  <li key={url}>
                     <a
-                      href={`mailto:${email}`}
+                      href={url}
                       rel="noopener noreferrer nofollow"
-                      className={`inline-block ${
-                        socialUrls.length > 3 ? '' : 'mr-6'
-                      }`}
+                      className="flex items-center space-x-2 break-all"
                     >
-                      <MdEmail size={20} /> Email
+                      {mapSocialIcon(type)}
+                      <span>{prettyUrl(url)}</span>
                     </a>
-                  </div>
-                )}
-                {socialUrls &&
-                  socialUrls.map((url) => (
-                    <div className="col-span-6" key={url.url}>
-                      <a
-                        href={url.url}
-                        rel="noopener noreferrer nofollow"
-                        className="flex items-center"
-                      >
-                        <span>{mapSocialIcon(url.type)}</span>
-                        <span className="inline-block ml-2">
-                          {url.type === 'government' ? 'website' : url.type}
-                        </span>
-                      </a>
-                    </div>
-                  ))}
-              </>
-            )}
-          </div>
-          {/* <div className="p-3 text-center rounded border border-gray-300 font-medium cursor-pointer transition-colors hover:bg-white hover:text-primary mb-4">
-            Learn More About {firstName} {lastName}
-          </div> */}
-          <CTA id="candidate-card-cta">
-            <TealButton>
-              <div className="flex items-center justify-center  ">
-                <div className="mr-1">Access Voter Data &amp; Tools</div>
-                <FaArrowRight />
-              </div>
-            </TealButton>
-          </CTA>
-          <div className="mt-8 flex border border-white p-2 rounded-lg">
-            <MdVolunteerActivism size={30} />
-            <Body2 className="ml-2">
-              <strong>GoodParty.org</strong> helps non-partisan and independent
-              candidates win their elections.
-            </Body2>
-          </div>
+                  </li>
+                )
+              })}
+            </ul>
+          )}
+
+          {/* CTA — profile only */}
+          {variant === 'profile' && (
+            <CTA id="candidate-card-cta" className="mt-auto">
+              <TealButton>
+                <div className="flex items-center justify-center">
+                  <span className="mr-1">Access Voter Data &amp; Tools</span>
+                  <FaArrowRight />
+                </div>
+              </TealButton>
+            </CTA>
+          )}
+
+          {/* Footer — profile only */}
+          {variant === 'profile' && (
+            <div className="mt-8 flex border border-white p-2 rounded-lg">
+              <MdVolunteerActivism size={30} />
+              <Body2 className="ml-2">
+                <strong>GoodParty.org</strong> helps non-partisan and independent
+                candidates win their elections.
+              </Body2>
+            </div>
+          )}
         </div>
-      </StickyCard>
+      </Wrapper>
     </div>
   )
 }
diff --git a/app/candidate/[name]/[office]/components/CandidateSchema.js b/app/candidate/[name]/[office]/components/CandidateSchema.js
index 1f552d332..093669ebb 100644
--- a/app/candidate/[name]/[office]/components/CandidateSchema.js
+++ b/app/candidate/[name]/[office]/components/CandidateSchema.js
@@ -7,17 +7,17 @@ export default function CandidateSchema({ candidate, slug }) {
     firstName,
     lastName,
     party,
-    office,
-    city,
+    positionName,
+    placeName,
     state,
     image,
-    socialUrls,
+    urls,
     email,
   } = candidate
 
   let sameAs = []
-  if (socialUrls) {
-    sameAs = socialUrls.map((url) => url.url)
+  if (urls) {
+    sameAs = urls.map((url) => url.url)
   }
 
   const address = {
@@ -25,8 +25,8 @@ export default function CandidateSchema({ candidate, slug }) {
     addressRegion: state,
   }
 
-  if (city) {
-    address.addressLocality = city
+  if (placeName) {
+    address.addressLocality = placeName
   }
 
   return (
@@ -36,7 +36,7 @@ export default function CandidateSchema({ candidate, slug }) {
         '@type': 'Person',
         name: `${firstName} ${lastName}`,
         image,
-        jobTitle: `Candidate for ${office}`,
+        jobTitle: `Candidate for ${positionName}`,
         affiliation: {
           '@type': 'PoliticalParty',
           name: party,
diff --git a/app/candidate/[name]/[office]/components/ContentSection.js b/app/candidate/[name]/[office]/components/ContentSection.js
index 9265d5528..1d2c7cdac 100644
--- a/app/candidate/[name]/[office]/components/ContentSection.js
+++ b/app/candidate/[name]/[office]/components/ContentSection.js
@@ -1,26 +1,13 @@
 import AboutCard from './AboutCard'
 import FreeToolsCard from './FreeToolsCard'
 import OfficeCard from './OfficeCard'
-import P2vCard from './P2vCard'
-import TargetCard from './TargetCard'
 import TopIssuesCard from './TopIssuesCard'
 
 export default function ContentSection(props) {
   const { candidate } = props
-  const { p2vData } = candidate
+
   return (
     <div className="grid grid-cols-12 gap-4 mb-4 pt-5">
-      {p2vData ? (
-        <>
-          <div className="col-span-12 md:col-span-6">
-            <P2vCard {...props} />
-          </div>
-
-          <div className="col-span-12 md:col-span-6">
-            <TargetCard {...props} />
-          </div>
-        </>
-      ) : null}
       <div className="col-span-12">
         <FreeToolsCard />
       </div>
@@ -29,7 +16,7 @@ export default function ContentSection(props) {
       </div>
       <div className="col-span-12 md:col-span-6">
         <OfficeCard {...props} />
-        <TopIssuesCard {...props} />
+        <TopIssuesCard candidate={candidate} maxToShow={3} />
       </div>
     </div>
   )
diff --git a/app/candidate/[name]/[office]/components/OfficeCard.js b/app/candidate/[name]/[office]/components/OfficeCard.js
index 192d33885..2ccde14db 100644
--- a/app/candidate/[name]/[office]/components/OfficeCard.js
+++ b/app/candidate/[name]/[office]/components/OfficeCard.js
@@ -6,22 +6,23 @@ import H5 from '@shared/typography/H5'
 
 export default function OfficeCard(props) {
   const { candidate } = props
-  const { office, city, state, officeDescription, term, salary } = candidate
+  const { positionName, placeName, state, positionDescription, electionFrequency } = candidate
 
   const descLabels = [
-    { title: 'about role', description: officeDescription },
-    { title: 'term', description: term },
-    { title: 'salary', description: salary },
+    { title: 'about role', description: positionDescription },
+    { title: 'term', description: electionFrequency?.length
+        ? `${electionFrequency[0]} years`
+        : 'Unknown' },
   ]
 
   return (
     <section className="bg-primary-dark border border-gray-700 p-6 rounded-2xl">
       <Overline className="text-gray-400 mb-2">Running for</Overline>
-      <MarketingH4>{office}</MarketingH4>
+      <MarketingH4>{positionName}</MarketingH4>
       <div className="flex mt-2 mb-8 items-center">
         <FaMapMarkerAlt className="text-secondary-light mr-2" size={20} />
         <H5>
-          {city ? `${city}, ` : ''} {state}
+          {placeName ? `${placeName}, ` : ''} {state}
         </H5>
       </div>
       {descLabels.map((item) => (
diff --git a/app/candidate/[name]/[office]/components/TopIssuesCard.js b/app/candidate/[name]/[office]/components/TopIssuesCard.js
index 472ebb214..bd699454a 100644
--- a/app/candidate/[name]/[office]/components/TopIssuesCard.js
+++ b/app/candidate/[name]/[office]/components/TopIssuesCard.js
@@ -2,10 +2,9 @@ import MarketingH4 from '@shared/typography/MarketingH4'
 import Overline from '@shared/typography/Overline'
 import DescriptionLabel from './DescriptionLabel'
 
-export default function TopIssuesCard(props) {
-  const { candidate } = props
-  const { topIssues } = candidate
-  if (!topIssues || topIssues.length === 0) {
+export default function TopIssuesCard({candidate, maxToShow = 3}) {
+  const stances = candidate?.Stances ?? []
+  if (stances.length === 0) {
     return null
   }
 
@@ -14,11 +13,11 @@ export default function TopIssuesCard(props) {
       <Overline className="text-gray-400 mb-2">Voter Issues</Overline>
       <MarketingH4 className="mb-8">Top Issues</MarketingH4>
 
-      {topIssues.map((item, index) => (
+      {stances.slice(0, maxToShow).map((stance, idx) => (
         <DescriptionLabel
-          key={index}
-          title={`Issue ${index + 1} - ${item.issue}`}
-          description={item.stance}
+          key={stance.id}
+          title={`Issue ${idx + 1} - ${stance.Issue?.name ?? ''}`}
+          description={stance.stanceStatement}
         />
       ))}
     </section>
diff --git a/app/candidate/[name]/[office]/page.js b/app/candidate/[name]/[office]/page.js
index c0369405f..57f3cfb36 100644
--- a/app/candidate/[name]/[office]/page.js
+++ b/app/candidate/[name]/[office]/page.js
@@ -1,49 +1,47 @@
 import pageMetaData from 'helpers/metadataHelper'
 import CandidatePage from './components/CandidatePage'
-import gpApi from 'gpApi'
-import gpFetch from 'gpApi/gpFetch'
 import { permanentRedirect } from 'next/navigation'
 import CandidateSchema from './components/CandidateSchema'
+import slugify from 'slugify'
+import { electionApiRoutes } from 'gpApi/routes'
+import { unAuthFetch } from 'gpApi/unAuthFetch'
 
 export const revalidate = 3600
 export const dynamic = 'force-static'
 
-export const fetchCandidate = async (name, office, bustCache) => {
-  const api = gpApi.candidate.find
+export const fetchCandidate = async ({slug, raceSlug, includeStances = false}) => {
+  const api = electionApiRoutes.candidacies.find.path
   const payload = {
-    name,
-    office,
-    bustCache,
+    ...(slug && {slug}),
+    ...(raceSlug && {raceSlug}),
+    includeStances,
   }
-  return await gpFetch(api, payload, 3600)
+  const res = await unAuthFetch(api, payload, 3600)
+
+  if (Array.isArray(res)) {
+    return res[0]
+  }
+  return false
 }
 
 export async function generateMetadata({ params, searchParams }) {
-  const { bustCache } = searchParams
   const { name, office } = params
-  const { candidate } = await fetchCandidate(
-    name,
-    office,
-    bustCache === 'true',
-  )
+  const slug = `${slugify(name)}/${slugify(office)}`
+  const candidate = await fetchCandidate({slug})
   const { firstName, lastName, about, image } = candidate || {}
   const meta = pageMetaData({
-    title: `${firstName} ${lastName} for ${candidate?.office} | GoodParty.org`,
+    title: `${firstName} ${lastName} for ${candidate?.positionName} | GoodParty.org`,
     description: about,
     image,
-    slug: `/candidate/${name}/${office}`,
+    slug: `/candidate/${slug}`,
   })
   return meta
 }
 
 export default async function Page({ params, searchParams }) {
-  const { bustCache } = searchParams
   const { name, office } = params
-  const { candidate } = await fetchCandidate(
-    name,
-    office,
-    bustCache === 'true',
-  )
+  const slug = `${slugify(name)}/${slugify(office)}`
+  const candidate = await fetchCandidate({slug, includeStances: true})
   if (!candidate) {
     permanentRedirect('/candidates')
   }
diff --git a/app/sitemaps/candidates/[state]/sitemap.js b/app/sitemaps/candidates/[state]/sitemap.js
index 63bab6b9c..6933b7896 100644
--- a/app/sitemaps/candidates/[state]/sitemap.js
+++ b/app/sitemaps/candidates/[state]/sitemap.js
@@ -12,9 +12,10 @@ import { flatStates } from 'helpers/statesHelper'
 import { APP_BASE } from 'appEnv'
 
 const fetchCandidates = async (state) => {
-  const api = gpApi.candidate.list
+  const api = gpApi.elections.candidacies
   const payload = {
     state,
+    columns: 'slug'
   }
 
   return await gpFetch(api, payload, 3600)
diff --git a/gpApi/index.js b/gpApi/index.js
index f674d94d3..63aea6899 100644
--- a/gpApi/index.js
+++ b/gpApi/index.js
@@ -38,36 +38,6 @@ const gpApi = {
       withAuth: true,
     },
   },
-  elections: {
-    places: {
-      url: `${electionBase}places`,
-      method: 'GET',
-    },
-    races: {
-      url: `${electionBase}races`,
-      method: 'GET',
-    },
-    featuredCities: {
-      url: `${electionBase}places/most-elections`,
-      method: 'GET',
-    },
-  },
-
-  candidate: {
-    find: {
-      url: `${base}candidate`,
-      method: 'GET',
-    },
-    list: {
-      url: `${base}candidates`,
-      method: 'GET',
-    },
-    delete: {
-      url: `${base}candidate`,
-      method: 'DELETE',
-      withAuth: true, //admin
-    },
-  },
 }
 
 export default gpApi
diff --git a/gpApi/routes.js b/gpApi/routes.js
index 52e872ad3..3af5eafcc 100644
--- a/gpApi/routes.js
+++ b/gpApi/routes.js
@@ -509,3 +509,28 @@ export const apiRoutes = {
     },
   },
 }
+
+export const electionApiRoutes = {
+  places: {
+    find: {
+      path: '/places',
+      method: 'GET'
+    },
+    featuredCities: {
+      path: '/candidacies/most-elections',
+      method: 'GET',
+    }
+  },
+  races: {
+    find: {
+      path: '/races',
+      method: 'GET'
+    }
+  },
+  candidacies: {
+    find: {
+      path: '/candidacies',
+      method: 'GET',
+    },
+  }
+}
diff --git a/next.config.js b/next.config.js
index 68105c4b8..4a218ca4e 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,7 +1,7 @@
 const withPWA = require('next-pwa')({
   dest: 'public',
   disable: process.env.NODE_ENV === 'development',
-});
+})
 
 const nextConfig = {
   reactStrictMode: true,
@@ -11,6 +11,7 @@ const nextConfig = {
       'assets.goodparty.org',
       'images.ctfassets.net',
       'maps.googleapis.com',
+      'assets.civicengine.com'
     ],
   },
   env: {
@@ -30,8 +31,8 @@ const nextConfig = {
         source: '/robots.txt',
         destination: '/api/robots',
       },
-    ];
+    ]
   },
-};
+}
 
-module.exports = withPWA(nextConfig);
+module.exports = withPWA(nextConfig)


Reviews:
user: login: tomer-tgp
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T17:21:20Z
user: login: tomer-tgp
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T17:22:19Z
user: login: tomer-tgp
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T17:24:52Z
user: login: oldfashionedrobot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:38:19Z
user: login: tomer-tgp
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T19:15:47Z
user: login: tomer-tgp
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-08T16:38:21Z

Review comments:
diff_hunk: @@ -86,66 +98,67 @@ export default function CandidateCard(props) {
           <H1 className="mb-4">
             {firstName} {lastName}
           </H1>
+
           <div className="flex mb-3 items-center">
             <MdStars className="text-secondary-light" size={20} />
             <H5 className="ml-2">{party}</H5>
           </div>
+
           <div className="flex mb-3 items-center">
             <IoPersonSharp className="text-secondary-light" size={20} />
             <H5 className="ml-2">Running for {office}</H5>
path: app/candidate/[name]/[office]/components/CandidateCard.js
user: login: tomer-tgp
body: should this be positionName?
In the picture you have this is empty
created_at: 2025-05-07T17:22:15Z
author_association: COLLABORATOR
diff_hunk: @@ -29,13 +29,29 @@ export default function PositionPage(props) {
       city ? `${city}/` : ''
     }${race.slug}`
   }
+
+  const candidateLink = (c) => `/candidate/${c.slug}`
+  const candidatesForLinks = candidates?.map((c) => ({
+     ...c,
+    name: `${c.firstName} ${c.lastName}`
+  }))
+
   race.loc = loc
 
   return (
     <div className="bg-indigo-100">
       <MaxWidth>
         <Hero {...race} />
         <PositionDetails race={race} positions={positions} />
+              {/* ---------- Candidates running in this race ---------- */}
+              {candidatesForLinks?.length > 0 && (
+          <LinksSection
+            title={`Candidates running for ${race.normalizedPositionName}`}
path: app/(landing)/elections/position/[...loc]/components/PositionPage.js
user: login: tomer-tgp
body: Independent Candidates running for
created_at: 2025-05-07T17:24:49Z
author_association: COLLABORATOR
diff_hunk: @@ -21,6 +21,19 @@ const fetchRace = async (raceSlug) => {
   return null
 }
 
+const fetchCandidates = async (raceSlug) => {
+  const api = gpApi.elections.candidacies
+  const payload = {
+    raceSlug
+  }
+
+  const res = await gpFetch(api, payload, 3600)
path: app/(landing)/elections/position/[...loc]/page.js
user: login: oldfashionedrobot
body: is this an endpoint that doesn't need an auth token? If so, you should be able to the `unAuthFetch` method we have. The gpFetch is old should be getting replaced everywhere once we fully migrate off the old `tgp-api` 
created_at: 2025-05-07T17:38:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,19 @@ const fetchRace = async (raceSlug) => {
   return null
 }
 
+const fetchCandidates = async (raceSlug) => {
+  const api = gpApi.elections.candidacies
+  const payload = {
+    raceSlug
+  }
+
+  const res = await gpFetch(api, payload, 3600)
path: app/(landing)/elections/position/[...loc]/page.js
user: login: tomer-tgp
body: This should be `unAuthFetch`
created_at: 2025-05-07T19:15:46Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-06T16:01:15Z
body: [vc]: #qO5eqmMre5xBSPjHkUYnyMkiYU/5nTq9Bdh8im7bBxQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJncC1zdHlsZWd1aWRlIiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2dvb2QtcGFydHkvZ3Atc3R5bGVndWlkZS82elk2WFg5YmROZXRueG5DTFV1NnU0TG40ZGZqIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQifSx7Im5hbWUiOiJncC11aSIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9nb29kLXBhcnR5L2dwLXVpL0ZFRXY5bXFGM3IxS2Q5SzdOZlhZWkxQU2pxZWUiLCJwcmV2aWV3VXJsIjoiZ3AtdWktZ2l0LXdlYi00MDM5LWdvb2QtcGFydHkudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **gp-ui** | ✅ Ready ([Inspect](https://vercel.com/good-party/gp-ui/FEEv9mqF3r1Kd9K7NfXYZLPSjqee)) | [Visit Preview](https://gp-ui-git-web-4039-good-party.vercel.app) | May 8, 2025 3:51pm |

<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **gp-styleguide** | ⬜️ Ignored ([Inspect](https://vercel.com/good-party/gp-styleguide/6zY6XX9bdNetnxnCLUu6u4Ln4dfj)) |  | May 8, 2025 3:51pm |
</details>



author_association: NONE
user: login: oakinh
created_at: 2025-05-07T21:47:43Z
body: I made a separate `electionApiRoutes` const instead of reusing `apiRoutes` due to a naming collision with `elections` and to avoid future such issues
author_association: COLLABORATOR