diff --git a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx index cfc741a6c..f40cc8825 100644 --- a/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/LockDeviceDetails.tsx @@ -7,11 +7,10 @@ import { NestedAccessCodeTable } from 'lib/seam/components/AccessCodeTable/Acces import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js' import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js' import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js' +import { LockDeviceLockButtons } from 'lib/seam/components/DeviceDetails/LockDeviceLockButtons.js' import { deviceErrorFilter, deviceWarningFilter } from 'lib/seam/filters.js' import type { LockDevice } from 'lib/seam/locks/lock-device.js' -import { useToggleLock } from 'lib/seam/locks/use-toggle-lock.js' import { Alerts } from 'lib/ui/Alert/Alerts.js' -import { Button } from 'lib/ui/Button.js' import { BatteryStatusIndicator } from 'lib/ui/device/BatteryStatusIndicator.js' import { DeviceImage } from 'lib/ui/device/DeviceImage.js' import { EditableDeviceName } from 'lib/ui/device/EditableDeviceName.js' @@ -48,20 +47,6 @@ export function LockDeviceDetails({ const [snackbarVariant, setSnackbarVariant] = useState('success') - const toggleLock = useToggleLock({ - onSuccess: () => { - setSnackbarVisible(true) - setSnackbarVariant('success') - }, - onError: () => { - setSnackbarVisible(true) - setSnackbarVariant('error') - }, - }) - - const lockStatus = device.properties.locked ? t.locked : t.unlocked - const toggleLockLabel = device.properties.locked ? t.unlock : t.lock - const accessCodeCount = accessCodes?.length if (accessCodes == null) { @@ -163,28 +148,12 @@ export function LockDeviceDetails({
- {device.properties.locked && device.properties.online && ( -
-
- {t.lockStatus} - {lockStatus} -
-
- {!disableLockUnlock && - device.capabilities_supported.includes('lock') && ( - - )} -
-
- )} - + void + setSnackbarVariant: (variant: SnackbarVariant) => void +} + +export function LockDeviceLockButtons({ + device, + setSnackbarVariant, + setSnackbarVisible, + disableLockUnlock, +}: LockDeviceLockButtonsProps): JSX.Element | null { + const lockStatus = device.properties.locked ? t.locked : t.unlocked + const toggleLockLabel = device.properties.locked ? t.unlock : t.lock + + const toggleLock = useToggleLock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + const lock = useLock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + const unlock = useUnlock({ + onSuccess: () => { + setSnackbarVisible(true) + setSnackbarVariant('success') + }, + onError: () => { + setSnackbarVisible(true) + setSnackbarVariant('error') + }, + }) + + if (disableLockUnlock) { + return null + } + + if ( + device.can_remotely_lock === true && + device.can_remotely_unlock === true + ) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + if (device.can_remotely_lock === true) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + if (device.can_remotely_unlock === true) { + return ( +
+
+ {t.lockStatus} + {lockStatus} +
+
+ +
+
+ ) + } + + return null +} + +const t = { + unlock: 'Unlock', + lock: 'Lock', + locked: 'Locked', + unlocked: 'Unlocked', + lockStatus: 'Lock status', + statusUnknown: 'Unknown', +} diff --git a/src/lib/seam/locks/use-lock-door.ts b/src/lib/seam/locks/use-lock-door.ts new file mode 100644 index 000000000..311716763 --- /dev/null +++ b/src/lib/seam/locks/use-lock-door.ts @@ -0,0 +1,101 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, +} from '@seamapi/http/connect' +import { NullSeamClientError, useSeamClient } from '@seamapi/react-query' +import type { ActionAttempt, Device } from '@seamapi/types/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +export type UseLockData = undefined + +export type UseLockMutationVariables = Pick & { + properties: Required> +} + +type LockActionAttempt = Extract + +type MutationError = + | SeamHttpApiError + | SeamActionAttemptFailedError + | SeamActionAttemptTimeoutError + +interface UseLockParams { + onError?: () => void + onSuccess?: () => void +} + +export function useLock( + params: UseLockParams = {} +): UseMutationResult { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables) => { + const { + device_id: deviceId, + properties: { locked }, + } = variables + if (client === null) throw new NullSeamClientError() + if (locked == null) return + await client.locks.lockDoor({ device_id: deviceId }) + }, + onMutate: (variables) => { + queryClient.setQueryData(['devices', 'list', {}], (devices) => { + if (devices == null) { + return devices + } + + return devices.map((device) => { + if ( + device.device_id !== variables.device_id || + device.properties.locked == null + ) { + return device + } + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + }) + }) + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device?.properties.locked == null) return device + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + } + ) + }, + onError: async (_error, variables) => { + params.onError?.() + + await queryClient.invalidateQueries({ + queryKey: ['devices', 'list'], + }) + await queryClient.invalidateQueries({ + queryKey: ['devices', 'get', { device_id: variables.device_id }], + }) + }, + onSuccess() { + params.onSuccess?.() + }, + }) +} diff --git a/src/lib/seam/locks/use-unlock-door.ts b/src/lib/seam/locks/use-unlock-door.ts new file mode 100644 index 000000000..755b9b2f2 --- /dev/null +++ b/src/lib/seam/locks/use-unlock-door.ts @@ -0,0 +1,104 @@ +import type { + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, +} from '@seamapi/http/connect' +import { NullSeamClientError, useSeamClient } from '@seamapi/react-query' +import type { ActionAttempt, Device } from '@seamapi/types/connect' +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' + +export type UseUnlockData = undefined + +export type UseUnlockMutationVariables = Pick & { + properties: Required> +} + +type UnlockActionAttempt = Extract< + ActionAttempt, + { action_type: 'UNLOCK_DOOR' } +> + +type MutationError = + | SeamHttpApiError + | SeamActionAttemptFailedError + | SeamActionAttemptTimeoutError + +interface UseUnlockParams { + onError?: () => void + onSuccess?: () => void +} + +export function useUnlock( + params: UseUnlockParams = {} +): UseMutationResult { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables) => { + const { + device_id: deviceId, + properties: { locked }, + } = variables + if (client === null) throw new NullSeamClientError() + if (locked == null) return + await client.locks.unlockDoor({ device_id: deviceId }) + }, + onMutate: (variables) => { + queryClient.setQueryData(['devices', 'list', {}], (devices) => { + if (devices == null) { + return devices + } + + return devices.map((device) => { + if ( + device.device_id !== variables.device_id || + device.properties.locked == null + ) { + return device + } + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + }) + }) + + queryClient.setQueryData( + ['devices', 'get', { device_id: variables.device_id }], + (device) => { + if (device?.properties.locked == null) return device + + return { + ...device, + properties: { + ...device.properties, + locked: !variables.properties.locked, + }, + } + } + ) + }, + onError: async (_error, variables) => { + params.onError?.() + + await queryClient.invalidateQueries({ + queryKey: ['devices', 'list'], + }) + await queryClient.invalidateQueries({ + queryKey: ['devices', 'get', { device_id: variables.device_id }], + }) + }, + onSuccess() { + params.onSuccess?.() + }, + }) +} diff --git a/src/styles/_device-details.scss b/src/styles/_device-details.scss index 66b5fed87..e0c9e48d6 100644 --- a/src/styles/_device-details.scss +++ b/src/styles/_device-details.scss @@ -43,6 +43,7 @@ > .seam-right { display: flex; align-items: center; + gap: 8px; } }