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 +