diff --git a/package.json b/package.json index 1849ba9..fa4c954 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "source": [ "src/calculator.ts", "src/rewiring-fonts.css", - "src/index.html" + "src/index.html", + "src/rhode-island.html" ], "outputFormat": "global", "context": "browser", diff --git a/src/api/calculator-types-v1.ts b/src/api/calculator-types-v1.ts new file mode 100644 index 0000000..866e4d1 --- /dev/null +++ b/src/api/calculator-types-v1.ts @@ -0,0 +1,63 @@ +export type IncentiveType = + | 'tax_credit' + | 'pos_rebate' + | 'rebate' + | 'account_credit' + | 'performance_rebate'; +export type AuthorityType = 'federal' | 'state' | 'utility'; + +export type AmountType = 'dollar_amount' | 'percent' | 'dollars_per_unit'; +export interface Amount { + type: AmountType; + number: number; + maximum?: number; + representative?: number; + unit?: string; +} + +export type ItemType = + | 'battery_storage_installation' + | 'efficiency_rebates' + | 'electric_panel' + | 'electric_stove' + | 'electric_vehicle_charger' + | 'electric_wiring' + | 'geothermal_heating_installation' + | 'heat_pump_air_conditioner_heater' + | 'heat_pump_clothes_dryer' + | 'heat_pump_water_heater' + | 'new_electric_vehicle' + | 'rooftop_solar_installation' + | 'used_electric_vehicle' + | 'weatherization'; + +export interface Item { + type: ItemType; + name: string; + url: string; +} + +export interface Incentive { + type: IncentiveType; + authority_type: AuthorityType; + authority_name: string | null; + program: string; + program_url?: string; + item: Item; + amount: Amount; + start_date: number; + end_date: number; + short_description?: string; + + eligible: boolean; +} + +export interface APIResponse { + savings: { + tax_credit: number; + pos_rebate: number; + rebate: number; + account_credit: number; + }; + incentives: Incentive[]; +} diff --git a/src/calculator-form.ts b/src/calculator-form.ts index 3f2f1a0..8f542ce 100644 --- a/src/calculator-form.ts +++ b/src/calculator-form.ts @@ -1,13 +1,14 @@ -import { html, css } from 'lit'; +import { html, css, nothing } from 'lit'; import { downIcon, questionIcon } from './icons'; import { select, selectStyles, OptionParam } from './select'; import { inputStyles } from './styles/input'; import './currency-input'; import '@shoelace-style/shoelace/dist/themes/light.css'; import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js'; +import { PROJECTS } from './projects'; const buttonStyles = css` - button { + button.calculate { appearance: none; font-family: inherit; font-size: 16px; @@ -24,7 +25,7 @@ const buttonStyles = css` width: 100%; } - button:hover { + button.calculate:hover { background-color: var(--ra-embed-primary-button-background-hover-color); } @@ -74,110 +75,147 @@ const HOUSEHOLD_SIZE_OPTIONS: OptionParam[] = [1, 2, 3, 4, 5, 6, 7, 8].map( ); export const formTemplate = ( - [zip, ownerStatus, householdIncome, taxFiling, householdSize]: Array, + [ + project, + zip, + ownerStatus, + householdIncome, + taxFiling, + householdSize, + ]: Array, + showProjectField: boolean, onSubmit: (e: SubmitEvent) => void, -) => html` -
-
-
-
- -`; + + `; +}; diff --git a/src/calculator.ts b/src/calculator.ts index edaf7e5..02fd9e5 100644 --- a/src/calculator.ts +++ b/src/calculator.ts @@ -150,12 +150,14 @@ export class RewiringAmericaCalculator extends LitElement { ? nothing : formTemplate( [ + '', this.zip, this.ownerStatus, this.householdIncome, this.taxFiling, this.householdSize, ], + false, (event: SubmitEvent) => this.submit(event), )}
diff --git a/src/icon-tab-bar.ts b/src/icon-tab-bar.ts new file mode 100644 index 0000000..de04605 --- /dev/null +++ b/src/icon-tab-bar.ts @@ -0,0 +1,124 @@ +import { css, html } from 'lit'; +import { PROJECTS, Project, shortLabel } from './projects'; +import { select } from './select'; + +export const iconTabBarStyles = css` + .icon-tab-bar { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; + + width: 83.3%; + margin-left: auto; + margin-right: auto; + margin-bottom: 1.5rem; + } + + button.icon-tab { + /* Override default button styles */ + background-color: transparent; + font-family: inherit; + cursor: pointer; + + min-width: 6rem; + + height: 3rem; + border: 1px solid #9b9b9b; + border-radius: 1.5rem; + padding: 0.75rem 1rem; + + /* Manage the gap between the icon and the caption */ + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + justify-content: center; + + & .caption { + color: var(--color-purple-500, #4a00c3); + font-size: 1rem; + font-weight: 500; + line-height: 125%; + white-space: nowrap; + } + } + + button.icon-tab--selected { + cursor: default; + + background: var(--color-purple-500, #4a00c3); + border-color: var(--color-purple-500, #4a00c3); + + & .caption { + color: white; + } + } + + .icon-dropdown { + margin-bottom: 1.5rem; + } + + /* + * Only show the tabs/pills on medium and large layout. + * Only show dropdown on small layout. + */ + @media only screen and (max-width: 640px) { + .icon-tab-bar { + display: none; + } + } + + @media only screen and (min-width: 641px) { + .icon-dropdown { + display: none; + } + } +`; + +/** + * On medium and large layouts, this is a horizontally flowed row of pill-shaped + * buttons, with icon and text, representing projects. Clicking one selects that + * single project. The pills can flow onto multiple lines. + * + * On small layouts, this is a single-select dropdown. + */ +export const iconTabBarTemplate = ( + tabs: Project[], + selectedTab: Project, + onTabSelected: (newSelection: Project) => void, +) => { + const iconTabs = tabs.map(project => { + const isSelected = project === selectedTab; + const selectedClass = isSelected ? 'icon-tab--selected' : ''; + return html` + + `; + }); + + const options = tabs.map(project => ({ + value: project, + label: PROJECTS[project].label, + })); + + return html` +
${iconTabs}
+
+ ${select({ + id: 'project-selector', + required: true, + currentValue: selectedTab, + options, + onChange: event => + onTabSelected((event.target as HTMLInputElement).value as Project), + ariaLabel: 'Project', + })} +
+ `; +}; diff --git a/src/icons.ts b/src/icons.ts index 85fbd31..ef23a1f 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,4 +1,4 @@ -import { html } from 'lit'; +import { html, svg } from 'lit'; // FIXME: does this need to be nested like this? export const downIcon = (w: number, h: number) => html` html` `; + +export const exclamationPoint = (w: number = 16, h: number = 16) => html` + + + +`; + +export const upRightArrow = ( + w: number = 20, + h: number = 20, +) => svg` + + +`; diff --git a/src/projects.ts b/src/projects.ts new file mode 100644 index 0000000..bbc3fea --- /dev/null +++ b/src/projects.ts @@ -0,0 +1,205 @@ +import { TemplateResult, svg } from 'lit'; +import { ItemType } from './api/calculator-types-v1'; + +const RA_PURPLE = '#4a00c3'; +const color = (selected: boolean) => (selected ? 'white' : RA_PURPLE); + +const CLOTHES_DRYER_ICON = ( + selected: boolean, + w: number = 20, + h: number = 20, +) => + svg` + + + `; + +const COOKING_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + + + + `; + +const EV_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + + `; + +const ELECTRICAL_WIRING_ICON = ( + selected: boolean, + w: number = 20, + h: number = 20, +) => svg` + + + +`; + +const HVAC_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + `; + +const BATTERY_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + + + `; + +const SOLAR_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + + + + `; + +const WATER_HEATER_ICON = (selected: boolean, w: number = 20, h: number = 20) => + svg` + + + + + + `; + +type ProjectInfo = { + label: string; + shortLabel?: string; + icon: (selected: boolean, w?: number, h?: number) => TemplateResult<2>; + items: ItemType[]; +}; + +export type Project = + | 'heat_pump_clothes_dryer' + | 'hvac' + | 'ev' + | 'solar' + | 'battery' + | 'heat_pump_water_heater' + | 'cooking' + | 'wiring'; + +export const shortLabel = (p: Project) => + PROJECTS[p].shortLabel ?? PROJECTS[p].label; + +/** + * Icons, labels, and API `item` values for the various projects for which we + * show incentives. + */ +export const PROJECTS: Record = { + heat_pump_clothes_dryer: { + items: ['heat_pump_clothes_dryer'], + label: 'Clothes dryer', + icon: CLOTHES_DRYER_ICON, + }, + hvac: { + items: [ + 'heat_pump_air_conditioner_heater', + 'geothermal_heating_installation', + ], + label: 'Heating, ventilation & cooling', + shortLabel: 'HVAC', + icon: HVAC_ICON, + }, + ev: { + items: [ + 'new_electric_vehicle', + 'used_electric_vehicle', + 'electric_vehicle_charger', + ], + label: 'Electric vehicle', + shortLabel: 'EV', + icon: EV_ICON, + }, + solar: { + items: ['rooftop_solar_installation'], + label: 'Solar', + icon: SOLAR_ICON, + }, + battery: { + items: ['battery_storage_installation'], + label: 'Battery storage', + icon: BATTERY_ICON, + }, + heat_pump_water_heater: { + items: ['heat_pump_water_heater'], + label: 'Water heater', + icon: WATER_HEATER_ICON, + }, + cooking: { + items: ['electric_stove'], + label: 'Cooking stove/range', + shortLabel: 'Cooking', + icon: COOKING_ICON, + }, + wiring: { + items: ['electric_panel', 'electric_wiring'], + label: 'Electrical wiring', + shortLabel: 'Electrical', + icon: ELECTRICAL_WIRING_ICON, + }, +}; diff --git a/src/rhode-island.html b/src/rhode-island.html new file mode 100644 index 0000000..cecdd4d --- /dev/null +++ b/src/rhode-island.html @@ -0,0 +1,75 @@ + + + + + + + + Rewiring America Incentives Calculator - Rhode Island + + + + + + +
+

