Skip to content

Commit

Permalink
Support VoteAmerica+ registration & analytics (#1)
Browse files Browse the repository at this point in the history
* In progress: VA analytics support.

* Finalize VA analytics run.

* Fix naming
  • Loading branch information
davepeck authored Feb 28, 2024
1 parent 939f8a9 commit 18bfc23
Show file tree
Hide file tree
Showing 14 changed files with 559 additions and 230 deletions.
16 changes: 16 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"[typescript]": {
"editor.codeActionsOnSave": {
"source.formatDocument": "explicit",
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
}
},
"[typescriptreact]": {
"editor.codeActionsOnSave": {
"source.formatDocument": "explicit",
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
}
}
}
205 changes: 78 additions & 127 deletions src/components/More.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,60 @@
import { useCallback, useEffect, useState } from "react";
import clsx from "clsx";
import { assertNever } from "../asserts";
import { useCallback, useEffect, useState } from "react";
import { type RegistrationHandler } from "../utils/analytics";
import { assertNever } from "../utils/asserts";

import { bestRegistrationUrl } from "../vote_gov";
import { ArrowDown, CornerDownLeft, Share } from "./Icons";
import { STATE_NAMES } from "../states";
import type { State } from "../states";
import { browserSupportsShare, defaultShare } from "./ShareButton";
import { stateSelection, type StateSelection } from "../election/powerRankings";
import {
CANDIDATES_2020,
ELECTION_2020,
winner,
formatMargin,
marginPercent,
} from "../presidency";

/** Countmore power rankings */
const POWER_RANKINGS: Record<string, number> = {
AZ: 40,
GA: 40,
MI: 40,
NV: 40,
PA: 40,
WI: 40,
NC: 20,
FL: 10,
ME: 10,
MN: 10,
NH: 10,
TX: 10,
NE: 10,
// everyone else gets 0
// See https://docs.google.com/spreadsheets/d/1ST7LSXFAVyXs2Kbqs7MCrVyVArXOwd0VvKVL5e_14oc/edit#gid=1670074123
};

/** Return the power ranking for a state */
const powerRanking = (st: string): number => POWER_RANKINGS[st] || 0;

/** Return true if the state is one of the key battleground states. */
export const isBattleground = (st: State): boolean => powerRanking(st) === 40;

/** Which state is most impactful to vote in for the 2024 presidential election? */
type StateSelection = "home" | "school" | "toss-up" | "same";

/** Return the state selection for a state */
const stateSelection = (homeSt: string, schoolSt: string): StateSelection => {
if (homeSt === schoolSt) return "same";
const homeRank = powerRanking(homeSt);
const schoolRank = powerRanking(schoolSt);
if (homeRank > schoolRank) {
return "home";
} else if (homeRank < schoolRank) {
return "school";
} else {
return "toss-up";
}
};
winner,
} from "../election/presidency";
import { STATE_NAMES, type State } from "../election/states";
import { bestRegistrationUrl } from "../election/voteGov";
import {
fireClickRegisterEvent,
fireSelectStatesEvent,
} from "../utils/analytics";
import { ArrowDown, CornerDownLeft, Share } from "./Icons";
import { browserSupportsShare, defaultShare } from "./ShareButton";

/** A state selection result. */
interface StateSelectionResult {
/** The state selection */
selection: StateSelection;

/** The home state */
homeSt: State;
homeState: State;

/** The school state */
schoolSt: State;
schoolState: State;
}

/** Return the selected state for a result. */
const selectedState = (result: StateSelectionResult): State =>
result.selection === "home" ? result.homeSt : result.schoolSt;
result.selection === "home" ? result.homeState : result.schoolState;

/** Return the selected state name for a result. */
const selectedStateName = (result: StateSelectionResult): string =>
STATE_NAMES[selectedState(result)];

/** Return the 'other' state for a result. */
const otherState = (result: StateSelectionResult): State =>
result.selection === "home" ? result.schoolSt : result.homeSt;
result.selection === "home" ? result.schoolState : result.homeState;

