From 0c6e6e13eafd635a9b72027c3ceb6d593042ae5f Mon Sep 17 00:00:00 2001 From: Ricardo Reynoso Date: Mon, 18 May 2026 22:29:36 -0700 Subject: [PATCH] Clean up Tracking subtabs: shared styles file, alphabetize, fix lint warnings - Move the shared Tracking styled-components into their own file (Style/SupporterTrackingStyles.jsx) so they don't pollute Style/ManageMyCandidates or SupporterTracking. - Add a small TrackingHeaderActionContext file so the three subtabs can share the header-action slot without a circular import. - Alphabetize the styled-components in all the Tracking files and the shared style files, just to keep things easy to find. - Add PropTypes to ActionPill, the Menus exports, SupportersJoined/Invited/ToRemind, and CardOpinionRow. Also move getPendingActions/getActionsLabel out of SupportersJoined so the useMemo hook stops complaining about missing deps. --- .../ManageMyCandidates/ActionPill.jsx | 55 +- .../components/ManageMyCandidates/Menus.jsx | 309 ++-- .../ManageMyCandidates/SendButtons.jsx | 60 +- .../components/Style/ManageMyCandidates.jsx | 92 +- .../Style/SupporterTrackingStyles.jsx | 178 +++ .../ManageMyCandidates/SupporterTracking.jsx | 335 ++-- .../ManageMyCandidates/SupportersInvited.jsx | 604 +++---- .../ManageMyCandidates/SupportersJoined.jsx | 1404 ++++++----------- .../ManageMyCandidates/SupportersToRemind.jsx | 255 +-- .../TrackingHeaderActionContext.js | 10 + 10 files changed, 1576 insertions(+), 1726 deletions(-) create mode 100644 src/js/components/Style/SupporterTrackingStyles.jsx create mode 100644 src/js/pages/ManageMyCandidates/TrackingHeaderActionContext.js diff --git a/src/js/components/ManageMyCandidates/ActionPill.jsx b/src/js/components/ManageMyCandidates/ActionPill.jsx index 84a4bd11e..d95f5c81a 100644 --- a/src/js/components/ManageMyCandidates/ActionPill.jsx +++ b/src/js/components/ManageMyCandidates/ActionPill.jsx @@ -1,36 +1,57 @@ import React from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; -export default function ActionPill({ onClick, label, contentText = null}) { +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; + +export default function ActionPill ({ onClick, label, contentText = null }) { return ( - {label} - {contentText &&