Rewiring America Incentives Calculator

+ +
+ + diff --git a/src/select.ts b/src/select.ts index bc51c6a..41d55ac 100644 --- a/src/select.ts +++ b/src/select.ts @@ -12,6 +12,9 @@ export interface SelectParam { options: OptionParam[]; currentValue: string; tabIndex?: number; + onChange?: (event: InputEvent) => void; + ariaLabel?: string; + disabled?: boolean; } export const option = ({ label, value }: OptionParam, selected: boolean) => @@ -23,6 +26,9 @@ export const select = ({ options, currentValue, tabIndex, + onChange, + ariaLabel, + disabled, }: SelectParam) => { return html`
@@ -30,7 +36,10 @@ export const select = ({ id="${id}" name="${id}" ?required=${required} + ?disabled=${disabled} tabindex="${ifDefined(tabIndex)}" + aria-label="${ifDefined(ariaLabel)}" + @change=${onChange} > ${options.map(o => option(o, o.value === currentValue))} @@ -94,6 +103,10 @@ export const selectStyles = css` margin-top: 4px; } + .select select[disabled] { + cursor: default; + } + .select select, .select::after { grid-area: select; diff --git a/src/state-calculator.ts b/src/state-calculator.ts new file mode 100644 index 0000000..178a1d4 --- /dev/null +++ b/src/state-calculator.ts @@ -0,0 +1,263 @@ +import { LitElement, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { Task, TaskStatus, initialState } from '@lit-labs/task'; +import { baseStyles } from './styles'; +import { formTemplate, formStyles } from './calculator-form'; +import { FilingStatus, OwnerStatus } from './calculator-types'; +import { CALCULATOR_FOOTER } from './calculator-footer'; +import { fetchApi } from './api/fetch'; +import { + stateIncentivesTemplate, + stateIncentivesStyles, + cardStyles, + separatorStyles, +} from './state-incentive-details'; +import { Project } from './projects'; +import { + utilitySelectorStyles, + utilitySelectorTemplate, +} from './utility-selector'; +import { iconTabBarStyles } from './icon-tab-bar'; + +import '@shoelace-style/shoelace/dist/components/spinner/spinner'; +import { STATES } from './states'; + +const loadingTemplate = () => html` +
+
+ +
+
+`; + +const errorTemplate = (error: unknown) => html` +
+ ${typeof error === 'object' && error && 'message' in error && error.message + ? error.message + : 'Error loading incentives.'} +
+`; + +const DEFAULT_CALCULATOR_API_HOST: string = 'https://api.rewiringamerica.org'; + +@customElement('rewiring-america-state-calculator') +export class RewiringAmericaStateCalculator extends LitElement { + static override styles = [ + baseStyles, + cardStyles, + ...formStyles, + stateIncentivesStyles, + utilitySelectorStyles, + separatorStyles, + iconTabBarStyles, + ]; + + /* supported properties to control showing/hiding of each card in the widget */ + + @property({ type: Boolean, attribute: 'hide-form' }) + hideForm: boolean = false; + + @property({ type: Boolean, attribute: 'hide-details' }) + hideDetails: boolean = false; + + /* supported properties to control which API path and key is used to load the calculator results */ + + @property({ type: String, attribute: 'api-key' }) + apiKey: string = ''; + + @property({ type: String, attribute: 'api-host' }) + apiHost: string = DEFAULT_CALCULATOR_API_HOST; + + /** + * Property to customize the calculator for a particular state. Must be the + * two-letter code, uppercase (example: "NY"). + * + * Currently the only customization is to display the name of the state. + * TODO: Have a nice error message if you enter a zip/address outside this + * state, if it's defined. + */ + @property({ type: String, attribute: 'state' }) + state: string = ''; + + /* supported properties to allow pre-filling the form */ + + @property({ type: String, attribute: 'zip' }) + zip: string = ''; + + @property({ type: String, attribute: 'owner-status' }) + ownerStatus: OwnerStatus = 'homeowner'; + + @property({ type: String, attribute: 'household-income' }) + householdIncome: string = '0'; + + @property({ type: String, attribute: 'tax-filing' }) + taxFiling: FilingStatus = 'single'; + + @property({ type: String, attribute: 'household-size' }) + householdSize: string = '1'; + + @property({ type: String }) + utility: string = ''; + + @property({ type: String }) + selectedProject: Project = 'hvac'; + + @property({ type: String }) + selectedOtherTab: Project = 'heat_pump_clothes_dryer'; + + submit(e: SubmitEvent) { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + const prevZip = this.zip; + this.zip = (formData.get('zip') as string) || ''; + this.ownerStatus = (formData.get('owner_status') as OwnerStatus) || ''; + this.householdIncome = (formData.get('household_income') as string) || ''; + this.taxFiling = (formData.get('tax_filing') as FilingStatus) || ''; + this.householdSize = (formData.get('household_size') as string) || ''; + this.selectedProject = (formData.get('project') as Project) || ''; + + // Zip is the only thing that determines what utilities are available, so + // only fetch utilities if zip has changed since last calculation. + const zipChanged = this.zip !== prevZip; + if (zipChanged) { + // This will run _task when it's done. + this._utilitiesTask.run(); + } else { + this._task.run(); + } + } + + isFormComplete() { + return !!( + this.zip && + this.ownerStatus && + this.taxFiling && + this.householdIncome && + this.householdSize && + this.selectedProject + ); + } + + private _utilitiesTask = new Task(this, { + autoRun: false, + task: async () => { + const query = new URLSearchParams({ + 'location[zip]': this.zip, + }); + const utilityMap = await fetchApi( + this.apiKey, + this.apiHost, + '/api/v1/utilities', + query, + ); + + return Object.keys(utilityMap).map(id => ({ + value: id, + label: utilityMap[id].name, + })); + }, + onComplete: options => { + // Preserve the previous utility selection if it's still available. + if (!options.map(o => o.value).includes(this.utility)) { + this.utility = options[0].value; + } + this._task.run(); + }, + }); + + private _task = new Task(this, { + autoRun: false, + task: async () => { + if (!this.isFormComplete()) { + // this is a special response type provided by Task to keep it in the INITIAL state + return initialState; + } + + const query = new URLSearchParams({ + 'location[zip]': this.zip, + owner_status: this.ownerStatus, + household_income: this.householdIncome, + tax_filing: this.taxFiling, + household_size: this.householdSize, + }); + query.append('authority_types', 'federal'); + query.append('authority_types', 'state'); + query.append('authority_types', 'utility'); + query.set('utility', this.utility); + + return await fetchApi( + this.apiKey, + this.apiHost, + '/api/v1/calculator', + query, + ); + }, + }); + + override render() { + return html` +
+
+

Your household info

+ ${this.hideForm + ? nothing + : formTemplate( + [ + this.selectedProject, + this.zip, + this.ownerStatus, + this.householdIncome, + this.taxFiling, + this.householdSize, + ], + true, + (event: SubmitEvent) => this.submit(event), + 'grid-3-2-1', + )} +
+ ${this._utilitiesTask.render({ + pending: loadingTemplate, + complete: options => + utilitySelectorTemplate( + STATES[this.state], + this.utility, + options, + newUtility => { + this.utility = newUtility; + this._task.run(); + }, + ), + error: errorTemplate, + })} + ${this._task.status !== TaskStatus.INITIAL && + this._utilitiesTask.status === TaskStatus.COMPLETE + ? html`
` + : nothing} + ${this._task.render({ + pending: loadingTemplate, + complete: results => + this._utilitiesTask.status !== TaskStatus.COMPLETE + ? nothing + : stateIncentivesTemplate( + results, + this.selectedProject, + this.selectedOtherTab, + newSelection => (this.selectedOtherTab = newSelection), + ), + error: errorTemplate, + })} + ${CALCULATOR_FOOTER} +
+ `; + } +} + +/** + * Tell TypeScript that the HTML tag's type signature corresponds to the + * class's type signature. + */ +declare global { + interface HTMLElementTagNameMap { + 'rewiring-america-state-calculator': RewiringAmericaStateCalculator; + } +} diff --git a/src/state-incentive-details.ts b/src/state-incentive-details.ts new file mode 100644 index 0000000..31d11ef --- /dev/null +++ b/src/state-incentive-details.ts @@ -0,0 +1,436 @@ +import { css, html, nothing } from 'lit'; +import { APIResponse, Incentive, ItemType } from './api/calculator-types-v1'; +import { exclamationPoint, questionIcon, upRightArrow } from './icons'; +import { PROJECTS, Project, shortLabel } from './projects'; +import { iconTabBarTemplate } from './icon-tab-bar'; + +export const stateIncentivesStyles = css` + .loading { + text-align: center; + font-size: 2rem; + } + + .incentive { + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + } + + .incentive__chip { + display: flex; + gap: 0.625rem; + justify-content: center; + align-items: center; + + background-color: #f0edf8; + border-radius: 0.25rem; + font-weight: 700; + font-size: 0.6875rem; + letter-spacing: 0.03438rem; + line-height: 125%; + padding: 0.25rem 0.625rem; + color: #111; + text-transform: uppercase; + width: fit-content; + } + + .incentive__chip--warning { + background-color: #fef2ca; + padding: 0.1875rem 0.625rem 0.1875rem 0.1875rem; + color: #846f24; + } + + .incentive__subtitle { + color: #111; + font-weight: 500; + line-height: 125%; + } + + .incentive__blurb { + color: #757575; + line-height: 150%; + } + + .incentive__title { + color: #111; + font-size: 1.5rem; + line-height: 150%; + } + + .incentive__link-button { + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + align-self: stretch; + + height: 2.25rem; + padding: 0.375rem 0.875rem; + + border-radius: 0.25rem; + border: 1px solid #9b9b9b; + + color: var(--rewiring-purple); + font-size: 1rem; + font-weight: 500; + line-height: 125%; + text-decoration: none; + } + + .nowrap { + white-space: nowrap; + } + + .grid-3-2-1, + .grid-4-2-1 { + display: grid; + gap: 1rem; + align-items: end; + } + .grid-3-2-1--align-start, + .grid-4-2-1--align-start { + align-items: start; + } + + @media only screen and (max-width: 640px) { + .grid-3-2-1, + .grid-4-2-1 { + grid-template-columns: 1fr; + } + } + + @media only screen and (min-width: 641px) and (max-width: 768px) { + .grid-3-2-1, + .grid-4-2-1 { + grid-template-columns: 1fr 1fr; + } + } + + @media only screen and (min-width: 769px) { + .grid-3-2-1 { + grid-template-columns: 1fr 1fr 1fr; + } + .grid-4-2-1 { + grid-template-columns: 1fr 1fr 1fr 1fr; + } + } + + .grid-section { + & .card { + margin: 0; + } + } + + @media only screen and (max-width: 640px) { + .grid-section { + margin: 0 1rem; + min-width: 200px; + } + } + + .grid-section__header { + margin-bottom: 1.5rem; + + color: #111; + text-align: center; + + font-size: 2rem; + font-weight: 500; + line-height: 125%; + } + + .summary { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + flex: 1 0 0; + padding: 0.75rem; + } + + .summary__title { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .summary__caption { + color: #111; + + font-size: 0.6875rem; + font-weight: 700; + line-height: 125%; + letter-spacing: 0.03438rem; + text-transform: uppercase; + } + + .summary__body { + color: #111; + + font-size: 1.5rem; + font-weight: 400; + line-height: 165%; + } +`; + +export const cardStyles = css` + .card { + margin: 0; + + border: var(--ra-embed-card-border); + border-radius: 0.5rem; + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.08); + background-color: var(--ra-embed-card-background); + overflow: clip; + } + + /* Extra small devices */ + @media only screen and (max-width: 640px) { + .card { + margin: 0 1rem; + min-width: 200px; + } + } + + .card-content { + padding: 1rem; + display: grid; + grid-template-rows: min-content; + gap: 1rem; + } +`; + +export const separatorStyles = css` + .separator { + background: #e2e2e2; + width: 100%; + height: 1px; + } + + @media only screen and (max-width: 640px) { + .separator { + display: none; + } + } +`; + +const titleTemplate = (incentive: Incentive) => { + const item = itemName(incentive.item.type); + const amount = incentive.amount; + if (amount.type === 'dollar_amount') { + return amount.maximum + ? `Up to $${amount.maximum.toLocaleString()} off ${item}` + : `$${amount.number.toLocaleString()} off ${item}`; + } else if (amount.type === 'percent') { + const percentStr = `${Math.round(amount.number * 100)}%`; + return amount.maximum + ? `${percentStr} of cost of ${item}, up to $${amount.maximum.toLocaleString()}` + : `${percentStr} of cost of ${item}`; + } else if (amount.type === 'dollars_per_unit') { + const perUnitStr = `$${amount.number.toLocaleString()}/${amount.unit}`; + return amount.maximum + ? `${perUnitStr} off ${item}, up to $${amount.maximum.toLocaleString()}` + : `${perUnitStr} off ${item}`; + } else { + return nothing; + } +}; + +/** + * TODO this is an internationalization sin. Figure out something better! + */ +const itemName = (itemType: ItemType) => + itemType === 'battery_storage_installation' + ? 'battery storage' + : itemType === 'electric_panel' + ? 'an electric panel' + : itemType === 'electric_stove' + ? 'an electric/induction stove' + : itemType === 'electric_vehicle_charger' + ? 'an EV charger' + : itemType === 'electric_wiring' + ? 'electric wiring' + : itemType === 'geothermal_heating_installation' + ? 'geothermal heating installation' + : itemType === 'heat_pump_air_conditioner_heater' + ? 'a heat pump' + : itemType === 'heat_pump_clothes_dryer' + ? 'a heat pump clothes dryer' + : itemType === 'heat_pump_water_heater' + ? 'a heat pump water heater' + : itemType === 'new_electric_vehicle' + ? 'a new electric vehicle' + : itemType === 'rooftop_solar_installation' + ? 'rooftop solar' + : itemType === 'used_electric_vehicle' + ? 'a used electric vehicle' + : itemType === 'weatherization' + ? 'weatherization' + : null; + +const formatIncentiveType = (incentive: Incentive) => + incentive.type === 'tax_credit' + ? 'Tax credit' + : incentive.type === 'pos_rebate' + ? 'Upfront discount' + : incentive.type === 'rebate' + ? 'Rebate' + : incentive.type === 'account_credit' + ? 'Account credit' + : incentive.type === 'performance_rebate' + ? 'Performance rebate' + : 'Incentive'; + +/** TODO get real dates in the data! */ +const startDateTemplate = (incentive: Incentive) => + incentive.type === 'pos_rebate' + ? html`
+ ${exclamationPoint()} Available early 2024 +
` + : nothing; + +const incentiveCardTemplate = (incentive: Incentive) => html` +
+
+
+
${formatIncentiveType(incentive)}
+
${titleTemplate(incentive)}
+
${incentive.program}
+
+
${incentive.short_description}
+ ${startDateTemplate(incentive)} + + ${incentive.program_url ? 'Visit site' : 'Learn more'} + ${incentive.program_url ? upRightArrow() : nothing} + +
+
+
+`; + +const summaryBoxTemplate = ( + caption: string, + body: string, + tooltip: string, +) => html` +
+
+
+ ${caption} + + ${questionIcon(18, 18)} + +
+
${body}
+
+
+`; + +const atAGlanceTemplate = (response: APIResponse) => { + return html` +
+

Incentives at a glance

+
+ ${summaryBoxTemplate( + 'Upfront discounts', + `$${response.savings.pos_rebate.toLocaleString()}`, + "Money saved on a project's upfront costs.", + )} + ${summaryBoxTemplate( + 'Rebates', + `$${response.savings.rebate.toLocaleString()}`, + 'Money paid back to you after a project is completed.', + )} + ${summaryBoxTemplate( + 'Account credits', + `$${response.savings.account_credit.toLocaleString()}`, + 'Money credited to your utility account, going towards paying your next bills.', + )} + ${summaryBoxTemplate( + 'Tax credits', + `$${response.savings.tax_credit.toLocaleString()}`, + 'Your taxes may be reduced by up to this amount.', + )} +
+
+ `; +}; + +const gridTemplate = ( + heading: string, + incentives: Incentive[], + tabs: Project[], + selectedTab: Project, + onTabSelected: (newSelection: Project) => void, +) => + incentives.length > 0 + ? html` +
+

${heading}

+ ${iconTabBarTemplate(tabs, selectedTab, onTabSelected)} +
+ ${incentives.map(incentiveCardTemplate)} +
+
+ ` + : nothing; +/** + * Renders the "at a glance" summary section, a grid of incentive cards about + * the project you selected in the main form, then a grid of tab-bar switchable + * incentive cards about other projects. + * + * @param selectedProject The project whose incentives should get hoisted into + * their own section above all the others. + * @param selectedOtherTab The project among the "others" section whose tab is + * currently selected. + */ +export const stateIncentivesTemplate = ( + response: APIResponse, + selectedProject: Project, + selectedOtherTab: Project, + onTabSelected: (newSelection: Project) => void, +) => { + const allEligible = response.incentives.filter(i => i.eligible); + + const incentivesByProject = Object.fromEntries( + Object.entries(PROJECTS).map(([project, info]) => [ + project, + allEligible.filter(i => info.items.includes(i.item.type)), + ]), + ) as Record; + + // Only offer "other" tabs if there are incentives for that project. + const otherTabs = ( + Object.entries(incentivesByProject) as [Project, Incentive[]][] + ) + .filter( + ([project, incentives]) => + project !== selectedProject && incentives.length > 0, + ) + .sort(([a], [b]) => shortLabel(a).localeCompare(shortLabel(b))) + .map(([project]) => project); + + return html` ${atAGlanceTemplate(response)} + ${gridTemplate( + "Incentives you're interested in", + incentivesByProject[selectedProject] ?? [], + [selectedProject], + selectedProject, + () => {}, + )} + ${gridTemplate( + 'Other incentives available to you', + incentivesByProject[selectedOtherTab] ?? [], + otherTabs, + // If a nonexistent tab is selected, pretend the first one is selected. + otherTabs.includes(selectedOtherTab) ? selectedOtherTab : otherTabs[0], + onTabSelected, + )}`; +}; diff --git a/src/states.ts b/src/states.ts new file mode 100644 index 0000000..061eb42 --- /dev/null +++ b/src/states.ts @@ -0,0 +1,22 @@ +import { TemplateResult, svg } from 'lit'; + +const RI_ICON = + () => svg` + + + `; + +export type StateInfo = { + name: string; + icon: () => TemplateResult<2>; +}; + +/** + * States that are supported for customization. + */ +export const STATES: Record = { + RI: { + name: 'Rhode Island', + icon: RI_ICON, + }, +}; diff --git a/src/utility-selector.ts b/src/utility-selector.ts new file mode 100644 index 0000000..9100ed9 --- /dev/null +++ b/src/utility-selector.ts @@ -0,0 +1,132 @@ +import { css, html } from 'lit'; +import { questionIcon } from './icons'; +import { OptionParam, select } from './select'; +import { StateInfo } from './states'; + +export const utilitySelectorStyles = css` + .utility-selector { + display: grid; + align-items: center; + + & .map { + position: relative; + text-align: center; + + & svg { + vertical-align: top; + } + } + + & h1 { + position: absolute; + top: 50%; + transform: translate(0, -50%); + + font-weight: 700; + line-height: 125%; + + text-align: center; + font-size: 1.75rem; + } + + & .spacer { + /* Only relevant on small layout */ + height: 1.5rem; + } + + & .selector { + height: min-content; + } + } + + /* Extra small devices: map above selector */ + @media only screen and (max-width: 640px) { + .utility-selector { + grid-template-columns: 1fr; + + margin-left: 1rem; + margin-right: 1rem; + min-width: 200px; + + & .card { + /* Margin is provided by the outer element */ + margin: 0; + } + } + } + + /* Medium and large: map and selector side by side */ + @media only screen and (min-width: 641px) { + .utility-selector { + grid-template-columns: 5fr 2fr 5fr; + } + } + + /* Large: bigger text, left-aligned */ + @media only screen and (min-width: 769px) { + .utility-selector { + & h1 { + text-align: left; + font-size: 2.25rem; + } + } + } +`; + +const utilityFormTemplate = ( + utilityId: string, + utilityOptions: OptionParam[], + onChange: (utilityId: string) => void, +) => { + return html` +
+
+ +
+
+ `; +}; + +/** + * The state map + utility selector section. They're displayed side-by-side on + * large and medium layouts, and map above selector on small. + * + * Because the utility selector is outside the main calculator form, changing + * the selection should immediately reload incentives (as opposed to waiting for + * a button press), so there's a callback for it here. + */ +export const utilitySelectorTemplate = ( + stateInfo: StateInfo, + utilityId: string, + utilityOptions: OptionParam[], + onChange: (utilityId: string) => void, +) => + html`
+
+ ${stateInfo.icon()} +

Incentives available to you in ${stateInfo.name}

+
+
+
+ ${utilityFormTemplate(utilityId, utilityOptions, onChange)} +
+
`;