From 15402dce1849140f274c67b6ceff10f0b136fbe4 Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Tue, 23 Apr 2024 16:41:05 -0400 Subject: [PATCH] Exclude IRA rebates; add ability to show generic info about HEAR/HER ## Description This excludes all federal non-tax-credit incentives that come from the API, and adds in these visually-distinct cards with less-specific info about HEAR rebates if the API response indicates the user is below 150% AMI. One slight difference from the design spec is that the description text is grey-500, rather than 400, which fails the a11y test for not having enough contrast with the yellow background. I can't even see the difference at a glance, so I think it's fine. (As a drive-by, I fixed a couple places where we had `gray` instead of `grey`.) Once we settle on copy, I'll start the translation workflow. We don't have any state-specific logic yet, but there's a clear place to add it once we figure out what exactly we want for each state. Some states will want us to show nothing at all (either because they don't want to commit to anything or because they already have actual HEAR programs represented in the API), some states may want semi-specific info in advance of rolling out actual programs, some may tell us they're going to exclude certain items, etc. All that can be built on this foundation. As background, I don't want to put this in the API because the kind of info we want for HEAR just doesn't fit within the current API shape: it's too vague and uncertain. I don't want to extend the API shape to encompass stuff like this because I think it's valuable, on principle, to hold the line that the API only returns concrete, actionable incentives that are certain, or close to certain, to be available. https://app.asana.com/0/1206661332626418/1206925887004341 ## Test Plan Cypress tests pass. --- cypress/e2e/state-calculator.cy.ts | 2 +- src/api/calculator-types-v1.ts | 1 + src/card.tsx | 54 ++++--- src/i18n/strings/es.ts | 11 ++ src/ira-rebates.ts | 74 ++++++++++ src/state-incentive-details.tsx | 218 ++++++++++++++++++++--------- translations/es.xlf | 33 +++++ 7 files changed, 303 insertions(+), 90 deletions(-) create mode 100644 src/ira-rebates.ts diff --git a/cypress/e2e/state-calculator.cy.ts b/cypress/e2e/state-calculator.cy.ts index 3dfe558..0f04530 100644 --- a/cypress/e2e/state-calculator.cy.ts +++ b/cypress/e2e/state-calculator.cy.ts @@ -55,7 +55,7 @@ describe('rewiring-america-state-calculator', () => { cy.get('rewiring-america-state-calculator') .shadow() - .contains('$8,000 off a heat pump'); + .contains('Discount off a heat pump'); cy.get('rewiring-america-state-calculator') .shadow() diff --git a/src/api/calculator-types-v1.ts b/src/api/calculator-types-v1.ts index 530c693..a4f8299 100644 --- a/src/api/calculator-types-v1.ts +++ b/src/api/calculator-types-v1.ts @@ -107,4 +107,5 @@ export interface APIResponse { }; location: APILocation; incentives: Incentive[]; + is_under_150_ami: boolean; } diff --git a/src/card.tsx b/src/card.tsx index 6efcee3..0cf7fe0 100644 --- a/src/card.tsx +++ b/src/card.tsx @@ -2,6 +2,12 @@ import { PropsWithChildren, forwardRef } from 'react'; import clsx from 'clsx'; +export enum CardStyle { + NORMAL, + HIGHLIGHTED, + FLAT, +} + /** * Renders a padded card with white background and drop shadow. "isFlat" uses * a yellow background and no shadow instead. Children are placed in a @@ -9,29 +15,35 @@ import clsx from 'clsx'; */ export const Card = forwardRef< HTMLDivElement, - PropsWithChildren<{ id?: string; isFlat?: boolean }> ->(({ id, isFlat, children }, ref) => ( -
+ PropsWithChildren<{ id?: string; cardStyle?: CardStyle }> +>(({ id, cardStyle, children }, ref) => { + const style = cardStyle ?? CardStyle.NORMAL; + return (
- {children} +
+ {children} +
-
-)); + ); +}); diff --git a/src/i18n/strings/es.ts b/src/i18n/strings/es.ts index f5740b5..c154acb 100644 --- a/src/i18n/strings/es.ts +++ b/src/i18n/strings/es.ts @@ -162,4 +162,15 @@ export const templates = { sfc7214f623fe475d: `Selecciona la empresa a la que paga su factura de electricidad.`, sfe16afc784bb9d76: `Tejado solar`, sfe81d5d73a35a2cf: `Calcular`, + sb1ef6ac20f1ddfff: `Discount off an electric panel`, + sf8b5deb9ea9f5054: `Discount off an electric stove`, + sae2ffd247e500180: `Discount off electric wiring`, + s9de4186c3f39ea44: `Discount off a heat pump water heater`, + s2b1b3dc2f8da1ce4: `Discount off a heat pump`, + s635e3d7c474426ce: `Discount off a heat pump clothes dryer`, + s1cc63fbd986ae5d9: `Discount off weatherization`, + s1e73057deee4510e: `However, rebates will be implemented differently in each state, so we cannot guarantee final amounts, eligibility, or timeline.`, + sb3615709fedc9d4d: `Federal Home Electrification and Appliance Rebates (HEAR)`, + scc19aa488a295dfd: str`The federal guidelines allot a discount of up to \$${0}.`, + sca61f9664c0f6099: `https://homes.rewiringamerica.org/federal-incentives/home-electrification-appliance-rebates`, }; diff --git a/src/ira-rebates.ts b/src/ira-rebates.ts new file mode 100644 index 0000000..26fec26 --- /dev/null +++ b/src/ira-rebates.ts @@ -0,0 +1,74 @@ +import { str } from './i18n/str'; +import { MsgFn } from './i18n/use-translated'; +import { Project } from './projects'; + +export type IRARebate = { + project: Project; + headline: string; + program: string; + description: string; + url: string; +}; + +const hearRebates: { + project: Project; + getHeadline: (msg: MsgFn) => string; + maxAmount: number; +}[] = [ + { + project: 'wiring', + getHeadline: msg => msg('Discount off an electric panel'), + maxAmount: 4000, + }, + { + project: 'cooking', + getHeadline: msg => msg('Discount off an electric stove'), + maxAmount: 840, + }, + { + project: 'wiring', + getHeadline: msg => msg('Discount off electric wiring'), + maxAmount: 2500, + }, + { + project: 'heat_pump_water_heater', + getHeadline: msg => msg('Discount off a heat pump water heater'), + maxAmount: 1750, + }, + { + project: 'hvac', + getHeadline: msg => msg('Discount off a heat pump'), + maxAmount: 8000, + }, + { + project: 'heat_pump_clothes_dryer', + getHeadline: msg => msg('Discount off a heat pump clothes dryer'), + maxAmount: 840, + }, + { + project: 'weatherization_and_efficiency', + getHeadline: msg => msg('Discount off weatherization'), + maxAmount: 1600, + }, +]; + +/* @ts-expect-error(6133) we will condition logic on state in future. */ +export function getRebatesFor(state: string, msg: MsgFn): IRARebate[] { + const disclaimerText = msg( + 'However, rebates will be implemented differently in each state, so we cannot guarantee final amounts, eligibility, or timeline.', + ); + return hearRebates.map(rebate => ({ + project: rebate.project, + headline: rebate.getHeadline(msg), + program: msg('Federal Home Electrification and Appliance Rebates (HEAR)'), + description: + msg( + str`The federal guidelines allot a discount of up to $${rebate.maxAmount.toLocaleString()}.`, + ) + + ' ' + + disclaimerText, + url: msg( + 'https://homes.rewiringamerica.org/federal-incentives/home-electrification-appliance-rebates', + ), + })); +} diff --git a/src/state-incentive-details.tsx b/src/state-incentive-details.tsx index ad85c05..c5538aa 100644 --- a/src/state-incentive-details.tsx +++ b/src/state-incentive-details.tsx @@ -9,13 +9,14 @@ import { } from './api/calculator-types-v1'; import { getYear, isInFuture } from './api/dates'; import { PrimaryButton, TextButton } from './buttons'; -import { Card } from './card'; +import { Card, CardStyle } from './card'; import { TextInput } from './components/text-input'; import { wasEmailSubmitted } from './email-signup'; import { str } from './i18n/str'; import { MsgFn, useTranslated } from './i18n/use-translated'; import { IconTabBar } from './icon-tab-bar'; import { ExclamationPoint, UpRightArrow } from './icons'; +import { IRARebate, getRebatesFor } from './ira-rebates'; import { PartnerLogos } from './partner-logos'; import { PROJECTS, Project, shortLabel } from './projects'; import { Separator } from './separator'; @@ -151,6 +152,11 @@ const getStartYearIfInFuture = (incentive: Incentive) => ? getYear(incentive.start_date) : null; +const isIRARebate = (incentive: Incentive) => + incentive.authority_type === 'federal' && + (incentive.payment_methods.includes('pos_rebate') || + incentive.payment_methods.includes('performance_rebate')); + const Chip: FC> = ({ isWarning, children, @@ -170,7 +176,7 @@ const Chip: FC> = ({ 'uppercase', isWarning && 'bg-yellow-200 text-[#806c23] py-[0.1875rem] pl-[0.1875rem] pr-2.5', - !isWarning && 'bg-purple-100 text-gray-700 px-2.5 py-1', + !isWarning && 'bg-purple-100 text-grey-700 px-2.5 py-1', )} > {isWarning ? : null} @@ -207,56 +213,47 @@ const LinkButton: FC> = ({ ); -const IncentiveCard: FC<{ incentive: Incentive }> = ({ incentive }) => { - const { msg } = useTranslated(); - const [buttonUrl, buttonContent] = incentive.more_info_url - ? [incentive.more_info_url, msg('Learn more')] - : [ - incentive.program_url, - <> - {msg('Visit site')} - - , - ]; - const futureStartYear = getStartYearIfInFuture(incentive); - - // The API cannot precisely tell, from zip code alone, whether the user is in - // a specific city or county; it takes a permissive approach and returns - // incentives for localities the user *might* be in. So this indicates that - // the user should check for themselves. - // - // This is a blunt-instrument approach; in many cases there's actually no - // ambiguity as to which city or county a zip code is in, but the API - // currently doesn't take that into account. - const locationEligibilityText = ['city', 'county', 'other'].includes( - incentive.authority_type, - ) - ? msg('Eligibility depends on residence location.') - : null; - return ( - -
- {formatIncentiveType(incentive, msg)} -
- {formatTitle(incentive, msg)} -
-
- {incentive.program} -
- -
- {incentive.short_description} {locationEligibilityText} -
- {futureStartYear && ( - - {msg(str`Expected in ${futureStartYear}`)} - +const IncentiveCard: FC<{ + cardStyle: CardStyle; + typeChip: string; + headline: string; + subHeadline: string; + body: string; + warningChip: string | null; + buttonUrl: string; + buttonContent: string | React.ReactElement; +}> = ({ + cardStyle, + typeChip, + headline, + subHeadline, + body, + warningChip, + buttonUrl, + buttonContent, +}) => ( + +
+ {typeChip} +
{headline}
+
+ {subHeadline} +
+ +
{buttonContent} + > + {body}
- - ); -}; + {warningChip && {warningChip}} + {buttonContent} +
+
+); function scrollToForm(event: React.MouseEvent) { const calculator = ( @@ -329,7 +326,7 @@ const renderNoResults = (emailSubmitter: ((email: string) => void) | null) => { ); return ( - +

{msg('No incentives available for this project')}

@@ -346,23 +343,86 @@ const renderNoResults = (emailSubmitter: ((email: string) => void) | null) => { ); }; -const renderCardCollection = (incentives: Incentive[]) => ( -
- {incentives - // Sort incentives that haven't started yet at the end - .sort( - (a, b) => - (getStartYearIfInFuture(a) ?? 0) - (getStartYearIfInFuture(b) ?? 0), - ) - .map((incentive, index) => ( - - ))} -
-); +const renderCardCollection = ( + incentives: Incentive[], + iraRebates: IRARebate[], +) => { + const { msg } = useTranslated(); + return ( +
+ {incentives + // Sort incentives that haven't started yet at the end + .sort( + (a, b) => + (getStartYearIfInFuture(a) ?? 0) - (getStartYearIfInFuture(b) ?? 0), + ) + .map((incentive, index) => { + const futureStartYear = getStartYearIfInFuture(incentive); + + // The API cannot precisely tell, from zip code alone, whether the + // user is in a specific city or county; it takes a permissive + // approach and returns incentives for localities the user *might* be + // in. So this indicates that the user should check for themselves. + // + // This is a blunt-instrument approach; in many cases there's actually + // no ambiguity as to which city or county a zip code is in, but the + // API currently doesn't take that into account. + const locationEligibilityText = ['city', 'county', 'other'].includes( + incentive.authority_type, + ) + ? msg('Eligibility depends on residence location.') + : ''; + + const [buttonUrl, buttonContent] = incentive.more_info_url + ? [incentive.more_info_url, msg('Learn more')] + : [ + incentive.program_url, + <> + {msg('Visit site')} + + , + ]; + return ( + + ); + }) + .concat( + iraRebates.map((rebate, index) => ( + + )), + )} +
+ ); +}; type IncentiveGridProps = { heading: string; incentives: Incentive[]; + iraRebates: IRARebate[]; tabs: Project[]; selectedTab: Project; onTabSelected: (newSelection: Project) => void; @@ -371,7 +431,15 @@ type IncentiveGridProps = { const IncentiveGrid = forwardRef( ( - { heading, incentives, tabs, selectedTab, onTabSelected, emailSubmitter }, + { + heading, + incentives, + iraRebates, + tabs, + selectedTab, + onTabSelected, + emailSubmitter, + }, ref, ) => { return tabs.length > 0 ? ( @@ -384,8 +452,8 @@ const IncentiveGrid = forwardRef( selectedTab={selectedTab} onTabSelected={onTabSelected} /> - {incentives.length > 0 - ? renderCardCollection(incentives) + {incentives.length > 0 || iraRebates.length > 0 + ? renderCardCollection(incentives, iraRebates) : renderNoResults(emailSubmitter)}
) : null; @@ -418,7 +486,11 @@ export const StateIncentives: FC = ({ emailSubmitter, }) => { const { msg } = useTranslated(); - const allEligible = response.incentives.filter(i => i.eligible); + + // We're filtering out IRA rebates in favor of state-specific handling. + const allEligible = response.incentives + .filter(i => i.eligible) + .filter(i => !isIRARebate(i)); const incentivesByProject = Object.fromEntries( Object.entries(PROJECTS).map(([project, info]) => [ @@ -448,12 +520,21 @@ export const StateIncentives: FC = ({ const selectedIncentives = incentivesByProject[projectTab] ?? []; const selectedOtherIncentives = incentivesByProject[otherTab] ?? []; + const iraRebates = response.is_under_150_ami + ? getRebatesFor(response.location.state, msg) + : []; + const selectedIraRebates = iraRebates.filter(r => r.project === projectTab); + const selectedOtherIraRebates = iraRebates.filter( + r => r.project === otherTab, + ); + return ( <> = ({ ref={secondResultsRef} heading={msg('Other incentives available to you')} incentives={selectedOtherIncentives} + iraRebates={selectedOtherIraRebates} tabs={otherProjects} selectedTab={otherTab} onTabSelected={setOtherTab} diff --git a/translations/es.xlf b/translations/es.xlf index ec0d402..9139583 100644 --- a/translations/es.xlf +++ b/translations/es.xlf @@ -584,6 +584,39 @@ Eligibility depends on residence location. La elegibilidad depende de la ubicación de residencia. + + Discount off an electric panel + + + Discount off an electric stove + + + Discount off electric wiring + + + Discount off a heat pump water heater + + + Discount off a heat pump + + + Discount off a heat pump clothes dryer + + + Discount off weatherization + + + However, rebates will be implemented differently in each state, so we cannot guarantee final amounts, eligibility, or timeline. + + + Federal Home Electrification and Appliance Rebates (HEAR) + + + The federal guidelines allot a discount of up to $. + + + https://homes.rewiringamerica.org/federal-incentives/home-electrification-appliance-rebates +