Skip to content

Commit

Permalink
MultiInput component; magic code via MultiInput
Browse files Browse the repository at this point in the history
  • Loading branch information
Soxasora committed Jan 29, 2025
1 parent 6155eef commit 9658807
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 13 deletions.
101 changes: 99 additions & 2 deletions components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ function FormGroup ({ className, label, children }) {

function InputInner ({
prepend, append, hint, warn, showValid, onChange, onBlur, overrideValue, appendValue,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength,
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, hideError,
...props
}) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
Expand Down Expand Up @@ -574,7 +574,7 @@ function InputInner ({
onKeyDown={onKeyDownInner}
onChange={onChangeInner}
onBlur={onBlurInner}
isInvalid={invalid}
isInvalid={!hideError && invalid} // if hideError is true, handle error showing separately
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(isClient && clear && field.value && !props.readOnly) &&
Expand Down Expand Up @@ -1241,5 +1241,102 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini
)
}

export function MultiInput ({
name, label, groupClassName, length = 4, charLength = 1, upperCase, showSequence,
onChange, autoFocus, hideError, inputType = 'text',
...props
}) {
const [inputs, setInputs] = useState(new Array(length).fill(''))
const inputRefs = useRef(new Array(length).fill(null))
const [, meta, helpers] = useField({ name })

useEffect(() => {
autoFocus && inputRefs.current[0].focus() // focus the first input if autoFocus is true
}, [autoFocus])

const updateInputs = useCallback((newInputs) => {
setInputs(newInputs)
const combinedValue = newInputs.join('') // join the inputs to get the value
helpers.setValue(combinedValue) // set the value to the formik field
onChange?.(combinedValue)
}, [onChange, helpers])

const handleChange = useCallback((formik, e, index) => { // formik is not used but it's required to get the value
const newValue = upperCase // convert the input to uppercase if upperCase is true
? e.target.value.slice(-charLength).toUpperCase()
: e.target.value.slice(-charLength)

const newInputs = [...inputs]
newInputs[index] = newValue
updateInputs(newInputs)

// focus the next input if the current input is filled
if (newValue.length === charLength && index < length - 1) {
inputRefs.current[index + 1].focus()
}
}, [inputs, charLength, upperCase, onChange, length])

const handleKeyDown = useCallback((e, index) => {
switch (e.key) {
case 'Backspace': {
e.preventDefault()
const newInputs = [...inputs]
// if current input is empty move focus to the previous input else clear the current input
const targetIndex = inputs[index] === '' && index > 0 ? index - 1 : index
newInputs[targetIndex] = ''
updateInputs(newInputs)
inputRefs.current[targetIndex]?.focus()
break
}
case 'ArrowLeft': {
if (index > 0) { // focus the previous input if it's not the first input
e.preventDefault()
inputRefs.current[index - 1]?.focus()
}
break
}
case 'ArrowRight': {
if (index < length - 1) { // focus the next input if it's not the last input
e.preventDefault()
inputRefs.current[index + 1]?.focus()
}
break
}
}
}, [inputs, length, updateInputs])

return (
<FormGroup label={label} className={groupClassName}>
<div className='d-flex flex-row gap-2'>
{inputs.map((value, index) => (
<InputInner
name={name}
key={index}
type={inputType}
value={value}
innerRef={(el) => { inputRefs.current[index] = el }}
onChange={(formik, e) => handleChange(formik, e, index)}
onKeyDown={e => handleKeyDown(e, index)}
style={{
textAlign: 'center',
maxWidth: `${charLength * 40}px` // adjusts the max width of the input based on the charLength
}}
prepend={showSequence && <InputGroup.Text>{index + 1}</InputGroup.Text>} // show the index of the input
hideError
{...props}
/>
))}
</div>
<div>
{hideError && meta.touched && meta.error && ( // custom error message is showed if hideError is true
<BootstrapForm.Control.Feedback type='invalid' className='d-block'>
{meta.error}
</BootstrapForm.Control.Feedback>
)}
</div>
</FormGroup>
)
}

export const ClientInput = Client(Input)
export const ClientCheckbox = Client(Checkbox)
6 changes: 5 additions & 1 deletion components/media-or-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,12 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
// hack
// if it's not a video it will throw an error, so we can assume it's an image
const img = new window.Image()
img.onload = () => setIsImage(true)
img.src = src
img.decode().then(() => { // decoding beforehand to prevent wrong image cropping
setIsImage(true)
}).catch((e) => {
console.error('Cannot decode image', e)
})
}
video.src = src

