From 4a43722570f1e6c522d37be4b4ad800dcddf6db6 Mon Sep 17 00:00:00 2001 From: Ricardo Reynoso Date: Sun, 30 Nov 2025 18:28:33 -0800 Subject: [PATCH 1/3] [WV-2287] Moved ManageMyCandidates navigation to ManageMyCandidatesLanding.jsx and UI components to other files [TEAM REVIEW] --- src/App.jsx | 4 +- .../ManageMyCandidatesLanding.jsx | 726 +++++++++++++ .../ManageMyCandidates/SupporterAnalytics.jsx | 19 + .../ManageMyCandidates/SupporterTracking.jsx | 19 + src/js/pages/More/ManageMyCandidates.jsx | 976 +++--------------- 5 files changed, 930 insertions(+), 814 deletions(-) create mode 100644 src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx create mode 100644 src/js/pages/ManageMyCandidates/SupporterAnalytics.jsx create mode 100644 src/js/pages/ManageMyCandidates/SupporterTracking.jsx diff --git a/src/App.jsx b/src/App.jsx index 60c75509f..c3ec0a3fd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -86,7 +86,7 @@ const HowWeVoteHelps = React.lazy(() => import(/* webpackChunkName: 'HowWeVoteHe const Intro = React.lazy(() => import(/* webpackChunkName: 'Intro' */ './js/pages/Intro/Intro')); const IntroNetwork = React.lazy(() => import(/* webpackChunkName: 'IntroNetwork' */ './js/pages/Intro/IntroNetwork')); const Location = React.lazy(() => import(/* webpackChunkName: 'Location' */ './js/pages/Settings/Location')); -const ManageMyCandidates = React.lazy(() => import(/* webpackChunkName: 'ManageMyCandidates' */ './js/pages/More/ManageMyCandidates')); +const ManageMyCandidatesLanding = React.lazy(() => import(/* webpackChunkName: 'ManageMyCandidatesLanding' */ './js/pages/ManageMyCandidates/ManageMyCandidatesLanding')); const Measure = React.lazy(() => import(/* webpackChunkName: 'Measure' */ './js/pages/Ballot/Measure')); const News = React.lazy(() => import(/* webpackChunkName: 'News' */ './js/pages/Activity/News')); const Office = React.lazy(() => import(/* webpackChunkName: 'Office' */ './js/pages/Ballot/Office')); @@ -581,7 +581,7 @@ class App extends Component { - + diff --git a/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx b/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx new file mode 100644 index 000000000..6450827f3 --- /dev/null +++ b/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx @@ -0,0 +1,726 @@ +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import styled from 'styled-components'; +import { Helmet } from 'react-helmet-async'; +import { + Edit as EditIcon, + KeyboardArrowDown as ArrowDownIcon, + CheckCircle as CheckIcon } from '@mui/icons-material'; +import PropTypes from 'prop-types'; +import PoliticianStore from '../../common/stores/PoliticianStore'; +import VoterStore from '../../stores/VoterStore'; +import { PageContentContainer } from '../../components/Style/pageLayoutStyles'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import EditInvitationModal from '../../components/More/EditInvitationModal'; +import PasteListModal from '../../components/More/PasteListModal'; +import PreviewInvitationModal from '../../components/More/PreviewInvitationModal'; +import UploadCSVModal from '../../components/More/UploadCSVModal'; + + +const PoliticiansManagedController = React.lazy(() => import('../../components/PoliticiansManaged/PoliticiansManagedController')); +const ImportAndInvitePage = React.lazy(() => import('../More/ManageMyCandidates')); +const TrackingPage = React.lazy(() => import('./SupporterTracking')); +const AnalyticsPage = React.lazy(() => import('./SupporterAnalytics')); + +export default function ManageMyCandidatesLanding () { + // Left nav active tab + const location = useLocation(); + const getActiveTab = (path) => { + if (path.includes('/tracking')) return 'tracking'; + if (path.includes('/analytics')) return 'analytics'; + return 'import'; // default + }; + const active = getActiveTab(location.pathname); + + const demoPoliticians = useMemo(() => ([ + { we_vote_id: 'cand_1', politician_name: 'John Dough' }, + { we_vote_id: 'cand_2', politician_name: 'Jane Dough' }, + { we_vote_id: 'cand_3', politician_name: 'Kateryna Dough' }, + ]), []); + + const [politiciansToManage, setPoliticiansToManage] = useState(demoPoliticians); // Place demoPoliticians in useState to use dummy data, otherwise place: [] + const [selectedPoliticianWeVoteId, setSelectedPoliticianWeVoteId] = useState(''); + + const selectedPolitician = politiciansToManage.find((politician) => politician.we_vote_id === selectedPoliticianWeVoteId) || null; + const [invitationBody, setInvitationBody] = useState(`Hello friend, + +We’d like to invite you to join WeVote to help support ${selectedPolitician?.politician_name || 'our campaign'}. +Thanks for your help!`); + // Edit modal + const [showEdit, setShowEdit] = useState(false); + const [draftInvite, setDraftInvite] = useState(invitationBody); + const [initialInvite, setInitialInvite] = useState(invitationBody); + // Preview modal + const [showPreview, setShowPreview] = useState(false); + const handlePreviewOpen = () => setShowPreview(true); + const handlePreviewClose = () => setShowPreview(false); + // Upload CSV modal + const [showUpload, setShowUpload] = useState(false); + // CSV upload and paste + const [showPaste, setShowPaste] = useState(false); + const [pasteText, setPasteText] = useState(''); + const [pasteErrors, setPasteErrors] = useState([]); + const [mirrorHTML, setMirrorHTML] = useState(''); + const [importedVoters, setImportedVoters] = useState([]); + // One-by-one inputs + const [oneName, setOneName] = useState(''); + const [oneEmail, setOneEmail] = useState(''); + const [onePhone, setOnePhone] = useState(''); + const fileInputRef = useRef(null); + const [allColumnsOK, setAllColumnsOK] = useState(false); + + const openUploadModal = () => { setAllColumnsOK(false); setShowUpload(true); }; + const closeUploadModal = () => { setAllColumnsOK(false); setShowUpload(false); }; + const handleSelectCSV = () => fileInputRef.current?.click(); + + useEffect(() => { + if (!selectedPoliticianWeVoteId && politiciansToManage.length > 0) { + setSelectedPoliticianWeVoteId(politiciansToManage[0].we_vote_id); + } + }, [politiciansToManage, selectedPoliticianWeVoteId]); + + // Reset draft when invitationBody or showEdit changes + useEffect(() => { + if (!showEdit) { + setDraftInvite(invitationBody); + setInitialInvite(invitationBody); + } + }, [invitationBody, showEdit, setDraftInvite, setInitialInvite]); + const [copiedMsg, setCopiedMsg] = useState(''); + const [isSuccessToast, setIsSuccessToast] = useState(false); + const openEditModal = () => { setInitialInvite(draftInvite); setShowEdit(true); }; + const handleEditInvite = () => { + setShowPreview(false); + openEditModal(); + }; + + const toastTimerRef = useRef(null); + const idRef = useRef(1); + const makeVoterRecord = useCallback((partial, source = 'Paste list') => ({ + id: `v_${Date.now()}_${idRef.current++}`, + addedAt: new Date().toISOString(), + addedBy: 'You', + source, + ...partial, + }), []); + const notify = useCallback((msg, success = true) => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(success); + setCopiedMsg(msg); + toastTimerRef.current = setTimeout(() => { setCopiedMsg(''); setIsSuccessToast(false); }, 2200); + }, []); + const handleInviteSelected = useCallback((rows) => { notify(`Invited ${rows.length} voter${rows.length === 1 ? '' : 's'} by email.`); }, [notify]); + const handleInviteEmailOneOrMany = useCallback((rows) => { notify(`Email invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); + const handleInviteTextOneOrMany = useCallback((rows) => { notify(`Text invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); + const handleHideOne = useCallback((row) => { setImportedVoters((p) => p.filter((r) => (r.id || r._idx) !== (row.id || row._idx))); notify('Hidden from list.', true); }, [notify]); + const handleHideMany = useCallback((rows) => { const ids = new Set(rows.map((r) => r.id || r._idx)); setImportedVoters((p) => p.filter((r) => !ids.has(r.id || r._idx))); notify(`Hidden ${rows.length} item${rows.length === 1 ? '' : 's'}.`, true); }, [notify]); + const handleSaveInvite = () => { + setInvitationBody(draftInvite); + setShowEdit(false); + notify('Invitation updated.', true); + }; + + const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; + + const escapeHTML = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + + function parsePastedList (text) { + const lines = text.split(/\r?\n/); + const rows = []; + const errors = []; + + lines.forEach((raw, idx) => { + const line = raw.trim(); + if (!line) return; + + const parts = line.split(',').map((s) => s.trim()).filter(Boolean); + const emailCount = (line.match(emailRE) || []).length; + + if (parts.length > 3) { + errors.push({ line: idx, reason: 'Too many commas' }); + return; + } + if (emailCount > 1) { + errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' }); + return; + } + // Must have at least "Name, Email" + if (parts.length < 2) { + errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' }); + return; + } + // Validate email + const emailPart = parts[1].replace(/[<>]/g, ''); + if (!emailRE.test(emailPart)) { + errors.push({ line: idx, reason: 'Invalid email' }); + return; + } + + rows.push({ + name: parts[0] || '', + email: emailPart || '', + phone: parts[2] || '', + }); + }); + + return { rows, errors }; + } + + function buildMirrorHTML (text, errors) { + const errSet = new Set(errors.map((e) => e.line)); + return text.split(/\r?\n/).map((line, i) => { + const isError = errSet.has(i) && line.trim().length > 0; + const safe = escapeHTML(line); + return isError ? `${safe}` : safe; + }).join('\n'); + } + + const handleCopyInviteBody = async () => { + try { + await navigator.clipboard.writeText(`${invitationBody}\n\nhttps://wevote.us/join/${selectedPoliticianWeVoteId}`); + notify('Invitation copied to clipboard. Press ⌘V / Ctrl+V to paste.', true); + } catch { + notify('Copy failed. Select the text and copy manually.', false, 3000); + } + }; + + const handleDownloadSample = () => { + const csv = + 'Name,Email,Mobile,Address\n' + + 'John Smith,js@gmail.com,"(123) 456-7890","123 State St, Anytown, CA 94117"\n'; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'wevote_sample.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleImportOne = useCallback(() => { + const name = oneName.trim(); + const email = oneEmail.trim(); + const phone = onePhone.trim(); + + if (!emailRE.test(email)) { notify('Enter a valid email.', false); return; } + + setImportedVoters((prev) => [ + ...prev, + makeVoterRecord({ name, email, phone }, 'Manual entry'), + ]); + + setOneName(''); setOneEmail(''); setOnePhone(''); + notify('Voter added. Ready to invite.', true); + }, [oneName, oneEmail, onePhone, notify, makeVoterRecord, setImportedVoters]); + + const handleCSVSelected = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!/\.csv$/i.test(file.name)) { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(false); + setCopiedMsg('Please choose a .csv file.'); + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2500); + e.target.value = ''; + return; + } + setShowUpload(true); + + const text = await file.text(); + const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim().length > 0); + const [headerLine, ...dataLines] = lines; + const headers = headerLine.split(',').map((h) => h.trim().toLowerCase()); + + const nameIdx = headers.indexOf('name'); + const emailIdx = headers.indexOf('email'); + const mobileIdx = headers.indexOf('mobile'); + const phoneIdx = headers.indexOf('phone'); + const phoneCol = mobileIdx > -1 ? mobileIdx : phoneIdx; + const addressIdx = headers.indexOf('address'); + + const ok = nameIdx > -1 && emailIdx > -1 && phoneCol > -1 && addressIdx > -1; + + const rows = dataLines.map((line) => { + const cols = line.split(',').map((supporter) => supporter.trim()); + return { + name: nameIdx > -1 ? cols[nameIdx] : '', + email: emailIdx > -1 ? cols[emailIdx] : '', + phone: phoneCol > -1 ? cols[phoneCol] : '', + address: addressIdx > -1 ? cols[addressIdx] : '', + }; + }).filter((r) => r.name || r.email || r.phone || r.address); + + if (ok && rows.length > 0) { + setImportedVoters(rows.map((r) => makeVoterRecord(r, 'CSV upload'))); + closeUploadModal(); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(true); + setCopiedMsg('All of your columns will be imported.'); + toastTimerRef.current = setTimeout(() => { + setCopiedMsg(''); + setIsSuccessToast(false); + }, 2200); + } else { + setAllColumnsOK(ok); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(false); + if (!ok) { + setCopiedMsg('We could not find all required columns (Name, Email, Mobile/Phone, Address). Please adjust and re-upload.'); + } else if (rows.length === 0) { + setCopiedMsg('No rows found in this CSV.'); + } + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 3000); + } + + e.target.value = ''; + }; + + const handlePasteList = () => { + setShowPaste(true); + setPasteErrors([]); + setMirrorHTML(''); + }; + + const closePaste = () => { + setShowPaste(false); + setPasteErrors([]); + setMirrorHTML(''); + }; + + const onPasteTextChange = (e) => { + const { value } = e.target; + setPasteText(value); + const { errors } = parsePastedList(value); + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(value, errors)); + }; + + const handlePasteImport = () => { + const { rows, errors } = parsePastedList(pasteText); + if (errors.length) { + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(pasteText, errors)); + return; // block import until fixed + } + setImportedVoters((prev) => [...prev, ...rows.map((r) => makeVoterRecord(r, 'Paste list'))]); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setCopiedMsg(`Imported ${rows.length} voter${rows.length !== 1 ? 's' : ''} from pasted list.`); + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2200); + }; + + const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]); + + const updatePoliticiansToManage = () => { + const test = true; + if (test) { + // setPoliticiansToManage(PoliticianStore.getPoliticianListVoterCanEdit()); + } + }; + + const onPoliticianStoreChange = useCallback(() => { + updatePoliticiansToManage(); + }, []); + + const onVoterStoreChange = useCallback(() => { + updatePoliticiansToManage(); + }, []); + + useEffect(() => { + updatePoliticiansToManage(); + }, []); + + useEffect(() => { + const politicianStoreListener = PoliticianStore.addListener(onPoliticianStoreChange); + onPoliticianStoreChange(); + return () => { + politicianStoreListener.remove(); + }; + }, []); + + useEffect(() => { + const voterStoreListener = VoterStore.addListener(onVoterStoreChange); + onVoterStoreChange(); + return () => { + voterStoreListener.remove(); + }; + }, []); + + // Main render + const history = useHistory(); + const handleClaimEdit = () => history.push(`/candidate/${selectedPoliticianWeVoteId}/edit`); + const handleClaimImport = () => history.push('/managecandidates'); + const handleClaimTracking = () => history.push('/managecandidates/tracking'); + const handleClaimAnalytics = () => history.push('/managecandidates/analytics'); + + return ( + + Manage My Candidates - WeVote + + +
+ Manage my candidates + + {selectedPolitician?.politician_name || 'Select candidate'} + + +
+
+ + + {/* Left nav */} + + + + Import & invite voters + + + + + Tracking + + + + + Analytics + + + + + + + Edit candidate profile + + + + {/* Right content */} + + }> + {active === 'import' && ( + setShowEdit(true)} + oneName={oneName} + oneEmail={oneEmail} + onePhone={onePhone} + setOneName={setOneName} + setOneEmail={setOneEmail} + setOnePhone={setOnePhone} + handleImportOne={handleImportOne} + emailRE={emailRE} + openUploadModal={openUploadModal} + handlePasteList={handlePasteList} + importedVoters={importedVoters} + handleInviteSelected={handleInviteSelected} + handleInviteEmailOneOrMany={handleInviteEmailOneOrMany} + handleInviteTextOneOrMany={handleInviteTextOneOrMany} + handleHideOne={handleHideOne} + handleHideMany={handleHideMany} + /> + )} + {active === 'tracking' && } + {active === 'analytics' && } + + + + + {/* Preview modal */} + + {/* Edit modal */} + setShowEdit(false)} + draftInvite={draftInvite} + setDraftInvite={setDraftInvite} + initialInvite={initialInvite} + onSave={handleSaveInvite} + notify={notify} + selectedPoliticianId={selectedPoliticianWeVoteId} + /> + {/* Paste list modal */} + + {/* Upload CSV modal */} + + + {copiedMsg && createPortal( + + {isSuccessToast && ( + + )} + {copiedMsg} + , + document.body, + )} + }> + + +
+ ); +} + +// Analytics icon +const AnalyticsIcon = ({ size = 22, title = 'Analytics' }) => ( + + {title ? {title} : null} + + +); +AnalyticsIcon.propTypes = { + size: PropTypes.number, + title: PropTypes.string, +}; + +// Tracking icon +const TrackingIcon = ({ size = 22, title = 'Tracking', ...props }) => ( + + {title ? {title} : null} + + +); + +// Import & invite icon +const ImportInviteIcon = ({ size = 22, title = 'Import & invite', ...props }) => ( + + {title ? {title} : null} + + +); + +const HeaderRow = styled.div` + margin: 6px 0 12px; +`; + +const PageKicker = styled.h2` + color: ${DesignTokenColors.neutralUI600}; + font-size: 12px; + letter-spacing: 0.06em; + margin: 0 0 4px; + text-transform: uppercase; +`; + +const TitleRow = styled.div` + align-items: center; + display: inline-flex; + gap: 6px; +`; + +const Title = styled.h1` + font-size: 32px; + font-weight: 400; + line-height: 1.2; + margin: 0; +`; + +const CandidatePicker = styled.button` + align-items: center; + background: transparent; + border: none; + border-radius: 8px; + color: ${DesignTokenColors.neutralUI700}; + cursor: pointer; + display: inline-flex; + gap: 2px; + padding: 2px 4px; + position: relative; + + &:focus-visible { outline: 2px solid ${DesignTokenColors.primary500}; outline-offset: 2px; } + &:hover > ul, &:focus-within > ul { display: block; } +`; + +const PickerMenu = styled.ul` + background: ${DesignTokenColors.whiteUI}; + border: 1px solid ${DesignTokenColors.neutralUI200}; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(16,24,40,0.08); + display: none; + left: 0; + list-style: none; + margin: 8px 0 0; + max-height: 260px; + overflow: auto; + padding: 6px; + position: absolute; + top: 100%; + width: 260px; + z-index: 2; +`; + +const PickerItem = styled.li` + border-radius: 8px; + cursor: pointer; + padding: 10px 12px; + + &:hover { background: ${DesignTokenColors.neutralUI50}; } + &[aria-selected="true"] { font-weight: 600; } +`; + +const Layout = styled.div` + display: grid; + gap: 24px; + grid-template-columns: 260px 1fr; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +`; + +const LeftNav = styled.nav` + border-right: 1px solid ${DesignTokenColors.neutralUI200}; + padding-right: 18px; + + @media (max-width: 900px) { + border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; + border-right: none; + padding: 0 0 12px 0; + } +`; + +const NavPill = styled.button` + align-items: center; + background: ${({ $active }) => ($active ? DesignTokenColors.primary50 : 'transparent')}; + border: none; + border-radius: 25px; + color: ${({ $active }) => ($active ? DesignTokenColors.primary700 : DesignTokenColors.neutralUI900)}; + cursor: pointer; + display: flex; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; + gap: 10px; + margin: 6px 0; + padding: 10px 16px; + text-align: left; + width: 100%; + + &:hover { background: ${DesignTokenColors.neutralUI50}; } +`; + +const PillIcon = styled.span` + align-items: center; + color: inherit; + display: inline-flex; + height: 24px; + justify-content: center; + width: 24px; +`; + +const SideDivider = styled.div` + border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; + margin: 16px 0; +`; + +const RightPanel = styled.section` + padding-top: 6px; +`; + +const Toast = styled.div` + align-items: center; + background: ${({ $success }) => ($success ? DesignTokenColors.neutralUI50 : DesignTokenColors.neutralUI900)}; + border: ${({ $success }) => ($success ? `1px solid ${DesignTokenColors.neutralUI200}` : 'none')}; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(16,24,40,0.18); + color: ${({ $success }) => ($success ? DesignTokenColors.neutralUI900 : DesignTokenColors.whiteUI)}; + display: inline-flex; + font-size: 14px; + gap: 10px; + left: 50%; + max-width: 90vw; + position: fixed; + text-align: left; + top: 10%; + transform: translateX(-50%); + z-index: 10000; + padding: 10px 12px; +`; + +const SuccessIcon = styled.span` + color: ${DesignTokenColors.confirmation500}; + display: inline-flex; + line-height: 1; +`; diff --git a/src/js/pages/ManageMyCandidates/SupporterAnalytics.jsx b/src/js/pages/ManageMyCandidates/SupporterAnalytics.jsx new file mode 100644 index 000000000..1f0e79ce8 --- /dev/null +++ b/src/js/pages/ManageMyCandidates/SupporterAnalytics.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styled from 'styled-components'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; + +export default function SupporterAnalytics () { + return ( + + Analytics (coming soon) + + ); +} + +const Placeholder = styled.div` + background: ${DesignTokenColors.neutralUI50}; + border: 1px dashed ${DesignTokenColors.neutralUI300}; + border-radius: 12px; + color: ${DesignTokenColors.neutralUI600}; + padding: 24px; +`; diff --git a/src/js/pages/ManageMyCandidates/SupporterTracking.jsx b/src/js/pages/ManageMyCandidates/SupporterTracking.jsx new file mode 100644 index 000000000..ed5b8b05d --- /dev/null +++ b/src/js/pages/ManageMyCandidates/SupporterTracking.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styled from 'styled-components'; +import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; + +export default function SupporterTracking () { + return ( + + Tracking (coming soon) + + ); +} + +const Placeholder = styled.div` + background: ${DesignTokenColors.neutralUI50}; + border: 1px dashed ${DesignTokenColors.neutralUI300}; + border-radius: 12px; + color: ${DesignTokenColors.neutralUI600}; + padding: 24px; +`; diff --git a/src/js/pages/More/ManageMyCandidates.jsx b/src/js/pages/More/ManageMyCandidates.jsx index 234a84c80..c2a89c823 100644 --- a/src/js/pages/More/ManageMyCandidates.jsx +++ b/src/js/pages/More/ManageMyCandidates.jsx @@ -1,627 +1,138 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { useHistory } from 'react-router-dom'; +import React, { Suspense } from 'react'; import styled from 'styled-components'; -import { Helmet } from 'react-helmet-async'; import { Edit as EditIcon, - KeyboardArrowDown as ArrowDownIcon, ContentCopy as CopyIcon, Visibility as EyeIcon, Facebook as FacebookIcon, X as XIcon, - FileUpload as UploadIcon, - CheckCircle as CheckIcon } from '@mui/icons-material'; + FileUpload as UploadIcon } from '@mui/icons-material'; import PropTypes from 'prop-types'; -import PoliticianStore from '../../common/stores/PoliticianStore'; -import VoterStore from '../../stores/VoterStore'; -import { PageContentContainer } from '../../components/Style/pageLayoutStyles'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; -import EditInvitationModal from '../../components/More/EditInvitationModal'; -import PasteListModal from '../../components/More/PasteListModal'; -import PreviewInvitationModal from '../../components/More/PreviewInvitationModal'; -import UploadCSVModal from '../../components/More/UploadCSVModal'; - - -const ImportedVotersList = React.lazy(() => import(/* webpackChunkName: 'ImportedVotersList' */ '../../components/PoliticiansManaged/ImportedVotersList')); -const PoliticiansManagedController = React.lazy(() => import(/* webpackChunkName: 'PoliticiansManagedController' */ '../../components/PoliticiansManaged/PoliticiansManagedController')); -export default function ManageMyCandidates () { - const demoPoliticians = useMemo(() => ([ - { we_vote_id: 'cand_1', politician_name: 'John Dough' }, - { we_vote_id: 'cand_2', politician_name: 'Jane Dough' }, - { we_vote_id: 'cand_3', politician_name: 'Kateryna Dough' }, - ]), []); - - const [politiciansToManage, setPoliticiansToManage] = useState(demoPoliticians); // Place demoPoliticians in useState to use dummy data, otherwise place: [] - const [selectedPoliticianWeVoteId, setSelectedPoliticianWeVoteId] = useState(''); - // Left nav active tab - const [active, setActive] = useState('import'); - // Invitation text - const selectedPolitician = politiciansToManage.find((politician) => politician.we_vote_id === selectedPoliticianWeVoteId) || null; - const [invitationBody, setInvitationBody] = useState(`Hello friend, - -We’d like to invite you to join WeVote to help support ${selectedPolitician?.politician_name || 'our campaign'}. -Thanks for your help!`); - // Edit modal - const [showEdit, setShowEdit] = useState(false); - const [draftInvite, setDraftInvite] = useState(invitationBody); - const [initialInvite, setInitialInvite] = useState(invitationBody); - // Preview modal - const [showPreview, setShowPreview] = useState(false); - const handlePreviewOpen = () => setShowPreview(true); - const handlePreviewClose = () => setShowPreview(false); - // Upload CSV modal - const [showUpload, setShowUpload] = useState(false); - // CSV upload and paste - const [showPaste, setShowPaste] = useState(false); - const [pasteText, setPasteText] = useState(''); - const [pasteErrors, setPasteErrors] = useState([]); - const [mirrorHTML, setMirrorHTML] = useState(''); - const [importedVoters, setImportedVoters] = useState([]); - // One-by-one inputs - const [oneName, setOneName] = useState(''); - const [oneEmail, setOneEmail] = useState(''); - const [onePhone, setOnePhone] = useState(''); - const fileInputRef = useRef(null); - const [allColumnsOK, setAllColumnsOK] = useState(false); - - const openUploadModal = () => { setAllColumnsOK(false); setShowUpload(true); }; - const closeUploadModal = () => { setAllColumnsOK(false); setShowUpload(false); }; - const handleSelectCSV = () => fileInputRef.current?.click(); - - useEffect(() => { - if (!selectedPoliticianWeVoteId && politiciansToManage.length > 0) { - setSelectedPoliticianWeVoteId(politiciansToManage[0].we_vote_id); - } - }, [politiciansToManage, selectedPoliticianWeVoteId]); - - // Reset draft when invitationBody or showEdit changes - useEffect(() => { - if (!showEdit) { - setDraftInvite(invitationBody); - setInitialInvite(invitationBody); - } - }, [invitationBody, showEdit, setDraftInvite, setInitialInvite]); - const [copiedMsg, setCopiedMsg] = useState(''); - const [isSuccessToast, setIsSuccessToast] = useState(false); - const openEditModal = () => { setInitialInvite(draftInvite); setShowEdit(true); }; - const handleEditInvite = () => { - setShowPreview(false); - openEditModal(); - }; - - const toastTimerRef = useRef(null); - const idRef = useRef(1); - const makeVoterRecord = useCallback((partial, source = 'Paste list') => ({ - id: `v_${Date.now()}_${idRef.current++}`, - addedAt: new Date().toISOString(), - addedBy: 'You', - source, - ...partial, - }), []); - const notify = useCallback((msg, success = true) => { - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(success); - setCopiedMsg(msg); - toastTimerRef.current = setTimeout(() => { setCopiedMsg(''); setIsSuccessToast(false); }, 2200); - }, []); - const handleInviteSelected = useCallback((rows) => { notify(`Invited ${rows.length} voter${rows.length === 1 ? '' : 's'} by email.`); }, [notify]); - const handleInviteEmailOneOrMany = useCallback((rows) => { notify(`Email invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); - const handleInviteTextOneOrMany = useCallback((rows) => { notify(`Text invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); - const handleHideOne = useCallback((row) => { setImportedVoters((p) => p.filter((r) => (r.id || r._idx) !== (row.id || row._idx))); notify('Hidden from list.', true); }, [notify]); - const handleHideMany = useCallback((rows) => { const ids = new Set(rows.map((r) => r.id || r._idx)); setImportedVoters((p) => p.filter((r) => !ids.has(r.id || r._idx))); notify(`Hidden ${rows.length} item${rows.length === 1 ? '' : 's'}.`, true); }, [notify]); - const handleSaveInvite = () => { - setInvitationBody(draftInvite); - setShowEdit(false); - notify('Invitation updated.', true); - }; - - const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; - - const escapeHTML = (s) => s.replace(/[&<>]/g, (element) => ({ '&': '&', '<': '<', '>': '>' }[element])); - - function parsePastedList (text) { - const lines = text.split(/\r?\n/); - const rows = []; - const errors = []; - - lines.forEach((raw, idx) => { - const line = raw.trim(); - if (!line) return; - - const parts = line.split(',').map((s) => s.trim()).filter(Boolean); - const emailCount = (line.match(emailRE) || []).length; - - if (parts.length > 3) { - errors.push({ line: idx, reason: 'Too many commas' }); - return; - } - if (emailCount > 1) { - errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' }); - return; - } - // Must have at least "Name, Email" - if (parts.length < 2) { - errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' }); - return; - } - // Validate email - const emailPart = parts[1].replace(/[<>]/g, ''); - if (!emailRE.test(emailPart)) { - errors.push({ line: idx, reason: 'Invalid email' }); - return; - } - - rows.push({ - name: parts[0] || '', - email: emailPart || '', - phone: parts[2] || '', - }); - }); - - return { rows, errors }; - } - - function buildMirrorHTML (text, errors) { - const errSet = new Set(errors.map((e) => e.line)); - return text.split(/\r?\n/).map((line, i) => { - const isError = errSet.has(i) && line.trim().length > 0; - const safe = escapeHTML(line); - return isError ? `${safe}` : safe; - }).join('\n'); - } - - const handleCopyInviteBody = async () => { - try { - await navigator.clipboard.writeText(`${invitationBody}\n\nhttps://wevote.us/join/${selectedPoliticianWeVoteId}`); - notify('Invitation copied to clipboard. Press ⌘V / Ctrl+V to paste.', true); - } catch { - notify('Copy failed. Select the text and copy manually.', false, 3000); - } - }; - - const handleDownloadSample = () => { - const csv = - 'Name,Email,Mobile,Address\n' + - 'John Smith,js@gmail.com,"(123) 456-7890","123 State St, Anytown, CA 94117"\n'; - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'wevote_sample.csv'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const handleImportOne = useCallback(() => { - const name = oneName.trim(); - const email = oneEmail.trim(); - const phone = onePhone.trim(); - - if (!emailRE.test(email)) { notify('Enter a valid email.', false); return; } - - setImportedVoters((prev) => [ - ...prev, - makeVoterRecord({ name, email, phone }, 'Manual entry'), - ]); - - setOneName(''); setOneEmail(''); setOnePhone(''); - notify('Voter added. Ready to invite.', true); - }, [oneName, oneEmail, onePhone, notify, makeVoterRecord, setImportedVoters]); - - const handleCSVSelected = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (!/\.csv$/i.test(file.name)) { - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(false); - setCopiedMsg('Please choose a .csv file.'); - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2500); - e.target.value = ''; - return; - } - setShowUpload(true); - - const text = await file.text(); - const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim().length > 0); - const [headerLine, ...dataLines] = lines; - const headers = headerLine.split(',').map((h) => h.trim().toLowerCase()); - - const nameIdx = headers.indexOf('name'); - const emailIdx = headers.indexOf('email'); - const mobileIdx = headers.indexOf('mobile'); - const phoneIdx = headers.indexOf('phone'); - const phoneCol = mobileIdx > -1 ? mobileIdx : phoneIdx; - const addressIdx = headers.indexOf('address'); - - const ok = nameIdx > -1 && emailIdx > -1 && phoneCol > -1 && addressIdx > -1; - - const rows = dataLines.map((line) => { - const cols = line.split(',').map((supporter) => supporter.trim()); - return { - name: nameIdx > -1 ? cols[nameIdx] : '', - email: emailIdx > -1 ? cols[emailIdx] : '', - phone: phoneCol > -1 ? cols[phoneCol] : '', - address: addressIdx > -1 ? cols[addressIdx] : '', - }; - }).filter((r) => r.name || r.email || r.phone || r.address); - - if (ok && rows.length > 0) { - setImportedVoters(rows.map((r) => makeVoterRecord(r, 'CSV upload'))); - closeUploadModal(); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(true); - setCopiedMsg('All of your columns will be imported.'); - toastTimerRef.current = setTimeout(() => { - setCopiedMsg(''); - setIsSuccessToast(false); - }, 2200); - } else { - setAllColumnsOK(ok); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(false); - if (!ok) { - setCopiedMsg('We could not find all required columns (Name, Email, Mobile/Phone, Address). Please adjust and re-upload.'); - } else if (rows.length === 0) { - setCopiedMsg('No rows found in this CSV.'); - } - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 3000); - } - - e.target.value = ''; - }; - const handlePasteList = () => { - setShowPaste(true); - setPasteErrors([]); - setMirrorHTML(''); - }; - - const closePaste = () => { - setShowPaste(false); - setPasteErrors([]); - setMirrorHTML(''); - }; - - const onPasteTextChange = (e) => { - const { value } = e.target; - setPasteText(value); - const { errors } = parsePastedList(value); - setPasteErrors(errors); - setMirrorHTML(buildMirrorHTML(value, errors)); - }; - - const handlePasteImport = () => { - const { rows, errors } = parsePastedList(pasteText); - if (errors.length) { - setPasteErrors(errors); - setMirrorHTML(buildMirrorHTML(pasteText, errors)); - return; // block import until fixed - } - setImportedVoters((prev) => [...prev, ...rows.map((r) => makeVoterRecord(r, 'Paste list'))]); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setCopiedMsg(`Imported ${rows.length} voter${rows.length !== 1 ? 's' : ''} from pasted list.`); - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2200); - }; - - const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]); - - const updatePoliticiansToManage = () => { - const test = true; - if (test) { - // setPoliticiansToManage(PoliticianStore.getPoliticianListVoterCanEdit()); - } - }; - - const onPoliticianStoreChange = useCallback(() => { - updatePoliticiansToManage(); - }, []); - - const onVoterStoreChange = useCallback(() => { - updatePoliticiansToManage(); - }, []); - - useEffect(() => { - updatePoliticiansToManage(); - }, []); - - useEffect(() => { - const politicianStoreListener = PoliticianStore.addListener(onPoliticianStoreChange); - onPoliticianStoreChange(); - return () => { - politicianStoreListener.remove(); - }; - }, []); - - useEffect(() => { - const voterStoreListener = VoterStore.addListener(onVoterStoreChange); - onVoterStoreChange(); - return () => { - voterStoreListener.remove(); - }; - }, []); - - // Main render - const history = useHistory(); - const handleClaimEdit = () => history.push(`/candidate/${selectedPoliticianWeVoteId}/edit`); +const ImportedVotersList = React.lazy(() => import('../../components/PoliticiansManaged/ImportedVotersList')); + +export default function ManageMyCandidates ({ + handleCopyInviteBody, + handlePreviewOpen, + openEditModal, + oneName, + oneEmail, + onePhone, + setOneName, + setOneEmail, + setOnePhone, + handleImportOne, + emailRE, + openUploadModal, + handlePasteList, + importedVoters, + handleInviteSelected, + handleInviteEmailOneOrMany, + handleInviteTextOneOrMany, + handleHideOne, + handleHideMany, +}) { return ( - - Manage My Candidates - WeVote - - -
- Manage my candidates - - {selectedPolitician?.politician_name || 'Select candidate'} - - -
-
- - - {/* Left nav */} - - setActive('import')}> - - Import & invite voters - - - setActive('tracking')}> - - Tracking - - - setActive('analytics')}> - - Analytics - - - - - - - Edit candidate profile - - - - {/* Right content */} - - {active === 'import' && ( - <> -

Import & invite voters

- - {/* Invitation action strip */} - - Import voters, then invite them to join WeVote. - - Invitation: - - - - - - - - - - Post to: - - - - - - - - -
-

Enter voters one-by-one

- - setOneName(e.target.value)} /> - setOneEmail(e.target.value)} /> - setOnePhone(e.target.value)} /> - - Import voter - - -
- -
-

