diff --git a/.storybook/seed-fake.js b/.storybook/seed-fake.js index 15e258c37..9cba1b6ea 100644 --- a/.storybook/seed-fake.js +++ b/.storybook/seed-fake.js @@ -412,6 +412,34 @@ export const seedFake = (db) => { image_url: 'https://connect.getseam.com/assets/images/devices/ecobee_3-lite_front.png', image_alt_text: 'Placeholder Lock Image', + available_climate_presets: [ + { + climate_preset_key: 'occupied', + name: 'Occupied', + display_name: 'Occupied', + fan_mode_setting: 'auto', + hvac_mode_setting: 'heat_cool', + cooling_set_point_celsius: 25, + heating_set_point_celsius: 20, + cooling_set_point_fahrenheit: 77, + heating_set_point_fahrenheit: 68, + can_edit: true, + can_delete: true, + }, + { + climate_preset_key: 'unoccupied', + name: 'Unoccupied', + display_name: 'Unoccupied', + fan_mode_setting: 'auto', + hvac_mode_setting: 'heat_cool', + cooling_set_point_celsius: 30, + heating_set_point_celsius: 15, + cooling_set_point_fahrenheit: 86, + heating_set_point_fahrenheit: 59, + can_edit: false, + can_delete: false, + }, + ], }, errors: [], }) diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg new file mode 100644 index 000000000..59561a578 --- /dev/null +++ b/assets/icons/trash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 2a8b9a2f3..5a27f508a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.6.0", "license": "MIT", "dependencies": { + "@floating-ui/react": "^0.27.5", "@seamapi/http": "^1.20.0", "@tanstack/react-query": "^5.27.5", "classnames": "^2.3.2", @@ -25,7 +26,7 @@ "@rollup/plugin-replace": "^5.0.5", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.1", - "@seamapi/fake-seam-connect": "^1.69.1", + "@seamapi/fake-seam-connect": "^1.76.0", "@seamapi/types": "^1.344.3", "@storybook/addon-designs": "^7.0.1", "@storybook/addon-essentials": "^7.0.2", @@ -3008,7 +3009,6 @@ "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.8" @@ -3018,18 +3018,30 @@ "version": "1.6.12", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.8" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.5.tgz", + "integrity": "sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.0.0" @@ -3040,11 +3052,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", - "dev": true, - "license": "MIT" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", @@ -5814,11 +5824,10 @@ } }, "node_modules/@seamapi/fake-seam-connect": { - "version": "1.72.1", - "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.72.1.tgz", - "integrity": "sha512-ydDnKE+iOVRU0xEBpTYwpNjIRgV/pVkmPvXNRG76JBYtYf2whZxAPhcMUEjQbRk6tW1r8ykY051aLsWoKfjNPQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.76.0.tgz", + "integrity": "sha512-6+Ie7nFtR+7ZW0MJxWkn9mm2T2Vv0HQAU7VHf/6oRS7reJ1NqhwfWch1VEA/axd+r4VlCO4Fc2rPu3bRUBu5SQ==", "dev": true, - "license": "MIT", "bin": { "fake-seam-connect": "dist/server.js" }, @@ -21591,7 +21600,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -22468,7 +22476,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -23882,6 +23889,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/table": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", diff --git a/package.json b/package.json index 4919d7061..617758ff0 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ } }, "dependencies": { + "@floating-ui/react": "^0.27.5", "@seamapi/http": "^1.20.0", "@tanstack/react-query": "^5.27.5", "classnames": "^2.3.2", @@ -143,7 +144,7 @@ "@rollup/plugin-replace": "^5.0.5", "@rxfork/r2wc-react-to-web-component": "^2.4.0", "@seamapi/fake-devicedb": "^1.6.1", - "@seamapi/fake-seam-connect": "^1.69.1", + "@seamapi/fake-seam-connect": "^1.76.0", "@seamapi/types": "^1.344.3", "@storybook/addon-designs": "^7.0.1", "@storybook/addon-essentials": "^7.0.2", diff --git a/src/lib/icons/Trash.tsx b/src/lib/icons/Trash.tsx new file mode 100644 index 000000000..147271e88 --- /dev/null +++ b/src/lib/icons/Trash.tsx @@ -0,0 +1,28 @@ +/* + * Automatically generated by SVGR from assets/icons/*.svg. + * Do not edit this file or add other components to this directory. + */ +import type { SVGProps } from 'react' +export function TrashIcon(props: SVGProps): JSX.Element { + return ( + + + + + ) +} diff --git a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx index 3379bcd1e..c2c7c126a 100644 --- a/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/ThermostatDeviceDetails.tsx @@ -14,6 +14,7 @@ import { useHeatCoolThermostat } from 'lib/seam/thermostats/use-heat-cool-thermo import { useHeatThermostat } from 'lib/seam/thermostats/use-heat-thermostat.js' import { useSetThermostatFanMode } from 'lib/seam/thermostats/use-set-thermostat-fan-mode.js' import { useSetThermostatOff } from 'lib/seam/thermostats/use-set-thermostat-off.js' +import { Button } from 'lib/ui/Button.js' import { AccordionRow } from 'lib/ui/layout/AccordionRow.js' import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' import { DetailRow } from 'lib/ui/layout/DetailRow.js' @@ -21,6 +22,7 @@ import { DetailSection } from 'lib/ui/layout/DetailSection.js' import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js' import { Snackbar } from 'lib/ui/Snackbar/Snackbar.js' import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' +import { ClimatePresets } from 'lib/ui/thermostat/ClimatePresets.js' import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js' import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' @@ -40,16 +42,37 @@ export function ThermostatDeviceDetails({ className, onEditName, }: ThermostatDeviceDetailsProps): JSX.Element | null { + const [temperatureUnit, setTemperatureUnit] = useState< + 'fahrenheit' | 'celsius' + >('fahrenheit') + const [climateSettingsVisible, setClimateSettingsVisible] = useState(false) + if (device == null) { return null } + if (climateSettingsVisible) { + return ( + { + setClimateSettingsVisible(false) + }} + /> + ) + } + return ( - + @@ -58,6 +81,12 @@ export function ThermostatDeviceDetails({ tooltipContent={t.currentSettingsTooltip} > + { + setClimateSettingsVisible(true) + }} + device={device} + /> @@ -299,16 +328,38 @@ function ClimateSettingRow({ ) } +interface ClimatePresetRowProps { + device: ThermostatDevice + onClickManage: () => void +} + +function ClimatePresetRow({ + device, + onClickManage, +}: ClimatePresetRowProps): JSX.Element { + return ( + + + {t.manageNPresets( + (device.properties.available_climate_presets ?? []).length + )} + + + ) +} + const t = { thermostat: 'Thermostat', currentSettings: 'Current settings', currentSettingsTooltip: 'These are the settings currently on the device. If you change them here, they change on the device.', climate: 'Climate', + climatePresets: 'Climate presets', fanMode: 'Fan mode', none: 'None', fanModeSuccess: 'Successfully updated fan mode!', fanModeError: 'Error updating fan mode. Please try again.', climateSettingError: 'Error updating climate setting. Please try again.', saved: 'Saved', + manageNPresets: (n: number) => `Manage (${n} Preset${n <= 1 ? '' : 's'})`, } diff --git a/src/lib/seam/thermostats/thermostat-device.ts b/src/lib/seam/thermostats/thermostat-device.ts index 000cee4bf..28c77502f 100644 --- a/src/lib/seam/thermostats/thermostat-device.ts +++ b/src/lib/seam/thermostats/thermostat-device.ts @@ -13,6 +13,7 @@ export type ThermostatDevice = Omit & { | 'available_hvac_mode_settings' | 'fan_mode_setting' | 'current_climate_setting' + | 'available_climate_presets' > > } @@ -36,3 +37,6 @@ export interface ClimateSetting { export const isThermostatDevice = ( device: Device ): device is ThermostatDevice => 'is_fan_running' in device.properties + +export type ThermostatClimatePreset = + ThermostatDevice['properties']['available_climate_presets'][number] diff --git a/src/lib/seam/thermostats/unit-conversion.ts b/src/lib/seam/thermostats/unit-conversion.ts index f09c69a26..414b4cd78 100644 --- a/src/lib/seam/thermostats/unit-conversion.ts +++ b/src/lib/seam/thermostats/unit-conversion.ts @@ -1,8 +1,12 @@ import type { Device } from '@seamapi/types/connect' -export const celsiusToFahrenheit = (t: number): number => (t * 9) / 5 + 32 +type ConversionReturn = T extends NonNullable ? number : T -export const fahrenheitToCelsius = (t: number): number => (t - 32) * (5 / 9) +export const celsiusToFahrenheit = (t: T): ConversionReturn => + (typeof t === 'number' ? (t * 9) / 5 + 32 : t) as ConversionReturn + +export const fahrenheitToCelsius = (t: T): ConversionReturn => + (typeof t === 'number' ? (t - 32) * (5 / 9) : t) as ConversionReturn export const getCoolingSetPointCelsius = ( variables: { @@ -87,3 +91,9 @@ export const getHeatingSetPointFahrenheit = ( undefined ) } + +export function getTemperatureUnitSymbol( + type: 'fahrenheit' | 'celsius' +): string { + return type === 'fahrenheit' ? '°F' : '°C' +} diff --git a/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts new file mode 100644 index 000000000..cf53bbf51 --- /dev/null +++ b/src/lib/seam/thermostats/use-create-thermostat-climate-preset.ts @@ -0,0 +1,101 @@ +import type { + SeamHttpApiError, + ThermostatsCreateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseCreateThermostatClimatePresetParams = never +export type UseCreateThermostatClimatePresetData = undefined + +export type UseCreateThermostatClimatePresetVariables = + ThermostatsCreateClimatePresetBody + +export function useCreateThermostatClimatePreset(): UseMutationResult< + UseCreateThermostatClimatePresetData, + SeamHttpApiError, + UseCreateThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseCreateThermostatClimatePresetData, + SeamHttpApiError, + UseCreateThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.createClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, variables) + } + + return device + }) + } + ) + }, + }) +} + +const getUpdatedDevice = ( + device: ThermostatDevice, + variables: UseCreateThermostatClimatePresetVariables +): ThermostatDevice => { + const preset: ThermostatClimatePreset = { + ...variables, + cooling_set_point_celsius: fahrenheitToCelsius( + variables.cooling_set_point_fahrenheit + ), + heating_set_point_celsius: fahrenheitToCelsius( + variables.heating_set_point_fahrenheit + ), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: false, + } + + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: [ + preset, + ...(device.properties.available_climate_presets ?? []), + ], + }, + } +} diff --git a/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts new file mode 100644 index 000000000..9181d7f95 --- /dev/null +++ b/src/lib/seam/thermostats/use-delete-thermostat-climate-preset.ts @@ -0,0 +1,84 @@ +import type { + SeamHttpApiError, + ThermostatsDeleteClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { ThermostatDevice } from 'lib/seam/thermostats/thermostat-device.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseDeleteThermostatClimatePresetParams = never + +export type UseDeleteThermostatClimatePresetData = undefined + +export type UseDeleteThermostatClimatePresetVariables = + ThermostatsDeleteClimatePresetBody + +export function useDeleteThermostatClimatePreset(): UseMutationResult< + UseDeleteThermostatClimatePresetData, + SeamHttpApiError, + UseDeleteThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseDeleteThermostatClimatePresetData, + SeamHttpApiError, + UseDeleteThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.deleteClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, variables) + } + + return device + }) + } + ) + }, + }) +} + +function getUpdatedDevice( + device: ThermostatDevice, + variables: UseDeleteThermostatClimatePresetVariables +): ThermostatDevice { + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: + device.properties.available_climate_presets.filter((preset) => { + return preset.climate_preset_key !== variables.climate_preset_key + }), + }, + } +} diff --git a/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts new file mode 100644 index 000000000..6e9487f8d --- /dev/null +++ b/src/lib/seam/thermostats/use-update-thermostat-climate-preset.ts @@ -0,0 +1,101 @@ +import type { + SeamHttpApiError, + ThermostatsUpdateClimatePresetBody, +} from '@seamapi/http/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' +import { NullSeamClientError, useSeamClient } from 'lib/seam/use-seam-client.js' + +export type UseUpdateThermostatClimatePresetParams = never +export type UseUpdateThermostatClimatePresetData = undefined + +export type UseUpdateThermostatClimatePresetVariables = + ThermostatsUpdateClimatePresetBody + +export function useUpdateThermostatClimatePreset(): UseMutationResult< + UseUpdateThermostatClimatePresetData, + SeamHttpApiError, + UseUpdateThermostatClimatePresetVariables +> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation< + UseUpdateThermostatClimatePresetData, + SeamHttpApiError, + UseUpdateThermostatClimatePresetVariables + >({ + mutationFn: async (variables) => { + if (client === null) throw new NullSeamClientError() + await client.thermostats.createClimatePreset(variables) + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device == null) { + return + } + + return getUpdatedDevice(device, variables) + } + ) + + queryClient.setQueryData( + ['devices', 'list', { device_id: variables.device_id }], + (devices): ThermostatDevice[] => { + if (devices == null) { + return [] + } + + return devices.map((device) => { + if (device.device_id === variables.device_id) { + return getUpdatedDevice(device, variables) + } + + return device + }) + } + ) + }, + }) +} + +function getUpdatedDevice( + device: ThermostatDevice, + variables: UseUpdateThermostatClimatePresetVariables +): ThermostatDevice { + const preset: ThermostatClimatePreset = { + ...variables, + cooling_set_point_celsius: fahrenheitToCelsius( + variables.cooling_set_point_fahrenheit + ), + heating_set_point_celsius: fahrenheitToCelsius( + variables.heating_set_point_fahrenheit + ), + display_name: variables.name ?? variables.climate_preset_key, + can_delete: true, + can_edit: true, + manual_override_allowed: true, + } + + return { + ...device, + properties: { + ...device.properties, + available_climate_presets: [ + preset, + ...(device.properties.available_climate_presets ?? []), + ], + }, + } +} diff --git a/src/lib/ui/Button.tsx b/src/lib/ui/Button.tsx index c074c2b38..65762fd04 100644 --- a/src/lib/ui/Button.tsx +++ b/src/lib/ui/Button.tsx @@ -1,14 +1,17 @@ import classNames from 'classnames' import type { MouseEventHandler, PropsWithChildren } from 'react' +import { Spinner } from 'lib/ui/Spinner/Spinner.js' + interface ButtonProps extends PropsWithChildren { - variant?: 'solid' | 'outline' | 'neutral' + variant?: 'solid' | 'outline' | 'neutral' | 'danger' size?: 'small' | 'medium' | 'large' type?: 'button' | 'submit' disabled?: boolean onClick?: MouseEventHandler className?: string onMouseDown?: MouseEventHandler + loading?: boolean } export function Button({ @@ -20,6 +23,7 @@ export function Button({ className, onMouseDown, type = 'button', + loading = false, }: ButtonProps): JSX.Element { return ( { + if (loading || disabled) { + e.preventDefault() + return + } + + onClick?.(e) + }} onMouseDown={onMouseDown} type={type} > - {children} + {children} + {loading && ( + + + + )} ) } diff --git a/src/lib/ui/IconButton.tsx b/src/lib/ui/IconButton.tsx index f8ccb78cf..cf2c71092 100644 --- a/src/lib/ui/IconButton.tsx +++ b/src/lib/ui/IconButton.tsx @@ -1,9 +1,26 @@ import classNames from 'classnames' +import type { Ref } from 'react' import type { ButtonProps } from 'lib/ui/types.js' -export function IconButton({ className, ...props }: ButtonProps): JSX.Element { +export type IconProps = ButtonProps & { + elRef?: Ref +} + +export function IconButton({ + className, + elRef, + ...props +}: IconProps): JSX.Element { return ( - + ) } diff --git a/src/lib/ui/Popover/Popover.tsx b/src/lib/ui/Popover/Popover.tsx new file mode 100644 index 000000000..6a49873a9 --- /dev/null +++ b/src/lib/ui/Popover/Popover.tsx @@ -0,0 +1,168 @@ +import { + autoUpdate, + flip, + limitShift, + offset, + type ReferenceElement, + shift, + useFloating, +} from '@floating-ui/react' +import { + type ReactNode, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' + +import { seamComponentsClassName } from 'lib/seam/SeamProvider.js' + +export interface PopoverInstance { + show: () => void + hide: () => void + toggle: () => void +} + +type PopoverChildren = ( + params: { + setRef: (ref: HTMLElement | undefined | null) => void + } & PopoverInstance +) => ReactNode + +export interface PopoverProps { + children: PopoverChildren + content: ReactNode | ((instance: PopoverInstance) => ReactNode) + instanceRef?: Ref + preventCloseOnClickOutside?: boolean +} + +export function Popover(props: PopoverProps): JSX.Element { + const { children, content, instanceRef, preventCloseOnClickOutside } = props + + const [open, setOpen] = useState(false) + + const { refs, floatingStyles } = useFloating({ + whileElementsMounted: autoUpdate, + transform: false, + open, + onOpenChange: setOpen, + placement: 'bottom', + middleware: [ + shift({ + crossAxis: true, + limiter: limitShift(), + }), + flip(), + offset(5), + ], + }) + + const referenceEl = useRef() + const floatingEl = useRef() + + const setFLoating = useCallback( + (ref: HTMLElement | null): void => { + refs.setFloating(ref) + floatingEl.current = ref + }, + [refs, floatingEl] + ) + + const toggle = useCallback(() => { + setOpen((value) => !value) + }, []) + + const instance = useMemo( + () => ({ + show: () => { + setOpen(true) + }, + hide: () => { + setOpen(false) + }, + toggle, + }), + [toggle] + ) + + const setReference = useCallback( + (ref: ReferenceElement | undefined | null): void => { + if (!(ref instanceof HTMLElement) || referenceEl.current === ref) return + + if (referenceEl.current != null) { + referenceEl.current.removeEventListener('click', toggle) + } + + refs.setReference(ref) + ref.addEventListener('click', toggle) + referenceEl.current = ref + }, + [toggle, refs] + ) + + useImperativeHandle(instanceRef, () => instance) + + /** + * Closes the popover when the user clicks outside of it. + */ + const windowClickHandler = useCallback((e: MouseEvent): void => { + const target = e.target as HTMLElement + + // If the target is the reference element, do nothing. + if ( + referenceEl.current === target || + referenceEl.current?.contains(target) === true + ) { + return + } + + const closest = target.closest('[data-seam-popover]') + + // Prevents closing if target is floating element, also adds support for nested popovers somehow :) + if ( + closest != null && + referenceEl.current != null && + !closest.contains(referenceEl.current) + ) { + return + } + + setOpen(false) + }, []) + + useEffect(() => { + setTimeout(() => { + if (preventCloseOnClickOutside === false) return + + window.addEventListener('click', windowClickHandler) + }, 0) + + return () => { + window.removeEventListener('click', windowClickHandler) + } + }, [windowClickHandler, preventCloseOnClickOutside]) + + return ( + <> + {children({ setRef: setReference, ...instance })} + {open && + createPortal( + + + {typeof content === 'function' ? content(instance) : content} + + , + document.body + )} + > + ) +} diff --git a/src/lib/ui/Popover/PopoverContentPrompt.tsx b/src/lib/ui/Popover/PopoverContentPrompt.tsx new file mode 100644 index 000000000..e214049cf --- /dev/null +++ b/src/lib/ui/Popover/PopoverContentPrompt.tsx @@ -0,0 +1,58 @@ +import { Button } from 'lib/ui/Button.js' + +export interface PopoverContentPromptProps { + onConfirm?: () => void + onCancel?: () => void + prompt?: string + description?: string + confirmText?: string + cancelText?: string + confirmLoading?: boolean +} + +export function PopoverContentPrompt( + props: PopoverContentPromptProps +): JSX.Element { + const { + confirmText = t.confirm, + cancelText = t.cancel, + confirmLoading = false, + prompt = t.areYouSure, + description, + onConfirm, + onCancel, + } = props + + return ( + + + {prompt} + {description != null && ( + + {description} + + )} + + + + {confirmText} + + + + {cancelText} + + + + ) +} + +const t = { + confirm: 'Confirm', + cancel: 'Cancel', + areYouSure: 'Are you sure?', +} diff --git a/src/lib/ui/thermostat/ClimateModeMenu.tsx b/src/lib/ui/thermostat/ClimateModeMenu.tsx index 1c3289ab8..a5fa680c9 100644 --- a/src/lib/ui/thermostat/ClimateModeMenu.tsx +++ b/src/lib/ui/thermostat/ClimateModeMenu.tsx @@ -1,3 +1,6 @@ +import classNames from 'classnames' +import type { CSSProperties } from 'react' + import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' import { OffIcon } from 'lib/icons/Off.js' import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' @@ -11,20 +14,49 @@ interface ClimateModeMenuProps { mode: HvacModeSetting onChange: (mode: HvacModeSetting) => void supportedModes?: HvacModeSetting[] + buttonTextVisible?: boolean + className?: string + style?: CSSProperties + block?: boolean + size?: 'regular' | 'large' } export function ClimateModeMenu({ mode, onChange, supportedModes = ['heat', 'cool', 'heat_cool', 'off'], + buttonTextVisible = false, + className, + style, + block, + size = 'regular', }: ClimateModeMenuProps): JSX.Element { return ( ( - + + + {buttonTextVisible && ( + + {t[mode]} + + )} + )} diff --git a/src/lib/ui/thermostat/ClimatePreset.tsx b/src/lib/ui/thermostat/ClimatePreset.tsx new file mode 100644 index 000000000..2b4e82c42 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePreset.tsx @@ -0,0 +1,397 @@ +import classNames from 'classnames' +import { + type HTMLAttributes, + type Ref, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' +import { Controller, useForm, type UseFormReturn } from 'react-hook-form' + +import type { + FanModeSetting, + HvacModeSetting, + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' +import { fahrenheitToCelsius } from 'lib/seam/thermostats/unit-conversion.js' +import { useCreateThermostatClimatePreset } from 'lib/seam/thermostats/use-create-thermostat-climate-preset.js' +import { useUpdateThermostatClimatePreset } from 'lib/seam/thermostats/use-update-thermostat-climate-preset.js' +import { Button } from 'lib/ui/Button.js' +import { FormField } from 'lib/ui/FormField.js' +import { InputLabel } from 'lib/ui/InputLabel.js' +import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { TextField } from 'lib/ui/TextField/TextField.js' +import { ClimateModeMenu } from 'lib/ui/thermostat/ClimateModeMenu.js' +import { FanModeMenu } from 'lib/ui/thermostat/FanModeMenu.js' +import { TemperatureControlGroup } from 'lib/ui/thermostat/TemperatureControlGroup.js' + +export type ClimatePresetProps = { + preset?: ThermostatClimatePreset + onBack: () => void + device: ThermostatDevice +} & Omit, 'children'> + +export function ClimatePreset(props: ClimatePresetProps): JSX.Element { + const { preset, onBack, device, ...attrs } = props + + return ( + + + {preset == null ? ( + + ) : ( + + )} + + ) +} + +interface PresetFormProps { + defaultValues: { + key: string + name: string + hvacMode: HvacModeSetting | undefined + heatPoint: number | undefined + coolPoint: number | undefined + fanMode: FanModeSetting | undefined + } + onSubmit: (values: PresetFormProps['defaultValues']) => void + device: ThermostatDevice + loading: boolean + instanceRef?: Ref | undefined> + withKeyField?: boolean +} + +function PresetForm(props: PresetFormProps): JSX.Element { + const { + defaultValues, + device, + instanceRef, + loading, + onSubmit, + withKeyField, + } = props + const _form = useForm({ defaultValues }) + + useImperativeHandle(instanceRef, () => _form) + + const { + register, + handleSubmit, + formState: { errors }, + watch, + setValue, + control, + } = _form + + const state = watch() + + const onHvacModeChange = (mode: HvacModeSetting): void => { + if (mode === 'heat_cool') { + setValue('heatPoint', defaultValues.heatPoint) + setValue('coolPoint', defaultValues.coolPoint) + } else if (mode === 'heat') { + setValue('heatPoint', defaultValues.heatPoint) + setValue('coolPoint', undefined) + } else if (mode === 'cool') { + setValue('heatPoint', undefined) + setValue('coolPoint', defaultValues.coolPoint) + } else { + setValue('heatPoint', undefined) + setValue('coolPoint', undefined) + } + } + + const otherPresets = useMemo(() => { + if (withKeyField !== true) return [] + + return (device.properties.available_climate_presets ?? []).filter( + (other) => other.climate_preset_key !== defaultValues.key + ) + }, [defaultValues, device, withKeyField]) + + const onValid = useCallback(() => { + onSubmit(state) + }, [onSubmit, state]) + + return ( + + { + void handleSubmit(onValid)(e) + }} + > + {withKeyField === true && ( + + Key + other.climate_preset_key === normalizedValue + ) + }, + }), + }} + /> + + )} + + + {t.nameField} + + + + {state.fanMode != null && ( + + {t.fanModeField} + + value != null ? ( + + ) : ( + <>> + ) + } + /> + + )} + + {state.hvacMode != null && ( + + {t.hvacModeField} + + value == null ? ( + <>> + ) : ( + { + onHvacModeChange(value) + onChange(value) + }} + /> + ) + } + /> + + )} + + {state.hvacMode !== 'off' && state.hvacMode != null && ( + + {t.heatCoolField} + { + setValue('heatPoint', value) + }} + onCoolValueChange={(value) => { + setValue('coolPoint', value) + }} + heatValue={state.heatPoint ?? 0} + coolValue={state.coolPoint ?? 0} + minHeat={65} + maxHeat={100} + minCool={50} + maxCool={90} + delta={5} + /> + + )} + + + + {t.save} + + + + + ) +} + +interface CreateFormProps { + device: ThermostatDevice + onComplete: () => void +} + +function CreateForm({ device, onComplete }: CreateFormProps): JSX.Element { + const instanceRef = useRef>() + const mutation = useCreateThermostatClimatePreset() + + const onSubmit = useCallback( + (values: PresetFormProps['defaultValues']) => { + if (instanceRef.current == null) return + + const key = values.key.replace(/\s+/g, '').trim() + const otherPresets = device.properties.available_climate_presets ?? [] + + if (otherPresets.some((other) => other.climate_preset_key === key)) { + instanceRef.current.setError('key', { + type: 'validate', + message: t.keyErrorMsg, + }) + return + } + + mutation.mutate( + { + climate_preset_key: key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + }, + { onSuccess: onComplete } + ) + }, + [device, mutation, onComplete] + ) + + return ( + + ) +} + +interface UpdateFormProps { + device: ThermostatDevice + onComplete: () => void + preset: ThermostatClimatePreset +} + +function UpdateForm({ + device, + onComplete, + preset, +}: UpdateFormProps): JSX.Element { + const mutation = useUpdateThermostatClimatePreset() + const defaultValues = useMemo( + () => ({ + coolPoint: preset.cooling_set_point_fahrenheit ?? 60, + heatPoint: preset.heating_set_point_fahrenheit ?? 80, + name: preset.display_name, + hvacMode: preset.hvac_mode_setting, + fanMode: preset.fan_mode_setting, + key: preset.climate_preset_key, + }), + [preset] + ) + + const onSubmit = useCallback( + (values: PresetFormProps['defaultValues']) => { + mutation.mutate( + { + climate_preset_key: values.key, + device_id: device.device_id, + name: values.name === '' ? undefined : values.name, + cooling_set_point_fahrenheit: values.coolPoint, + heating_set_point_fahrenheit: values.heatPoint, + fan_mode_setting: values.fanMode, + cooling_set_point_celsius: fahrenheitToCelsius(values.coolPoint), + heating_set_point_celsius: fahrenheitToCelsius(values.heatPoint), + hvac_mode_setting: values.hvacMode, + manual_override_allowed: false, + }, + { onSuccess: onComplete } + ) + }, + [device, mutation, onComplete] + ) + + return ( + + ) +} + +const t = { + keyErrorMsg: 'Preset with this key already exists.', + nameField: 'Display Name (Optional)', + fanModeField: 'Fan Mode', + hvacModeField: 'HVAC Mode', + heatCoolField: 'Heat / Cool', + delete: 'Delete', + save: 'Save', + max20Chars: 'max 20 chars', + min3Chars: 'min 3 chars', + crateNewPreset: 'Create New Preset', +} diff --git a/src/lib/ui/thermostat/ClimatePresets.tsx b/src/lib/ui/thermostat/ClimatePresets.tsx new file mode 100644 index 000000000..9afeaef64 --- /dev/null +++ b/src/lib/ui/thermostat/ClimatePresets.tsx @@ -0,0 +1,222 @@ +import classNames from 'classnames' +import { type HTMLAttributes, type ReactNode, useState } from 'react' + +import { AddIcon } from 'lib/icons/Add.js' +import { EditIcon } from 'lib/icons/Edit.js' +import { FanIcon } from 'lib/icons/Fan.js' +import { ThermostatCoolIcon } from 'lib/icons/ThermostatCool.js' +import { ThermostatHeatIcon } from 'lib/icons/ThermostatHeat.js' +import { TrashIcon } from 'lib/icons/Trash.js' +import type { + ThermostatClimatePreset, + ThermostatDevice, +} from 'lib/seam/thermostats/thermostat-device.js' +import { getTemperatureUnitSymbol } from 'lib/seam/thermostats/unit-conversion.js' +import { useDeleteThermostatClimatePreset } from 'lib/seam/thermostats/use-delete-thermostat-climate-preset.js' +import { Button } from 'lib/ui/Button.js' +import { IconButton } from 'lib/ui/IconButton.js' +import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { Popover } from 'lib/ui/Popover/Popover.js' +import { PopoverContentPrompt } from 'lib/ui/Popover/PopoverContentPrompt.js' +import { Spinner } from 'lib/ui/Spinner/Spinner.js' +import { ClimatePreset } from 'lib/ui/thermostat/ClimatePreset.js' + +interface ClimatePresetsManagement { + device: ThermostatDevice + onBack: () => void + temperatureUnit: 'fahrenheit' | 'celsius' +} + +const CreateNewPresetSymbol = Symbol('CreateNewPreset') + +export function ClimatePresets(props: ClimatePresetsManagement): JSX.Element { + const { device, onBack } = props + + const [selectedClimatePreset, setSelectedClimatePreset] = useState< + ThermostatClimatePreset | typeof CreateNewPresetSymbol | null + >(null) + + const [inDeletionPresetKey, setInDeletionPresetKey] = useState< + ThermostatClimatePreset['climate_preset_key'] | null + >(null) + const deleteMutation = useDeleteThermostatClimatePreset() + + if ( + selectedClimatePreset != null || + selectedClimatePreset === CreateNewPresetSymbol + ) { + return ( + { + setSelectedClimatePreset(null) + }} + device={device} + preset={ + selectedClimatePreset === CreateNewPresetSymbol + ? undefined + : selectedClimatePreset + } + /> + ) + } + + return ( + + + + { + setSelectedClimatePreset(CreateNewPresetSymbol) + }} + className='seam-climate-presets-add-button' + > + + {t.createNew} + + + + {device.properties.available_climate_presets.map((preset) => ( + { + setSelectedClimatePreset(preset) + }} + onClickDelete={() => { + setInDeletionPresetKey(preset.climate_preset_key) + deleteMutation.mutate({ + climate_preset_key: preset.climate_preset_key, + device_id: device.device_id, + }) + }} + temperatureUnit={props.temperatureUnit} + preset={preset} + key={preset.climate_preset_key} + deletionLoading={ + deleteMutation.isPending && + inDeletionPresetKey === preset.climate_preset_key + } + disabled={ + deleteMutation.isPending && + inDeletionPresetKey !== preset.climate_preset_key + } + /> + ))} + + + + ) +} + +function PresetCard( + props: HTMLAttributes & { + preset: ThermostatClimatePreset + temperatureUnit: 'fahrenheit' | 'celsius' + onClickEdit: () => void + onClickDelete: () => void + deletionLoading?: boolean + disabled?: boolean + } +): JSX.Element { + const { + preset, + temperatureUnit, + onClickEdit, + onClickDelete, + deletionLoading = false, + disabled = false, + ...attrs + } = props + + const heatPoint = + temperatureUnit === 'fahrenheit' + ? preset.heating_set_point_fahrenheit + : (preset.heating_set_point_celsius ?? undefined) + + const coolPoint = + temperatureUnit === 'fahrenheit' + ? preset.cooling_set_point_fahrenheit + : (preset.cooling_set_point_celsius ?? undefined) + + const unitSymbol = getTemperatureUnitSymbol(temperatureUnit) + + const chips = ( + [ + heatPoint != null + ? { icon: , value: `${heatPoint} ${unitSymbol}` } + : undefined, + coolPoint != null + ? { icon: , value: `${coolPoint} ${unitSymbol}` } + : undefined, + preset.fan_mode_setting != null + ? { icon: , value: preset.fan_mode_setting } + : undefined, + ].filter(Boolean) as Array<{ icon: ReactNode; value: string }> + ).map(({ icon, value }, index) => ( + + {icon} + {value} + + )) + + return ( + + + + {preset.display_name} + + {preset.name != null && ( + + {preset.climate_preset_key} + + )} + + + + + + + + ( + { + onClickDelete() + hide() + }} + /> + )} + > + {({ setRef }) => ( + + {deletionLoading ? : } + + )} + + + + + {chips} + + ) +} + +const t = { + title: 'Climate Presets', + createNew: 'Create New', + delete: 'Delete', + edit: 'Edit', +} diff --git a/src/lib/ui/thermostat/FanModeMenu.tsx b/src/lib/ui/thermostat/FanModeMenu.tsx index e8c3b2780..6c1cc126c 100644 --- a/src/lib/ui/thermostat/FanModeMenu.tsx +++ b/src/lib/ui/thermostat/FanModeMenu.tsx @@ -1,3 +1,5 @@ +import classNames from 'classnames' + import { ChevronDownIcon } from 'lib/icons/ChevronDown.js' import { FanIcon } from 'lib/icons/Fan.js' import { FanOutlineIcon } from 'lib/icons/FanOutline.js' @@ -10,13 +12,29 @@ const modes: FanModeSetting[] = ['auto', 'on'] interface FanModeMenuProps { mode: FanModeSetting onChange: (mode: FanModeSetting) => void + block?: boolean + size?: 'regular' | 'large' } -export function FanModeMenu({ mode, onChange }: FanModeMenuProps): JSX.Element { +export function FanModeMenu({ + mode, + onChange, + block, + size = 'regular', +}: FanModeMenuProps): JSX.Element { return ( ( - + diff --git a/src/lib/ui/thermostat/ThermostatCard.tsx b/src/lib/ui/thermostat/ThermostatCard.tsx index e16a1ae38..a13dbdd28 100644 --- a/src/lib/ui/thermostat/ThermostatCard.tsx +++ b/src/lib/ui/thermostat/ThermostatCard.tsx @@ -12,12 +12,17 @@ import { Temperature } from 'lib/ui/thermostat/Temperature.js' interface ThermostatCardProps { device: ThermostatDevice onEditName?: (newName: string) => void + onTemperatureUnitChange?: (temperatureUnit: 'fahrenheit' | 'celsius') => void } export function ThermostatCard(props: ThermostatCardProps): JSX.Element { return ( - + ) } @@ -30,9 +35,10 @@ function Content(props: ThermostatCardProps): JSX.Element | null { >('fahrenheit') const toggleTemperatureUnit = (): void => { - setTemperatureUnit( - temperatureUnit === 'fahrenheit' ? 'celsius' : 'fahrenheit' - ) + const newUnit = temperatureUnit === 'fahrenheit' ? 'celsius' : 'fahrenheit' + + setTemperatureUnit(newUnit) + props.onTemperatureUnitChange?.(newUnit) } const { diff --git a/src/lib/ui/types.ts b/src/lib/ui/types.ts index 2c4b64c66..03042fd96 100644 --- a/src/lib/ui/types.ts +++ b/src/lib/ui/types.ts @@ -1,5 +1,5 @@ -import type { HTMLAttributes, HtmlHTMLAttributes } from 'react' +import type { ButtonHTMLAttributes, HTMLAttributes } from 'react' export type DivProps = HTMLAttributes -export type ButtonProps = HtmlHTMLAttributes -export type SpanProps = HtmlHTMLAttributes +export type ButtonProps = ButtonHTMLAttributes +export type SpanProps = HTMLAttributes diff --git a/src/styles/_buttons.scss b/src/styles/_buttons.scss index ff6799f5e..4e7a0f828 100644 --- a/src/styles/_buttons.scss +++ b/src/styles/_buttons.scss @@ -1,10 +1,11 @@ @use './typography'; @use './colors'; +@use 'sass:color'; @mixin icon-button { .seam-icon-btn { border-radius: 6px; - border: 1px solid rgb(0 122 252 / 50%); + border: 1px solid rgba(colors.$primary, 50%); cursor: pointer; background: colors.$white; padding: 2px; @@ -19,7 +20,19 @@ } &:hover { - background: rgb(0 122 252 / 8%); + background: rgba(colors.$primary, 0.08); + } + + &.seam-icon-btn-disabled, + &:disabled { + color: colors.$text-gray-2-5; + border-color: colors.$text-gray-3; + background: colors.$white; + cursor: not-allowed; + + path { + fill: colors.$text-gray-2-5; + } } } } @@ -101,6 +114,23 @@ } } + &.seam-btn-danger { + color: colors.$status-red; + border: 1px solid colors.$status-red; + background: transparent; + + &:hover { + color: color.scale(colors.$status-red, $lightness: 30%); + border-color: color.scale(colors.$status-red, $lightness: 30%); + } + + &.seam-btn-disabled, + &:disabled { + color: colors.$text-gray-2-5; + border-color: colors.$text-gray-2-5; + } + } + &.seam-btn-neutral { color: colors.$text-gray-1; border: 1px solid colors.$text-gray-2-5; @@ -125,6 +155,7 @@ @include button-size; @include button-variant; + position: relative; font-weight: 600; cursor: pointer; @@ -132,6 +163,29 @@ &:disabled { cursor: not-allowed; } + + .seam-btn-loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + + --spinner-color: currentcolor; + } + + .seam-btn-content { + display: contents; + } + + &.seam-btn-loading { + .seam-btn-content { + visibility: hidden; + } + } } } diff --git a/src/styles/_main.scss b/src/styles/_main.scss index ff634d820..53403d84a 100644 --- a/src/styles/_main.scss +++ b/src/styles/_main.scss @@ -30,6 +30,7 @@ @use './tab-set'; @use './noise-sensor'; @use './seam-editable-device-name'; +@use './popover'; .seam-components { // Reset @@ -68,4 +69,5 @@ @include thermostat.all; @include seam-table.all; @include noise-sensor.all; + @include popover.all; } diff --git a/src/styles/_popover.scss b/src/styles/_popover.scss new file mode 100644 index 000000000..5a0ed570a --- /dev/null +++ b/src/styles/_popover.scss @@ -0,0 +1,46 @@ +@use './colors'; + +@mixin popover { + .seam-popover { + display: inline-block; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 10px -2px rgb(0 0 0 / 20%); + background-color: colors.$bg-a; + border: 1px solid colors.$bg-c; + } +} + +@mixin popover-content-prompt { + .seam-popover-content-prompt { + display: inline-flex; + flex-flow: column nowrap; + padding: 12px; + gap: 12px; + background-color: colors.$bg-a; + } + + .seam-popover-content-prompt-text { + font-weight: 600; + text-align: center; + } + + .seam-popover-content-prompt-description { + font-weight: 400; + text-align: center; + color: colors.$text-gray-2; + font-size: 12px; + } + + .seam-popover-content-prompt-buttons { + display: flex; + flex-flow: row nowrap; + gap: 6px; + justify-content: flex-end; + } +} + +@mixin all { + @include popover; + @include popover-content-prompt; +} diff --git a/src/styles/_spinner.scss b/src/styles/_spinner.scss index 55f6513c9..f58bdf84d 100644 --- a/src/styles/_spinner.scss +++ b/src/styles/_spinner.scss @@ -17,7 +17,7 @@ .seam-spinner { width: $default-size; height: $default-size; - border: $default-border-width solid colors.$primary; + border: $default-border-width solid var(--spinner-color, colors.$primary); border-top: $default-border-width solid transparent; display: inline-block; border-radius: 50%; diff --git a/src/styles/_thermostat.scss b/src/styles/_thermostat.scss index 509fef738..f252b1559 100644 --- a/src/styles/_thermostat.scss +++ b/src/styles/_thermostat.scss @@ -1,4 +1,6 @@ @use './colors'; +@use 'sass:color'; +@use './access-code-form' as acf; @mixin climate-setting-control-group { .seam-climate-setting-control-group { @@ -542,7 +544,6 @@ @mixin fan-mode-menu { .seam-fan-mode-menu-button { width: 130px; - height: 32px; display: flex; justify-content: space-between; align-items: center; @@ -554,6 +555,14 @@ cursor: pointer; transition: 0.2s ease; + &.seam-fan-mode-menu-button-size-regular { + height: 32px; + } + + &.seam-fan-mode-menu-button-size-large { + height: 48px; + } + svg { font-size: 20px; } @@ -579,6 +588,10 @@ } } + &.seam-fan-mode-menu-button-block-sized { + width: 100%; + } + .seam-fan-mode-menu-button-text { color: colors.$text-gray-1; font-size: 16px; @@ -597,12 +610,23 @@ border: 1px solid colors.$text-gray-3; background: colors.$white; display: flex; - justify-content: space-between; align-items: center; flex-direction: row; cursor: pointer; transition: 0.2s ease; + &.seam-climate-mode-menu-button-regular { + height: 32px; + } + + &.seam-climate-mode-menu-button-large { + height: 48px; + } + + &.seam-climate-mode-menu-button-block { + width: 100%; + } + &:hover { border-color: colors.$text-gray-2; } @@ -618,6 +642,12 @@ .seam-climate-mode-menu-button-chevron { font-size: 20px; + margin-left: auto; + } + + .seam-climate-mode-menu-button-text { + font-size: 14px; + margin-left: 6px; } } @@ -656,6 +686,126 @@ } } +@mixin climate-presets { + .seam-thermostat-climate-presets-body { + margin: 0 24px 24px; + display: flex; + flex-flow: column nowrap; + gap: 10px; + justify-content: center; + } + + .seam-thermostat-climate-presets-cards { + display: flex; + flex-flow: row wrap; + gap: 10px; + justify-content: center; + } + + .seam-climate-presets-add-button { + margin: 0 auto; + display: inline-flex !important; + align-items: center; + gap: 6px; + + svg { + font-size: 20px; + } + + svg, + path { + fill: currentcolor; + } + } + + .seam-thermostat-climate-presets-card { + border-radius: 12px; + background-color: colors.$bg-a; + width: 100%; + max-width: 250px; + overflow: hidden; + + .seam-thermostat-climate-presets-card-top { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 8px; + width: 100%; + justify-content: space-between; + border-bottom: 1px solid color.scale(colors.$bg-a, $lightness: -5%); + padding: 8px; + background-color: colors.$bg-b; + + .seam-thermostat-climate-presets-card-name { + font-size: 16px; + font-weight: 600; + line-height: 118%; + white-space: nowrap; + } + + .seam-thermostat-climate-presets-card-name-key { + font-size: 12px; + font-weight: 400; + line-height: 118%; + white-space: nowrap; + color: colors.$text-gray-2; + } + + .seam-thermostat-climate-presets-card-buttons { + display: flex; + flex-flow: row nowrap; + gap: 4px; + justify-content: flex-end; + } + } + + .seam-thermostat-climate-presets-card-body { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px; + justify-content: space-around; + } + + .seam-thermostat-climate-preset-chip { + display: inline-flex; + flex-flow: row nowrap; + gap: 4px; + align-items: center; + justify-content: space-between; + width: max-content; + + .seam-thermostat-climate-preset-chip-icon { + color: colors.$text-gray-1; + font-weight: 600; + + svg { + position: relative; + top: 2px; + } + } + + .seam-thermostat-climate-preset-chip-value { + font-weight: 600; + } + } + } +} + +@mixin climate-preset { + .seam-thermostat-climate-preset { + @include acf.main; + + .seam-climate-preset-buttons { + display: flex; + flex-flow: row nowrap; + gap: 8px; + } + } +} + @mixin all { @include climate-setting-control-group; @include temperature-control-group; @@ -666,4 +816,6 @@ @include fan-mode-menu; @include climate-mode-menu; @include status; + @include climate-presets; + @include climate-preset; }