Expand Down
2 changes: 1 addition & 1 deletion lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export const emailSchema = object({
})

export const emailTokenSchema = object({
token: string().required('required').trim().matches(/^[0-9a-z]{6}$/i, 'must be 6 alphanumeric characters')
token: string().required('required').trim().matches(/^[0-9a-z]{6}$/i, 'must be 6 alphanumeric character')
})

export const urlSchema = object({
Expand Down
2 changes: 1 addition & 1 deletion pages/auth/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function AuthError ({ error }) {
<StaticLayout>
<Image className='rounded-1 shadow-sm' width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/double.gif`} fluid />
<h2 className='pt-4'>Where did the magic go?</h2>
<h4 className='text-muted pt-2'>Get another magic code by logging in or try again by going back.</h4>
<h4 className='text-muted text-center pt-2'>Get another magic code by logging in or try again by going back.</h4>
<Button
className='align-items-center my-3'
style={{ borderWidth: '2px' }}
Expand Down
27 changes: 20 additions & 7 deletions pages/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { StaticLayout } from '@/components/layout'
import { getGetServerSideProps } from '@/api/ssrApollo'
import { useRouter } from 'next/router'
import { useState, useEffect, useCallback } from 'react'
import { Form, SubmitButton, PasswordInput } from '@/components/form'
import { Form, SubmitButton, MultiInput } from '@/components/form'
import { emailTokenSchema } from '@/lib/validate'

// force SSR to include CSP nonces
Expand Down Expand Up @@ -31,24 +31,37 @@ export default function Email () {
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
</video>
<h2 className='pt-4'>Check your email</h2>
<h4 className='text-muted pt-2 pb-4'>a 5-minutes magic code has been sent to {callback ? callback.email : 'your email address'}</h4>
<MagicCodeForm onSubmit={(token) => pushCallback(token)} />
<h4 className='text-muted pt-2 pb-4'>a magic code has been sent to {callback ? callback.email : 'your email address'}</h4>
<MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} />
</div>
</StaticLayout>
)
}

export const MagicCodeForm = ({ onSubmit }) => {
export const MagicCodeForm = ({ onSubmit, disabled }) => {
return (
<Form
initial={{
token: ''
}}
schema={emailTokenSchema}
onSubmit={({ token }) => { onSubmit(token.toLowerCase()) }}
onSubmit={(values) => {
onSubmit(values.token.toLowerCase()) // token is displayed in uppercase but we need to check it in lowercase
}}
>
<PasswordInput name='token' required placeholder='input your 6-digit magic code' />
<SubmitButton variant='primary' className='px-4'>verify</SubmitButton>
<MultiInput
length={8}
charLength={1}
name='token'
required
upperCase // display token in uppercase
autoFocus
groupClassName='d-flex flex-wrap justify-content-center'
inputType='text'
hideError // hide error message on every input, allow custom error message
disabled={disabled} // disable the form if no callback is provided
/>
<SubmitButton variant='primary' className='px-4' disabled={disabled}>verify</SubmitButton>
</Form>
)
}
2 changes: 1 addition & 1 deletion worker/earn.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function earnStmts (data, { models }) {
})]
}

const DAILY_STIMULUS_SATS = 75_000
const DAILY_STIMULUS_SATS = 50_000
export async function earnRefill ({ models, lnd }) {
return await performPaidAction('DONATE',
{ sats: DAILY_STIMULUS_SATS },
Expand Down

0 comments on commit 9658807

Please sign in to comment.