Upload voters from a file or paste a list

- - - - Upload CSV file - - OR - - - Paste list - - -
- - {importedVoters.length === 0 ? ( - - You don’t have any voters to invite. Import some using the options above. - - ) : ( - }> - ({ _idx, ...v }))} // ensure stable key if no id - onInviteSelected={handleInviteSelected} - onInviteEmail={handleInviteEmailOneOrMany} - onInviteText={handleInviteTextOneOrMany} - onHide={handleHideOne} - onHideSelected={handleHideMany} - /> - - )} - - )} - - {active === 'tracking' && ( - Tracking (coming soon) - )} - - {active === 'analytics' && ( - Analytics (coming soon) - )} -
-
- - {/* Preview modal */} - - {/* Edit modal */} - setShowEdit(false)} - draftInvite={draftInvite} - setDraftInvite={setDraftInvite} - initialInvite={initialInvite} - onSave={handleSaveInvite} - notify={notify} - selectedPoliticianId={selectedPoliticianWeVoteId} - /> - {/* Paste list modal */} - - {/* Upload CSV modal */} - - - {copiedMsg && createPortal( - - {isSuccessToast && ( - - )} - {copiedMsg} - , - document.body, + <> +

Import & invite voters

+ + {/* Invitation action strip */} + + Import voters, then invite them to join WeVote. + + Invitation: + + + + + + + + + + Post to: + + + + + + + + +
+

Enter voters one-by-one

+ + setOneName(e.target.value)} /> + setOneEmail(e.target.value)} /> + setOnePhone(e.target.value)} /> + + Import voter + + +
+ +
+

