From 4e519b57d3827160e103fd1dad6993a6113fd64f Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Mon, 28 Aug 2023 19:29:59 -0400 Subject: [PATCH] Project tabs --- src/calculator-form.ts | 8 +- src/projects.ts | 243 ++++++++++++++++++++++++++++++++- src/state-calculator.ts | 17 ++- src/state-incentive-details.ts | 139 +++++++++++++++++-- 4 files changed, 387 insertions(+), 20 deletions(-) diff --git a/src/calculator-form.ts b/src/calculator-form.ts index 265a321..a36fb5a 100644 --- a/src/calculator-form.ts +++ b/src/calculator-form.ts @@ -8,7 +8,7 @@ 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; @@ -25,7 +25,7 @@ const buttonStyles = css` width: 100%; } - button:hover { + button.calculate:hover { background-color: var(--ra-embed-primary-button-background-hover-color); } @@ -244,7 +244,9 @@ export const formTemplate = (
- +
diff --git a/src/projects.ts b/src/projects.ts index 9b569f6..2649362 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -1,15 +1,244 @@ +import { TemplateResult, svg } from 'lit'; import { ItemType } from './api/calculator-types-v1'; -type Project = { +const CLOTHES_DRYER_ICON = ( + selected: boolean, + w: number = 48, + h: number = 48, +) => + selected + ? svg` + + + + + + + + + +` + : svg` + + + + + + + + + +`; + +const COOKING_ICON = (selected: boolean, w: number = 48, h: number = 48) => + selected + ? svg` + + + + + + + + + + + + +` + : svg` + + + + + + + + + + + + +`; + +const EV_ICON = (selected: boolean, w: number = 48, h: number = 48) => + selected + ? svg` + + + + + + + + + + + + + + + + + + + +` + : svg` + + + + + + + + + + + + + + + +`; + +const ELECTRICAL_WIRING_ICON = ( + selected: boolean, + w: number = 48, + h: number = 48, +) => + selected + ? svg` + + + + + + + + + + + + + + + + + + +` + : svg` + + + + + + + + + + + + + + + + + + +`; + +const HVAC_ICON = (selected: boolean, w: number = 48, h: number = 48) => + selected + ? svg` + + + + + + + + + + + +` + : svg` + + + + + + + + + + + +`; + +const RENEWABLES_ICON = (selected: boolean, w: number = 48, h: number = 48) => + selected + ? svg` + + + + + + +` + : svg` + + + + + + +`; + +const WATER_HEATER_ICON = (selected: boolean, w: number = 48, h: number = 48) => + selected + ? svg` + + + + + + + +` + : svg` + + + + + + + +`; + +type ProjectInfo = { label: string; shortLabel?: string; + icon: (selected: boolean, w?: number, h?: number) => TemplateResult<2>; items: ItemType[]; }; -export const PROJECTS: Record = { +export type Project = + | 'heat_pump_clothes_dryer' + | 'hvac' + | 'ev' + | 'renewables' + | 'heat_pump_water_heater' + | 'cooking' + | 'wiring'; + +export const PROJECTS: Record = { heat_pump_clothes_dryer: { items: ['heat_pump_clothes_dryer'], label: 'Clothes dryer', + icon: CLOTHES_DRYER_ICON, }, hvac: { items: [ @@ -18,6 +247,7 @@ export const PROJECTS: Record = { ], label: 'Heating, ventilation & cooling', shortLabel: 'HVAC', + icon: HVAC_ICON, }, ev: { items: [ @@ -27,27 +257,28 @@ export const PROJECTS: Record = { ], label: 'Electric vehicle', shortLabel: 'EV', + icon: EV_ICON, }, renewables: { items: ['rooftop_solar_installation', 'battery_storage_installation'], label: 'Renewables', + icon: RENEWABLES_ICON, }, heat_pump_water_heater: { items: ['heat_pump_water_heater'], label: 'Water heater', - }, - weatherization: { - items: ['weatherization'], - label: 'Weatherization', + 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/state-calculator.ts b/src/state-calculator.ts index 54d2466..57c4415 100644 --- a/src/state-calculator.ts +++ b/src/state-calculator.ts @@ -16,9 +16,11 @@ import { cardStyles, dividerStyles, separatorStyles, + iconTabStyles, } from './state-incentive-details'; import { OptionParam } from './select'; import { STATES } from './states'; +import { Project } from './projects'; const loadingTemplate = () => html`
Loading...
@@ -44,6 +46,7 @@ export class RewiringAmericaStateCalculator extends LitElement { stateIncentivesStyles, dividerStyles, separatorStyles, + iconTabStyles, ]; /* supported properties to control showing/hiding of each card in the widget */ @@ -94,7 +97,10 @@ export class RewiringAmericaStateCalculator extends LitElement { utility: string = ''; @property({ type: String }) - selectedProject: string = 'hvac'; + selectedProject: Project = 'hvac'; + + @property({ type: String }) + selectedOtherTab: Project = 'heat_pump_clothes_dryer'; submit(e: SubmitEvent) { e.preventDefault(); @@ -104,7 +110,7 @@ export class RewiringAmericaStateCalculator extends LitElement { 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 string) || ''; + this.selectedProject = (formData.get('project') as Project) || ''; } isFormComplete() { @@ -236,7 +242,12 @@ export class RewiringAmericaStateCalculator extends LitElement { ${this._task.render({ pending: loadingTemplate, complete: results => - stateIncentivesTemplate(results, this.selectedProject), + stateIncentivesTemplate( + results, + this.selectedProject, + this.selectedOtherTab, + newSelection => (this.selectedOtherTab = newSelection), + ), error: errorTemplate, })} ` diff --git a/src/state-incentive-details.ts b/src/state-incentive-details.ts index 481e6ca..a82e2e7 100644 --- a/src/state-incentive-details.ts +++ b/src/state-incentive-details.ts @@ -1,7 +1,7 @@ import { css, html, nothing } from 'lit'; import { APIResponse, Amount, Incentive } from './api/calculator-types-v1'; import { exclamationPoint } from './icons'; -import { PROJECT_OPTIONS } from './calculator-form'; +import { PROJECTS, Project } from './projects'; export const stateIncentivesStyles = css` .incentive { @@ -174,6 +174,67 @@ export const dividerStyles = css` } `; +export const iconTabStyles = css` + .icon-tab-bar { + display: flex; + align-items: start; + justify-content: center; + } + + button.icon-tab { + /* Override default button styles */ + background-color: transparent; + border: 0; + font-family: inherit; + padding: 0; + cursor: pointer; + + width: 6rem; + + /* Manage the gap between the icon and the caption */ + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + justify-content: center; + + & .background { + /* Get the icon centered in the rounded box */ + display: flex; + align-items: center; + justify-content: center; + + width: 4rem; + height: 4rem; + + border-radius: 0.75rem; + background: #fff; + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.08); + } + & .caption { + color: #846f24; + text-align: center; + font-size: 0.6875rem; + font-weight: 500; + line-height: 125%; + letter-spacing: 0.03438rem; + text-transform: uppercase; + } + } + + button.icon-tab--selected { + cursor: default; + + & .background { + background: var(--color-purple-500, #4a00c3); + } + & .caption { + color: var(--color-purple-500, #4a00c3); + font-weight: 700; + } + } +`; + export const separatorStyles = css` .separator { background: #e2e2e2; @@ -190,6 +251,8 @@ const DUMMY_TEXT = const nowrap = (text: string) => html`${text}`; +const shortLabel = (p: Project) => PROJECTS[p].shortLabel ?? PROJECTS[p].label; + const amountTemplate = (amount: Amount) => amount.type === 'dollar_amount' ? amount.maximum @@ -285,10 +348,31 @@ export const atAGlanceTemplate = ( `; }; -export const gridTemplate = (heading: string, incentives: Incentive[]) => { +export const gridTemplate = ( + heading: string, + incentives: Incentive[], + 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` + + `; + }); + return incentives.length > 0 ? html`

${heading}

+
${iconTabs}
${incentives.map(incentiveBoxTemplate)}
@@ -296,20 +380,59 @@ export const gridTemplate = (heading: string, incentives: Incentive[]) => { : 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: string, + selectedProject: Project, + selectedOtherTab: Project, + onTabSelected: (newSelection: Project) => void, ) => { const allEligible = [ ...response.pos_rebate_incentives, ...response.tax_credit_incentives, ].filter(i => i.eligible); - const selectedItems = PROJECT_OPTIONS[selectedProject]!.items; - const selected = allEligible.filter(i => selectedItems.includes(i.item.type)); - const other = allEligible.filter(i => !selectedItems.includes(i.item.type)); + 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, allEligible.length)} - ${gridTemplate("Incentives you're interested in", selected)} - ${gridTemplate('Other incentives available to you', other)}`; + ${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, + )}`; };