{contentText}

} + {label} + {contentText && {contentText}}
); } - -const MediumBoldText = styled.span` - font-weight: 500; -`; +ActionPill.propTypes = { + onClick: PropTypes.func, + label: PropTypes.node, + contentText: PropTypes.node, +}; const ActionPillStyle = styled.button` - border: 1px solid #bfdbfe; - background: #eff6ff; - color: #1d4ed8; - border-radius: 999px; - padding: 8px 10px; - font-size: 12px; + align-items: center; + background: ${DesignTokenColors.whiteUI}; + border: 1.5px solid ${DesignTokenColors.primary700}; + border-radius: 9999px; + color: ${DesignTokenColors.primary700}; cursor: pointer; + display: flex; + flex-direction: column; + font-size: 13px; + gap: 2px; + justify-content: center; + padding: 8px 16px; + text-align: center; white-space: nowrap; &:hover { - filter: brightness(0.98); + background: ${DesignTokenColors.primary50}; } + @media (max-width: 575px) { flex: 1; + width: 100%; } - @media (min-width: 576px) { - min-width: 280px; - } +`; + +const PillContent = styled.span` + color: ${DesignTokenColors.primary600}; + font-size: 12px; +`; + +const PillLabel = styled.span` + align-items: center; + display: inline-flex; + font-weight: 600; + gap: 6px; `; diff --git a/src/js/components/ManageMyCandidates/Menus.jsx b/src/js/components/ManageMyCandidates/Menus.jsx index 0a9e65287..d93eacd18 100644 --- a/src/js/components/ManageMyCandidates/Menus.jsx +++ b/src/js/components/ManageMyCandidates/Menus.jsx @@ -1,15 +1,43 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import Button from '@mui/material/Button'; import ButtonBase from '@mui/material/ButtonBase'; -import Checkbox from '@mui/material/Checkbox'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import styled from 'styled-components'; -export function CandidateActionsFilterMenu({ selectAnchorEl, setSelectAnchorEl, checkedBoolean, indeterminateBoolean, handleSelectCheckboxClick, menuOptions = null}) { +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; + +const menuOptionsPropType = PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.node, + onClick: PropTypes.func, + icon: PropTypes.elementType, + disabled: PropTypes.bool, + info: PropTypes.bool, +})); + +export function SelectAllCheckbox ({ checked, indeterminate, onClick, ariaLabel = 'Select all' }) { + return ( + {}} + onClick={onClick} + // eslint-disable-next-line no-param-reassign + ref={(el) => { if (el) el.indeterminate = !!indeterminate; }} + type="checkbox" + /> + ); +} +SelectAllCheckbox.propTypes = { + checked: PropTypes.bool, + indeterminate: PropTypes.bool, + onClick: PropTypes.func, + ariaLabel: PropTypes.string, +}; + +export function CandidateActionsFilterMenu ({ selectAnchorEl, setSelectAnchorEl, checkedBoolean, indeterminateBoolean, handleSelectCheckboxClick, menuOptions = null }) { const selectMenuOpen = Boolean(selectAnchorEl); const openSelectMenu = useCallback((e) => setSelectAnchorEl(e.currentTarget), [setSelectAnchorEl]); @@ -28,16 +56,12 @@ export function CandidateActionsFilterMenu({ selectAnchorEl, setSelectAnchorEl, aria-haspopup="menu" aria-expanded={selectMenuOpen ? 'true' : undefined} > - {}} /> -
Select All
+ Select all ); } +CandidateActionsFilterMenu.propTypes = { + selectAnchorEl: PropTypes.object, + setSelectAnchorEl: PropTypes.func, + checkedBoolean: PropTypes.bool, + indeterminateBoolean: PropTypes.bool, + handleSelectCheckboxClick: PropTypes.func, + menuOptions: menuOptionsPropType, +}; -export function CandidateTraitsFilterMenu({ filterAnchorEl, setFilterAnchorEl, filterLabel = 'Select Filter', menuOptions = null}) { +export function CandidateTraitsFilterMenu ({ filterAnchorEl, setFilterAnchorEl, filterLabel = 'Select Filter', menuOptions = null }) { const filterMenuOpen = Boolean(filterAnchorEl); const openFilterMenu = useCallback((e) => setFilterAnchorEl(e.currentTarget), [setFilterAnchorEl]); @@ -78,7 +110,7 @@ export function CandidateTraitsFilterMenu({ filterAnchorEl, setFilterAnchorEl, f return ( <> ); } +CandidateTraitsFilterMenu.propTypes = { + filterAnchorEl: PropTypes.object, + setFilterAnchorEl: PropTypes.func, + filterLabel: PropTypes.string, + menuOptions: menuOptionsPropType, +}; + +export function DropdownMenu ({ anchorEl, onClose, menuOptions = [], align = 'right', menuId }) { + const [position, setPosition] = useState({ top: 0, left: 0 }); + + useLayoutEffect(() => { + if (!anchorEl) return; + const rect = anchorEl.getBoundingClientRect(); + const menuWidth = 220; + const menuHeight = 100; + const spaceBelow = window.innerHeight - rect.bottom; + const scrollY = window.scrollY || document.documentElement.scrollTop; + const scrollX = window.scrollX || document.documentElement.scrollLeft; + setPosition({ + top: (spaceBelow < menuHeight ? rect.top - menuHeight - 6 : rect.bottom + 6) + scrollY, + left: (align === 'right' ? rect.right - menuWidth : rect.left) + scrollX, + }); + }, [anchorEl, align]); + + if (!anchorEl) return null; + + const handleClick = (option) => () => { + if (option.disabled) return; + if (option.onClick) option.onClick(); + onClose(); + }; -export function CandidateRowMenu({ rowMenuAnchorEl, setRowMenuAnchorEl, setRowMenuVoter, menuOptions = null }) { - const rowMenuOpen = Boolean(rowMenuAnchorEl); + return createPortal( + <> + + + {menuOptions.map((option, idx) => { + const Icon = option.icon; + if (option.info) { + return {option.label}; + } + return ( + + {Icon && } + {option.label} + + ); + })} + + , + document.body, + ); +} +DropdownMenu.propTypes = { + anchorEl: PropTypes.object, + onClose: PropTypes.func, + menuOptions: menuOptionsPropType, + align: PropTypes.oneOf(['left', 'right']), + menuId: PropTypes.string, +}; +export function CandidateRowMenu ({ rowMenuAnchorEl, setRowMenuAnchorEl, setRowMenuVoter, menuOptions = null }) { const closeRowMenu = useCallback(() => { setRowMenuAnchorEl(null); setRowMenuVoter(null); }, [setRowMenuAnchorEl, setRowMenuVoter]); - const onClickFunction = useCallback((optionOnClickFunction) => { - optionOnClickFunction(); - closeRowMenu(); - }, [closeRowMenu]); - return ( - - {menuOptions && menuOptions.map((option) => ( - onClickFunction(option.onClick)}> - - - - - - ))} - + ); } +CandidateRowMenu.propTypes = { + rowMenuAnchorEl: PropTypes.object, + setRowMenuAnchorEl: PropTypes.func, + setRowMenuVoter: PropTypes.func, + menuOptions: menuOptionsPropType, +}; -export const SelectControl = styled(ButtonBase)` - display: inline-flex; +export const AllButton = styled.button` align-items: center; - gap: 2px; - padding: 2px 4px; - border-radius: 8px; -`; - -export const CaretButton = styled.button` - border: none; background: transparent; - padding: 0; - margin: 0; + border: none; + color: ${DesignTokenColors.neutralUI700}; cursor: pointer; display: inline-flex; - align-items: center; -`; - - -export const AllButton = styled(Button)` - && { - min-width: auto; - padding: 0 0 0 4px; - text-transform: none; - font-size: 14px; - color: #111827; - } - - && .MuiButton-endIcon { - margin-left: 4px; - margin-right: 0; - } -`; - -export const CaretIcon = styled.span` - font-size: 32px; - padding: 0 4px; - color: #6b7280; - display: inline-flex; - align-items: center; + font: inherit; + font-size: 14px; + gap: 2px; + line-height: 1; + margin: 0; + padding: 0; `; const CandidateSubMenu = styled(Menu).attrs({ @@ -191,28 +257,91 @@ const CandidateSubMenu = styled(Menu).attrs({ }, })``; -const CandidateRowSubMenu = styled(Menu).attrs({ - anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, - transformOrigin: { vertical: 'top', horizontal: 'right' }, - PaperProps: { - style: { - borderRadius: 12, - overflow: 'hidden', - minWidth: 220, - }, - }, -})``; +export const CaretButton = styled.button` + align-items: center; + background: transparent; + border: none; + cursor: pointer; + display: inline-flex; + margin: 0; + padding: 0; +`; +export const CaretIcon = styled.span` + align-items: center; + color: ${DesignTokenColors.neutralUI600}; + display: inline-flex; + font-size: 18px; + padding: 0 2px; +`; + +const ClickAwayOverlay = styled.div` + background: transparent; + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 999; +`; + +const DropdownInfo = styled.div` + color: ${DesignTokenColors.neutralUI600}; + font-size: 13px; + padding: 6px 10px 8px; +`; const MenuItemText = styled.span` -font-size: 14px; -color: #111827; + color: #111827; + font-size: 14px; +`; + +const RowMenuCard = styled.div` + background: ${DesignTokenColors.whiteUI}; + border: 1px solid ${DesignTokenColors.neutralUI200}; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.08); + min-width: 220px; + padding: 6px; + position: absolute; + z-index: 1000; `; -const StyledMenuItem = styled(MenuItem)` - && { - font-size: 14px; - padding-top: 10px; - padding-bottom: 10px; +const RowMenuItem = styled.button` + align-items: center; + background: transparent; + border: 0; + border-radius: 8px; + color: ${DesignTokenColors.neutralUI900}; + cursor: pointer; + display: flex; + font: inherit; + font-size: 14px; + gap: 8px; + padding: 8px 10px; + text-align: left; + width: 100%; + + &:hover { + background: ${DesignTokenColors.neutralUI50}; } + + &:disabled { + color: ${DesignTokenColors.neutralUI500}; + cursor: not-allowed; + } +`; + +const SelectAllLabel = styled.span` + color: ${DesignTokenColors.neutralUI700}; + font-size: 14px; + line-height: 1; +`; + +export const SelectControl = styled(ButtonBase)` + align-items: center; + border-radius: 8px; + display: inline-flex; + gap: 6px; + padding: 0 2px; `; diff --git a/src/js/components/ManageMyCandidates/SendButtons.jsx b/src/js/components/ManageMyCandidates/SendButtons.jsx index cf7c01fb9..8cab128ea 100644 --- a/src/js/components/ManageMyCandidates/SendButtons.jsx +++ b/src/js/components/ManageMyCandidates/SendButtons.jsx @@ -1,43 +1,48 @@ import React from 'react'; +import PropTypes from 'prop-types'; import Button from '@mui/material/Button'; import styled from 'styled-components'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; -export function SendMessageButton({ disbaleBoolean, sendMessageFunction, buttonText = '' }) { +const sendButtonPropTypes = { + verb: PropTypes.string, + count: PropTypes.number, + onClick: PropTypes.func, +}; + +function formatLabel (verb, count) { + return `${verb} (${count})`; +} + +export function SendMessageButton ({ verb = 'Send message', count = 0, onClick }) { return ( - {buttonText} + {formatLabel(verb, count)} ); } +SendMessageButton.propTypes = sendButtonPropTypes; -export function SendMessageButtonMobile({ disbaleBoolean, sendThankYouFunction, buttonText = '' }) { +export function SendMessageButtonMobile ({ verb = 'Send message', count = 0, onClick }) { return ( - {buttonText} + {formatLabel(verb, count)} ); } - -export const SendButton = styled(Button)` - && { - margin-left: 12px; - text-transform: none; - border-radius: 999px; - } -`; +SendMessageButtonMobile.propTypes = sendButtonPropTypes; const MobileBottomBar = styled.div` display: flex; @@ -51,11 +56,34 @@ const MobileBottomBar = styled.div` padding-bottom: calc(72px + env(safe-area-inset-bottom)); background: ${DesignTokenColors.whiteUI}; border-top: 1px solid #e5e7eb; + box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.08), 0 -1px 2px rgba(0, 0, 0, 0.1); `; const MobileSendButton = styled(Button)` && { border-radius: 999px; + font-size: 13px; + padding: 5px 18px; text-transform: none; } + + &&.Mui-disabled { + background: ${DesignTokenColors.neutralUI200}; + color: ${DesignTokenColors.whiteUI}; + } +`; + +export const SendButton = styled(Button)` + && { + border-radius: 999px; + margin-left: 12px; + padding: 4px 14px; + text-transform: none; + white-space: nowrap; + } + + &&.Mui-disabled { + background: ${DesignTokenColors.neutralUI200}; + color: ${DesignTokenColors.whiteUI}; + } `; diff --git a/src/js/components/Style/ManageMyCandidates.jsx b/src/js/components/Style/ManageMyCandidates.jsx index dba5c4bd9..4460f1486 100644 --- a/src/js/components/Style/ManageMyCandidates.jsx +++ b/src/js/components/Style/ManageMyCandidates.jsx @@ -2,17 +2,6 @@ import styled from 'styled-components'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; -export const CardList = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - @media (max-width: 575px) { - padding-bottom: 72px; - } - border-collapse: separate; - border-spacing: 0 10px; -`; - export const Card = styled.div` border: 1px solid #e5e7eb; border-radius: 16px; @@ -21,13 +10,6 @@ export const Card = styled.div` box-shadow: 0 1px 0 rgba(0,0,0,0.02); `; -export const CardTopRow = styled.div` - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; -`; - export const CardActionsAndOpinion = styled.div` display: flex; justify-content: space-evenly; @@ -54,18 +36,63 @@ export const CardInfoValue = styled.div` margin-top: auto; `; -export const VerticalBarWrapper = styled.div` - align-self: stretch; +export const CardList = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + @media (max-width: 575px) { + padding-bottom: 72px; + } + border-collapse: separate; + border-spacing: 0 10px; +`; + +export const CardTopRow = styled.div` display: flex; align-items: center; - margin: ${(p) => (p.$tight ? '0 4px' : '0 12px')}; + gap: 10px; + margin-bottom: 10px; `; -export const VerticalBar = styled.div` - width: 1px; - height: 80%; - background: #d1d5db; - border-radius: 999px; +export const Container = styled.div` + display: flex; + flex-direction: column; +`; + +export const KebabBtn = styled.button` + border: none; + background: transparent; + cursor: pointer; + color: #6b7280; + padding: 2px 6px; + border-radius: 10px; + + &:hover { + background: ${DesignTokenColors.neutralUI50}; + } +`; + +export const LeftTools = styled.div` + display: flex; + align-items: center; +`; + +export const NameRow = styled.div` + display: flex; + gap: 10px; + align-items: center; +`; + +export const NameText = styled.div` + color: ${DesignTokenColors.neutralUI900}; + font-size: 15px; + font-weight: 600; +`; + +export const RightOptions = styled.div` + margin-left: auto; + display: flex; + align-items: center; `; export const ToolbarRow = styled.div` @@ -77,7 +104,16 @@ export const ToolbarRow = styled.div` margin-left: 12px; `; -export const LeftTools = styled.div` +export const VerticalBar = styled.div` + width: 1px; + height: 80%; + background: #d1d5db; + border-radius: 999px; +`; + +export const VerticalBarWrapper = styled.div` + align-self: stretch; display: flex; align-items: center; -`; \ No newline at end of file + margin: ${(p) => (p.$tight ? '0 4px' : '0 12px')}; +`; diff --git a/src/js/components/Style/SupporterTrackingStyles.jsx b/src/js/components/Style/SupporterTrackingStyles.jsx new file mode 100644 index 000000000..24c74800b --- /dev/null +++ b/src/js/components/Style/SupporterTrackingStyles.jsx @@ -0,0 +1,178 @@ +/* Styled components specific to the ManageMyCandidates > Tracking subtabs + (Joined / Invited / Reminder needed). Extends the more general primitives + from Style/ManageMyCandidates (Card, CardList, NameText, etc.). */ + +import styled from 'styled-components'; + +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import { Card, CardList, NameText } from './ManageMyCandidates'; + +/* Small blue text link with optional inline icons, used for inline row actions + like "Send email invite" / "Re-send text invite" on Invited and Reminder. */ +export const ActionLinkButton = styled.button` + align-items: center; + background: none; + border: none; + color: ${DesignTokenColors.primary600}; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 500; + gap: 6px; + padding: 4px 0; + white-space: nowrap; + + &:hover { + color: ${DesignTokenColors.primary700}; + text-decoration: underline; + } +`; + +/* Shared base for a CSS-grid row used in the table-style desktop view of Invited/Reminder. + Caller supplies $cols (a grid-template-columns string). + Defined before DesktopGridHeader because DesktopGridHeader extends it. */ +export const DesktopGridRow = styled.div.withConfig({ + shouldForwardProp: (prop) => prop !== '$cols', +})` + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: ${(p) => p.$cols}; +`; + +/* Header variant of DesktopGridRow: bottom border, column-label styling. */ +export const DesktopGridHeader = styled(DesktopGridRow)` + border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; + color: ${DesignTokenColors.neutralUI600}; + font-size: 13px; + min-width: max-content; + padding: 8px 14px; +`; + +/* Card list that flattens out (no row gap) on desktop, keeps mobile gap from CardList */ +export const FlatCardList = styled(CardList)` + @media (min-width: 576px) { + gap: 0; + } +`; + +/* Rounded card on mobile (inherits Card's subtle #e5e7eb border + gray bg, matching Joined), + flat row (no rounded corners, bottom border only) on desktop. + min-width: max-content ensures the bottom border extends across the full grid row + when columns overflow the viewport horizontally. */ +export const FlatRowCard = styled(Card)` + background: ${(p) => (p.$selected ? DesignTokenColors.primary50 : DesignTokenColors.neutralUI50)}; + padding: 12px 14px; + + @media (min-width: 576px) { + background: ${(p) => (p.$selected ? DesignTokenColors.primary50 : DesignTokenColors.whiteUI)}; + border-radius: 0; + border-left: none; + border-right: none; + border-top: none; + box-shadow: none; + min-width: max-content; + } +`; + +/* Full-width rounded pill button used in the mobile card actions */ +export const MobileActionPill = styled.button` + background: ${DesignTokenColors.whiteUI}; + border: 1px solid ${DesignTokenColors.primary600}; + border-radius: 9999px; + color: ${DesignTokenColors.primary700}; + cursor: pointer; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + width: 100%; + + &:hover { + background: ${DesignTokenColors.primary50}; + } + + &:disabled { + background: ${DesignTokenColors.neutralUI50}; + border-color: ${DesignTokenColors.neutralUI300}; + color: ${DesignTokenColors.neutralUI600}; + cursor: default; + } +`; + +/* Mobile vertical stack of action pills under "What you can do" */ +export const MobileActions = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +`; + +/* Small gray label above a value in the mobile expanded fields */ +export const MobileFieldLabel = styled.div` + color: ${DesignTokenColors.neutralUI600}; + font-size: 13px; + margin-top: 8px; +`; + +/* Bold value paired with MobileFieldLabel */ +export const MobileFieldValue = styled.div` + color: ${DesignTokenColors.neutralUI900}; + font-size: 14px; + font-weight: 500; +`; + +/* Mobile-only toolbar row sitting above the card list (Select all + search etc.) */ +export const MobileToolbar = styled.div` + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; + margin-bottom: 12px; +`; + +/* Name + checkbox cell for grid-column layouts (Invited/Reminder). + min-width: 0 + nowrap+ellipsis on the child NameText so long names truncate. */ +export const NameCell = styled.div` + align-items: center; + display: flex; + gap: 10px; + min-width: 0; + + ${NameText} { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +`; + +/* 32x32 transparent icon button, used for the mobile toolbar search affordance */ +export const SearchBtn = styled.button` + align-items: center; + background: none; + border: none; + border-radius: 8px; + color: ${DesignTokenColors.neutralUI700}; + cursor: pointer; + display: inline-flex; + height: 32px; + justify-content: center; + padding: 0; + width: 32px; + + &:hover { + background: ${DesignTokenColors.neutralUI50}; + color: ${DesignTokenColors.neutralUI900}; + } +`; + +/* Inline "Select all" label that wraps a checkbox + text */ +export const SelectAllInline = styled.label` + align-items: center; + color: ${DesignTokenColors.neutralUI700}; + display: inline-flex; + font-size: 14px; + gap: 6px; + line-height: 1; + margin: 0; +`; diff --git a/src/js/pages/ManageMyCandidates/SupporterTracking.jsx b/src/js/pages/ManageMyCandidates/SupporterTracking.jsx index 0e343dccf..44ff2dbaa 100644 --- a/src/js/pages/ManageMyCandidates/SupporterTracking.jsx +++ b/src/js/pages/ManageMyCandidates/SupporterTracking.jsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; import { Search as SearchIcon } from '@mui/icons-material'; -import IconButton from '@mui/material/IconButton'; import Popover from '@mui/material/Popover'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import CloseIcon from '@mui/icons-material/Close'; @@ -14,6 +14,7 @@ import PoliticianStore from '../../common/stores/PoliticianStore'; import SupportersJoined from './SupportersJoined'; import SupportersInvited from './SupportersInvited'; import SupportersToRemind from './SupportersToRemind'; +import TrackingHeaderActionContext from './TrackingHeaderActionContext'; function pushDataLayer (buttonId = '', politicianWeVoteId = null) { const dataLayerObject = { @@ -31,165 +32,152 @@ function pushDataLayer (buttonId = '', politicianWeVoteId = null) { TagManager.dataLayer({ dataLayer: dataLayerObject }); } +const LOREM_IPSUM_PLACEHOLDER = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.'; + +/** + * Spoofed records (replace later with DB-backed rows) + * status: "joined" | "invited" | "reminder" + */ +const SPOOF = [ + { + id: 'v_1', + status: 'joined', + name: 'Steve Smith', + endorsed: false, + friendsInvited: 0, + messageSentCount: 0, + publicOpinion: '', + }, + { + id: 'v_2', + status: 'joined', + name: 'Jane Smith', + endorsed: true, + friendsInvited: 0, + messageSentCount: 0, + publicOpinion: '', + }, + { + id: 'v_3', + status: 'joined', + name: 'Steve Smith', + endorsed: true, + friendsInvited: 0, + messageSentCount: 0, + publicOpinion: LOREM_IPSUM_PLACEHOLDER, + }, + { + id: 'v_4', + status: 'joined', + name: 'Jen Smith', + endorsed: true, + friendsInvited: 2, + messageSentCount: 0, + publicOpinion: LOREM_IPSUM_PLACEHOLDER, + }, + { + id: 'v_5', + status: 'joined', + name: 'Mary Smith', + endorsed: true, + friendsInvited: 2, + messageSentCount: 1, + publicOpinion: LOREM_IPSUM_PLACEHOLDER, + }, + + { + id: 'v_6', + status: 'invited', + joined: true, + name: 'Alex Doe', + emailInviteSent: true, + textInviteSent: true, + inviteLinkClicked: true, + friendsInvited: 2, + }, + { + id: 'v_7', + status: 'remind', + reminderSubStatus: 'invited', + name: 'John Smith', + emailInviteSent: true, + textInviteSent: false, + invitedDaysAgo: 9, + }, + { + id: 'v_10', + status: 'remind', + reminderSubStatus: 'invited', + name: 'James Smith', + emailInviteSent: false, + textInviteSent: true, + invitedDaysAgo: 11, + }, + { + id: 'v_11', + status: 'remind', + reminderSubStatus: 'invited', + name: 'Jennifer Smith', + emailInviteSent: true, + textInviteSent: true, + invitedDaysAgo: 14, + }, + { + id: 'v_12', + status: 'remind', + reminderSubStatus: 'joined', + name: 'Morgan Lee', + joined: true, + endorsed: false, + friendsInvited: 0, + joinedDaysAgo: 8, + }, + { + id: 'v_8', + status: 'invited', + joined: false, + name: 'Alex Doe', + emailInviteSent: false, + textInviteSent: true, + inviteLinkClicked: false, + friendsInvited: 0, + }, + { + id: 'v_9', + status: 'invited', + joined: false, + name: 'SuperLong FirstNameLastName', + emailInviteSent: true, + textInviteSent: false, + inviteLinkClicked: false, + friendsInvited: 0, + }, +]; + export default function SupporterTracking ({ selectedPoliticianWeVoteId = null }) { const [activeTab, setActiveTab] = useState('joined'); - - /** - * Spoofed records (replace later with DB-backed rows) - * status: "joined" | "invited" | "reminder" - */ - const SPOOF = [ - { - id: 'v_1', - status: 'joined', - name: 'Steve Smith', - endorsed: false, - friendsInvited: 0, - messageSentCount: 0, - publicOpinion: - '', - }, - { - id: 'v_2', - status: 'joined', - name: 'Jane Smith', - endorsed: true, - friendsInvited: 0, - messageSentCount: 0, - publicOpinion: '', - }, - { - id: 'v_3', - status: 'joined', - name: 'Steve Smith', - endorsed: true, - friendsInvited: 0, - messageSentCount: 0, - publicOpinion: - 'Lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet...', - }, - { - id: 'v_4', - status: 'joined', - name: 'Jen Smith', - endorsed: true, - friendsInvited: 2, - messageSentCount: 0, - publicOpinion: - 'Lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet...', - }, - { - id: 'v_5', - status: 'joined', - name: 'Mary Smith', - endorsed: true, - friendsInvited: 2, - messageSentCount: 1, - publicOpinion: - 'Lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet...', - }, - - // A couple non-joined for tabs - // { - // id: "v_6", - // status: "invited", - // name: "Alex Doe", - // endorsed: false, - // friendsInvited: 0, - // messageSentCount: 0, - // publicOpinion: "", - // }, - { - id: 'v_6', - status: 'invited', - joined: true, - name: 'Alex Doe', - emailInviteSent: true, - textInviteSent: true, - inviteLinkClicked: true, - friendsInvited: 2, - - }, - { - id: 'v_7', - status: 'remind', - reminderSubStatus: 'invited', - name: 'John Smith', - emailInviteSent: true, - textInviteSent: false, - invitedDaysAgo: 9, - }, - { - id: 'v_10', - status: 'remind', - reminderSubStatus: 'invited', - name: 'James Smith', - emailInviteSent: false, - textInviteSent: true, - invitedDaysAgo: 11, - }, - { - id: 'v_11', - status: 'remind', - reminderSubStatus: 'invited', - name: 'Jennifer Smith', - emailInviteSent: true, - textInviteSent: true, - invitedDaysAgo: 14, - }, - { - id: 'v_12', - status: 'remind', - reminderSubStatus: 'joined', - name: 'Morgan Lee', - joined: true, - endorsed: false, - friendsInvited: 0, - joinedDaysAgo: 8, - }, - { - id: 'v_8', - status: 'invited', - joined: false, - name: 'Alex Doe', - emailInviteSent: false, - textInviteSent: true, - inviteLinkClicked: false, - friendsInvited: 0, - }, - { - id: 'v_9', - status: 'invited', - joined: false, - name: 'SuperLong FirstNameLastName', - emailInviteSent: true, - textInviteSent: false, - inviteLinkClicked: false, - friendsInvited: 0, - }, - ]; + const [headerActionSlot, setHeaderActionSlot] = useState(null); const counts = useMemo(() => { const c = { joined: 0, invited: 0, remind: 0 }; - for (const r of SPOOF) c[r.status] += 1; + SPOOF.forEach((r) => { c[r.status] += 1; }); return c; }, []); const renderTabContent = () => { switch (activeTab) { case 'joined': - return r.status === 'joined')}/>; + return r.status === 'joined')} />; case 'invited': - return r.status === 'invited')}/>; + return r.status === 'invited')} />; case 'remind': - return r.status === 'remind')}/>; + return r.status === 'remind')} />; default: - return r.status === 'joined')}/>; + return r.status === 'joined')} />; } }; const [anchorEl, setAnchorEl] = React.useState(null); - const [buttonEl, setButtonEl] = React.useState(null); const open = Boolean(anchorEl); @@ -200,7 +188,7 @@ export default function SupporterTracking ({ selectedPoliticianWeVoteId = null } const handleClose = () => setAnchorEl(null); return ( - <> +

Tracking

@@ -232,6 +220,9 @@ export default function SupporterTracking ({ selectedPoliticianWeVoteId = null } }, }} > + {/* TODO: auto-open this popover on the user's first visit to the page + (e.g., gate behind a localStorage flag like `seenTrackingInfoPopover`, + set the flag once the user dismisses it so it doesn't reopen). */} @@ -254,43 +245,50 @@ export default function SupporterTracking ({ selectedPoliticianWeVoteId = null } id="trackingJoinedWeVoteTab" active={activeTab === 'joined'} onClick={() => { pushDataLayer('trackingJoinedWeVoteTab', selectedPoliticianWeVoteId); setActiveTab('joined'); }} - data-hidden-bold-text={`Joined WeVote (${counts['joined']})`} + data-hidden-bold-text={`Joined WeVote (${counts.joined})`} > - Joined WeVote - ( - {counts['joined']} + Joined WeVote + Joined + {' ('} + {counts.joined} ) { pushDataLayer('trackingInvitedTab', selectedPoliticianWeVoteId); setActiveTab('invited'); }} - data-hidden-bold-text={`Invited (${counts['invited']})`} + data-hidden-bold-text={`Invited (${counts.invited})`} > Invited ( - {counts['invited']} + {counts.invited} ) { pushDataLayer('trackingReminderNeededTab', selectedPoliticianWeVoteId); setActiveTab('remind'); }} - data-hidden-bold-text={`Reminder needed (${counts['remind']})`} + data-hidden-bold-text={`Reminder needed (${counts.remind})`} > - Reminder needed - ( - {counts['remind']} + Reminder needed + Remind + {' ('} + {counts.remind} ) + {renderTabContent()} - +
); } +SupporterTracking.propTypes = { + selectedPoliticianWeVoteId: PropTypes.string, +}; + // Styles const H2 = styled.h2` @@ -300,6 +298,12 @@ const H2 = styled.h2` margin: 0; `; +const HeaderActionSlot = styled.div` + align-items: center; + display: flex; + margin-left: auto; +`; + const HeaderDivider = styled.span` border-left: 1.5px solid ${DesignTokenColors.neutralUI100}; height: 30px; @@ -397,12 +401,23 @@ const SubHeaderRow = styled.div` align-items: center; gap: 12px; margin: 0 0 6px; + + @media (max-width: 575px) { + gap: 5px; + justify-content: center; + } `; const Tab = styled.button` background: none; border: none; padding: 12px 16px 4px 16px; + flex-shrink: 0; + @media (max-width: 575px) { + flex: 1; + padding-left: 0; + padding-right: 0; + } font-size: 15px; font-weight: ${(props) => (props.active ? '600' : '500')}; color: ${(props) => (props.active ? DesignTokenColors.primary600 : DesignTokenColors.neutralUI700)}; @@ -447,11 +462,19 @@ const TabContent = styled.div` const TabRow = styled.div` display: flex; gap: 0; - border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; - margin-bottom: 4px; + margin-bottom: 16px; + + @media (max-width: 575px) { + border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; + margin-bottom: 4px; + } `; const TrackingText = styled.p` color: ${DesignTokenColors.neutralUI700}; margin: 0; + + @media (max-width: 575px) { + font-size: 13px; + } `; diff --git a/src/js/pages/ManageMyCandidates/SupportersInvited.jsx b/src/js/pages/ManageMyCandidates/SupportersInvited.jsx index 510cbda2d..a5973bcf8 100644 --- a/src/js/pages/ManageMyCandidates/SupportersInvited.jsx +++ b/src/js/pages/ManageMyCandidates/SupportersInvited.jsx @@ -1,144 +1,59 @@ -import React, { useEffect, useMemo, useState } from 'react'; +/* eslint-disable no-alert */ +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import EmailIcon from '@mui/icons-material/Email'; +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; import MailOutlineIcon from '@mui/icons-material/MailOutline'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; -import SmsIcon from '@mui/icons-material/Sms'; +import SearchIcon from '@mui/icons-material/Search'; +import SmsOutlinedIcon from '@mui/icons-material/SmsOutlined'; import Alert from '@mui/material/Alert'; import Snackbar from '@mui/material/Snackbar'; import styled from 'styled-components'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; -import ActionPill from '../../components/ManageMyCandidates/ActionPill'; -import { CandidateActionsFilterMenu, CandidateTraitsFilterMenu, CandidateRowMenu } from '../../components/ManageMyCandidates/Menus'; +import { CandidateRowMenu, SelectAllCheckbox } from '../../components/ManageMyCandidates/Menus'; import { SendMessageButton, SendMessageButtonMobile } from '../../components/ManageMyCandidates/SendButtons'; -import { CardList, Card, CardTopRow, CardActionsAndOpinion, CardInfo, CardInfoTitle, CardInfoValue, VerticalBarWrapper, VerticalBar, ToolbarRow, LeftTools } from '../../components/Style/ManageMyCandidates'; +import TrackingHeaderActionContext from './TrackingHeaderActionContext'; +import { CardTopRow, Container, KebabBtn, NameRow, NameText, RightOptions } from '../../components/Style/ManageMyCandidates'; +import { ActionLinkButton, DesktopGridHeader, DesktopGridRow, FlatCardList, FlatRowCard, MobileToolbar, NameCell, SearchBtn, SelectAllInline } from '../../components/Style/SupporterTrackingStyles'; -const FILTERS = { - ALL: 'all', - HAS_INVITED: 'hasInvited', - HAS_ENDORSED: 'hasEndorsed', -}; +const GRID_COLS = 'minmax(160px, 220px) minmax(140px, 1.1fr) minmax(140px, 1.1fr) minmax(110px, 0.7fr) minmax(70px, 0.4fr) minmax(110px, 0.6fr)'; export default function SupportersInvited ({ supporters }) { + const headerActionSlot = useContext(TrackingHeaderActionContext); const [selected, setSelected] = useState(() => new Set()); - const [activeFilter, setActiveFilter] = useState(FILTERS.ALL); - const [copyOpen, setCopyOpen] = useState(false); - const [isSending, setIsSending] = useState(false); const [sendToast, setSendToast] = useState({ open: false, ok: true, msg: '' }); - - // ----- checkbox dropdown menu state ----- - const [selectAnchorEl, setSelectAnchorEl] = useState(null); - // ----- "All" dropdown menu state ----- - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - // ----- per-row "triple dot" menu state ----- const [rowMenuAnchorEl, setRowMenuAnchorEl] = useState(null); const [rowMenuVoter, setRowMenuVoter] = useState(null); - const getPendingActions = (v) => ([ - !v.endorsed && 'Endorse', - !v.publicOpinion && 'Write public opinion', - v.friendsInvited === 0 && 'Invite friends', - ].filter(Boolean)); - - const getActionsLabel = (v) => getPendingActions(v).join(', '); - - // ----- derived data for selected voters, dropdown + counts ----- - const selectedVoters = useMemo(() => { - const selectedIds = selected; - return supporters.filter((v) => selectedIds.has(v.id)); - }, [supporters, selected]); - - const visibleSupporters = useMemo(() => { - switch (activeFilter) { - case FILTERS.HAS_INVITED: - return supporters.filter((v) => (v.friendsInvited || 0) > 0); - case FILTERS.HAS_ENDORSED: - return supporters.filter((v) => !!v.endorsed); - case FILTERS.ALL: - default: - return supporters; - } - }, [supporters, activeFilter]); - - const actionGroups = useMemo(() => { - // key: actionsLabel, value: array of supporter ids - const map = new Map(); - - visibleSupporters.forEach((v) => { - const actionsLabel = getActionsLabel(v); - if (!actionsLabel) return; // no pending actions => don't appear in "Ask to:" menu - const prev = map.get(actionsLabel) || []; - prev.push(v.id); - map.set(actionsLabel, prev); - }); - - // Sort by count desc, then label asc - return Array.from(map.entries()) - .map(([label, ids]) => ({ label, ids, count: ids.length })) - .sort((a, b) => (b.count - a.count) || a.label.localeCompare(b.label)); - }, [getActionsLabel, visibleSupporters]); - useEffect(() => { - const visibleIds = new Set(visibleSupporters.map((v) => v.id)); + const visibleIds = new Set(supporters.map((v) => v.id)); setSelected((prev) => new Set([...prev].filter((id) => visibleIds.has(id)))); - }, [visibleSupporters]); + }, [supporters]); - const totalVisibleCount = visibleSupporters.length; - const selectedVisibleCount = useMemo( - () => visibleSupporters.filter((v) => selected.has(v.id)).length, - [visibleSupporters, selected], + const totalCount = supporters.length; + const selectedCount = useMemo( + () => supporters.filter((v) => selected.has(v.id)).length, + [supporters, selected], ); - - // ----- "All" filter dropdown data (counts) ----- - const filterLabel = useMemo(() => { - switch (activeFilter) { - case FILTERS.HAS_INVITED: return 'Has invited friends'; - case FILTERS.HAS_ENDORSED: return 'Has endorsed'; - default: return 'All'; - } - }, [activeFilter]); - - const filterGroups = useMemo(() => { - const hasInvitedIds = []; - const hasEndorsedIds = []; - - supporters.forEach((v) => { - if ((v.friendsInvited || 0) > 0) hasInvitedIds.push(v.id); - if (v.endorsed) hasEndorsedIds.push(v.id); - }); - - return { - hasInvitedIds, - hasInvitedCount: hasInvitedIds.length, - hasEndorsedIds, - hasEndorsedCount: hasEndorsedIds.length, - }; - }, [supporters]); + const allChecked = totalCount > 0 && selectedCount === totalCount; + const indeterminate = selectedCount > 0 && selectedCount < totalCount; const toggleSelected = (id) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; const handleSelectCheckboxClick = (e) => { - e.stopPropagation(); // important: don't open the dropdown menu - setSelected((prev) => { - if (prev.size === 0) { - // select all visible - return new Set(visibleSupporters.map((v) => v.id)); - } - // clear all selection - return new Set(); - }); + e.stopPropagation(); + setSelected((prev) => (prev.size === 0 ? new Set(supporters.map((v) => v.id)) : new Set())); }; const openRowMenu = (e, voter) => { @@ -147,101 +62,81 @@ export default function SupportersInvited ({ supporters }) { setRowMenuVoter(voter); }; - const handleSendMessage = () => { - if (rowMenuVoter) { - alert(`Send message to: ${rowMenuVoter.name}`); - } + const handleResendSelected = () => { + if (selectedCount === 0) return; + setSendToast({ + open: true, + ok: true, + msg: `Resent invitation to ${selectedCount} voter${selectedCount === 1 ? '' : 's'}.`, + }); }; - const handleSendThankYou = async () => { - console.log('selectedVoters', selectedVoters); - if (selectedVoters.length === 0) { - return; - } - - setIsSending(true); - try { - // TODO: replace with real API call(s) - // await sendThankYouMessage(selectedVoters, thankYouMessage); - handleSendMessage(); - console.log('here'); - setSendToast({ - open: true, - ok: true, - msg: `Sent to ${selectedVoters.length} supporter${selectedVoters.length === 1 ? '' : 's'}.`, - }); - console.log('here2'); - - // Optional: mark them as "messageSentCount + 1" in state once you have stateful supporters - // Optional: clear selection after send - // setSelected(new Set()); - } catch (err) { - console.error(err); - setSendToast({ open: true, ok: false, msg: 'Send failed. Please try again.' }); - } finally { - setIsSending(false); - } + const getInvitedViaLabel = (v) => { + const channels = []; + if (v.emailInviteSent) channels.push('Email'); + if (v.textInviteSent) channels.push('text'); + if (channels.length === 0) return '—'; + channels[0] = channels[0].charAt(0).toUpperCase() + channels[0].slice(1); + return channels.join(', '); }; return ( - {/* - - */} - - {/* Abstract Out */} - - - - 0 && selectedVisibleCount === totalVisibleCount} - indeterminateBoolean={selectedVisibleCount > 0 && selectedVisibleCount < totalVisibleCount} - handleSelectCheckboxClick={handleSelectCheckboxClick} - menuOptions={[ - { label: 'All', onClick: () => setSelected(new Set(visibleSupporters.map((v) => v.id)))}, - ...actionGroups.map((g) => ({ - label: `Ask to: ${g.label} - (${g.count})`, - onClick: () => setSelected(new Set(g.ids)) })), - { label: 'None', onClick: () => setSelected(new Set()) }, - ]} - /> - - - - - - setActiveFilter(FILTERS.ALL) }, - { label: `Has invited friends - (${filterGroups.hasInvitedCount})`, - onClick: () => setActiveFilter(FILTERS.HAS_INVITED) }, - { label: `Has endorsed - (${filterGroups.hasEndorsedCount})`, - onClick: () => setActiveFilter(FILTERS.HAS_ENDORSED) }, - ]} - /> - - + {headerActionSlot && createPortal( - - - - - {visibleSupporters.map((v) => { + verb="Resend invite" + count={selectedCount} + onClick={handleResendSelected} + />, + headerActionSlot, + )} + + {/* Mobile toolbar */} + + + + Select all + + console.log('TODO: implement search feature')} + > + + + + + {/* Desktop column headers (with Select all in the first cell) */} + + + + + Select all + + + + + Invite link clicked + Joined + Friends invited + + + + {supporters.map((v) => { const isChecked = selected.has(v.id); - return ( - - - + + {/* Desktop row */} + + {v.name} + + + + {v.emailInviteSent ? ( + + + Email invite sent + + ) : ( + alert(`Send email invite to ${v.name}`)}> + + Send email invite + + )} + + + + {v.textInviteSent ? ( + + + Text invite sent + + ) : ( + alert(`Send text invite to ${v.name}`)}> + + Send text invite + + )} + + + + {v.inviteLinkClicked ? ( + + + Yes + + ) : 'No'} + + + + {v.joined ? ( + + + Yes + + ) : 'No'} + + + + {v.friendsInvited > 0 && ( + + + {v.friendsInvited} + + )} + + + + {/* Mobile card */} +
+ + + toggleSelected(v.id)} + aria-label={`Select ${v.name}`} + /> + {v.name} + + + openRowMenu(e, v)}> + + + + + + + + Invited via + {getInvitedViaLabel(v)} + + + Invite link clicked + + {v.inviteLinkClicked ? ( + + + Yes + + ) : 'No'} + + + + Joined + + {v.joined ? ( + + + Yes + + ) : 'No'} + + + + Friends invited + + {v.friendsInvited > 0 ? ( + + + {v.friendsInvited} + + ) : 'None'} + + + - - - - - - - openRowMenu(e, v)} - > - - - - - - - - - - Invited via - - - <> - {!!v.emailInviteSent && } - {!!v.textInviteSent && } - - - - - - - - - Link clicked - - - {!!v.inviteLinkClicked ? ( - <> - - Yes - - ) : ( - 'No' - )} - - - - - - - - Joined - - - {!!v.joined ? ( - <> - - Yes - - ) : ( - 'No' - )} - - - - - - - - Friends invited - - - {!!v.friendsInvited ? ( - <> - - {v.friendsInvited} - - ) : ( - 'None' - )} - - - - - - -
{!v.emailInviteSent && ( - alert(`Send an email & ask ${v.name} to join WeVote`)} - label={( - <> - - Send email invite - - )} - /> + alert(`Send email invite to ${v.name}`)}> + + Send email invite + )} - {!v.textInviteSent && ( - alert(`Send a text & ask ${v.name} to join WeVote`)} - label={( - <> - - Send text invite - - )} - /> + alert(`Send text invite to ${v.name}`)}> + + Send text invite + )}
- + ); })} - + - {/* Pop up to display when a user copies or sends the Thank You message */} - setCopyOpen(false)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - sx={{ '&.MuiSnackbar-anchorOriginTopCenter': { top: 80 } }} - > - - Copied! - - setSendToast((t) => ({ ...t, open: false }))} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - sx={{ - '&.MuiSnackbar-anchorOriginTopCenter': { top: 80 }, - }} + sx={{ '&.MuiSnackbar-anchorOriginTopCenter': { top: 80 } }} > {sendToast.msg} @@ -415,44 +313,56 @@ export default function SupportersInvited ({ supporters }) { ); } +SupportersInvited.propTypes = { + supporters: PropTypes.arrayOf(PropTypes.object), +}; /* ===== Styled components ===== */ -const Container = styled.div` + +const Field = styled.div` display: flex; flex-direction: column; + gap: 2px; + min-width: 0; `; -const NameRow = styled.div` - display: flex; +const FieldGrid = styled.div` + display: grid; gap: 10px; - align-items: center; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 6px; `; -const NameText = styled.div` - font-size: 16px; - font-weight: 700; +const FieldLabel = styled.div` + color: ${DesignTokenColors.neutralUI600}; + font-size: 12px; `; -const KebabBtn = styled.button` - border: none; - background: transparent; - cursor: pointer; - color: #6b7280; - padding: 2px 6px; - border-radius: 10px; - - &:hover { - background: ${DesignTokenColors.neutralUI50}; - } +const FieldValue = styled.div` + color: ${DesignTokenColors.neutralUI900}; + font-size: 13px; + font-weight: 500; `; -const RightOptions = styled.div` - margin-left: auto; - display: flex; +const HeaderCell = styled.div``; + +const SentStatus = styled.span` align-items: center; + color: ${DesignTokenColors.neutralUI800}; + display: inline-flex; + font-size: 14px; + gap: 4px; `; -const CheckCircleIconGreenLarge = styled(CheckCircleIcon)` - color: green; - font-size: large; +const StatusCell = styled.div` + color: ${DesignTokenColors.neutralUI800}; + font-size: 14px; +`; + +const YesStatus = styled.span` + align-items: center; + color: ${DesignTokenColors.neutralUI900}; + display: inline-flex; + font-weight: 600; + gap: 4px; `; diff --git a/src/js/pages/ManageMyCandidates/SupportersJoined.jsx b/src/js/pages/ManageMyCandidates/SupportersJoined.jsx index b9a616f64..b4b7358d5 100644 --- a/src/js/pages/ManageMyCandidates/SupportersJoined.jsx +++ b/src/js/pages/ManageMyCandidates/SupportersJoined.jsx @@ -1,32 +1,35 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import Alert from '@mui/material/Alert'; -import Button from '@mui/material/Button'; -import ButtonBase from '@mui/material/ButtonBase'; -import Checkbox from '@mui/material/Checkbox'; +/* eslint-disable no-alert */ +import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import DoneIcon from '@mui/icons-material/Done'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Drawer from '@mui/material/Drawer'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; import MailOutlineIcon from '@mui/icons-material/MailOutline'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; -import Snackbar from '@mui/material/Snackbar'; -import TextField from '@mui/material/TextField'; +import SearchIcon from '@mui/icons-material/Search'; import ThumbUpIcon from '@mui/icons-material/ThumbUp'; import VisibilityIcon from '@mui/icons-material/Visibility'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Drawer from '@mui/material/Drawer'; +import Snackbar from '@mui/material/Snackbar'; +import TextField from '@mui/material/TextField'; +import styled from 'styled-components'; + import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import ActionPill from '../../components/ManageMyCandidates/ActionPill'; +import { CandidateActionsFilterMenu, CandidateRowMenu, CandidateTraitsFilterMenu, DropdownMenu, SelectAllCheckbox } from '../../components/ManageMyCandidates/Menus'; +import { SendMessageButton, SendMessageButtonMobile } from '../../components/ManageMyCandidates/SendButtons'; +import TrackingHeaderActionContext from './TrackingHeaderActionContext'; +import { Card, CardList, CardTopRow, Container, KebabBtn, LeftTools, NameRow, NameText, RightOptions, ToolbarRow, VerticalBar, VerticalBarWrapper } from '../../components/Style/ManageMyCandidates'; +import { MobileActionPill, MobileActions, MobileFieldLabel, MobileFieldValue, MobileToolbar, SearchBtn, SelectAllInline } from '../../components/Style/SupporterTrackingStyles'; const FILTERS = { ALL: 'all', @@ -40,7 +43,20 @@ Thank you so much. May the odds be ever in your favor. We love you. From the developers at WeVote <3`; +function getPendingActions (v) { + return [ + !v.endorsed && 'Endorse', + !v.publicOpinion && 'Write public opinion', + v.friendsInvited === 0 && 'Invite friends', + ].filter(Boolean); +} + +function getActionsLabel (v) { + return getPendingActions(v).join(', '); +} + export default function SupportersJoined ({ supporters }) { + const headerActionSlot = useContext(TrackingHeaderActionContext); const [selected, setSelected] = useState(() => new Set()); const [expanded, setExpanded] = useState(() => new Set()); const [activeFilter, setActiveFilter] = useState(FILTERS.ALL); @@ -49,32 +65,22 @@ export default function SupportersJoined ({ supporters }) { const [viewOpen, setViewOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [copyOpen, setCopyOpen] = useState(false); - const [isSending, setIsSending] = useState(false); + const [, setIsSending] = useState(false); const [sendToast, setSendToast] = useState({ open: false, ok: true, msg: '' }); - const totalCount = supporters.length; - const selectedCount = selected.size; - // ----- checkbox dropdown menu state ----- const [selectAnchorEl, setSelectAnchorEl] = useState(null); - const selectMenuOpen = Boolean(selectAnchorEl); - // ----- "All" dropdown menu state ----- const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const filterMenuOpen = Boolean(filterAnchorEl); - // ----- per-row "triple dot" menu state ----- const [rowMenuAnchorEl, setRowMenuAnchorEl] = useState(null); const [rowMenuVoter, setRowMenuVoter] = useState(null); - const rowMenuOpen = Boolean(rowMenuAnchorEl); - // ----- candidate drawer open state ----- + const [candidateDrawerOpen, setCandidateDrawerOpen] = useState(false); const [candidateDrawerVoter, setCandidateDrawerVoter] = useState(null); - // ----- thank you dropdown (mobile) ----- + const [thankYouAnchorEl, setThankYouAnchorEl] = useState(null); const thankYouMenuOpen = Boolean(thankYouAnchorEl); - const openThankYouMenu = (e) => setThankYouAnchorEl(e.currentTarget); const closeThankYouMenu = () => setThankYouAnchorEl(null); - // ----- helpers (single source of truth for "actions") ----- const copyThankYouMessage = async () => { try { await navigator.clipboard.writeText(thankYouMessage); @@ -84,33 +90,20 @@ export default function SupportersJoined ({ supporters }) { } }; - const getPendingActions = (v) => ([ - !v.endorsed && 'Endorse', - !v.publicOpinion && 'Write public opinion', - v.friendsInvited === 0 && 'Invite friends', - ].filter(Boolean)); - - const getActionsLabel = (v) => getPendingActions(v).join(', '); - - // ----- derived data for selected voters, dropdown + counts ----- - const selectedVoters = useMemo(() => { - const selectedIds = selected; - return supporters.filter((v) => selectedIds.has(v.id)); - }, [supporters, selected]); + const selectedVoters = useMemo( + () => supporters.filter((v) => selected.has(v.id)), + [supporters, selected], + ); const actionGroups = useMemo(() => { - // key: actionsLabel, value: array of supporter ids const map = new Map(); - supporters.forEach((v) => { const actionsLabel = getActionsLabel(v); - if (!actionsLabel) return; // no pending actions => don't appear in "Ask to:" menu + if (!actionsLabel) return; const prev = map.get(actionsLabel) || []; prev.push(v.id); map.set(actionsLabel, prev); }); - - // Sort by count desc, then label asc return Array.from(map.entries()) .map(([label, ids]) => ({ label, ids, count: ids.length })) .sort((a, b) => (b.count - a.count) || a.label.localeCompare(b.label)); @@ -127,6 +120,7 @@ export default function SupportersJoined ({ supporters }) { return supporters; } }, [supporters, activeFilter]); + useEffect(() => { const visibleIds = new Set(visibleSupporters.map((v) => v.id)); setSelected((prev) => new Set([...prev].filter((id) => visibleIds.has(id)))); @@ -135,12 +129,11 @@ export default function SupportersJoined ({ supporters }) { const totalVisibleCount = visibleSupporters.length; const selectedVisibleCount = useMemo( () => visibleSupporters.filter((v) => selected.has(v.id)).length, - [visibleSupporters, selected] + [visibleSupporters, selected], ); - const checked = totalVisibleCount > 0 && selectedVisibleCount === totalVisibleCount; + const allChecked = totalVisibleCount > 0 && selectedVisibleCount === totalVisibleCount; const indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < totalVisibleCount; - // ----- "All" filter dropdown data (counts) ----- const filterLabel = useMemo(() => { switch (activeFilter) { case FILTERS.HAS_INVITED: return 'Has invited friends'; @@ -152,12 +145,10 @@ export default function SupportersJoined ({ supporters }) { const filterGroups = useMemo(() => { const hasInvitedIds = []; const hasEndorsedIds = []; - supporters.forEach((v) => { if ((v.friendsInvited || 0) > 0) hasInvitedIds.push(v.id); if (v.endorsed) hasEndorsedIds.push(v.id); }); - return { hasInvitedIds, hasInvitedCount: hasInvitedIds.length, @@ -169,7 +160,8 @@ export default function SupportersJoined ({ supporters }) { const toggleSelected = (id) => { setSelected((prev) => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; @@ -177,81 +169,15 @@ export default function SupportersJoined ({ supporters }) { const toggleExpanded = (id) => { setExpanded((prev) => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - }; - - const selectAllVisible = () => { - setSelected((prev) => { - const next = new Set(prev); - const allSelected = visibleSupporters.every((r) => next.has(r.id)); - if (allSelected) { - visibleSupporters.forEach((r) => next.delete(r.id)); - } else { - visibleSupporters.forEach((r) => next.add(r.id)); - } + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; const handleSelectCheckboxClick = (e) => { - e.stopPropagation(); // important: don't open the dropdown menu - setSelected((prev) => { - if (prev.size === 0) { - // select all visible - return new Set(visibleSupporters.map((v) => v.id)); - } - // clear all selection - return new Set(); - }); - }; - - const openSelectMenu = (e) => setSelectAnchorEl(e.currentTarget); - const closeSelectMenu = () => setSelectAnchorEl(null); - - const selectIds = (ids) => { - setSelected(new Set(ids)); - closeSelectMenu(); - }; - - const handleAll = () => { - setSelected(new Set(supporters.map((v) => v.id))); - closeSelectMenu(); - }; - - const handleNone = () => { - setSelected(new Set()); - closeSelectMenu(); - }; - - const openFilterMenu = (e) => setFilterAnchorEl(e.currentTarget); - const closeFilterMenu = () => setFilterAnchorEl(null); - - const setFilterAll = () => { - setActiveFilter(FILTERS.ALL); - closeFilterMenu(); - }; - - const setFilterHasInvited = () => { - setActiveFilter(FILTERS.HAS_INVITED); - closeFilterMenu(); - }; - - const setFilterHasEndorsed = () => { - setActiveFilter(FILTERS.HAS_ENDORSED); - closeFilterMenu(); - }; - - const openRowMenu = (e, voter) => { e.stopPropagation(); - setRowMenuAnchorEl(e.currentTarget); - setRowMenuVoter(voter); - }; - - const closeRowMenu = () => { - setRowMenuAnchorEl(null); - setRowMenuVoter(null); + setSelected((prev) => (prev.size === 0 ? new Set(visibleSupporters.map((v) => v.id)) : new Set())); }; const openCandidateDrawer = (voter) => { @@ -264,36 +190,23 @@ export default function SupportersJoined ({ supporters }) { setCandidateDrawerVoter(null); }; - const handleEditVoter = () => { - if (!rowMenuVoter) return; - alert(`Edit voter: ${rowMenuVoter.name}`); - closeRowMenu(); - }; - - const handleSendMessage = () => { - if (!rowMenuVoter) return; - alert(`Send message to: ${rowMenuVoter.name}`); - closeRowMenu(); + const openRowMenu = (e, voter) => { + e.stopPropagation(); + setRowMenuAnchorEl(e.currentTarget); + setRowMenuVoter(voter); }; const handleSendThankYou = async () => { if (selectedVoters.length === 0) return; - setIsSending(true); try { // TODO: replace with real API call(s) // await sendThankYouMessage(selectedVoters, thankYouMessage); - handleSendMessage(); - setSendToast({ open: true, ok: true, msg: `Sent to ${selectedVoters.length} supporter${selectedVoters.length === 1 ? '' : 's'}.`, }); - - // Optional: mark them as "messageSentCount + 1" in state once you have stateful supporters - // Optional: clear selection after send - // setSelected(new Set()); } catch (err) { console.error(err); setSendToast({ open: true, ok: false, msg: 'Send failed. Please try again.' }); @@ -304,397 +217,303 @@ export default function SupportersJoined ({ supporters }) { return ( - - - - Sentiments and opinions posted by WeVote members are private by default. - You can view them only if the member chooses to make them public. - - - - - - - - - - Thank You message: - - setViewOpen(true)} - > - + + + Sentiments and opinions posted by WeVote members are private by default. + You can view them only if the member chooses to make them public. + + + + + Thank You message: + + setViewOpen(true)}> + - { - setDraftMessage(thankYouMessage); - setEditOpen(true); - }} + onClick={() => { setDraftMessage(thankYouMessage); setEditOpen(true); }} > - + - - - + + - - + + + (auto-sent after joining) + + - (auto-sent after joining) - - - - + - - {}} - /> - - - - - - - - All - - - {actionGroups.map((g) => ( - selectIds(g.ids)}> - - Ask to: {g.label} - ({g.count}) - - - ))} - - - None - - - - - - - - - {filterLabel} - - - - - All - - - - - Has invited friends - ({filterGroups.hasInvitedCount}) - - - - - - Has endorsed - ({filterGroups.hasEndorsedCount}) - - - + setSelected(new Set(supporters.map((v) => v.id))) }, + ...actionGroups.map((g) => ({ + label: `Ask to: ${g.label} - (${g.count})`, + onClick: () => setSelected(new Set(g.ids)), + })), + ]} + /> + + setActiveFilter(FILTERS.ALL) }, + { label: `Has invited friends - (${filterGroups.hasInvitedCount})`, + onClick: () => setActiveFilter(FILTERS.HAS_INVITED) }, + { label: `Has endorsed - (${filterGroups.hasEndorsedCount})`, + onClick: () => setActiveFilter(FILTERS.HAS_ENDORSED) }, + ]} + /> - {/* - - - - */} - {/* Thank you message dropdown (mobile) */} - + + {headerActionSlot && createPortal( + , + headerActionSlot, + )} + + + + + Select all + + Thank You message - - - - - - - - - { - closeThankYouMenu(); - setViewOpen(true); - }} - > - - - - - - - { - closeThankYouMenu(); - setDraftMessage(thankYouMessage); - setEditOpen(true); - }} - > - - - - - - - { - closeThankYouMenu(); - await copyThankYouMessage(); - }} - > - - - - - - - - Send Message to Selected ({selectedVoters.length}) - - + + + console.log('TODO: implement search feature')}> + + + + + setViewOpen(true) }, + { icon: EditOutlinedIcon, + label: 'Edit', + onClick: () => { setDraftMessage(thankYouMessage); setEditOpen(true); } }, + { icon: ContentCopyIcon, label: 'Copy', onClick: copyThankYouMessage }, + ]} + /> {visibleSupporters.map((v) => { const isChecked = selected.has(v.id); const isOpen = expanded.has(v.id); - const actions = getActionsLabel(v); const needsAction = actions.length > 0; const hasMessageSent = !!v.messageSentCount; + const hasOpinion = !!v.publicOpinion; + const showCandidateLink = v.endorsed || hasOpinion || !!v.friendsInvited; + + const renderPrimaryAction = () => { + if (hasMessageSent) { + return ( + + + {' '} + Message sent ( + {v.messageSentCount} + ) + + ); + } + if (needsAction) { + return ( + alert(`${v.endorsed ? 'Send thanks' : 'Send message'} & ask ${v.name} to ${actions}`)} + label={`${v.endorsed ? 'Send thanks' : 'Send message'} & ask to:`} + contentText={{actions}} + /> + ); + } + return ( + alert(`Send thanks to ${v.name}`)} + label="Send thanks" + /> + ); + }; return ( - - - toggleSelected(v.id)} - aria-label={`Select ${v.name}`} - /> - {v.name} - - +
+ + + toggleSelected(v.id)} + aria-label={`Select ${v.name}`} + /> + {v.name} {v.endorsed && ( - - Endorsed - + <> + + + + {' '} + Endorsed + + )} - {!!v.friendsInvited && ( - - {v.friendsInvited} friends invited - + <> + + + {v.friendsInvited} + {' '} + friends invited + + )} - - openCandidateDrawer(v)}> - View on candidate page - - - - - - - Friends invited: {v.friendsInvited} - - - - - openRowMenu(e, v)} - > - - - - - - toggleExpanded(v.id)} - aria-expanded={isOpen} - > - {isOpen ? ( - - ) : ( - + {showCandidateLink && ( + <> + + openCandidateDrawer(v)}> + View on candidate page + + )} - - - - - - + + + openRowMenu(e, v)}> + + + + + + toggleExpanded(v.id)} + > {v.endorsed && ( alert(`Like endorsement/opinion for ${v.name}`)} - > - - Like endorsement/opinion - - + label={( + <> + + {' '} + Like endorsement/opinion + + )} + /> )} - - {hasMessageSent ? ( - - {' '} - Message sent ({v.messageSentCount}) - - ) : needsAction ? ( - alert(`Send message & ask ${v.name} to ${actions}`)} - > - Send message & ask to: -
- {actions} -
- ) : ( - alert(`Send thanks to ${v.name}`)} - > - Send thanks - + {renderPrimaryAction()} + {!v.endorsed && !hasMessageSent && ( + Send reminder message in 3 days )} -
- - - - + +
- {v.publicOpinion && ( -
- toggleExpanded(v.id)} - aria-expanded={isOpen} - > - {isOpen ? ( - - ) : ( - - )} +
+ + + toggleSelected(v.id)} + aria-label={`Select ${v.name}`} + /> + {v.name} + + + + Friends invited: + {' '} + {v.friendsInvited || 0} + + openRowMenu(e, v)}> + + + toggleExpanded(v.id)} aria-expanded={isOpen}> + -
- )} + + -
- {v.publicOpinion ? ( - {isOpen ? v.publicOpinion : '...'} + What you can do + + {v.endorsed && ( + alert(`Give thumbs up on endorsement/opinion for ${v.name}`)}> + Give thumbs up on endorsement/opinion + + )} + {hasMessageSent ? ( + + Message sent ( + {v.messageSentCount} + ) + ) : ( - No public opinion available. + (needsAction ? + alert(`${v.endorsed ? 'Send thanks' : 'Send message'} & ask ${v.name} to ${actions}`) : + alert(`Send thanks to ${v.name}`))} + > + {needsAction && !v.endorsed ? 'Send message' : 'Send thanks'} + )} -
- + - {/* MOBILE: expanded details under the action buttons */} -
{isOpen && ( - - Public sentiment/opinion - - + <> + Public sentiment/opinion + + {v.endorsed ? ( <> - Endorsed + + {' '} + Endorsed - ) : ( - <>Not endorsed - )} + ) : 'Not endorsed'} - {v.publicOpinion ? ( - {v.publicOpinion} + {hasOpinion ? ( + {v.publicOpinion} ) : ( - No public opinion available. + No public opinion available. )} - - + + + {needsAction && ( + <> + What you can ask voters to do + {actions} + + )} + )}
@@ -702,99 +521,56 @@ export default function SupportersJoined ({ supporters }) { })} - - - Send Message to Selected ({selectedVoters.length}) - - - - - - - - - - - - - - - - - - - - {/* Drawer that opens with the candidate view of a voter, from "View on candidate page" link */} + + + rowMenuVoter && alert(`Edit voter: ${rowMenuVoter.name}`) }, + { icon: MailOutlineIcon, + label: 'Send message', + onClick: () => rowMenuVoter && alert(`Send message to: ${rowMenuVoter.name}`) }, + ]} + /> + -
-
+ + Candidate page - -
- -
- {/* http://localhost:3000/shannon-d-dicus-politician-from-california/-/ */} -