Upload voters from a file or paste a list

+ + + + Upload CSV file + + OR + + + Paste list + + +
+ + {importedVoters.length === 0 ? ( + + You don’t have any voters to invite. Import some using the options above. + + ) : ( + }> + ({ _idx, ...v }))} // ensure stable key if no id + onInviteSelected={handleInviteSelected} + onInviteEmail={handleInviteEmailOneOrMany} + onInviteText={handleInviteTextOneOrMany} + onHide={handleHideOne} + onHideSelected={handleHideMany} + /> + )} - }> - - -
+ ); } -// Analytics icon -const AnalyticsIcon = ({ size = 22, title = 'Analytics' }) => ( - - {title ? {title} : null} - - -); -AnalyticsIcon.propTypes = { - size: PropTypes.number, - title: PropTypes.string, +ManageMyCandidates.propTypes = { + handleCopyInviteBody: PropTypes.func.isRequired, + handlePreviewOpen: PropTypes.func.isRequired, + openEditModal: PropTypes.func.isRequired, + oneName: PropTypes.string.isRequired, + oneEmail: PropTypes.string.isRequired, + onePhone: PropTypes.string.isRequired, + setOneName: PropTypes.func.isRequired, + setOneEmail: PropTypes.func.isRequired, + setOnePhone: PropTypes.func.isRequired, + handleImportOne: PropTypes.func.isRequired, + emailRE: PropTypes.instanceOf(RegExp).isRequired, + openUploadModal: PropTypes.func.isRequired, + handlePasteList: PropTypes.func.isRequired, + importedVoters: PropTypes.arrayOf(PropTypes.object).isRequired, + handleInviteSelected: PropTypes.func.isRequired, + handleInviteEmailOneOrMany: PropTypes.func.isRequired, + handleInviteTextOneOrMany: PropTypes.func.isRequired, + handleHideOne: PropTypes.func.isRequired, + handleHideMany: PropTypes.func.isRequired, }; -// Tracking icon -const TrackingIcon = ({ size = 22, title = 'Tracking', ...props }) => ( - - {title ? {title} : null} - - -); - -// Import & invite icon -const ImportInviteIcon = ({ size = 22, title = 'Import & invite', ...props }) => ( - - {title ? {title} : null} - - -); - // Paste-list icon const PasteListIcon = ({ size = 22, title = 'Paste list', ...props }) => ( ( ); -const HeaderRow = styled.div` - margin: 6px 0 12px; -`; - -const PageKicker = styled.h2` - color: ${DesignTokenColors.neutralUI600}; - font-size: 12px; - letter-spacing: 0.06em; - margin: 0 0 4px; - text-transform: uppercase; -`; - -const TitleRow = styled.div` - align-items: center; - display: inline-flex; - gap: 6px; +const H2 = styled.h2` + color: ${DesignTokenColors.neutralUI900}; + font-size: 20px; + font-weight: 400; + margin: 0 0 10px; `; -const Title = styled.h1` - font-size: 32px; - font-weight: 400; - line-height: 1.2; - margin: 0; +const H3 = styled.h3` + color: ${DesignTokenColors.neutralUI900}; + font-size: 14px; + font-weight: 600; + margin: 0 0 8px; `; -const CandidatePicker = styled.button` +const IconButton = styled.button` align-items: center; - background: transparent; + background: none; border: none; border-radius: 8px; color: ${DesignTokenColors.neutralUI700}; cursor: pointer; display: inline-flex; - gap: 2px; - padding: 2px 4px; - position: relative; - - &:focus-visible { outline: 2px solid ${DesignTokenColors.primary500}; outline-offset: 2px; } - &:hover > ul, &:focus-within > ul { display: block; } -`; - -const PickerMenu = styled.ul` - background: ${DesignTokenColors.whiteUI}; - border: 1px solid ${DesignTokenColors.neutralUI200}; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(16,24,40,0.08); - display: none; - left: 0; - list-style: none; - margin: 8px 0 0; - max-height: 260px; - overflow: auto; padding: 6px; - position: absolute; - top: 100%; - width: 260px; - z-index: 2; -`; -const PickerItem = styled.li` - border-radius: 8px; - cursor: pointer; - padding: 10px 12px; - - &:hover { background: ${DesignTokenColors.neutralUI50}; } - &[aria-selected="true"] { font-weight: 600; } -`; - -const Layout = styled.div` - display: grid; - gap: 24px; - grid-template-columns: 260px 1fr; - - @media (max-width: 900px) { - grid-template-columns: 1fr; + &:hover { + background: ${DesignTokenColors.neutralUI50}; + color: ${DesignTokenColors.neutralUI900}; } `; -const LeftNav = styled.nav` - border-right: 1px solid ${DesignTokenColors.neutralUI200}; - padding-right: 18px; +const Input = styled.input` + background: ${DesignTokenColors.whiteUI}; + border: 1px solid ${DesignTokenColors.neutralUI300}; + border-radius: 10px; + flex: 1 1 220px; + min-width: 220px; + padding: 12px 14px; - @media (max-width: 900px) { - border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; - border-right: none; - padding: 0 0 12px 0; + @media (min-width: 1024px) { + flex: 0 0 260px; } -`; -const NavPill = styled.button` - align-items: center; - background: ${({ $active }) => ($active ? DesignTokenColors.primary50 : 'transparent')}; - border: none; - border-radius: 25px; - color: ${({ $active }) => ($active ? DesignTokenColors.primary700 : DesignTokenColors.neutralUI900)}; - cursor: pointer; - display: flex; - font-weight: ${({ $active }) => ($active ? 500 : 400)}; - gap: 10px; - margin: 6px 0; - padding: 10px 16px; - text-align: left; - width: 100%; - - &:hover { background: ${DesignTokenColors.neutralUI50}; } -`; - -const PillIcon = styled.span` - align-items: center; - color: inherit; - display: inline-flex; - height: 24px; - justify-content: center; - width: 24px; -`; - -const SideDivider = styled.div` - border-bottom: 1px solid ${DesignTokenColors.neutralUI200}; - margin: 16px 0; -`; - -const RightPanel = styled.section` - padding-top: 6px; + &:focus-visible { outline: 2px solid ${DesignTokenColors.primary500}; outline-offset: 2px; } `; -const H2 = styled.h2` - color: ${DesignTokenColors.neutralUI900}; - font-size: 20px; - font-weight: 400; - margin: 0 0 10px; +const InviteDivider = styled.span` + border-left: 1px solid ${DesignTokenColors.neutralUI200}; + height: 16px; + margin: 0 2px 0 4px; `; const InviteRow = styled.div` @@ -794,67 +224,13 @@ const InviteLabel = styled.span` font-weight: 500; `; -const InviteDivider = styled.span` - border-left: 1px solid ${DesignTokenColors.neutralUI200}; - height: 16px; - margin: 0 2px 0 4px; -`; - -const IconButton = styled.button` - align-items: center; - background: none; - border: none; - border-radius: 8px; - color: ${DesignTokenColors.neutralUI700}; - cursor: pointer; - display: inline-flex; - padding: 6px; - - &:hover { - background: ${DesignTokenColors.neutralUI50}; - color: ${DesignTokenColors.neutralUI900}; - } -`; - -const SocialIconButton = styled(IconButton)` - color: ${DesignTokenColors.neutralUI800}; -`; - -const Section = styled.section` - margin: 16px 0 22px; -`; - -const H3 = styled.h3` - color: ${DesignTokenColors.neutralUI900}; - font-size: 14px; - font-weight: 600; - margin: 0 0 8px; -`; - -const Row = styled.div` - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 12px; - - @media (min-width: 1024px) { - flex-wrap: nowrap; - } +const MutedNote = styled.p` + color: ${DesignTokenColors.neutralUI600}; + margin: 40px 0 0; `; -const Input = styled.input` - background: ${DesignTokenColors.whiteUI}; - border: 1px solid ${DesignTokenColors.neutralUI300}; - border-radius: 10px; - flex: 1 1 220px; - min-width: 220px; - padding: 12px 14px; - - @media (min-width: 1024px) { - flex: 0 0 260px; - } - - &:focus-visible { outline: 2px solid ${DesignTokenColors.primary500}; outline-offset: 2px; } +const Or = styled.span` + color: ${DesignTokenColors.neutralUI600}; `; const PrimaryButton = styled.button` @@ -876,45 +252,21 @@ const PillButton = styled(PrimaryButton)` gap: 8px; `; -const Or = styled.span` - color: ${DesignTokenColors.neutralUI600}; -`; - -const MutedNote = styled.p` - color: ${DesignTokenColors.neutralUI600}; - margin: 40px 0 0; -`; +const Row = styled.div` + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; -const Placeholder = styled.div` - background: ${DesignTokenColors.neutralUI50}; - border: 1px dashed ${DesignTokenColors.neutralUI300}; - border-radius: 12px; - color: ${DesignTokenColors.neutralUI600}; - padding: 24px; + @media (min-width: 1024px) { + flex-wrap: nowrap; + } `; -const Toast = styled.div` - align-items: center; - background: ${({ $success }) => ($success ? DesignTokenColors.neutralUI50 : DesignTokenColors.neutralUI900)}; - border: ${({ $success }) => ($success ? `1px solid ${DesignTokenColors.neutralUI200}` : 'none')}; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(16,24,40,0.18); - color: ${({ $success }) => ($success ? DesignTokenColors.neutralUI900 : DesignTokenColors.whiteUI)}; - display: inline-flex; - font-size: 14px; - gap: 10px; - left: 50%; - max-width: 90vw; - position: fixed; - text-align: left; - top: 10%; - transform: translateX(-50%); - z-index: 10000; - padding: 10px 12px; +const Section = styled.section` + margin: 16px 0 22px; `; -const SuccessIcon = styled.span` - color: ${DesignTokenColors.confirmation500}; - display: inline-flex; - line-height: 1; +const SocialIconButton = styled(IconButton)` + color: ${DesignTokenColors.neutralUI800}; `; From f9f8f6e913b1f1fcfd6d67cd837100cfe28dcaf3 Mon Sep 17 00:00:00 2001 From: Ricardo Reynoso Date: Mon, 8 Dec 2025 22:34:15 -0800 Subject: [PATCH 2/3] [WV-2287] Moved most logic back to Import and Invite page [TEAM REVIEW] --- src/App.jsx | 3 +- .../ManageMyCandidatesLanding.jsx | 400 +---------------- src/js/pages/More/ManageMyCandidates.jsx | 403 ++++++++++++++++-- 3 files changed, 375 insertions(+), 431 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index c3ec0a3fd..00c8c5aac 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -581,7 +581,8 @@ class App extends Component { - + + diff --git a/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx b/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx index 6450827f3..8934c75ed 100644 --- a/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx +++ b/src/js/pages/ManageMyCandidates/ManageMyCandidatesLanding.jsx @@ -1,21 +1,13 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { useHistory, useLocation } from 'react-router-dom'; +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; import styled from 'styled-components'; import { Helmet } from 'react-helmet-async'; -import { - Edit as EditIcon, - KeyboardArrowDown as ArrowDownIcon, - CheckCircle as CheckIcon } from '@mui/icons-material'; +import { Edit as EditIcon, KeyboardArrowDown as ArrowDownIcon } from '@mui/icons-material'; import PropTypes from 'prop-types'; import PoliticianStore from '../../common/stores/PoliticianStore'; import VoterStore from '../../stores/VoterStore'; import { PageContentContainer } from '../../components/Style/pageLayoutStyles'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; -import EditInvitationModal from '../../components/More/EditInvitationModal'; -import PasteListModal from '../../components/More/PasteListModal'; -import PreviewInvitationModal from '../../components/More/PreviewInvitationModal'; -import UploadCSVModal from '../../components/More/UploadCSVModal'; const PoliticiansManagedController = React.lazy(() => import('../../components/PoliticiansManaged/PoliticiansManagedController')); @@ -25,13 +17,17 @@ const AnalyticsPage = React.lazy(() => import('./SupporterAnalytics')); export default function ManageMyCandidatesLanding () { // Left nav active tab - const location = useLocation(); - const getActiveTab = (path) => { - if (path.includes('/tracking')) return 'tracking'; - if (path.includes('/analytics')) return 'analytics'; - return 'import'; // default - }; - const active = getActiveTab(location.pathname); + const { subtab } = useParams(); + const history = useHistory(); + const validTabs = useMemo(() => ['tracking', 'analytics'], []); + // All invalid subtabs redirect to /managecandidates just to be safe + useEffect(() => { + if (!subtab) return; + if (!validTabs.includes(subtab)) { + history.replace('/managecandidates'); + } + }, [subtab, history, validTabs]); + const active = validTabs.includes(subtab) ? subtab : 'import'; const demoPoliticians = useMemo(() => ([ { we_vote_id: 'cand_1', politician_name: 'John Dough' }, @@ -43,36 +39,6 @@ export default function ManageMyCandidatesLanding () { const [selectedPoliticianWeVoteId, setSelectedPoliticianWeVoteId] = useState(''); const selectedPolitician = politiciansToManage.find((politician) => politician.we_vote_id === selectedPoliticianWeVoteId) || null; - const [invitationBody, setInvitationBody] = useState(`Hello friend, - -We’d like to invite you to join WeVote to help support ${selectedPolitician?.politician_name || 'our campaign'}. -Thanks for your help!`); - // Edit modal - const [showEdit, setShowEdit] = useState(false); - const [draftInvite, setDraftInvite] = useState(invitationBody); - const [initialInvite, setInitialInvite] = useState(invitationBody); - // Preview modal - const [showPreview, setShowPreview] = useState(false); - const handlePreviewOpen = () => setShowPreview(true); - const handlePreviewClose = () => setShowPreview(false); - // Upload CSV modal - const [showUpload, setShowUpload] = useState(false); - // CSV upload and paste - const [showPaste, setShowPaste] = useState(false); - const [pasteText, setPasteText] = useState(''); - const [pasteErrors, setPasteErrors] = useState([]); - const [mirrorHTML, setMirrorHTML] = useState(''); - const [importedVoters, setImportedVoters] = useState([]); - // One-by-one inputs - const [oneName, setOneName] = useState(''); - const [oneEmail, setOneEmail] = useState(''); - const [onePhone, setOnePhone] = useState(''); - const fileInputRef = useRef(null); - const [allColumnsOK, setAllColumnsOK] = useState(false); - - const openUploadModal = () => { setAllColumnsOK(false); setShowUpload(true); }; - const closeUploadModal = () => { setAllColumnsOK(false); setShowUpload(false); }; - const handleSelectCSV = () => fileInputRef.current?.click(); useEffect(() => { if (!selectedPoliticianWeVoteId && politiciansToManage.length > 0) { @@ -80,240 +46,6 @@ Thanks for your help!`); } }, [politiciansToManage, selectedPoliticianWeVoteId]); - // Reset draft when invitationBody or showEdit changes - useEffect(() => { - if (!showEdit) { - setDraftInvite(invitationBody); - setInitialInvite(invitationBody); - } - }, [invitationBody, showEdit, setDraftInvite, setInitialInvite]); - const [copiedMsg, setCopiedMsg] = useState(''); - const [isSuccessToast, setIsSuccessToast] = useState(false); - const openEditModal = () => { setInitialInvite(draftInvite); setShowEdit(true); }; - const handleEditInvite = () => { - setShowPreview(false); - openEditModal(); - }; - - const toastTimerRef = useRef(null); - const idRef = useRef(1); - const makeVoterRecord = useCallback((partial, source = 'Paste list') => ({ - id: `v_${Date.now()}_${idRef.current++}`, - addedAt: new Date().toISOString(), - addedBy: 'You', - source, - ...partial, - }), []); - const notify = useCallback((msg, success = true) => { - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(success); - setCopiedMsg(msg); - toastTimerRef.current = setTimeout(() => { setCopiedMsg(''); setIsSuccessToast(false); }, 2200); - }, []); - const handleInviteSelected = useCallback((rows) => { notify(`Invited ${rows.length} voter${rows.length === 1 ? '' : 's'} by email.`); }, [notify]); - const handleInviteEmailOneOrMany = useCallback((rows) => { notify(`Email invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); - const handleInviteTextOneOrMany = useCallback((rows) => { notify(`Text invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); - const handleHideOne = useCallback((row) => { setImportedVoters((p) => p.filter((r) => (r.id || r._idx) !== (row.id || row._idx))); notify('Hidden from list.', true); }, [notify]); - const handleHideMany = useCallback((rows) => { const ids = new Set(rows.map((r) => r.id || r._idx)); setImportedVoters((p) => p.filter((r) => !ids.has(r.id || r._idx))); notify(`Hidden ${rows.length} item${rows.length === 1 ? '' : 's'}.`, true); }, [notify]); - const handleSaveInvite = () => { - setInvitationBody(draftInvite); - setShowEdit(false); - notify('Invitation updated.', true); - }; - - const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; - - const escapeHTML = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); - - function parsePastedList (text) { - const lines = text.split(/\r?\n/); - const rows = []; - const errors = []; - - lines.forEach((raw, idx) => { - const line = raw.trim(); - if (!line) return; - - const parts = line.split(',').map((s) => s.trim()).filter(Boolean); - const emailCount = (line.match(emailRE) || []).length; - - if (parts.length > 3) { - errors.push({ line: idx, reason: 'Too many commas' }); - return; - } - if (emailCount > 1) { - errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' }); - return; - } - // Must have at least "Name, Email" - if (parts.length < 2) { - errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' }); - return; - } - // Validate email - const emailPart = parts[1].replace(/[<>]/g, ''); - if (!emailRE.test(emailPart)) { - errors.push({ line: idx, reason: 'Invalid email' }); - return; - } - - rows.push({ - name: parts[0] || '', - email: emailPart || '', - phone: parts[2] || '', - }); - }); - - return { rows, errors }; - } - - function buildMirrorHTML (text, errors) { - const errSet = new Set(errors.map((e) => e.line)); - return text.split(/\r?\n/).map((line, i) => { - const isError = errSet.has(i) && line.trim().length > 0; - const safe = escapeHTML(line); - return isError ? `${safe}` : safe; - }).join('\n'); - } - - const handleCopyInviteBody = async () => { - try { - await navigator.clipboard.writeText(`${invitationBody}\n\nhttps://wevote.us/join/${selectedPoliticianWeVoteId}`); - notify('Invitation copied to clipboard. Press ⌘V / Ctrl+V to paste.', true); - } catch { - notify('Copy failed. Select the text and copy manually.', false, 3000); - } - }; - - const handleDownloadSample = () => { - const csv = - 'Name,Email,Mobile,Address\n' + - 'John Smith,js@gmail.com,"(123) 456-7890","123 State St, Anytown, CA 94117"\n'; - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'wevote_sample.csv'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const handleImportOne = useCallback(() => { - const name = oneName.trim(); - const email = oneEmail.trim(); - const phone = onePhone.trim(); - - if (!emailRE.test(email)) { notify('Enter a valid email.', false); return; } - - setImportedVoters((prev) => [ - ...prev, - makeVoterRecord({ name, email, phone }, 'Manual entry'), - ]); - - setOneName(''); setOneEmail(''); setOnePhone(''); - notify('Voter added. Ready to invite.', true); - }, [oneName, oneEmail, onePhone, notify, makeVoterRecord, setImportedVoters]); - - const handleCSVSelected = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (!/\.csv$/i.test(file.name)) { - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(false); - setCopiedMsg('Please choose a .csv file.'); - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2500); - e.target.value = ''; - return; - } - setShowUpload(true); - - const text = await file.text(); - const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim().length > 0); - const [headerLine, ...dataLines] = lines; - const headers = headerLine.split(',').map((h) => h.trim().toLowerCase()); - - const nameIdx = headers.indexOf('name'); - const emailIdx = headers.indexOf('email'); - const mobileIdx = headers.indexOf('mobile'); - const phoneIdx = headers.indexOf('phone'); - const phoneCol = mobileIdx > -1 ? mobileIdx : phoneIdx; - const addressIdx = headers.indexOf('address'); - - const ok = nameIdx > -1 && emailIdx > -1 && phoneCol > -1 && addressIdx > -1; - - const rows = dataLines.map((line) => { - const cols = line.split(',').map((supporter) => supporter.trim()); - return { - name: nameIdx > -1 ? cols[nameIdx] : '', - email: emailIdx > -1 ? cols[emailIdx] : '', - phone: phoneCol > -1 ? cols[phoneCol] : '', - address: addressIdx > -1 ? cols[addressIdx] : '', - }; - }).filter((r) => r.name || r.email || r.phone || r.address); - - if (ok && rows.length > 0) { - setImportedVoters(rows.map((r) => makeVoterRecord(r, 'CSV upload'))); - closeUploadModal(); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(true); - setCopiedMsg('All of your columns will be imported.'); - toastTimerRef.current = setTimeout(() => { - setCopiedMsg(''); - setIsSuccessToast(false); - }, 2200); - } else { - setAllColumnsOK(ok); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setIsSuccessToast(false); - if (!ok) { - setCopiedMsg('We could not find all required columns (Name, Email, Mobile/Phone, Address). Please adjust and re-upload.'); - } else if (rows.length === 0) { - setCopiedMsg('No rows found in this CSV.'); - } - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 3000); - } - - e.target.value = ''; - }; - - const handlePasteList = () => { - setShowPaste(true); - setPasteErrors([]); - setMirrorHTML(''); - }; - - const closePaste = () => { - setShowPaste(false); - setPasteErrors([]); - setMirrorHTML(''); - }; - - const onPasteTextChange = (e) => { - const { value } = e.target; - setPasteText(value); - const { errors } = parsePastedList(value); - setPasteErrors(errors); - setMirrorHTML(buildMirrorHTML(value, errors)); - }; - - const handlePasteImport = () => { - const { rows, errors } = parsePastedList(pasteText); - if (errors.length) { - setPasteErrors(errors); - setMirrorHTML(buildMirrorHTML(pasteText, errors)); - return; // block import until fixed - } - setImportedVoters((prev) => [...prev, ...rows.map((r) => makeVoterRecord(r, 'Paste list'))]); - if (toastTimerRef.current) clearTimeout(toastTimerRef.current); - setCopiedMsg(`Imported ${rows.length} voter${rows.length !== 1 ? 's' : ''} from pasted list.`); - toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2200); - }; - - const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]); - const updatePoliticiansToManage = () => { const test = true; if (test) { @@ -350,7 +82,6 @@ Thanks for your help!`); }, []); // Main render - const history = useHistory(); const handleClaimEdit = () => history.push(`/candidate/${selectedPoliticianWeVoteId}/edit`); const handleClaimImport = () => history.push('/managecandidates'); const handleClaimTracking = () => history.push('/managecandidates/tracking'); @@ -420,25 +151,8 @@ Thanks for your help!`); }> {active === 'import' && ( setShowEdit(true)} - oneName={oneName} - oneEmail={oneEmail} - onePhone={onePhone} - setOneName={setOneName} - setOneEmail={setOneEmail} - setOnePhone={setOnePhone} - handleImportOne={handleImportOne} - emailRE={emailRE} - openUploadModal={openUploadModal} - handlePasteList={handlePasteList} - importedVoters={importedVoters} - handleInviteSelected={handleInviteSelected} - handleInviteEmailOneOrMany={handleInviteEmailOneOrMany} - handleInviteTextOneOrMany={handleInviteTextOneOrMany} - handleHideOne={handleHideOne} - handleHideMany={handleHideMany} + selectedPolitician={selectedPolitician} + selectedPoliticianWeVoteId={selectedPoliticianWeVoteId} /> )} {active === 'tracking' && } @@ -446,62 +160,6 @@ Thanks for your help!`); - - {/* Preview modal */} - - {/* Edit modal */} - setShowEdit(false)} - draftInvite={draftInvite} - setDraftInvite={setDraftInvite} - initialInvite={initialInvite} - onSave={handleSaveInvite} - notify={notify} - selectedPoliticianId={selectedPoliticianWeVoteId} - /> - {/* Paste list modal */} - - {/* Upload CSV modal */} - - - {copiedMsg && createPortal( - - {isSuccessToast && ( - - )} - {copiedMsg} - , - document.body, - )} }> @@ -698,29 +356,3 @@ const SideDivider = styled.div` const RightPanel = styled.section` padding-top: 6px; `; - -const Toast = styled.div` - align-items: center; - background: ${({ $success }) => ($success ? DesignTokenColors.neutralUI50 : DesignTokenColors.neutralUI900)}; - border: ${({ $success }) => ($success ? `1px solid ${DesignTokenColors.neutralUI200}` : 'none')}; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(16,24,40,0.18); - color: ${({ $success }) => ($success ? DesignTokenColors.neutralUI900 : DesignTokenColors.whiteUI)}; - display: inline-flex; - font-size: 14px; - gap: 10px; - left: 50%; - max-width: 90vw; - position: fixed; - text-align: left; - top: 10%; - transform: translateX(-50%); - z-index: 10000; - padding: 10px 12px; -`; - -const SuccessIcon = styled.span` - color: ${DesignTokenColors.confirmation500}; - display: inline-flex; - line-height: 1; -`; diff --git a/src/js/pages/More/ManageMyCandidates.jsx b/src/js/pages/More/ManageMyCandidates.jsx index c2a89c823..470ee02b3 100644 --- a/src/js/pages/More/ManageMyCandidates.jsx +++ b/src/js/pages/More/ManageMyCandidates.jsx @@ -1,4 +1,5 @@ -import React, { Suspense } from 'react'; +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import {createPortal} from 'react-dom'; import styled from 'styled-components'; import { Edit as EditIcon, @@ -6,33 +7,283 @@ import { Visibility as EyeIcon, Facebook as FacebookIcon, X as XIcon, - FileUpload as UploadIcon } from '@mui/icons-material'; -import PropTypes from 'prop-types'; + FileUpload as UploadIcon, + CheckCircle as CheckIcon } from '@mui/icons-material'; import DesignTokenColors from '../../common/components/Style/DesignTokenColors'; +import EditInvitationModal from '../../components/More/EditInvitationModal'; +import PasteListModal from '../../components/More/PasteListModal'; +import PreviewInvitationModal from '../../components/More/PreviewInvitationModal'; +import UploadCSVModal from '../../components/More/UploadCSVModal'; + const ImportedVotersList = React.lazy(() => import('../../components/PoliticiansManaged/ImportedVotersList')); -export default function ManageMyCandidates ({ - handleCopyInviteBody, - handlePreviewOpen, - openEditModal, - oneName, - oneEmail, - onePhone, - setOneName, - setOneEmail, - setOnePhone, - handleImportOne, - emailRE, - openUploadModal, - handlePasteList, - importedVoters, - handleInviteSelected, - handleInviteEmailOneOrMany, - handleInviteTextOneOrMany, - handleHideOne, - handleHideMany, -}) { +export default function ManageMyCandidates ({ selectedPolitician, selectedPoliticianWeVoteId }) { + const [invitationBody, setInvitationBody] = useState(`Hello friend, + +We’d like to invite you to join WeVote to help support ${selectedPolitician?.politician_name || 'our campaign'}. +Thanks for your help!`); + // Edit modal + const [showEdit, setShowEdit] = useState(false); + const [draftInvite, setDraftInvite] = useState(invitationBody); + const [initialInvite, setInitialInvite] = useState(invitationBody); + // Preview modal + const [showPreview, setShowPreview] = useState(false); + const handlePreviewOpen = () => setShowPreview(true); + const handlePreviewClose = () => setShowPreview(false); + // Upload CSV modal + const [showUpload, setShowUpload] = useState(false); + // CSV upload and paste + const [showPaste, setShowPaste] = useState(false); + const [pasteText, setPasteText] = useState(''); + const [pasteErrors, setPasteErrors] = useState([]); + const [mirrorHTML, setMirrorHTML] = useState(''); + const [importedVoters, setImportedVoters] = useState([]); + // One-by-one inputs + const [oneName, setOneName] = useState(''); + const [oneEmail, setOneEmail] = useState(''); + const [onePhone, setOnePhone] = useState(''); + const fileInputRef = useRef(null); + const [allColumnsOK, setAllColumnsOK] = useState(false); + + const openUploadModal = () => { setAllColumnsOK(false); setShowUpload(true); }; + const closeUploadModal = () => { setAllColumnsOK(false); setShowUpload(false); }; + const handleSelectCSV = () => fileInputRef.current?.click(); + + // Reset draft when invitationBody or showEdit changes + useEffect(() => { + if (!showEdit) { + setDraftInvite(invitationBody); + setInitialInvite(invitationBody); + } + }, [invitationBody, showEdit, setDraftInvite, setInitialInvite]); + const [copiedMsg, setCopiedMsg] = useState(''); + const [isSuccessToast, setIsSuccessToast] = useState(false); + const openEditModal = () => { setInitialInvite(draftInvite); setShowEdit(true); }; + const handleEditInvite = () => { + setShowPreview(false); + openEditModal(); + }; + + const toastTimerRef = useRef(null); + const idRef = useRef(1); + const makeVoterRecord = useCallback((partial, source = 'Paste list') => ({ + id: `v_${Date.now()}_${idRef.current++}`, + addedAt: new Date().toISOString(), + addedBy: 'You', + source, + ...partial, + }), []); + const notify = useCallback((msg, success = true) => { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(success); + setCopiedMsg(msg); + toastTimerRef.current = setTimeout(() => { setCopiedMsg(''); setIsSuccessToast(false); }, 2200); + }, []); + const handleInviteSelected = useCallback((rows) => { notify(`Invited ${rows.length} voter${rows.length === 1 ? '' : 's'} by email.`); }, [notify]); + const handleInviteEmailOneOrMany = useCallback((rows) => { notify(`Email invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); + const handleInviteTextOneOrMany = useCallback((rows) => { notify(`Text invite sent to ${rows.length} voter${rows.length === 1 ? '' : 's'}.`); }, [notify]); + const handleHideOne = useCallback((row) => { setImportedVoters((p) => p.filter((r) => (r.id || r._idx) !== (row.id || row._idx))); notify('Hidden from list.', true); }, [notify]); + const handleHideMany = useCallback((rows) => { const ids = new Set(rows.map((r) => r.id || r._idx)); setImportedVoters((p) => p.filter((r) => !ids.has(r.id || r._idx))); notify(`Hidden ${rows.length} item${rows.length === 1 ? '' : 's'}.`, true); }, [notify]); + const handleSaveInvite = () => { + setInvitationBody(draftInvite); + setShowEdit(false); + notify('Invitation updated.', true); + }; + + const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; + + const escapeHTML = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + + function parsePastedList (text) { + const lines = text.split(/\r?\n/); + const rows = []; + const errors = []; + + lines.forEach((raw, idx) => { + const line = raw.trim(); + if (!line) return; + + const parts = line.split(',').map((s) => s.trim()).filter(Boolean); + const emailCount = (line.match(emailRE) || []).length; + + if (parts.length > 3) { + errors.push({ line: idx, reason: 'Too many commas' }); + return; + } + if (emailCount > 1) { + errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' }); + return; + } + // Must have at least "Name, Email" + if (parts.length < 2) { + errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' }); + return; + } + // Validate email + const emailPart = parts[1].replace(/[<>]/g, ''); + if (!emailRE.test(emailPart)) { + errors.push({ line: idx, reason: 'Invalid email' }); + return; + } + + rows.push({ + name: parts[0] || '', + email: emailPart || '', + phone: parts[2] || '', + }); + }); + + return { rows, errors }; + } + + function buildMirrorHTML (text, errors) { + const errSet = new Set(errors.map((e) => e.line)); + return text.split(/\r?\n/).map((line, i) => { + const isError = errSet.has(i) && line.trim().length > 0; + const safe = escapeHTML(line); + return isError ? `${safe}` : safe; + }).join('\n'); + } + + const handleCopyInviteBody = async () => { + try { + await navigator.clipboard.writeText(`${invitationBody}\n\nhttps://wevote.us/join/${selectedPoliticianWeVoteId}`); + notify('Invitation copied to clipboard. Press ⌘V / Ctrl+V to paste.', true); + } catch { + notify('Copy failed. Select the text and copy manually.', false, 3000); + } + }; + + const handleDownloadSample = () => { + const csv = + 'Name,Email,Mobile,Address\n' + + 'John Smith,js@gmail.com,"(123) 456-7890","123 State St, Anytown, CA 94117"\n'; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'wevote_sample.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleImportOne = useCallback(() => { + const name = oneName.trim(); + const email = oneEmail.trim(); + const phone = onePhone.trim(); + + if (!emailRE.test(email)) { notify('Enter a valid email.', false); return; } + + setImportedVoters((prev) => [ + ...prev, + makeVoterRecord({ name, email, phone }, 'Manual entry'), + ]); + + setOneName(''); setOneEmail(''); setOnePhone(''); + notify('Voter added. Ready to invite.', true); + }, [oneName, oneEmail, onePhone, notify, makeVoterRecord, setImportedVoters]); + + const handleCSVSelected = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!/\.csv$/i.test(file.name)) { + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(false); + setCopiedMsg('Please choose a .csv file.'); + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2500); + e.target.value = ''; + return; + } + setShowUpload(true); + + const text = await file.text(); + const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim().length > 0); + const [headerLine, ...dataLines] = lines; + const headers = headerLine.split(',').map((h) => h.trim().toLowerCase()); + + const nameIdx = headers.indexOf('name'); + const emailIdx = headers.indexOf('email'); + const mobileIdx = headers.indexOf('mobile'); + const phoneIdx = headers.indexOf('phone'); + const phoneCol = mobileIdx > -1 ? mobileIdx : phoneIdx; + const addressIdx = headers.indexOf('address'); + + const ok = nameIdx > -1 && emailIdx > -1 && phoneCol > -1 && addressIdx > -1; + + const rows = dataLines.map((line) => { + const cols = line.split(',').map((supporter) => supporter.trim()); + return { + name: nameIdx > -1 ? cols[nameIdx] : '', + email: emailIdx > -1 ? cols[emailIdx] : '', + phone: phoneCol > -1 ? cols[phoneCol] : '', + address: addressIdx > -1 ? cols[addressIdx] : '', + }; + }).filter((r) => r.name || r.email || r.phone || r.address); + + if (ok && rows.length > 0) { + setImportedVoters(rows.map((r) => makeVoterRecord(r, 'CSV upload'))); + closeUploadModal(); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(true); + setCopiedMsg('All of your columns will be imported.'); + toastTimerRef.current = setTimeout(() => { + setCopiedMsg(''); + setIsSuccessToast(false); + }, 2200); + } else { + setAllColumnsOK(ok); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setIsSuccessToast(false); + if (!ok) { + setCopiedMsg('We could not find all required columns (Name, Email, Mobile/Phone, Address). Please adjust and re-upload.'); + } else if (rows.length === 0) { + setCopiedMsg('No rows found in this CSV.'); + } + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 3000); + } + + e.target.value = ''; + }; + + const handlePasteList = () => { + setShowPaste(true); + setPasteErrors([]); + setMirrorHTML(''); + }; + + const closePaste = () => { + setShowPaste(false); + setPasteErrors([]); + setMirrorHTML(''); + }; + + const onPasteTextChange = (e) => { + const { value } = e.target; + setPasteText(value); + const { errors } = parsePastedList(value); + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(value, errors)); + }; + + const handlePasteImport = () => { + const { rows, errors } = parsePastedList(pasteText); + if (errors.length) { + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(pasteText, errors)); + return; // block import until fixed + } + setImportedVoters((prev) => [...prev, ...rows.map((r) => makeVoterRecord(r, 'Paste list'))]); + if (toastTimerRef.current) clearTimeout(toastTimerRef.current); + setCopiedMsg(`Imported ${rows.length} voter${rows.length !== 1 ? 's' : ''} from pasted list.`); + toastTimerRef.current = setTimeout(() => setCopiedMsg(''), 2200); + }; + + const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]); + return ( <>

