diff --git a/src/App.jsx b/src/App.jsx
index 348978189a..f5f359b7de 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -123,6 +123,7 @@ const SharedItemLanding = React.lazy(() => import(/* webpackChunkName: 'SharedIt
const SignInEmailProcess = React.lazy(() => import(/* webpackChunkName: 'SignInEmailProcess' */ './js/pages/Process/SignInEmailProcess'));
const SignInJumpProcess = React.lazy(() => import(/* webpackChunkName: 'SignInJumpProcess' */ './js/pages/Process/SignInJumpProcess'));
const Squads = React.lazy(() => import(/* webpackChunkName: 'Squads' */ './js/pages/Squads'));
+const SupporterLandingChooseCandidate = React.lazy(() => import(/* webpackChunkName: 'SupporterLandingChooseCandidate' */ './js/pages/SupporterLanding/SupporterLandingChooseCandidate'));
const SuperSharingAddContacts = React.lazy(() => import(/* webpackChunkName: 'SuperSharingAddContacts' */ './js/common/pages/SuperSharing/SuperSharingAddContacts'));
const SuperSharingChooseRecipients = React.lazy(() => import(/* webpackChunkName: 'SuperSharingChooseRecipients' */ './js/common/pages/SuperSharing/SuperSharingChooseRecipients'));
const SuperSharingComposeEmailMessage = React.lazy(() => import(/* webpackChunkName: 'SuperSharingComposeEmailMessage' */ './js/common/pages/SuperSharing/SuperSharingComposeEmailMessage'));
@@ -663,6 +664,7 @@ class App extends Component {
+
diff --git a/src/img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg b/src/img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg
new file mode 100755
index 0000000000..2380e908dc
--- /dev/null
+++ b/src/img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/js/components/SupporterLanding/ActionCountLabel.jsx b/src/js/components/SupporterLanding/ActionCountLabel.jsx
new file mode 100644
index 0000000000..efc2d7ed4a
--- /dev/null
+++ b/src/js/components/SupporterLanding/ActionCountLabel.jsx
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Small muted count shown under an action button, e.g. "143 have chosen".
+export default function ActionCountLabel ({ count, suffix }) {
+ return (
+
+ {count.toLocaleString()}
+ {' '}
+ {suffix}
+
+ );
+}
+ActionCountLabel.propTypes = {
+ count: PropTypes.number.isRequired,
+ suffix: PropTypes.string.isRequired,
+};
+
+const CountText = styled.span`
+ color: ${DesignTokenColors.neutral600};
+ font-size: 14px;
+ font-weight: 400;
+`;
diff --git a/src/js/components/SupporterLanding/CandidateInfoCard.jsx b/src/js/components/SupporterLanding/CandidateInfoCard.jsx
new file mode 100644
index 0000000000..a9f3b7b756
--- /dev/null
+++ b/src/js/components/SupporterLanding/CandidateInfoCard.jsx
@@ -0,0 +1,183 @@
+import { OpenInNew as OpenInNewIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import normalizedImagePath from '../../common/utils/normalizedImagePath';
+
+const avatarGeneric = '../../../img/global/svg-icons/avatar-generic.svg';
+
+// Candidate identity block. Desktop/tablet: name over party, a vertical divider, then
+// "Learn more". Mobile: name on its own row, then "Independent | Learn more about Jane"
+// on a single row beneath it.
+export default function CandidateInfoCard ({ candidate }) {
+ const { name, party, officeDescription, learnMoreUrl } = candidate;
+ const firstName = candidate.firstName || name.split(' ')[0];
+
+ const learnMore = (
+
+ Learn more about
+ {' '}
+ {firstName}
+
+
+ );
+
+ return (
+
+
+
+
+
+ {name}
+ {party}
+
+
+ {learnMore}
+
+
+
+ {name}
+
+ {party}
+
+ {learnMore}
+
+
+
+ {officeDescription}
+
+
+ );
+}
+CandidateInfoCard.propTypes = {
+ candidate: PropTypes.shape({
+ name: PropTypes.string,
+ firstName: PropTypes.string,
+ party: PropTypes.string,
+ officeDescription: PropTypes.string,
+ learnMoreUrl: PropTypes.string,
+ }).isRequired,
+};
+
+const CandidateAvatar = styled.img`
+ border: 2px solid ${DesignTokenColors.neutralUI200};
+ border-radius: 50%;
+ flex: 0 0 auto;
+ height: 72px;
+ width: 72px;
+
+ @media (max-width: 575px) {
+ height: 52px;
+ width: 52px;
+ }
+`;
+
+const CandidateDetails = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+`;
+
+const CandidateName = styled(Link)`
+ color: ${DesignTokenColors.primary600};
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 1.15;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ @media (max-width: 575px) {
+ font-size: 20px;
+ }
+`;
+
+const CardWrapper = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 16px;
+
+ @media (max-width: 575px) {
+ align-items: flex-start;
+ gap: 10px;
+ }
+`;
+
+const IdentityRow = styled.div`
+ align-items: center;
+ column-gap: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ row-gap: 4px;
+`;
+
+const LearnMoreLink = styled(Link)`
+ align-items: center;
+ color: ${DesignTokenColors.primary600};
+ display: inline-flex;
+ font-size: 16px;
+ font-weight: 400;
+ gap: 4px;
+ text-decoration: none;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ @media (max-width: 575px) {
+ font-size: 14px;
+ }
+`;
+
+const MobileDivider = styled.span`
+ background: ${DesignTokenColors.neutralUI200};
+ flex: 0 0 auto;
+ height: 16px;
+ width: 1px;
+`;
+
+const MobileIdentity = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+`;
+
+const MobileMeta = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 6px;
+`;
+
+const NameStack = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const OfficeDescription = styled.p`
+ color: ${DesignTokenColors.neutral600};
+ font-size: 14px;
+ line-height: 1.4;
+ margin: 0;
+`;
+
+const Party = styled.span`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 17px;
+ white-space: nowrap;
+
+ @media (max-width: 575px) {
+ font-size: 14px;
+ }
+`;
+
+const RowDivider = styled.span`
+ background: ${DesignTokenColors.neutralUI200};
+ height: 40px;
+ width: 1px;
+`;
diff --git a/src/js/components/SupporterLanding/ChooseButton.jsx b/src/js/components/SupporterLanding/ChooseButton.jsx
new file mode 100644
index 0000000000..6187b09111
--- /dev/null
+++ b/src/js/components/SupporterLanding/ChooseButton.jsx
@@ -0,0 +1,47 @@
+import { Check as CheckIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import GreenCheckBadge from './GreenCheckBadge';
+
+// Primary "Choose" action. Toggles to a "Chosen" pill (lighter blue + a green badge
+// with a white check) once the voter has chosen this candidate.
+export default function ChooseButton ({ chosen = false, onClick }) {
+ return (
+
+ );
+}
+ChooseButton.propTypes = {
+ chosen: PropTypes.bool,
+ onClick: PropTypes.func,
+};
+
+const Button = styled.button`
+ align-items: center;
+ background: ${({ $chosen }) => ($chosen ? DesignTokenColors.primary400 : DesignTokenColors.primary600)};
+ border: 1.5px solid ${({ $chosen }) => ($chosen ? DesignTokenColors.primary400 : DesignTokenColors.primary600)};
+ border-radius: 9999px;
+ box-sizing: border-box;
+ color: ${DesignTokenColors.whiteUI};
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 18px;
+ font-weight: 400;
+ gap: 8px;
+ justify-content: center;
+ padding: 4px 20px;
+ width: 150px;
+
+ &:hover {
+ background: ${({ $chosen }) => ($chosen ? DesignTokenColors.primary400 : DesignTokenColors.primary700)};
+ border-color: ${({ $chosen }) => ($chosen ? DesignTokenColors.primary400 : DesignTokenColors.primary700)};
+ }
+`;
diff --git a/src/js/components/SupporterLanding/ChooseExplainerColumns.jsx b/src/js/components/SupporterLanding/ChooseExplainerColumns.jsx
new file mode 100644
index 0000000000..92ac54156c
--- /dev/null
+++ b/src/js/components/SupporterLanding/ChooseExplainerColumns.jsx
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// The two-column explainer under the buttons (only shown before choosing):
+// left — what "Choose" does
+// right — the "Not ready to choose?" follow pitch, split by a vertical divider.
+export default function ChooseExplainerColumns ({ candidateFirstName }) {
+ return (
+
+
+ Choosing marks
+ {' '}
+ {candidateFirstName}
+ {' '}
+ on your WeVote digital ballot for the most impact.
+
+
+
+ Not ready to choose?
+ {' '}
+ Following
+ {' '}
+ {candidateFirstName}
+ {' '}
+ helps build public trust and lets WeVote suggest similar
+ candidates and measures to help you complete your ballot.
+ {' '}
+ Learn more
+
+
+ );
+}
+ChooseExplainerColumns.propTypes = {
+ candidateFirstName: PropTypes.string,
+};
+
+const ColumnDivider = styled.span`
+ align-self: stretch;
+ background: ${DesignTokenColors.neutralUI200};
+ justify-self: center;
+ width: 1px;
+`;
+
+// The divider sits under the "-OR-" above it (centre at 196px = 160 action column + 16
+// gap + 20 half of the 40px "-OR-"). Zero grid gap + fixed columns: left column 194px so
+// the 4px divider column centres at 196; the right paragraph aligns with the Follow
+// column via padding-left (track starts at 198, + 36 = 234). Only the left paragraph's
+// width changes when tuning.
+const ColumnsWrapper = styled.div`
+ align-items: stretch;
+ column-gap: 0;
+ display: grid;
+ grid-template-columns: 194px 4px 1fr;
+
+ & > p:first-of-type {
+ padding-right: 6px;
+ }
+
+ & > p:last-of-type {
+ padding-left: 36px;
+ }
+`;
+
+const ExplainerColumn = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
+
+const InlineLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 15px;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/ChooseFollowActions.jsx b/src/js/components/SupporterLanding/ChooseFollowActions.jsx
new file mode 100644
index 0000000000..b888c9d419
--- /dev/null
+++ b/src/js/components/SupporterLanding/ChooseFollowActions.jsx
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import ActionCountLabel from './ActionCountLabel';
+import ChooseButton from './ChooseButton';
+import FollowButton from './FollowButton';
+
+// The Choose / -OR- / Follow action row, with the running counts underneath each
+// button. On mobile the two actions stack (Choose above, Follow below).
+export default function ChooseFollowActions ({ chosen, following, chosenCount, followingCount, onChoose, onFollow }) {
+ return (
+
+
+
+
+
+
+ -OR-
+
+
+
+
+
+
+ );
+}
+ChooseFollowActions.propTypes = {
+ chosen: PropTypes.bool,
+ following: PropTypes.bool,
+ chosenCount: PropTypes.number,
+ followingCount: PropTypes.number,
+ onChoose: PropTypes.func,
+ onFollow: PropTypes.func,
+};
+
+const ActionColumn = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 160px;
+`;
+
+// Equal-width action columns with the "-OR-" between them via a plain flex gap, so the
+// "-OR-" is equidistant from both buttons. The middle "-OR-" has a fixed width so its
+// centre is deterministic (160 + 16 + 20 = 196px from the left), which the divider in
+// ChooseExplainerColumns lines up under.
+const ActionsWrapper = styled.div`
+ align-items: flex-start;
+ display: flex;
+ gap: 16px;
+`;
+
+const OrDivider = styled.span`
+ color: ${DesignTokenColors.neutral500};
+ font-size: 15px;
+ font-weight: 400;
+ padding-top: 10px;
+ text-align: center;
+ visibility: ${({ $chosen }) => ($chosen ? 'hidden' : 'visible')};
+ width: 40px;
+`;
diff --git a/src/js/components/SupporterLanding/CollapsedStepHeadline.jsx b/src/js/components/SupporterLanding/CollapsedStepHeadline.jsx
new file mode 100644
index 0000000000..f6f3954e44
--- /dev/null
+++ b/src/js/components/SupporterLanding/CollapsedStepHeadline.jsx
@@ -0,0 +1,38 @@
+import { KeyboardArrowDown as ArrowDownIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Mobile-only collapsed headline shown on later steps, standing in for the full welcome
+// block. Tapping it steps back toward the top of the flow.
+export default function CollapsedStepHeadline ({ headline, onClick }) {
+ return (
+
+
+ {headline}
+
+ );
+}
+CollapsedStepHeadline.propTypes = {
+ headline: PropTypes.string,
+ onClick: PropTypes.func,
+};
+
+const Headline = styled.span`
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.2;
+`;
+
+const Toggle = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.neutral900};
+ cursor: pointer;
+ display: flex;
+ gap: 6px;
+ padding: 0;
+ text-align: left;
+`;
diff --git a/src/js/components/SupporterLanding/CommentComposer.jsx b/src/js/components/SupporterLanding/CommentComposer.jsx
new file mode 100644
index 0000000000..f1300da239
--- /dev/null
+++ b/src/js/components/SupporterLanding/CommentComposer.jsx
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import HelpMeWriteButton from './HelpMeWriteButton';
+import VoterAvatar from './VoterAvatar';
+
+// The highlighted compose box: voter avatar + single-line input, with "Help me write"
+// beneath. Demo only — the input isn't wired to anything.
+export default function CommentComposer ({ placeholder }) {
+ return (
+
+
+
+
+
+ {/* Indent to line up with the input box (past the 40px avatar + 12px gap) */}
+
+
+
+
+ );
+}
+CommentComposer.propTypes = {
+ placeholder: PropTypes.string,
+};
+
+const Composer = styled.div`
+ background: ${DesignTokenColors.caution50};
+ border-radius: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px 20px;
+`;
+
+const HelpMeWriteRow = styled.div`
+ padding-left: 52px;
+`;
+
+const InputRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 12px;
+`;
+
+const TextInput = styled.input`
+ background: ${DesignTokenColors.whiteUI};
+ border: 1px solid ${DesignTokenColors.neutralUI300};
+ border-radius: 9999px;
+ color: ${DesignTokenColors.neutral900};
+ flex: 1;
+ font-size: 16px;
+ min-width: 0;
+ padding: 5px 18px;
+
+ &::placeholder {
+ color: ${DesignTokenColors.neutral500};
+ }
+
+ &:focus {
+ border-color: ${DesignTokenColors.primary600};
+ outline: none;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/CommentItem.jsx b/src/js/components/SupporterLanding/CommentItem.jsx
new file mode 100644
index 0000000000..a16d4433e1
--- /dev/null
+++ b/src/js/components/SupporterLanding/CommentItem.jsx
@@ -0,0 +1,125 @@
+import { MoreHoriz as MoreHorizIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import normalizedImagePath from '../../common/utils/normalizedImagePath';
+import CommentReactions from './CommentReactions';
+
+const avatarGeneric = '../../../img/global/svg-icons/avatar-generic.svg';
+
+// A single endorsement comment: avatar, author + reactions, the text, and a meta line
+// with an overflow (•••) menu button.
+export default function CommentItem ({ comment }) {
+ const { authorName, likeCount, text, meta } = comment;
+ return (
+ -
+
+
+
+ {authorName}
+
+
+ {text}
+
+ {meta}
+
+
+
+
+
+
+ );
+}
+CommentItem.propTypes = {
+ comment: PropTypes.shape({
+ authorName: PropTypes.string,
+ likeCount: PropTypes.number,
+ text: PropTypes.string,
+ meta: PropTypes.string,
+ }).isRequired,
+};
+
+const AuthorName = styled.span`
+ color: ${DesignTokenColors.neutral900};
+ font-size: 16px;
+ font-weight: 700;
+
+ @media (max-width: 575px) {
+ font-size: 14px;
+ }
+`;
+
+const CommentAvatar = styled.img`
+ background: ${DesignTokenColors.neutralUI100};
+ border-radius: 50%;
+ flex: 0 0 auto;
+ height: 44px;
+ width: 44px;
+
+ @media (max-width: 575px) {
+ height: 36px;
+ width: 36px;
+ }
+`;
+
+const CommentBody = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+`;
+
+const CommentMeta = styled.span`
+ color: ${DesignTokenColors.neutral500};
+ font-size: 13px;
+
+ /* Keep the meta line + ••• menu on a single row on phones */
+ @media (max-width: 575px) {
+ font-size: 12px;
+ white-space: nowrap;
+ }
+`;
+
+const CommentText = styled.p`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+
+ @media (max-width: 575px) {
+ font-size: 14px;
+ }
+`;
+
+const Item = styled.div`
+ display: flex;
+ gap: 14px;
+`;
+
+const MetaRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 8px;
+`;
+
+const MoreButton = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.neutral500};
+ cursor: pointer;
+ display: inline-flex;
+ padding: 0;
+
+ &:hover {
+ color: ${DesignTokenColors.neutral800};
+ }
+`;
+
+const TopRow = styled.div`
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+`;
diff --git a/src/js/components/SupporterLanding/CommentList.jsx b/src/js/components/SupporterLanding/CommentList.jsx
new file mode 100644
index 0000000000..7d8253f4a1
--- /dev/null
+++ b/src/js/components/SupporterLanding/CommentList.jsx
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import CommentItem from './CommentItem';
+import SeeMoreCommentsLink from './SeeMoreCommentsLink';
+
+// "WHAT OTHERS ARE SAYING" — the heading, the list of endorsement comments, and the
+// "See more comments" link.
+export default function CommentList ({ comments }) {
+ return (
+
+ What others are saying
+ {comments.map((comment) => (
+
+ ))}
+
+
+ );
+}
+CommentList.propTypes = {
+ comments: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ })),
+};
+
+const Heading = styled.h3`
+ color: ${DesignTokenColors.neutral600};
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ margin: 0;
+ text-transform: uppercase;
+`;
+
+const Section = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+`;
diff --git a/src/js/components/SupporterLanding/CommentReactions.jsx b/src/js/components/SupporterLanding/CommentReactions.jsx
new file mode 100644
index 0000000000..1fa98e502b
--- /dev/null
+++ b/src/js/components/SupporterLanding/CommentReactions.jsx
@@ -0,0 +1,72 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// The like / dislike pill next to a comment author: heart + count, a divider, then the
+// dislike (slashed heart). The two glyphs are the exact SVGs the ballot page's
+// HeartFavoriteToggle uses, so the reaction affordance matches the rest of the app.
+function HeartIcon () {
+ return (
+
+ );
+}
+
+function HeartDislikeIcon () {
+ return (
+
+ );
+}
+
+export default function CommentReactions ({ likeCount }) {
+ return (
+
+
+
+ {likeCount}
+
+
+
+
+
+
+ );
+}
+CommentReactions.propTypes = {
+ likeCount: PropTypes.number,
+};
+
+const Count = styled.span`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 15px;
+`;
+
+const Divider = styled.span`
+ background: ${DesignTokenColors.neutralUI200};
+ height: 18px;
+ width: 1px;
+`;
+
+const Pill = styled.div`
+ align-items: center;
+ background: ${DesignTokenColors.whiteUI};
+ border: 1px solid ${DesignTokenColors.neutralUI300};
+ border-radius: 9999px;
+ display: inline-flex;
+ gap: 8px;
+ padding: 2px 10px;
+`;
+
+const ReactionButton = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: inline-flex;
+ gap: 5px;
+ padding: 2px 0;
+`;
diff --git a/src/js/components/SupporterLanding/DecideLaterFooter.jsx b/src/js/components/SupporterLanding/DecideLaterFooter.jsx
new file mode 100644
index 0000000000..6250bce982
--- /dev/null
+++ b/src/js/components/SupporterLanding/DecideLaterFooter.jsx
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// "I'll decide later - Go to Step N" footer link below a step card. The prompt and link
+// label vary per step.
+export default function DecideLaterFooter ({ prompt, linkText, onGoToNextStep }) {
+ return (
+
+ {prompt}
+ {' - '}
+ {linkText}
+
+ );
+}
+DecideLaterFooter.propTypes = {
+ prompt: PropTypes.string,
+ linkText: PropTypes.string,
+ onGoToNextStep: PropTypes.func,
+};
+DecideLaterFooter.defaultProps = {
+ prompt: 'I’ll decide later',
+ linkText: 'Go to Step 2',
+};
+
+const FooterWrapper = styled.div`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 18px;
+ padding: 8px 0;
+ text-align: center;
+`;
+
+const GoLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary500};
+ cursor: pointer;
+ font-size: 18px;
+ font-weight: 500;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/FollowButton.jsx b/src/js/components/SupporterLanding/FollowButton.jsx
new file mode 100644
index 0000000000..834735bf04
--- /dev/null
+++ b/src/js/components/SupporterLanding/FollowButton.jsx
@@ -0,0 +1,45 @@
+import { Favorite as FavoriteIcon, FavoriteBorder as FavoriteBorderIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Secondary "Follow" action. Keeps its outlined pill shape in both states; when
+// following it shows a filled red heart + "Following".
+export default function FollowButton ({ following = false, onClick }) {
+ return (
+
+ );
+}
+FollowButton.propTypes = {
+ following: PropTypes.bool,
+ onClick: PropTypes.func,
+};
+
+const Button = styled.button`
+ align-items: center;
+ background: ${({ $following }) => ($following ? DesignTokenColors.primary50 : DesignTokenColors.whiteUI)};
+ border: 1.5px solid ${DesignTokenColors.primary600};
+ border-radius: 9999px;
+ box-sizing: border-box;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 18px;
+ font-weight: 400;
+ gap: 8px;
+ justify-content: center;
+ padding: 4px 20px;
+ width: 150px;
+
+ &:hover {
+ background: ${({ $following }) => ($following ? DesignTokenColors.primary100 : DesignTokenColors.primary50)};
+ }
+`;
diff --git a/src/js/components/SupporterLanding/GreenCheckBadge.jsx b/src/js/components/SupporterLanding/GreenCheckBadge.jsx
new file mode 100644
index 0000000000..bc5051bc99
--- /dev/null
+++ b/src/js/components/SupporterLanding/GreenCheckBadge.jsx
@@ -0,0 +1,26 @@
+import { Check as CheckIcon } from '@mui/icons-material';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Green circle with a white check, matching the "STEP N" complete badge. Shared by the
+// Choose and Make-support-public buttons.
+export default function GreenCheckBadge () {
+ return (
+
+
+
+ );
+}
+
+const Badge = styled.span`
+ align-items: center;
+ background: ${DesignTokenColors.confirmation600};
+ border-radius: 50%;
+ color: ${DesignTokenColors.whiteUI};
+ display: inline-flex;
+ height: 20px;
+ justify-content: center;
+ margin: -2px 0;
+ width: 20px;
+`;
diff --git a/src/js/components/SupporterLanding/HelpMeWriteButton.jsx b/src/js/components/SupporterLanding/HelpMeWriteButton.jsx
new file mode 100644
index 0000000000..430fd025b9
--- /dev/null
+++ b/src/js/components/SupporterLanding/HelpMeWriteButton.jsx
@@ -0,0 +1,35 @@
+import { DriveFileRenameOutline as PencilIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// "Help me write" AI-assist link shown under the comment input.
+export default function HelpMeWriteButton ({ onClick }) {
+ return (
+
+ );
+}
+HelpMeWriteButton.propTypes = {
+ onClick: PropTypes.func,
+};
+
+const Button = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 16px;
+ font-weight: 600;
+ gap: 6px;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/MakePublicButton.jsx b/src/js/components/SupporterLanding/MakePublicButton.jsx
new file mode 100644
index 0000000000..c4d37bb5c7
--- /dev/null
+++ b/src/js/components/SupporterLanding/MakePublicButton.jsx
@@ -0,0 +1,48 @@
+import { Public as PublicIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import GreenCheckBadge from './GreenCheckBadge';
+
+// "Make support public" action. Mirrors ChooseButton: a filled blue pill that toggles to
+// a lighter blue "made public" pill with a green check once support is public.
+export default function MakePublicButton ({ isPublic = false, label, labelPublic, onClick }) {
+ return (
+
+ );
+}
+MakePublicButton.propTypes = {
+ isPublic: PropTypes.bool,
+ label: PropTypes.string,
+ labelPublic: PropTypes.string,
+ onClick: PropTypes.func,
+};
+
+const Button = styled.button`
+ align-items: center;
+ background: ${({ $public }) => ($public ? DesignTokenColors.primary400 : DesignTokenColors.primary600)};
+ border: 1.5px solid ${({ $public }) => ($public ? DesignTokenColors.primary400 : DesignTokenColors.primary600)};
+ border-radius: 9999px;
+ box-sizing: border-box;
+ color: ${DesignTokenColors.whiteUI};
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 18px;
+ font-weight: 400;
+ gap: 8px;
+ justify-content: center;
+ padding: 6px 22px;
+
+ &:hover {
+ background: ${({ $public }) => ($public ? DesignTokenColors.primary400 : DesignTokenColors.primary700)};
+ border-color: ${({ $public }) => ($public ? DesignTokenColors.primary400 : DesignTokenColors.primary700)};
+ }
+`;
diff --git a/src/js/components/SupporterLanding/NameEntryForm.jsx b/src/js/components/SupporterLanding/NameEntryForm.jsx
new file mode 100644
index 0000000000..430a4e4a15
--- /dev/null
+++ b/src/js/components/SupporterLanding/NameEntryForm.jsx
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import NameField from './NameField';
+
+// The "By the way, we didn't catch your name!" block: heading, explainer, first + last
+// name fields, and an inline error shown when public is attempted without a name.
+export default function NameEntryForm ({
+ heading, text, firstName, lastName, onChangeFirst, onChangeLast,
+ firstNamePlaceholder, lastNamePlaceholder, showError, errorText,
+}) {
+ return (
+
+ {heading}
+ {text}
+
+
+
+
+ {showError && {errorText}}
+
+ );
+}
+NameEntryForm.propTypes = {
+ heading: PropTypes.string,
+ text: PropTypes.string,
+ firstName: PropTypes.string,
+ lastName: PropTypes.string,
+ onChangeFirst: PropTypes.func,
+ onChangeLast: PropTypes.func,
+ firstNamePlaceholder: PropTypes.string,
+ lastNamePlaceholder: PropTypes.string,
+ showError: PropTypes.bool,
+ errorText: PropTypes.string,
+};
+
+const ErrorText = styled.p`
+ color: ${DesignTokenColors.warning900};
+ font-size: 15px;
+ font-weight: 600;
+ margin: 4px 0 0;
+`;
+
+const FieldsRow = styled.div`
+ display: flex;
+ gap: 16px;
+ margin-top: 4px;
+
+ @media (max-width: 575px) {
+ flex-direction: column;
+ gap: 10px;
+ }
+`;
+
+const FormWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+const Heading = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ font-weight: 700;
+ margin: 0;
+`;
+
+const Text = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
diff --git a/src/js/components/SupporterLanding/NameField.jsx b/src/js/components/SupporterLanding/NameField.jsx
new file mode 100644
index 0000000000..43933df0c3
--- /dev/null
+++ b/src/js/components/SupporterLanding/NameField.jsx
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// A single rounded-rectangle text input for the first / last name entry, turning its
+// border warning-orange when in an error state.
+export default function NameField ({ value, onChange, placeholder, error = false }) {
+ return (
+
+ );
+}
+NameField.propTypes = {
+ value: PropTypes.string,
+ onChange: PropTypes.func,
+ placeholder: PropTypes.string,
+ error: PropTypes.bool,
+};
+
+const Input = styled.input`
+ background: ${DesignTokenColors.whiteUI};
+ border: 1px solid ${({ $error }) => ($error ? DesignTokenColors.warning900 : DesignTokenColors.neutralUI300)};
+ border-radius: 8px;
+ color: ${DesignTokenColors.neutral900};
+ flex: 1;
+ font-size: 16px;
+ min-width: 0;
+ padding: 7px 14px;
+
+ &::placeholder {
+ color: ${DesignTokenColors.neutral500};
+ }
+
+ &:focus {
+ border-color: ${({ $error }) => ($error ? DesignTokenColors.warning900 : DesignTokenColors.primary600)};
+ outline: none;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/PersonalMessageCollapse.jsx b/src/js/components/SupporterLanding/PersonalMessageCollapse.jsx
new file mode 100644
index 0000000000..e9ff619f2b
--- /dev/null
+++ b/src/js/components/SupporterLanding/PersonalMessageCollapse.jsx
@@ -0,0 +1,84 @@
+import { KeyboardArrowDown as ArrowDownIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Collapsible "Personal message from :" row. Collapsed shows a one-line
+// preview; expanded reveals the full note.
+export default function PersonalMessageCollapse ({ candidateName, message, previewText }) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+ setExpanded((prev) => !prev)}
+ >
+
+
+
+
+ {`Personal message from ${candidateName}:`}
+
+
+ {expanded ? `“${message}”` : `“${previewText}”`}
+
+
+
+ );
+}
+PersonalMessageCollapse.propTypes = {
+ candidateName: PropTypes.string,
+ message: PropTypes.string,
+ previewText: PropTypes.string,
+};
+
+const MessageToggle = styled.button`
+ align-items: baseline;
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 0;
+ text-align: left;
+`;
+
+const MessageWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+// One element for both the collapsed preview and the expanded full text, so the quote
+// starts in exactly the same place either way (no shift when toggling).
+const Quote = styled.span`
+ color: ${DesignTokenColors.neutral600};
+ font-style: italic;
+ line-height: 1.5;
+`;
+
+const ToggleArrow = styled.span`
+ align-self: center;
+ color: ${DesignTokenColors.neutral700};
+ display: inline-flex;
+ transform: rotate(${({ $expanded }) => ($expanded ? '180deg' : '0deg')});
+ transition: transform 0.15s ease;
+`;
+
+const ToggleLabel = styled.span`
+ color: ${DesignTokenColors.neutral900};
+ font-weight: 700;
+ text-decoration: none;
+
+ /* Hover treatment only on non-touch (desktop/tablet); avoids sticky blue+underline on mobile */
+ @media (min-width: 576px) {
+ &:hover {
+ color: ${DesignTokenColors.primary600};
+ text-decoration: underline;
+ }
+ }
+`;
diff --git a/src/js/components/SupporterLanding/PreviewEditInvitationLink.jsx b/src/js/components/SupporterLanding/PreviewEditInvitationLink.jsx
new file mode 100644
index 0000000000..64f8081bda
--- /dev/null
+++ b/src/js/components/SupporterLanding/PreviewEditInvitationLink.jsx
@@ -0,0 +1,37 @@
+import { Edit as EditIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// "Preview/Edit invitation" pencil link shown beside the invite text.
+export default function PreviewEditInvitationLink ({ label, onClick }) {
+ return (
+
+
+ {label}
+
+ );
+}
+PreviewEditInvitationLink.propTypes = {
+ label: PropTypes.string,
+ onClick: PropTypes.func,
+};
+
+const LinkButton = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 14px;
+ font-weight: 600;
+ gap: 6px;
+ padding: 0;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/ProfilePicturePrompt.jsx b/src/js/components/SupporterLanding/ProfilePicturePrompt.jsx
new file mode 100644
index 0000000000..de9687fc83
--- /dev/null
+++ b/src/js/components/SupporterLanding/ProfilePicturePrompt.jsx
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import VoterAvatar from './VoterAvatar';
+
+// Purple voter avatar plus an "Add your profile picture" nudge, stacked for the sidebar.
+export default function ProfilePicturePrompt ({ initials = 'Dn' }) {
+ return (
+
+
+
+ Add your profile picture
+ to build trust and show you're a real voter.
+
+
+ );
+}
+ProfilePicturePrompt.propTypes = {
+ initials: PropTypes.string,
+};
+
+const PromptLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 1.25;
+ padding: 0;
+ text-align: left;
+`;
+
+const PromptSubText = styled.span`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 14px;
+ line-height: 1.35;
+`;
+
+const PromptText = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+`;
+
+const PromptWrapper = styled.div`
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`;
diff --git a/src/js/components/SupporterLanding/SeeMoreCommentsLink.jsx b/src/js/components/SupporterLanding/SeeMoreCommentsLink.jsx
new file mode 100644
index 0000000000..8880ca5542
--- /dev/null
+++ b/src/js/components/SupporterLanding/SeeMoreCommentsLink.jsx
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// "See more comments" link at the bottom of the comment list.
+export default function SeeMoreCommentsLink ({ onClick }) {
+ return (
+ See more comments
+ );
+}
+SeeMoreCommentsLink.propTypes = {
+ onClick: PropTypes.func,
+};
+
+// Indented to line up with the comment text (past the 44px avatar + 14px gap), a touch
+// lighter than the link blue, and one weight step above the "Endorsed ..." meta line.
+const SeeMoreButton = styled.button`
+ align-self: flex-start;
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary500};
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 500;
+ margin-left: 58px;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ @media (max-width: 575px) {
+ margin-left: 50px;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/ShareOptionButton.jsx b/src/js/components/SupporterLanding/ShareOptionButton.jsx
new file mode 100644
index 0000000000..325313e9e3
--- /dev/null
+++ b/src/js/components/SupporterLanding/ShareOptionButton.jsx
@@ -0,0 +1,78 @@
+import {
+ ContentCopy as CopyIcon, Facebook as FacebookIcon, Link as LinkIcon,
+ People as PeopleIcon, QrCode2 as QrIcon, X as XIcon,
+} from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+const ICONS = {
+ people: PeopleIcon,
+ copy: CopyIcon,
+ link: LinkIcon,
+ qr: QrIcon,
+ facebook: FacebookIcon,
+ x: XIcon,
+};
+
+// A single share affordance: a round icon chip over a label. Default chips are gray;
+// `brand` ("facebook" | "x") gives the brand-colored chip.
+export default function ShareOptionButton ({ label, icon, brand, onClick }) {
+ const IconComponent = ICONS[icon];
+ return (
+
+
+
+
+
+
+ );
+}
+ShareOptionButton.propTypes = {
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ brand: PropTypes.oneOf(['facebook', 'x']),
+ onClick: PropTypes.func,
+};
+
+function chipBackground ($brand) {
+ if ($brand === 'facebook') return '#1877F2';
+ if ($brand === 'x') return '#000000';
+ return DesignTokenColors.neutralUI500;
+}
+
+const IconChip = styled.span`
+ align-items: center;
+ background: ${({ $brand }) => chipBackground($brand)};
+ border-radius: 50%;
+ color: ${DesignTokenColors.whiteUI};
+ display: flex;
+ height: 48px;
+ justify-content: center;
+ width: 48px;
+`;
+
+const Label = styled.span`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 12px;
+ line-height: 1.3;
+ text-align: center;
+
+ @media (max-width: 575px) {
+ font-size: 11px;
+ }
+`;
+
+const OptionButton = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ padding: 2px;
+`;
diff --git a/src/js/components/SupporterLanding/ShareOptionCard.jsx b/src/js/components/SupporterLanding/ShareOptionCard.jsx
new file mode 100644
index 0000000000..6d04c0df19
--- /dev/null
+++ b/src/js/components/SupporterLanding/ShareOptionCard.jsx
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import ShareOptionButton from './ShareOptionButton';
+
+// A white card holding one or two share options side by side.
+export default function ShareOptionCard ({ options, onShare }) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+ShareOptionCard.propTypes = {
+ options: PropTypes.arrayOf(PropTypes.shape({
+ key: PropTypes.string,
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ brand: PropTypes.string,
+ })),
+ onShare: PropTypes.func,
+};
+
+const Card = styled.div`
+ align-items: stretch;
+ background: ${DesignTokenColors.whiteUI};
+ border-radius: 12px;
+ display: flex;
+ gap: 8px;
+ padding: 16px 14px;
+
+ @media (max-width: 575px) {
+ gap: 4px;
+ padding: 14px 8px;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/ShareOptionsGrid.jsx b/src/js/components/SupporterLanding/ShareOptionsGrid.jsx
new file mode 100644
index 0000000000..9e69643fe7
--- /dev/null
+++ b/src/js/components/SupporterLanding/ShareOptionsGrid.jsx
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import ShareOptionCard from './ShareOptionCard';
+
+// Row of white share cards. Each card is weighted (per its label length) so labels wrap
+// to at most 2 lines on desktop; on mobile the cards reflow to two per row (2 × 2).
+export default function ShareOptionsGrid ({ groups, onShare }) {
+ return (
+
+ {groups.map((group) => (
+
+
+
+ ))}
+
+ );
+}
+ShareOptionsGrid.propTypes = {
+ groups: PropTypes.arrayOf(PropTypes.shape({
+ key: PropTypes.string,
+ flex: PropTypes.number,
+ options: PropTypes.array,
+ })),
+ onShare: PropTypes.func,
+};
+
+const CardSlot = styled.div`
+ display: flex;
+ flex: ${({ $flex }) => $flex};
+ min-width: 0;
+
+ & > div {
+ width: 100%;
+ }
+
+ /* Mobile: two cards per row (2 × 2) at a ~1:2 ratio, so the double-option cards stay
+ wide enough to keep their labels to 2 lines */
+ @media (max-width: 575px) {
+ flex: 0 0 ${({ $single }) => ($single ? 'calc(33.333% - 6px)' : 'calc(66.667% - 6px)')};
+ }
+`;
+
+const Grid = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+`;
diff --git a/src/js/components/SupporterLanding/StepCard.jsx b/src/js/components/SupporterLanding/StepCard.jsx
new file mode 100644
index 0000000000..4142a0cc0a
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepCard.jsx
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Shared gray rounded card chrome used by every step's card. Gaps vary per step via the
+// $gap / $mobileGap props.
+const StepCard = styled.section`
+ background: ${DesignTokenColors.neutralUI50};
+ border-radius: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: ${({ $gap }) => $gap || 20}px;
+ padding: 24px 28px;
+
+ @media (max-width: 575px) {
+ gap: ${({ $mobileGap }) => $mobileGap || 16}px;
+ padding: 18px 16px;
+ }
+`;
+
+export default StepCard;
diff --git a/src/js/components/SupporterLanding/StepCardHeader.jsx b/src/js/components/SupporterLanding/StepCardHeader.jsx
new file mode 100644
index 0000000000..0222cc2b1e
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepCardHeader.jsx
@@ -0,0 +1,155 @@
+import { Check as CheckIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Shared header for the step cards, laid out as two side-by-side columns so the reason
+// sits just to the right of the title (not flung to the card's far edge):
+// left column — "STEP N" kicker over the icon + title
+// right column — "WHY?" kicker over the "-> reason" (WHY? hidden on mobile)
+// The columns wrap on narrow screens, dropping the reason below the title. `titleIcon`
+// is the glyph before the title (a check for Step 1, a chat bubble for Step 2); `chosen`
+// adds the green completion check to the "STEP N" kicker.
+export default function StepCardHeader ({ stepNumber, kickerSuffix = '', titleIcon, title, why, chosen = false, fontSize = 21, mobileFontSize = 17, reasonGap = 10 }) {
+ return (
+
+
+
+ {chosen && }
+ {`STEP ${stepNumber}${kickerSuffix}`}
+
+
+ {titleIcon}
+ {title}
+ {/* Desktop keeps the arrow at the end of the title */}
+ →
+
+
+
+
+ WHY?
+
+ {/* Mobile pairs the arrow with the reason on its own line */}
+ →
+ {why}
+
+
+
+ );
+}
+StepCardHeader.propTypes = {
+ stepNumber: PropTypes.number,
+ kickerSuffix: PropTypes.string,
+ titleIcon: PropTypes.node,
+ title: PropTypes.node,
+ why: PropTypes.string,
+ chosen: PropTypes.bool,
+ fontSize: PropTypes.number,
+ mobileFontSize: PropTypes.number,
+ reasonGap: PropTypes.number,
+};
+StepCardHeader.defaultProps = {
+ titleIcon: ,
+};
+
+const Arrow = styled.span`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 26px;
+`;
+
+const HeaderWrapper = styled.div`
+ align-items: flex-start;
+ column-gap: ${({ $reasonGap }) => $reasonGap}px;
+ display: flex;
+ flex-wrap: wrap;
+ row-gap: 6px;
+
+ @media (max-width: 575px) {
+ row-gap: 0;
+ }
+`;
+
+const Kicker = styled.div`
+ align-items: center;
+ color: ${DesignTokenColors.neutral500};
+ display: flex;
+ font-size: 18px;
+ font-weight: 600;
+ gap: 6px;
+ height: 22px;
+ letter-spacing: 0.04em;
+`;
+
+const LeftColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+const ReasonGroup = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ min-height: 34px;
+
+ /* Mobile: drop the height reservation and indent so the arrow lines up under the
+ title text (past the checkmark), and sits tight beneath it */
+ @media (max-width: 575px) {
+ margin-left: 28px;
+ min-height: 0;
+ }
+`;
+
+const RightColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+const Title = styled.span`
+ font-size: ${({ $size }) => $size}px;
+ font-weight: 600;
+
+ /* Shrink so the title stays on one line even on narrow phones */
+ @media (max-width: 575px) {
+ font-size: ${({ $mobileSize }) => $mobileSize}px;
+ }
+`;
+
+const TitleGroup = styled.div`
+ align-items: center;
+ color: ${DesignTokenColors.neutral700};
+ display: flex;
+ gap: 8px;
+ min-height: 34px;
+
+ @media (max-width: 575px) {
+ min-height: 0;
+ }
+`;
+
+const Why = styled.span`
+ color: ${DesignTokenColors.neutral700};
+ font-size: ${({ $size }) => $size}px;
+ font-style: italic;
+ font-weight: 400;
+
+ @media (max-width: 575px) {
+ font-size: ${({ $mobileSize }) => $mobileSize}px;
+ }
+`;
+
+const WhyKicker = styled.span`
+ align-items: center;
+ color: ${DesignTokenColors.neutral500};
+ display: inline-flex;
+ font-size: 16px;
+ font-weight: 400;
+ height: 22px;
+ letter-spacing: 0.06em;
+
+ @media (max-width: 575px) {
+ display: none;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/StepFourCard.jsx b/src/js/components/SupporterLanding/StepFourCard.jsx
new file mode 100644
index 0000000000..b414cbe702
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepFourCard.jsx
@@ -0,0 +1,94 @@
+import { GroupAdd as GroupAddIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useReward } from 'react-rewards';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import PreviewEditInvitationLink from './PreviewEditInvitationLink';
+import ShareOptionsGrid from './ShareOptionsGrid';
+import StepCard from './StepCard';
+import StepCardHeader from './StepCardHeader';
+import { CONFETTI_COLORS } from './supporterLandingConstants';
+
+const CONFETTI_OPTIONS = {
+ elementCount: 90,
+ spread: 120,
+ elementSize: 8,
+ zIndex: 10,
+ colors: CONFETTI_COLORS,
+ startVelocity: 26,
+ decay: 0.9,
+};
+
+// Step 4 card — "Spread the word about Jane Dough". Clicking any share option (all no-ops
+// for now) fires the confetti and marks the flow complete ("Word spread!"). `completed`
+// is owned by the page so the stepper + the thank-you section below stay in sync.
+export default function StepFourCard ({ stepFour, groups, completed, onComplete }) {
+ const { reward: triggerConfetti } = useReward('spreadWordConfettiReward', 'confetti', CONFETTI_OPTIONS);
+
+ const handleShare = () => {
+ triggerConfetti();
+ if (onComplete) onComplete();
+ };
+
+ return (
+
+ }
+ title={completed ? stepFour.titleComplete : stepFour.title}
+ why={stepFour.why}
+ chosen={completed}
+ fontSize={18}
+ reasonGap={24}
+ />
+
+
+ {stepFour.body}
+
+
+
+
+
+
+ );
+}
+StepFourCard.propTypes = {
+ stepFour: PropTypes.shape({
+ kickerSuffix: PropTypes.string,
+ title: PropTypes.string,
+ titleComplete: PropTypes.string,
+ why: PropTypes.string,
+ body: PropTypes.string,
+ previewEditLabel: PropTypes.string,
+ }).isRequired,
+ groups: PropTypes.arrayOf(PropTypes.object),
+ completed: PropTypes.bool,
+ onComplete: PropTypes.func,
+};
+
+const BodyText = styled.span`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 14px;
+ line-height: 1.5;
+`;
+
+const InviteRow = styled.div`
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 10px;
+ /* Tighten the space below the header (WHY? / Multiplies your impact) */
+ margin-top: -8px;
+`;
+
+const RowDivider = styled.span`
+ background: ${DesignTokenColors.neutralUI300};
+ height: 20px;
+ width: 1px;
+
+ @media (max-width: 575px) {
+ display: none;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/StepFourComplete.jsx b/src/js/components/SupporterLanding/StepFourComplete.jsx
new file mode 100644
index 0000000000..0d607bd111
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepFourComplete.jsx
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Link } from 'react-router-dom';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Shown below the Step 4 card once the word is spread: a thank-you headline and a
+// "What else you can do" list of follow-on links.
+export default function StepFourComplete ({ heading, whatElseHeading, items }) {
+ return (
+
+ {heading}
+ {whatElseHeading}
+
+ {items.map((item) => (
+ -
+ {item.text}
+
+ {item.links.map((link) => (
+ {link.label}
+ ))}
+
+
+ ))}
+
+
+ );
+}
+StepFourComplete.propTypes = {
+ heading: PropTypes.string,
+ whatElseHeading: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ key: PropTypes.string,
+ text: PropTypes.string,
+ links: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, to: PropTypes.string })),
+ })),
+};
+
+const Item = styled.li`
+ color: ${DesignTokenColors.neutral700};
+`;
+
+const ItemText = styled.p`
+ font-size: 15px;
+ font-weight: 700;
+ margin: 0 0 4px;
+`;
+
+const Links = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+`;
+
+const List = styled.ul`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ list-style: disc;
+ margin: 0;
+ padding-left: 22px;
+`;
+
+const NavLink = styled(Link)`
+ color: ${DesignTokenColors.primary600};
+ font-size: 15px;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const ThanksHeading = styled.h2`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 24px;
+ font-weight: 700;
+ margin: 0;
+`;
+
+const WhatElseHeading = styled.h3`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 20px;
+ font-weight: 700;
+ margin: 0;
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding-top: 8px;
+`;
diff --git a/src/js/components/SupporterLanding/StepNode.jsx b/src/js/components/SupporterLanding/StepNode.jsx
new file mode 100644
index 0000000000..d11114dcb0
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepNode.jsx
@@ -0,0 +1,83 @@
+import { Check as CheckIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// A single numbered step in the progress bar: the circle (numbered / active / complete)
+// plus the label beneath it. A small green check badge appears when the step is complete.
+export default function StepNode ({ stepNumber, label, active = false, complete = false }) {
+ return (
+
+
+ {stepNumber}
+ {complete && (
+
+
+
+ )}
+
+
+
+ );
+}
+StepNode.propTypes = {
+ stepNumber: PropTypes.number.isRequired,
+ label: PropTypes.string.isRequired,
+ active: PropTypes.bool,
+ complete: PropTypes.bool,
+};
+
+const Circle = styled.div`
+ align-items: center;
+ background: ${({ $active }) => ($active ? DesignTokenColors.secondary800 : DesignTokenColors.whiteUI)};
+ border: 2px solid ${({ $active }) => ($active ? DesignTokenColors.secondary800 : DesignTokenColors.neutralUI300)};
+ border-radius: 50%;
+ color: ${({ $active }) => ($active ? DesignTokenColors.whiteUI : DesignTokenColors.neutral800)};
+ display: flex;
+ font-size: 18px;
+ font-weight: 700;
+ height: 36px;
+ justify-content: center;
+ position: relative;
+ width: 36px;
+`;
+
+const CompleteBadge = styled.span`
+ align-items: center;
+ background: ${DesignTokenColors.confirmation600};
+ border: 2px solid ${DesignTokenColors.whiteUI};
+ border-radius: 50%;
+ bottom: -5px;
+ color: ${DesignTokenColors.whiteUI};
+ display: flex;
+ height: 18px;
+ justify-content: center;
+ position: absolute;
+ right: -5px;
+ width: 18px;
+`;
+
+const Label = styled.span`
+ color: ${({ $active }) => ($active ? DesignTokenColors.secondary800 : DesignTokenColors.neutral700)};
+ font-size: 14px;
+ font-weight: ${({ $active }) => ($active ? 700 : 500)};
+ left: 50%;
+ position: absolute;
+ text-align: center;
+ top: calc(100% + 8px);
+ transform: translateX(-50%);
+ white-space: nowrap;
+
+ @media (max-width: 575px) {
+ display: none;
+ }
+`;
+
+// The wrapper is exactly the circle's size so the connecting lines meet the circles
+// edge-to-edge; the label is taken out of flow (absolutely positioned) so its width
+// doesn't push the nodes apart.
+const NodeWrapper = styled.div`
+ flex: 0 0 auto;
+ position: relative;
+`;
diff --git a/src/js/components/SupporterLanding/StepOneCard.jsx b/src/js/components/SupporterLanding/StepOneCard.jsx
new file mode 100644
index 0000000000..00f3631313
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepOneCard.jsx
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import ChooseExplainerColumns from './ChooseExplainerColumns';
+import ChooseFollowActions from './ChooseFollowActions';
+import StepCard from './StepCard';
+import StepCardHeader from './StepCardHeader';
+import StepOneCardMobileBody from './StepOneCardMobileBody';
+
+// The large gray Step 1 card. The header is shared; the body has a desktop/tablet
+// layout (side-by-side actions + two-column explainer) and a distinct mobile layout
+// (stacked full-width actions with interleaved explainer text).
+export default function StepOneCard ({ candidate, chosen, following, chosenCount, followingCount, onChoose, onFollow }) {
+ // Choosing implies following; either one marks Step 1 complete (green check).
+ const complete = chosen || following;
+ let title;
+ if (chosen) {
+ title = 'Chosen & following!';
+ } else if (following) {
+ title = 'Following!';
+ } else {
+ title = (
+ <>
+ {`Choose ${candidate.name} `}
+ (or follow)
+ >
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {chosen ? (
+
+ Thanks for choosing
+ {' '}
+ {candidate.firstName}
+ {' '}
+ on your WeVote ballot.
+
+ Choosing a candidate also follows them.
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+}
+StepOneCard.propTypes = {
+ candidate: PropTypes.shape({
+ name: PropTypes.string,
+ firstName: PropTypes.string,
+ }).isRequired,
+ chosen: PropTypes.bool,
+ following: PropTypes.bool,
+ chosenCount: PropTypes.number,
+ followingCount: PropTypes.number,
+ onChoose: PropTypes.func,
+ onFollow: PropTypes.func,
+};
+
+const DesktopBody = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+`;
+
+const MobileBodyWrapper = styled.div`
+ display: block;
+`;
+
+const OrFollow = styled.span`
+ font-weight: 400;
+`;
+
+const ThanksText = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
diff --git a/src/js/components/SupporterLanding/StepOneCardMobileBody.jsx b/src/js/components/SupporterLanding/StepOneCardMobileBody.jsx
new file mode 100644
index 0000000000..f175d1ac7e
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepOneCardMobileBody.jsx
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import ActionCountLabel from './ActionCountLabel';
+import ChooseButton from './ChooseButton';
+import FollowButton from './FollowButton';
+
+// Mobile-only body for the Step 1 card. Unlike the desktop two-column layout, each
+// action is a full-width button with its count beside it and its own explainer text
+// below, separated by a horizontal "OR" divider.
+export default function StepOneCardMobileBody ({ candidate, chosen, following, chosenCount, followingCount, onChoose, onFollow }) {
+ return (
+
+
+
+
+
+
+
+
+ {chosen ? (
+
+ Thanks for choosing
+ {' '}
+ {candidate.firstName}
+ {' '}
+ on your WeVote ballot. Choosing a candidate also follows them.
+
+ ) : (
+
+ Choosing marks
+ {' '}
+ {candidate.firstName}
+ {' '}
+ on your WeVote digital ballot for the most impact.
+
+ )}
+
+ {!chosen && (
+
+
+ OR
+
+
+ )}
+
+
+
+
+
+
+
+
+ {!chosen && (
+
+ Not ready to choose?
+ {' '}
+ Following
+ {' '}
+ {candidate.firstName}
+ {' '}
+ helps build public trust and lets WeVote suggest similar candidates and measures
+ to help you complete your ballot.
+ {' '}
+ Learn more
+
+ )}
+
+ );
+}
+StepOneCardMobileBody.propTypes = {
+ candidate: PropTypes.shape({
+ firstName: PropTypes.string,
+ }).isRequired,
+ chosen: PropTypes.bool,
+ following: PropTypes.bool,
+ chosenCount: PropTypes.number,
+ followingCount: PropTypes.number,
+ onChoose: PropTypes.func,
+ onFollow: PropTypes.func,
+};
+
+const ActionRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 12px;
+`;
+
+// Makes the wrapped Choose / Follow button stretch to fill the row.
+const ButtonSlot = styled.div`
+ flex: 1;
+ min-width: 0;
+
+ & > button {
+ width: 100%;
+ }
+`;
+
+// Fixed count width so both buttons end up the same width (and same centre).
+const CountSlot = styled.div`
+ flex: 0 0 128px;
+ width: 128px;
+`;
+
+const ExplainerText = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
+
+const InlineLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 15px;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const MobileBody = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+`;
+
+const OrLine = styled.span`
+ background: ${DesignTokenColors.neutralUI200};
+ flex: 1;
+ height: 1px;
+`;
+
+// Spans exactly the button width (row minus the count column + gap), so "OR" centres
+// on the buttons rather than on the full row.
+const OrRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ margin-right: 140px;
+`;
+
+const OrText = styled.span`
+ color: ${DesignTokenColors.neutral500};
+ font-size: 15px;
+ font-weight: 400;
+`;
+
+const ThanksText = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
diff --git a/src/js/components/SupporterLanding/StepProgressBar.jsx b/src/js/components/SupporterLanding/StepProgressBar.jsx
new file mode 100644
index 0000000000..24efb66e74
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepProgressBar.jsx
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import StepNode from './StepNode';
+
+// The "Previous 1—2—3—4 Next" stepper. `activeStep` is 1-indexed; steps before the
+// active one render as complete. Connectors sit between the circles.
+export default function StepProgressBar ({ steps, activeStep, chosen = false, onPrevious, onNext }) {
+ return (
+
+
+ Previous
+
+
+
+ {steps.map((step, index) => {
+ const isActive = step.stepNumber === activeStep;
+ const isComplete = step.stepNumber < activeStep || (isActive && chosen);
+ const label = (isComplete && step.labelChosen) ? step.labelChosen : step.label;
+ return (
+
+ {index > 0 && }
+
+
+ );
+ })}
+
+
+
+ Next
+
+
+ );
+}
+StepProgressBar.propTypes = {
+ steps: PropTypes.arrayOf(PropTypes.shape({
+ stepNumber: PropTypes.number,
+ label: PropTypes.string,
+ labelChosen: PropTypes.string,
+ })).isRequired,
+ activeStep: PropTypes.number,
+ chosen: PropTypes.bool,
+ onPrevious: PropTypes.func,
+ onNext: PropTypes.func,
+};
+
+const Connector = styled.div`
+ align-self: flex-start;
+ background: ${({ $filled }) => ($filled ? DesignTokenColors.neutral700 : DesignTokenColors.neutralUI200)};
+ flex: 1;
+ height: 2px;
+ margin-top: 17px;
+`;
+
+const NavAction = styled.button`
+ background: none;
+ border: none;
+ color: ${({ $primary, disabled }) => {
+ if (disabled) return DesignTokenColors.neutral400;
+ if ($primary) return DesignTokenColors.primary600;
+ return DesignTokenColors.neutral500;
+ }};
+ cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
+ font-size: 15px;
+ font-weight: ${({ $primary }) => ($primary ? 600 : 400)};
+ padding: 8px 0;
+ white-space: nowrap;
+
+ /* Mobile shows just the connected circles — no Previous / Next controls */
+ @media (max-width: 575px) {
+ display: none;
+ }
+`;
+
+const ProgressWrapper = styled.div`
+ align-items: flex-start;
+ display: flex;
+ gap: 40px;
+ justify-content: flex-start;
+`;
+
+const StepsTrack = styled.div`
+ align-items: flex-start;
+ display: flex;
+ flex: 1;
+ justify-content: space-between;
+ max-width: 480px;
+ /* Reserve room for the absolutely-positioned step labels below the circles */
+ padding-bottom: 30px;
+
+ @media (max-width: 575px) {
+ padding-bottom: 0;
+ }
+`;
diff --git a/src/js/components/SupporterLanding/StepThreeCard.jsx b/src/js/components/SupporterLanding/StepThreeCard.jsx
new file mode 100644
index 0000000000..539624d15c
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepThreeCard.jsx
@@ -0,0 +1,122 @@
+import { Public as PublicIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { useReward } from 'react-rewards';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import MakePublicButton from './MakePublicButton';
+import NameEntryForm from './NameEntryForm';
+import StepCard from './StepCard';
+import StepCardHeader from './StepCardHeader';
+import { CONFETTI_COLORS } from './supporterLandingConstants';
+import VisibilityCollapse from './VisibilityCollapse';
+
+const CONFETTI_OPTIONS = {
+ elementCount: 60,
+ spread: 90,
+ elementSize: 8,
+ zIndex: 10,
+ colors: CONFETTI_COLORS,
+ startVelocity: 22,
+ decay: 0.9,
+ angle: 90,
+};
+
+// Step 3 card — "Make your support public". States: default, name-entry form (when the
+// voter has no name), name-error (public attempted without a name), and made-public.
+// `isPublic` is owned by the page so the stepper and this card can't disagree.
+export default function StepThreeCard ({ stepThree, voterHasName, isPublic, onMadePublic }) {
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [showError, setShowError] = useState(false);
+
+ const needsName = !voterHasName && !isPublic;
+
+ // Confetti burst from the button when support is made public (matches the YourRank pattern).
+ const { reward: triggerConfetti } = useReward('makePublicConfettiReward', 'confetti', CONFETTI_OPTIONS);
+
+ const handleMakePublic = () => {
+ if (needsName && !firstName.trim()) {
+ setShowError(true);
+ return;
+ }
+ setShowError(false);
+ triggerConfetti();
+ if (onMadePublic) onMadePublic();
+ };
+
+ return (
+
+ }
+ title={isPublic ? stepThree.titlePublic : stepThree.title}
+ why={stepThree.why}
+ chosen={isPublic}
+ />
+
+ {stepThree.body}
+
+ {needsName && (
+ setFirstName(e.target.value)}
+ onChangeLast={(e) => setLastName(e.target.value)}
+ firstNamePlaceholder={stepThree.firstNamePlaceholder}
+ lastNamePlaceholder={stepThree.lastNamePlaceholder}
+ showError={showError}
+ errorText={stepThree.nameError}
+ />
+ )}
+
+
+ {/* id anchors the confetti burst origin */}
+
+
+
+
+
+
+
+ );
+}
+StepThreeCard.propTypes = {
+ stepThree: PropTypes.shape({
+ title: PropTypes.string,
+ titlePublic: PropTypes.string,
+ why: PropTypes.string,
+ body: PropTypes.string,
+ buttonLabel: PropTypes.string,
+ buttonLabelPublic: PropTypes.string,
+ nameHeading: PropTypes.string,
+ nameText: PropTypes.string,
+ nameError: PropTypes.string,
+ firstNamePlaceholder: PropTypes.string,
+ lastNamePlaceholder: PropTypes.string,
+ visibilityLabel: PropTypes.string,
+ }).isRequired,
+ voterHasName: PropTypes.bool,
+ isPublic: PropTypes.bool,
+ onMadePublic: PropTypes.func,
+};
+
+const BodyText = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.5;
+ margin: 0;
+`;
+
+const ButtonRow = styled.div`
+ align-items: flex-start;
+ display: flex;
+`;
+
diff --git a/src/js/components/SupporterLanding/StepTwoCard.jsx b/src/js/components/SupporterLanding/StepTwoCard.jsx
new file mode 100644
index 0000000000..3e47c693ca
--- /dev/null
+++ b/src/js/components/SupporterLanding/StepTwoCard.jsx
@@ -0,0 +1,42 @@
+import { ChatBubbleOutline as ChatBubbleOutlineIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import CommentComposer from './CommentComposer';
+import CommentList from './CommentList';
+import StepCard from './StepCard';
+import StepCardHeader from './StepCardHeader';
+
+// Step 2 card — "Share why you're supporting Jane Dough": header, the compose box, and
+// the "What others are saying" comment list. The title shortens on mobile.
+export default function StepTwoCard ({ stepTwo, comments }) {
+ const title = (
+ <>
+ {stepTwo.title}
+ {stepTwo.titleMobile}
+ >
+ );
+
+ return (
+
+ }
+ title={title}
+ why={stepTwo.why}
+ fontSize={18}
+ mobileFontSize={15}
+ />
+
+
+
+ );
+}
+StepTwoCard.propTypes = {
+ stepTwo: PropTypes.shape({
+ title: PropTypes.string,
+ titleMobile: PropTypes.string,
+ why: PropTypes.string,
+ placeholder: PropTypes.string,
+ }).isRequired,
+ comments: PropTypes.arrayOf(PropTypes.object),
+};
diff --git a/src/js/components/SupporterLanding/SupporterLandingMobileHeader.jsx b/src/js/components/SupporterLanding/SupporterLandingMobileHeader.jsx
new file mode 100644
index 0000000000..e3d9b8eef7
--- /dev/null
+++ b/src/js/components/SupporterLanding/SupporterLandingMobileHeader.jsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import styled from 'styled-components';
+import AppObservableStore from '../../common/stores/AppObservableStore';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import normalizedImagePath from '../../common/utils/normalizedImagePath';
+import VoterAvatar from './VoterAvatar';
+
+// Mobile-only landing header (< 576px), matching the phone mockup:
+// row 1: We Vote logo | "How WeVote works · Your ballot" | avatar
+// row 2: mission tagline | "Add your profile picture"
+// On desktop/tablet this is hidden and SupporterLandingTopBar + the sidebar take over.
+const weVoteLogo = '../../../img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg';
+
+export default function SupporterLandingMobileHeader () {
+ const openHowItWorksModal = () => AppObservableStore.setShowHowItWorksModal(true);
+
+ return (
+
+
+
+
+ How WeVote works
+ |
+ Your ballot
+
+
+
+
+ Helping turn your values into voting decisions
+ Add your profile picture
+
+
+ );
+}
+
+const BottomRow = styled.div`
+ align-items: flex-start;
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+`;
+
+const Header = styled.header`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 0 4px;
+`;
+
+const LogoImage = styled.img`
+ display: block;
+ height: auto;
+ width: 92px;
+`;
+
+const NavBar = styled.span`
+ color: ${DesignTokenColors.neutralUI300};
+`;
+
+const NavCluster = styled.nav`
+ align-items: center;
+ display: flex;
+ gap: 8px;
+`;
+
+const NavLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+// Matches the nav links above; right edge lines up with the avatar's edit badge
+// (which sits 2px past the avatar's edge).
+const ProfileLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 13px;
+ margin-right: -2px;
+ padding: 0;
+ text-align: right;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const Tagline = styled.p`
+ color: ${DesignTokenColors.neutral600};
+ font-size: 14px;
+ font-style: italic;
+ line-height: 1.35;
+ margin: 0;
+ max-width: 165px;
+`;
+
+const TopRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+`;
diff --git a/src/js/components/SupporterLanding/SupporterLandingSidebar.jsx b/src/js/components/SupporterLanding/SupporterLandingSidebar.jsx
new file mode 100644
index 0000000000..34dca0df23
--- /dev/null
+++ b/src/js/components/SupporterLanding/SupporterLandingSidebar.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import ProfilePicturePrompt from './ProfilePicturePrompt';
+
+// Desktop-only left rail: mission tagline and the profile-picture nudge. The brand
+// logo lives in the global app header, so it is intentionally not repeated here.
+export default function SupporterLandingSidebar () {
+ return (
+
+ Helping turn your values into voting decisions
+
+
+ );
+}
+
+const Sidebar = styled.aside`
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ /* Pull the sidebar up under the logo, cancelling the top bar's bottom padding
+ without moving the top bar nav or the main column */
+ margin-top: -12px;
+ min-width: 175px;
+ padding: 0;
+ width: 190px;
+`;
+
+const Tagline = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 15px;
+ line-height: 1.4;
+ margin: 0;
+ /* Wrap within the width of the We Vote logo that sits above it */
+ max-width: 141px;
+`;
diff --git a/src/js/components/SupporterLanding/SupporterLandingTopBar.jsx b/src/js/components/SupporterLanding/SupporterLandingTopBar.jsx
new file mode 100644
index 0000000000..8560600a4d
--- /dev/null
+++ b/src/js/components/SupporterLanding/SupporterLandingTopBar.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import styled from 'styled-components';
+import AppObservableStore from '../../common/stores/AppObservableStore';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+import normalizedImagePath from '../../common/utils/normalizedImagePath';
+
+// Landing-specific header row: the We Vote wordmark on the left (the same navy
+// -color-dark svg the global site header uses) and the landing nav on the right.
+// Desktop / tablet only — on mobile the global app header covers this.
+const weVoteLogo = '../../../img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg';
+
+export default function SupporterLandingTopBar () {
+ // Opens the same "How It Works" modal the global header's "How it works" menu item does.
+ const openHowItWorksModal = () => AppObservableStore.setShowHowItWorksModal(true);
+
+ return (
+
+
+
+
+ );
+}
+
+const LogoImage = styled.img`
+ display: block;
+ height: auto;
+ width: 125px;
+`;
+
+const Nav = styled.nav`
+ align-items: center;
+ display: flex;
+ gap: 20px;
+`;
+
+const NavLink = styled.button`
+ background: none;
+ border: none;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+const TopBar = styled.header`
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 0 12px;
+`;
+
+const ViewBallotButton = styled.button`
+ background: ${DesignTokenColors.whiteUI};
+ border: 1px solid ${DesignTokenColors.primary600};
+ border-radius: 9999px;
+ color: ${DesignTokenColors.primary600};
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 500;
+ padding: 5px 20px;
+ white-space: nowrap;
+
+ &:hover {
+ background: ${DesignTokenColors.primary50};
+ }
+`;
diff --git a/src/js/components/SupporterLanding/VisibilityCollapse.jsx b/src/js/components/SupporterLanding/VisibilityCollapse.jsx
new file mode 100644
index 0000000000..8cb3021af4
--- /dev/null
+++ b/src/js/components/SupporterLanding/VisibilityCollapse.jsx
@@ -0,0 +1,65 @@
+import { KeyboardArrowDown as ArrowDownIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Collapsible "Visibility of opinion & choice for Jane Dough" row at the bottom of the
+// Step 3 card. Expands to a short demo summary of who can see the voter's support.
+export default function VisibilityCollapse ({ label }) {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+
+ setExpanded((prev) => !prev)}>
+
+
+
+
+
+ {expanded && (
+ Your choice and opinion are currently visible to your WeVote Friends only.
+ )}
+
+ );
+}
+VisibilityCollapse.propTypes = {
+ label: PropTypes.string,
+};
+
+const Arrow = styled.span`
+ color: ${DesignTokenColors.neutral700};
+ display: inline-flex;
+ transform: rotate(${({ $expanded }) => ($expanded ? '180deg' : '0deg')});
+ transition: transform 0.15s ease;
+`;
+
+const Detail = styled.p`
+ color: ${DesignTokenColors.neutral700};
+ font-size: 14px;
+ line-height: 1.5;
+ margin: 0 0 0 26px;
+`;
+
+const Label = styled.span`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 15px;
+ font-weight: 400;
+`;
+
+const Toggle = styled.button`
+ align-items: center;
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ gap: 6px;
+ padding: 0;
+ text-align: left;
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
diff --git a/src/js/components/SupporterLanding/VoterAvatar.jsx b/src/js/components/SupporterLanding/VoterAvatar.jsx
new file mode 100644
index 0000000000..c2043617e8
--- /dev/null
+++ b/src/js/components/SupporterLanding/VoterAvatar.jsx
@@ -0,0 +1,57 @@
+import { Edit as EditIcon } from '@mui/icons-material';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// The purple "voter" avatar with initials and an edit-pencil badge. Sized via the `size`
+// prop so it can serve the sidebar, mobile header, and comment composer.
+export default function VoterAvatar ({ initials = 'D', size = 40 }) {
+ return (
+
+ {initials}
+
+
+
+
+ );
+}
+VoterAvatar.propTypes = {
+ initials: PropTypes.string,
+ size: PropTypes.number,
+};
+
+const AvatarButton = styled.button`
+ align-items: center;
+ background: #8E2DA8;
+ border: none;
+ border-radius: 50%;
+ box-sizing: border-box;
+ cursor: pointer;
+ display: flex;
+ flex: 0 0 auto;
+ height: ${({ $size }) => $size}px;
+ justify-content: center;
+ position: relative;
+ width: ${({ $size }) => $size}px;
+`;
+
+const EditBadge = styled.span`
+ align-items: center;
+ background: ${DesignTokenColors.info500};
+ border: 2px solid ${DesignTokenColors.whiteUI};
+ border-radius: 50%;
+ bottom: -2px;
+ color: ${DesignTokenColors.whiteUI};
+ display: flex;
+ height: ${({ $size }) => Math.round($size * 0.5)}px;
+ justify-content: center;
+ position: absolute;
+ right: -2px;
+ width: ${({ $size }) => Math.round($size * 0.5)}px;
+`;
+
+const Initials = styled.span`
+ color: ${DesignTokenColors.whiteUI};
+ font-size: ${({ $size }) => Math.round($size * 0.4)}px;
+`;
diff --git a/src/js/components/SupporterLanding/WelcomeMessage.jsx b/src/js/components/SupporterLanding/WelcomeMessage.jsx
new file mode 100644
index 0000000000..a60c36bb4b
--- /dev/null
+++ b/src/js/components/SupporterLanding/WelcomeMessage.jsx
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'styled-components';
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// "Welcome ..." greeting plus the big "Help X get elected in 4 easy steps!" headline.
+export default function WelcomeMessage ({ voterFirstName, candidateName }) {
+ return (
+
+
+ Welcome to WeVote,
+ {' '}
+ {voterFirstName}
+ {' - '}
+ and thanks for accepting
+ {' '}
+ {candidateName}
+ ’s invitation!
+
+
+ Help
+ {' '}
+ {candidateName}
+ {' '}
+ get elected in 4 easy steps!
+
+
+ );
+}
+WelcomeMessage.propTypes = {
+ voterFirstName: PropTypes.string,
+ candidateName: PropTypes.string,
+};
+
+const Greeting = styled.p`
+ color: ${DesignTokenColors.neutral800};
+ font-size: 17px;
+ font-weight: 500;
+ margin: 0;
+`;
+
+const Headline = styled.h1`
+ color: ${DesignTokenColors.neutral900};
+ font-size: 26px;
+ font-weight: 600;
+ line-height: 1.2;
+ margin: 0;
+`;
+
+const WelcomeWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
diff --git a/src/js/components/SupporterLanding/supporterLandingConstants.js b/src/js/components/SupporterLanding/supporterLandingConstants.js
new file mode 100644
index 0000000000..d800d4558f
--- /dev/null
+++ b/src/js/components/SupporterLanding/supporterLandingConstants.js
@@ -0,0 +1,142 @@
+// Demo data for the SupporterLanding "Choose Jane Dough" experience.
+// This is placeholder content only, so the page can be previewed without being wired
+// up to the API / stores. Swap these values for live data when the feature is built out.
+import DesignTokenColors from '../../common/components/Style/DesignTokenColors';
+
+// Confetti palette shared by the Step 3 / Step 4 celebrations.
+export const CONFETTI_COLORS = [
+ DesignTokenColors.primary600,
+ DesignTokenColors.caution800,
+ DesignTokenColors.confirmation700,
+ DesignTokenColors.accent500,
+];
+
+export const SUPPORTER_LANDING_STEPS = [
+ {
+ stepNumber: 1, label: 'Choose', labelChosen: 'Chosen & following!', footerPrompt: 'I’ll decide later', footerLinkText: 'Go to Step 2',
+ },
+ {
+ stepNumber: 2, label: 'Share opinion', labelChosen: 'Opinion shared!', footerPrompt: 'I’ll share my opinion later', footerLinkText: 'Go to Step 3',
+ },
+ {
+ stepNumber: 3, label: 'Make public', labelChosen: 'Made public!', footerPrompt: 'I’ll make my support public later', footerLinkText: 'Go to Step 4',
+ },
+ { stepNumber: 4, label: 'Spread the word', labelChosen: 'Word spread!' },
+];
+
+export const DEMO_VOTER = {
+ firstName: 'David',
+};
+
+export const DEMO_CANDIDATE = {
+ name: 'Jane Dough',
+ firstName: 'Jane',
+ party: 'Independent',
+ officeDescription: "Candidate for U.S. Representative, California's 11th congressional district",
+ learnMoreUrl: '/jane-dough/-/',
+ personalMessage: "Hi David, I'm so glad you're here. Having your support means the world to me and this campaign. Together we can make a real difference for our community.",
+ personalMessagePreview: "Hi David, I'm so glad you're here. Having your...",
+};
+
+export const DEMO_COUNTS = {
+ chosenCount: 143,
+ followingCount: 2341,
+};
+
+// Step 2 — "Share why you're supporting Jane Dough"
+export const STEP_TWO = {
+ title: 'Share why you’re supporting Jane Dough',
+ titleMobile: 'Why you’re supporting Jane Dough',
+ why: 'Convinces others',
+ placeholder: 'Why I’m voting for Jane...',
+};
+
+// Step 3 — "Make your support public"
+export const STEP_THREE = {
+ title: 'Make your support public',
+ titlePublic: 'Your support is public!',
+ why: 'Influences more people',
+ body: 'By default, your choice and opinion for a candidate are visible to your WeVote Friends only. Making them public will influence more people to vote for Jane Dough.',
+ buttonLabel: 'Make support public',
+ buttonLabelPublic: 'Support made public',
+ nameHeading: 'By the way, we didn’t catch your name!',
+ nameText: 'Let others know your name to make your choice and opinion public. This builds trust and shows you’re a real voter.',
+ nameError: 'Let us know your name to make your support public!',
+ firstNamePlaceholder: 'First name',
+ lastNamePlaceholder: 'Last name',
+ visibilityLabel: 'Visibility of opinion & choice for Jane Dough',
+};
+
+// Demo: the voter has no name on file yet, so Step 3 shows the name-entry form.
+export const DEMO_VOTER_HAS_NAME = false;
+
+// Step 4 — "Spread the word about Jane Dough"
+export const STEP_FOUR = {
+ kickerSuffix: ' – FINAL STEP!',
+ title: 'Spread the word about Jane Dough',
+ titleComplete: 'Word spread!',
+ why: 'Multiplies your impact',
+ body: 'Invite your friends to WeVote to help Jane Dough get elected.',
+ previewEditLabel: 'Preview/Edit invitation',
+ completeHeading: 'Thanks for helping Jane Dough get elected!',
+ whatElseHeading: 'What else you can do:',
+};
+
+// Share options, grouped into cards. `flex` weights each card's width on desktop so the
+// longer labels get enough room to wrap to at most 2 lines.
+export const SHARE_OPTION_GROUPS = [
+ { key: 'contacts', flex: 1, options: [{ key: 'contacts', label: 'Share with contacts', icon: 'people' }]},
+ {
+ key: 'copy',
+ flex: 2.6,
+ options: [
+ { key: 'copyInvite', label: 'Copy invitation & link', icon: 'copy' },
+ { key: 'copyLink', label: 'Copy link', icon: 'link' },
+ ],
+ },
+ { key: 'qr', flex: 1, options: [{ key: 'qr', label: 'Share with QR code', icon: 'qr' }]},
+ {
+ key: 'social',
+ flex: 1.7,
+ options: [
+ { key: 'facebook', label: 'Facebook', icon: 'facebook', brand: 'facebook' },
+ { key: 'x', label: 'X', icon: 'x', brand: 'x' },
+ ],
+ },
+];
+
+// "What else you can do" list shown once Step 4 is complete.
+export const WHAT_ELSE_ITEMS = [
+ {
+ key: 'profile',
+ text: 'Complete your profile - to build trust and show you’re a real voter:',
+ links: [{ label: 'Add your name and photo', to: '/settings/profile' }],
+ },
+ {
+ key: 'explore',
+ text: 'Check out more of WeVote:',
+ links: [
+ { label: 'WeVote Home', to: '/ready' },
+ { label: 'How WeVote works', to: '/how-it-works' },
+ { label: 'Your ballot', to: '/ballot' },
+ { label: 'Candidates', to: '/candidates' },
+ ],
+ },
+];
+
+export const DEMO_COMMENTS = [
+ {
+ id: 'comment-1',
+ authorName: 'John Dough',
+ likeCount: 149,
+ text: 'We are proud to endorse this group of proven leaders and advocates for reelection this year.',
+ meta: 'Endorsed a year ago (visible to public)',
+ },
+ {
+ id: 'comment-2',
+ authorName: 'John Dough',
+ likeCount: 149,
+ text: 'We are proud to endorse this group of proven leaders and advocates for reelection this year.',
+ meta: 'Endorsed a year ago (visible to public)',
+ },
+];
diff --git a/src/js/pages/SupporterLanding/SupporterLandingChooseCandidate.jsx b/src/js/pages/SupporterLanding/SupporterLandingChooseCandidate.jsx
new file mode 100644
index 0000000000..3b7c4b28d8
--- /dev/null
+++ b/src/js/pages/SupporterLanding/SupporterLandingChooseCandidate.jsx
@@ -0,0 +1,193 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Helmet } from 'react-helmet-async';
+import styled from 'styled-components';
+import { renderLog } from '../../common/utils/logging';
+import CandidateInfoCard from '../../components/SupporterLanding/CandidateInfoCard';
+import CollapsedStepHeadline from '../../components/SupporterLanding/CollapsedStepHeadline';
+import DecideLaterFooter from '../../components/SupporterLanding/DecideLaterFooter';
+import PersonalMessageCollapse from '../../components/SupporterLanding/PersonalMessageCollapse';
+import StepFourCard from '../../components/SupporterLanding/StepFourCard';
+import StepFourComplete from '../../components/SupporterLanding/StepFourComplete';
+import StepOneCard from '../../components/SupporterLanding/StepOneCard';
+import StepProgressBar from '../../components/SupporterLanding/StepProgressBar';
+import StepThreeCard from '../../components/SupporterLanding/StepThreeCard';
+import StepTwoCard from '../../components/SupporterLanding/StepTwoCard';
+import SupporterLandingMobileHeader from '../../components/SupporterLanding/SupporterLandingMobileHeader';
+import SupporterLandingSidebar from '../../components/SupporterLanding/SupporterLandingSidebar';
+import SupporterLandingTopBar from '../../components/SupporterLanding/SupporterLandingTopBar';
+import WelcomeMessage from '../../components/SupporterLanding/WelcomeMessage';
+import {
+ DEMO_CANDIDATE, DEMO_COMMENTS, DEMO_COUNTS, DEMO_VOTER, DEMO_VOTER_HAS_NAME,
+ SHARE_OPTION_GROUPS, STEP_FOUR, STEP_THREE, STEP_TWO, SUPPORTER_LANDING_STEPS, WHAT_ELSE_ITEMS,
+} from '../../components/SupporterLanding/supporterLandingConstants';
+import { PageContentContainer } from '../../components/Style/pageLayoutStyles';
+
+// SupporterLanding — Step 1: "Choose Jane Dough".
+// DEMO ONLY: all data is placeholder (see supporterLandingConstants) and no store /
+// API calls are made. Clicking "Choose" flips the page into its "chosen & following"
+// state so both designs can be previewed. Renders under the normal app header/footer.
+// Route: /supporterlanding
+export default function SupporterLandingChooseCandidate () {
+ renderLog('SupporterLandingChooseCandidate');
+ const [chosen, setChosen] = useState(false);
+ const [following, setFollowing] = useState(false);
+ const [supportPublic, setSupportPublic] = useState(false);
+ const [wordSpread, setWordSpread] = useState(false);
+ const [activeStep, setActiveStep] = useState(1);
+
+ const chosenCount = DEMO_COUNTS.chosenCount + (chosen ? 1 : 0);
+
+ const handleChoose = () => setChosen((prev) => !prev);
+ const handleFollow = () => setFollowing((prev) => !prev);
+ const goToNextStep = () => setActiveStep((prev) => Math.min(prev + 1, SUPPORTER_LANDING_STEPS.length));
+ const goToPreviousStep = () => setActiveStep((prev) => Math.max(prev - 1, 1));
+
+ // After "Make support public", the "made public" state shows briefly, then auto-advances
+ // to Step 4 (the mockup pairs this with a 2s confetti moment). The timer is cancelled on
+ // unmount so it can't fire after the voter has navigated away.
+ const advanceTimerRef = useRef(null);
+ const handleMadePublic = () => {
+ setSupportPublic(true);
+ advanceTimerRef.current = setTimeout(goToNextStep, 2000);
+ };
+ useEffect(() => () => clearTimeout(advanceTimerRef.current), []);
+
+ // Whether the active step counts as complete (drives its green check + label).
+ // TODO: Make each step have its own route (do not use URL params)
+ // Use
+
+
+
+
+
+
+ {/* Sidebar + landing top bar are hidden on mobile, so the mobile header carries
+ the logo, nav, tagline and profile nudge instead */}
+
+
+ {activeStep === 1 ? (
+ <>
+
+ {/* On mobile the candidate card sits above the personal message (mockup order),
+ so this pair reverses below 576px */}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {activeStep === 1 && (
+
+ )}
+ {activeStep === 2 && }
+ {activeStep === 3 && (
+
+ )}
+ {activeStep === 4 && (
+ setWordSpread(true)}
+ />
+ )}
+ {activeStep === 4 && wordSpread && (
+
+ )}
+
+ {activeStep <= 3 && (
+
+ )}
+
+
+
+
+ );
+}
+
+const BodyLayout = styled.div`
+ display: flex;
+ gap: 32px;
+
+ @media (max-width: 575px) {
+ gap: 0;
+ }
+`;
+
+const MainColumn = styled.main`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 22px;
+ min-width: 0;
+ padding-top: 8px;
+
+ @media (max-width: 575px) {
+ gap: 16px;
+ }
+`;
+
+const OuterWrapper = styled.div`
+ margin: 0 auto;
+ padding: 0 20px 48px;
+ width: 100%;
+`;
+
+const PersonalAndCandidate = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+
+ @media (max-width: 575px) {
+ flex-direction: column-reverse;
+ }
+`;