From 4a34ebcc2a0d291ea1f6c0668a23badfcc160289 Mon Sep 17 00:00:00 2001 From: Ricardo Reynoso Date: Thu, 23 Jul 2026 17:12:06 -0700 Subject: [PATCH] Add Supporter Landing choose-candidate flow Add the /supporterlanding route and a four-step supporter landing page: choose a candidate, enter name, add a personal message, and share. Includes the SupporterLanding component set (step cards, progress bar, comment list/composer, share options, sidebar and mobile header) plus the tight horizontal We Vote logo used by the top bar. --- src/App.jsx | 2 + ...-vote-logo-horizontal-color-dark-tight.svg | 5 + .../SupporterLanding/ActionCountLabel.jsx | 25 +++ .../SupporterLanding/CandidateInfoCard.jsx | 183 +++++++++++++++++ .../SupporterLanding/ChooseButton.jsx | 47 +++++ .../ChooseExplainerColumns.jsx | 84 ++++++++ .../SupporterLanding/ChooseFollowActions.jsx | 63 ++++++ .../CollapsedStepHeadline.jsx | 38 ++++ .../SupporterLanding/CommentComposer.jsx | 65 ++++++ .../SupporterLanding/CommentItem.jsx | 125 ++++++++++++ .../SupporterLanding/CommentList.jsx | 40 ++++ .../SupporterLanding/CommentReactions.jsx | 72 +++++++ .../SupporterLanding/DecideLaterFooter.jsx | 46 +++++ .../SupporterLanding/FollowButton.jsx | 45 ++++ .../SupporterLanding/GreenCheckBadge.jsx | 26 +++ .../SupporterLanding/HelpMeWriteButton.jsx | 35 ++++ .../SupporterLanding/MakePublicButton.jsx | 48 +++++ .../SupporterLanding/NameEntryForm.jsx | 74 +++++++ .../components/SupporterLanding/NameField.jsx | 45 ++++ .../PersonalMessageCollapse.jsx | 84 ++++++++ .../PreviewEditInvitationLink.jsx | 37 ++++ .../SupporterLanding/ProfilePicturePrompt.jsx | 53 +++++ .../SupporterLanding/SeeMoreCommentsLink.jsx | 36 ++++ .../SupporterLanding/ShareOptionButton.jsx | 78 +++++++ .../SupporterLanding/ShareOptionCard.jsx | 45 ++++ .../SupporterLanding/ShareOptionsGrid.jsx | 48 +++++ .../components/SupporterLanding/StepCard.jsx | 20 ++ .../SupporterLanding/StepCardHeader.jsx | 155 ++++++++++++++ .../SupporterLanding/StepFourCard.jsx | 94 +++++++++ .../SupporterLanding/StepFourComplete.jsx | 93 +++++++++ .../components/SupporterLanding/StepNode.jsx | 83 ++++++++ .../SupporterLanding/StepOneCard.jsx | 106 ++++++++++ .../StepOneCardMobileBody.jsx | 158 ++++++++++++++ .../SupporterLanding/StepProgressBar.jsx | 100 +++++++++ .../SupporterLanding/StepThreeCard.jsx | 122 +++++++++++ .../SupporterLanding/StepTwoCard.jsx | 42 ++++ .../SupporterLandingMobileHeader.jsx | 112 ++++++++++ .../SupporterLandingSidebar.jsx | 37 ++++ .../SupporterLandingTopBar.jsx | 74 +++++++ .../SupporterLanding/VisibilityCollapse.jsx | 65 ++++++ .../SupporterLanding/VoterAvatar.jsx | 57 ++++++ .../SupporterLanding/WelcomeMessage.jsx | 54 +++++ .../supporterLandingConstants.js | 142 +++++++++++++ .../SupporterLandingChooseCandidate.jsx | 193 ++++++++++++++++++ 44 files changed, 3156 insertions(+) create mode 100755 src/img/global/svg-icons/we-vote-logo-horizontal-color-dark-tight.svg create mode 100644 src/js/components/SupporterLanding/ActionCountLabel.jsx create mode 100644 src/js/components/SupporterLanding/CandidateInfoCard.jsx create mode 100644 src/js/components/SupporterLanding/ChooseButton.jsx create mode 100644 src/js/components/SupporterLanding/ChooseExplainerColumns.jsx create mode 100644 src/js/components/SupporterLanding/ChooseFollowActions.jsx create mode 100644 src/js/components/SupporterLanding/CollapsedStepHeadline.jsx create mode 100644 src/js/components/SupporterLanding/CommentComposer.jsx create mode 100644 src/js/components/SupporterLanding/CommentItem.jsx create mode 100644 src/js/components/SupporterLanding/CommentList.jsx create mode 100644 src/js/components/SupporterLanding/CommentReactions.jsx create mode 100644 src/js/components/SupporterLanding/DecideLaterFooter.jsx create mode 100644 src/js/components/SupporterLanding/FollowButton.jsx create mode 100644 src/js/components/SupporterLanding/GreenCheckBadge.jsx create mode 100644 src/js/components/SupporterLanding/HelpMeWriteButton.jsx create mode 100644 src/js/components/SupporterLanding/MakePublicButton.jsx create mode 100644 src/js/components/SupporterLanding/NameEntryForm.jsx create mode 100644 src/js/components/SupporterLanding/NameField.jsx create mode 100644 src/js/components/SupporterLanding/PersonalMessageCollapse.jsx create mode 100644 src/js/components/SupporterLanding/PreviewEditInvitationLink.jsx create mode 100644 src/js/components/SupporterLanding/ProfilePicturePrompt.jsx create mode 100644 src/js/components/SupporterLanding/SeeMoreCommentsLink.jsx create mode 100644 src/js/components/SupporterLanding/ShareOptionButton.jsx create mode 100644 src/js/components/SupporterLanding/ShareOptionCard.jsx create mode 100644 src/js/components/SupporterLanding/ShareOptionsGrid.jsx create mode 100644 src/js/components/SupporterLanding/StepCard.jsx create mode 100644 src/js/components/SupporterLanding/StepCardHeader.jsx create mode 100644 src/js/components/SupporterLanding/StepFourCard.jsx create mode 100644 src/js/components/SupporterLanding/StepFourComplete.jsx create mode 100644 src/js/components/SupporterLanding/StepNode.jsx create mode 100644 src/js/components/SupporterLanding/StepOneCard.jsx create mode 100644 src/js/components/SupporterLanding/StepOneCardMobileBody.jsx create mode 100644 src/js/components/SupporterLanding/StepProgressBar.jsx create mode 100644 src/js/components/SupporterLanding/StepThreeCard.jsx create mode 100644 src/js/components/SupporterLanding/StepTwoCard.jsx create mode 100644 src/js/components/SupporterLanding/SupporterLandingMobileHeader.jsx create mode 100644 src/js/components/SupporterLanding/SupporterLandingSidebar.jsx create mode 100644 src/js/components/SupporterLanding/SupporterLandingTopBar.jsx create mode 100644 src/js/components/SupporterLanding/VisibilityCollapse.jsx create mode 100644 src/js/components/SupporterLanding/VoterAvatar.jsx create mode 100644 src/js/components/SupporterLanding/WelcomeMessage.jsx create mode 100644 src/js/components/SupporterLanding/supporterLandingConstants.js create mode 100644 src/js/pages/SupporterLanding/SupporterLandingChooseCandidate.jsx 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; + } +`;