Import & invite voters

@@ -107,32 +358,66 @@ export default function ManageMyCandidates ({ /> )} + + {/* Preview modal */} + + {/* Edit modal */} + setShowEdit(false)} + draftInvite={draftInvite} + setDraftInvite={setDraftInvite} + initialInvite={initialInvite} + onSave={handleSaveInvite} + notify={notify} + selectedPoliticianId={selectedPoliticianWeVoteId} + /> + {/* Paste list modal */} + + {/* Upload CSV modal */} + + + {copiedMsg && createPortal( + + {isSuccessToast && ( + + )} + {copiedMsg} + , + document.body, + )} ); } -ManageMyCandidates.propTypes = { - handleCopyInviteBody: PropTypes.func.isRequired, - handlePreviewOpen: PropTypes.func.isRequired, - openEditModal: PropTypes.func.isRequired, - oneName: PropTypes.string.isRequired, - oneEmail: PropTypes.string.isRequired, - onePhone: PropTypes.string.isRequired, - setOneName: PropTypes.func.isRequired, - setOneEmail: PropTypes.func.isRequired, - setOnePhone: PropTypes.func.isRequired, - handleImportOne: PropTypes.func.isRequired, - emailRE: PropTypes.instanceOf(RegExp).isRequired, - openUploadModal: PropTypes.func.isRequired, - handlePasteList: PropTypes.func.isRequired, - importedVoters: PropTypes.arrayOf(PropTypes.object).isRequired, - handleInviteSelected: PropTypes.func.isRequired, - handleInviteEmailOneOrMany: PropTypes.func.isRequired, - handleInviteTextOneOrMany: PropTypes.func.isRequired, - handleHideOne: PropTypes.func.isRequired, - handleHideMany: PropTypes.func.isRequired, -}; - // Paste-list icon const PasteListIcon = ({ size = 22, title = 'Paste list', ...props }) => ( ($success ? DesignTokenColors.neutralUI50 : DesignTokenColors.neutralUI900)}; + border: ${({ $success }) => ($success ? `1px solid ${DesignTokenColors.neutralUI200}` : 'none')}; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(16,24,40,0.18); + color: ${({ $success }) => ($success ? DesignTokenColors.neutralUI900 : DesignTokenColors.whiteUI)}; + display: inline-flex; + font-size: 14px; + gap: 10px; + left: 50%; + max-width: 90vw; + position: fixed; + text-align: left; + top: 10%; + transform: translateX(-50%); + z-index: 10000; + padding: 10px 12px; +`; From 9c92b8824cc29abbafde89c2d0e86dd866d1a536 Mon Sep 17 00:00:00 2001 From: Ricardo Reynoso Date: Tue, 9 Dec 2025 14:30:23 -0800 Subject: [PATCH 3/3] [WV-2287] Moved modal logic to the modal files [MERGE READY] --- .../components/More/EditInvitationModal.jsx | 27 +- src/js/components/More/PasteListModal.jsx | 106 +++++-- src/js/components/More/UploadCSVModal.jsx | 102 ++++++- src/js/pages/More/ManageMyCandidates.jsx | 279 ++++-------------- 4 files changed, 255 insertions(+), 259 deletions(-) diff --git a/src/js/components/More/EditInvitationModal.jsx b/src/js/components/More/EditInvitationModal.jsx index a24e254ee..af3e189e0 100644 --- a/src/js/components/More/EditInvitationModal.jsx +++ b/src/js/components/More/EditInvitationModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import styled, { createGlobalStyle } from 'styled-components'; import { ContentCopy as CopyIcon } from '@mui/icons-material'; @@ -8,13 +8,22 @@ import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA'; const EditInvitationModal = ({ isOpen, onClose, - draftInvite, - setDraftInvite, - initialInvite, + invitationBody, onSave, notify, selectedPoliticianId, }) => { + const [draftInvite, setDraftInvite] = useState(invitationBody); + const [initialInvite, setInitialInvite] = useState(invitationBody); + + // Reset draft when invitationBody or showEdit changes + useEffect(() => { + if (isOpen) { + setDraftInvite(invitationBody); + setInitialInvite(invitationBody); + } + }, [invitationBody, isOpen]); + const handleCopyInviteBody = async () => { try { await navigator.clipboard.writeText( @@ -26,6 +35,10 @@ const EditInvitationModal = ({ } }; + const handleSave = () => { + onSave(draftInvite); + }; + if (!isOpen) return null; const dialogTitleJSX = ( @@ -59,7 +72,7 @@ const EditInvitationModal = ({ Close Save invitation @@ -88,9 +101,7 @@ const EditInvitationModal = ({ EditInvitationModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, - draftInvite: PropTypes.string.isRequired, - setDraftInvite: PropTypes.func.isRequired, - initialInvite: PropTypes.string.isRequired, + invitationBody: PropTypes.string.isRequired, onSave: PropTypes.func.isRequired, notify: PropTypes.func.isRequired, selectedPoliticianId: PropTypes.string.isRequired, diff --git a/src/js/components/More/PasteListModal.jsx b/src/js/components/More/PasteListModal.jsx index 3803b6d13..df8a38db3 100644 --- a/src/js/components/More/PasteListModal.jsx +++ b/src/js/components/More/PasteListModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import styled, { createGlobalStyle } from 'styled-components'; import { WarningAmber as WarningIcon } from '@mui/icons-material'; @@ -7,16 +7,96 @@ import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA'; const escapeHTML = (s) => s.replace(/[&<>]/g, (element) => ({ '&': '&', '<': '<', '>': '>' }[element])); +const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; + +function parsePastedList (text) { + const lines = text.split(/\r?\n/); + const rows = []; + const errors = []; + + lines.forEach((raw, idx) => { + const line = raw.trim(); + if (!line) return; + + const parts = line.split(',').map((s) => s.trim()).filter(Boolean); + const emailCount = (line.match(emailRE) || []).length; + + if (parts.length > 3) { + errors.push({ line: idx, reason: 'Too many commas' }); + return; + } + if (emailCount > 1) { + errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' }); + return; + } + // Must have at least "Name, Email" + if (parts.length < 2) { + errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' }); + return; + } + // Validate email + const emailPart = parts[1].replace(/[<>]/g, ''); + if (!emailRE.test(emailPart)) { + errors.push({ line: idx, reason: 'Invalid email' }); + return; + } + + rows.push({ + name: parts[0] || '', + email: emailPart || '', + phone: parts[2] || '', + }); + }); + + return { rows, errors }; +} + +function buildMirrorHTML (text, errors) { + const errSet = new Set(errors.map((e) => e.line)); + return text.split(/\r?\n/).map((line, i) => { + const isError = errSet.has(i) && line.trim().length > 0; + const safe = escapeHTML(line); + return isError ? `${safe}` : safe; + }).join('\n'); +} + export default function PasteListModal ({ isOpen, onClose, - pasteText, - onPasteTextChange, - mirrorHTML, - pasteErrors, onImport, - prospectiveCount, }) { + const [pasteText, setPasteText] = useState(''); + const [pasteErrors, setPasteErrors] = useState([]); + const [mirrorHTML, setMirrorHTML] = useState(''); + + const onPasteTextChange = (e) => { + const { value } = e.target; + setPasteText(value); + const { errors } = parsePastedList(value); + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(value, errors)); + }; + + const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]); + + const handlePasteImport = () => { + const { rows, errors } = parsePastedList(pasteText); + if (errors.length) { + setPasteErrors(errors); + setMirrorHTML(buildMirrorHTML(pasteText, errors)); + return; // block import until fixed + } + onImport(rows); + }; + + const handleClose = () => { + // Reset state on close + setPasteText(''); + setPasteErrors([]); + setMirrorHTML(''); + onClose(); + }; + const dialogTitleJSX = ( Paste voters @@ -93,10 +173,10 @@ John Dough, jd@email.com, (213)-123-4567`}