import type { AccessCode, Device } from '@seamapi/types/connect'
import classNames from 'classnames'
import { DateTime } from 'luxon'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { getSystemTimeZone } from 'lib/dates.js'
import { useGenerateAccessCodeCode } from 'lib/seam/access-codes/use-generate-access-code-code.js'
import { AccessCodeFormDatePicker } from 'lib/ui/AccessCodeForm/AccessCodeFormDatePicker.js'
import { AccessCodeFormTimes } from 'lib/ui/AccessCodeForm/AccessCodeFormTimes.js'
import { AccessCodeFormTimeZonePicker } from 'lib/ui/AccessCodeForm/AccessCodeFormTimeZonePicker.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 { RadioField } from 'lib/ui/RadioField/RadioField.js'
import { TextField } from 'lib/ui/TextField/TextField.js'
import { useToggle } from 'lib/ui/use-toggle.js'
export interface AccessCodeFormSubmitData {
name: string
code: string
type: AccessCode['type']
device: Device
startDate: string
endDate: string
timeZone: string
}
export interface ResponseErrors {
unknown?: string | undefined
code?: string | undefined
}
export interface AccessCodeFormProps {
accessCode?: AccessCode
device: Device
isSubmitting: boolean
onSubmit: (data: AccessCodeFormSubmitData) => void
responseErrors: ResponseErrors | null
onBack: (() => void) | undefined
className: string | undefined
}
export function AccessCodeForm({
className,
...props
}: AccessCodeFormProps): JSX.Element | null {
return (
)
}
function Content({
onBack,
accessCode,
device,
onSubmit,
isSubmitting,
responseErrors,
}: Omit): JSX.Element {
const [type, setType] = useState(
accessCode?.type ?? 'ongoing'
)
const [datePickerVisible, setDatePickerVisible] = useState(false)
const [timeZone, setTimeZone] = useState(getSystemTimeZone())
const [startDate, setStartDate] = useState(
getAccessCodeDate('starts_at', accessCode) ?? getNow(timeZone)
)
const [endDate, setEndDate] = useState(
getAccessCodeDate('ends_at', accessCode) ?? getOneDayFromNow(timeZone)
)
const save = (data: { name: string; code: string }): void => {
if (isSubmitting) {
return
}
const start = startDate.toISO()
if (start === null) {
throw new Error(`Invalid start date: ${startDate.invalidReason}`)
}
const end = endDate.toISO()
if (end === null) {
throw new Error(`Invalid end date: ${endDate.invalidReason}`)
}
onSubmit({
name: data.name,
code: data.code,
type,
device,
startDate: start,
endDate: end,
timeZone,
})
}
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm({
defaultValues: {
name: accessCode?.name ?? '',
code: accessCode?.code ?? '',
},
})
const [timeZonePickerVisible, toggleTimeZonePicker] = useToggle()
const { isPending: isGeneratingCode, mutate: generateCode } =
useGenerateAccessCodeCode()
const submit = (): void => {}
if (timeZonePickerVisible) {
return (
{
setTimeZone(timeZone)
setStartDate(startDate.setZone(timeZone))
setEndDate(endDate.setZone(timeZone))
}}
onClose={toggleTimeZonePicker}
/>
)
}
if (datePickerVisible) {
return (
{
setDatePickerVisible(false)
}}
/>
)
}
const title = accessCode == null ? t.addNewAccessCode : t.editAccessCode
const handleGenerateCode = (): void => {
generateCode(
{
device_id: device.device_id,
},
{
onSuccess: (generatedCode) => {
setValue('code', generatedCode)
},
}
)
}
const hasCodeError = errors.code != null || responseErrors?.code != null
const codeLengthRequirement = getCodeLengthRequirement(device)
const codeLengthRequirementMessage =
codeLengthRequirement != null
? t.codeLengthRequirement(codeLengthRequirement)
: null
const canSpecifyPinCode =
device.properties.code_constraints?.every(
({ constraint_type: type }) => type !== 'cannot_specify_pin_code'
) ?? true
const hasCodeInputs =
accessCode?.is_offline_access_code !== true && canSpecifyPinCode
return (
<>
>
)
}
const validateCodeLength = (
device: Device,
value: string
): boolean | string => {
if (device.properties.supported_code_lengths == null) {
return true
}
if (device.properties.supported_code_lengths.includes(value.length)) {
return true
}
return t.codeLengthError(device.properties.supported_code_lengths.join(', '))
}
const getCodeLengthRequirement = (device: Device): string | null => {
const codeLengths = device.properties.supported_code_lengths
if (codeLengths == null) {
return null
}
if (codeLengths.length === 1) {
return `${codeLengths[0]}`
}
if (isSequential(codeLengths.join(''))) {
return `${codeLengths[0]}-${codeLengths[codeLengths.length - 1]}`
}
return codeLengths.join(', ')
}
// 0 - 99 in a string
// 0123456789101112...99
const sequentialNumbers = Array.from({ length: 100 }, (_, index) => index).join(
''
)
const isSequential = (numbers: string): boolean =>
sequentialNumbers.includes(numbers)
const getAccessCodeDate = (
key: 'starts_at' | 'ends_at',
accessCode?: AccessCode
): DateTime | null => {
if (accessCode == null) {
return null
}
if (accessCode.type !== 'time_bound') {
return null
}
const date = accessCode[key]
if (date == null) {
return null
}
return DateTime.fromISO(date)
}
const getNow = (timeZone: string): DateTime => DateTime.now().setZone(timeZone)
const getOneDayFromNow = (timeZone: string): DateTime =>
DateTime.now().setZone(timeZone).plus({ days: 1 })
const t = {
addNewAccessCode: 'Add new access code',
editAccessCode: 'Edit access code',
nameOverCharacterLimitError: '60 characters max',
nameRequiredError: 'Name is required',
nameInputLabel: 'Name the new code',
codeGenerateButton: 'Generate code',
codeInputLabel: 'Enter the code (PIN)',
codeRequiredError: 'Code is required',
codeLengthError: (lengths: string) =>
`Code length must be one of the following: ${lengths}`,
codeLengthRequirement: (lengths: string) => `${lengths} digit code`,
codeNumbersOnlyRequirement: 'Numbers only',
cancel: 'Cancel',
save: 'Save',
timingInputLabel: 'Timing',
typeOngoingLabel: 'Ongoing',
typeTimeBoundLabel: 'Start/end times',
}