/** Return the 'other' state name for a result. */
const otherStateName = (result: StateSelectionResult): string =>
STATE_NAMES[otherState(result)];

/** Return the 'home' state name for a result. */
const homeStateName = (result: StateSelectionResult): string =>
STATE_NAMES[result.homeSt];
STATE_NAMES[result.homeState];

/** Return the 'school' state name for a result. */
const schoolStateName = (result: StateSelectionResult): string =>
STATE_NAMES[result.schoolSt];
STATE_NAMES[result.schoolState];

/** Renders a share button if native browser sharing is available. */
const ShareButton: React.FC = () => {
Expand Down Expand Up @@ -181,19 +144,19 @@ const SubmitButton: React.FC<{ disabled: boolean }> = ({ disabled }) => (
const SelectStates: React.FC<{
onSelect: (result: StateSelectionResult) => void;
}> = ({ onSelect }) => {
const [homeSt, setHomeSt] = useState<State | "">("");
const [schoolSt, setSchoolSt] = useState<State | "">("");
const [homeState, setHomeState] = useState<State | "">("");
const [schoolState, setSchoolState] = useState<State | "">("");

return (
<form
className="flex flex-col space-y-[1.7rem]"
onSubmit={(e) => {
e.preventDefault();
if (!homeSt || !schoolSt) return;
if (!homeState || !schoolState) return;
onSelect({
selection: stateSelection(homeSt, schoolSt),
homeSt,
schoolSt,
selection: stateSelection(homeState, schoolState),
homeState,
schoolState,
});
}}
>
Expand All @@ -208,15 +171,15 @@ const SelectStates: React.FC<{
<div className="flex flex-col space-y-[1.7rem] xl:flex-row xl:space-y-0 xl:space-x-[1.7rem]">
<StateDropdown
id="home-state"
value={homeSt}
onChange={setHomeSt}
value={homeState}
onChange={setHomeState}
label="Home state"
className="xl:flex-grow"
/>
<StateDropdown
id="school-state"
value={schoolSt}
onChange={setSchoolSt}
value={schoolState}
onChange={setSchoolState}
label="School state"
className="xl:flex-grow"
/>
Expand All @@ -226,7 +189,7 @@ const SelectStates: React.FC<{
<ShareButton />
</div>
<div className="flex-1 flex flex-row flex-wrap justify-end">
<SubmitButton disabled={!homeSt || !schoolSt} />
<SubmitButton disabled={!homeState || !schoolState} />
</div>
</div>
</form>
Expand All @@ -239,39 +202,44 @@ const SelectStates: React.FC<{
//
//-------------------------------------------------------------------------

/** Props to the register-to-vote button. */
interface RegisterToVoteButtonProps {
state: State;
handler: RegistrationHandler;
className?: string;
}

/** Renders a single analytics-tracked "register to vote" button. */
const RegisterToVoteButton: React.FC<{ st: State; className?: string }> = ({
st,
const RegisterToVoteButton: React.FC<RegisterToVoteButtonProps> = ({
state,
handler,
className,
}) => {
const url = bestRegistrationUrl(st);
const url = bestRegistrationUrl(state);

const handleRegistrationClick = useCallback(
(e: React.MouseEvent, st: State, url: string) => {
(e: React.MouseEvent, state: State, url: string) => {
e.preventDefault();
fireClickRegisterEvent({ state, handler });

if (window.gtag) {
window.gtag("event", "register_to_vote", {
event_category: "engagement",
state: st.toUpperCase(),
url: url,
});
}
switch (handler) {
case "direct":
window.open(url, "_blank");
break;

// @ts-ignore-next-line The @types/facebook-pixel don't allow for
// the possibility that facebook's script doesn't load properly.
if (window.fbq) {
// see https://developers.facebook.com/docs/meta-pixel/reference
window.fbq("trackCustom", "RegisterToVote", {
kind: "RegisterToVote",
state: st.toUpperCase(),
battleground: isBattleground(st),
});
}
case "voteamerica":
window.document.location.href = "/va-register/?state=" + state;
break;

case "rockthevote":
// TODO rockthevote
throw new Error("rockthevote not implemented");

window.open(url, "_blank");
default:
assertNever(handler);
}
},
[]
[handler]
);

if (!url) return null;
Expand All @@ -282,14 +250,14 @@ const RegisterToVoteButton: React.FC<{ st: State; className?: string }> = ({
"inline-block bg-point rounded-md px-6 py-4 text-white text-xl font-extrabold md:hover:bg-hover transition-colors duration-200 mb-2",
className
)}
href={bestRegistrationUrl(st)!}
href={bestRegistrationUrl(state)!}
target="_blank"
onClick={(e) => handleRegistrationClick(e, st, url)}
aria-label={`Follow this link to register to vote in ${STATE_NAMES[st]}`}
onClick={(e) => handleRegistrationClick(e, state, url)}
aria-label={`Follow this link to register to vote in ${STATE_NAMES[state]}`}
>
Register to vote in{" "}
<span className="font-cabinet inline-block w-8 min-w-8 max-w-8">
{st.toUpperCase()}
{state.toUpperCase()}
</span>
</a>
);
Expand Down Expand Up @@ -415,10 +383,11 @@ const SelectionDetails: React.FC<{ result: StateSelectionResult }> = ({
);
};

const DescribeSelection: React.FC<{ result: StateSelectionResult }> = ({
result,
}) => {
const { selection, homeSt, schoolSt } = result;
const DescribeSelection: React.FC<{
result: StateSelectionResult;
handler: RegistrationHandler;
}> = ({ result, handler }) => {
const { selection, homeState, schoolState } = result;

return (
<div>
Expand All @@ -431,14 +400,15 @@ const DescribeSelection: React.FC<{ result: StateSelectionResult }> = ({
<ShareButton />
</div>
<div className="flex-1 flex flex-row flex-wrap justify-end -mb-2">
{selection !== "school" && bestRegistrationUrl(homeSt) && (
<RegisterToVoteButton st={homeSt} />
{selection !== "school" && bestRegistrationUrl(homeState) && (
<RegisterToVoteButton state={homeState} handler={handler} />
)}
{selection !== "home" &&
selection !== "same" &&
bestRegistrationUrl(schoolSt) && (
bestRegistrationUrl(schoolState) && (
<RegisterToVoteButton
st={schoolSt}
state={schoolState}
handler={handler}
className={selection === "toss-up" ? "ml-4" : ""}
/>
)}
Expand All @@ -455,37 +425,18 @@ const DescribeSelection: React.FC<{ result: StateSelectionResult }> = ({
//-------------------------------------------------------------------------

/** Render our primary user interface. */
export const More: React.FC = () => {
export const More: React.FC<{ handler: RegistrationHandler }> = ({
handler,
}) => {
const [result, setResult] = useState<StateSelectionResult | null>(null);

const handleSelection = useCallback((result: StateSelectionResult) => {
if (window.gtag) {
window.gtag("event", "select_states", {
event_category: "engagement",
home_state: result.homeSt.toUpperCase(),
school_state: result.schoolSt.toUpperCase(),
selection: result.selection,
swing: result.selection === "home" || result.selection === "school",
});

// @ts-ignore-next-line The @types/facebook-pixel don't allow for
// the possibility that facebook's script doesn't load properly.
if (window.fbq) {
// see https://developers.facebook.com/docs/meta-pixel/reference
window.fbq("trackCustom", "SelectStates", {
kind: "SelectStates",
home_state: result.homeSt.toUpperCase(),
school_state: result.schoolSt.toUpperCase(),
selection: result.selection,
swing: result.selection === "home" || result.selection === "school",
});
}
}
fireSelectStatesEvent(result);
setResult(result);
}, []);

return result ? (
<DescribeSelection result={result} />
<DescribeSelection result={result} handler={handler} />
) : (
<SelectStates onSelect={handleSelection} />
);
Expand Down
Loading

0 comments on commit 18bfc23

Please sign in to comment.