update form
This commit is contained in:
167
id.saladeaula.digital/client/app/components/ui/form.tsx
Normal file
167
id.saladeaula.digital/client/app/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const OK = 200
|
||||||
export const FOUND = 302
|
export const FOUND = 302
|
||||||
export const BAD_REQUEST = 400
|
export const BAD_REQUEST = 400
|
||||||
export const INTERNAL_SERVER = 500
|
export const INTERNAL_SERVER = 500
|
||||||
|
|||||||
@@ -30,16 +30,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
redirect: 'manual'
|
redirect: 'manual'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (r.status === httpStatus.BAD_REQUEST) {
|
// if (r.status === httpStatus.BAD_REQUEST) {
|
||||||
return new Response(null, {
|
// return new Response(null, {
|
||||||
status: httpStatus.FOUND,
|
// status: httpStatus.FOUND,
|
||||||
headers: {
|
// headers: {
|
||||||
Location: redirect.toString()
|
// Location: redirect.toString()
|
||||||
}
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (r.status === httpStatus.FOUND) {
|
||||||
|
return new Response(await r.text(), {
|
||||||
|
status: r.status,
|
||||||
|
headers: r.headers
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(await r.text(), {
|
return Response.json(await r.json(), {
|
||||||
status: r.status,
|
status: r.status,
|
||||||
headers: r.headers
|
headers: r.headers
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/form'
|
||||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { Loader2Icon } from 'lucide-react'
|
import { Loader2Icon } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useFetcher } from 'react-router'
|
import { useFetcher } from 'react-router'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -16,12 +24,18 @@ import * as httpStatus from '@/lib/http-status'
|
|||||||
|
|
||||||
import logo from '@/components/logo.svg'
|
import logo from '@/components/logo.svg'
|
||||||
|
|
||||||
const cpf = z.string().refine(isValidCPF, { message: 'CPF inválido' })
|
|
||||||
const email = z.string().email({ message: 'Email inválido' })
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.union([cpf, email]),
|
username: z
|
||||||
password: z.string().nonempty()
|
.string()
|
||||||
|
.nonempty('Digite um Email ou CPF')
|
||||||
|
.refine((val) => {
|
||||||
|
const onlyDigits = val.replace(/\D/g, '')
|
||||||
|
|
||||||
|
return onlyDigits.length === 11
|
||||||
|
? isValidCPF(val)
|
||||||
|
: z.string().email().safeParse(val).success
|
||||||
|
}, 'Deve ser um Email ou CPF válido'),
|
||||||
|
password: z.string().nonempty('Digite uma senha')
|
||||||
})
|
})
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>
|
type Schema = z.infer<typeof schema>
|
||||||
@@ -43,6 +57,13 @@ export async function action({ request, context }: Route.ActionArgs) {
|
|||||||
body: JSON.stringify(formData)
|
body: JSON.stringify(formData)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (r.status !== httpStatus.OK) {
|
||||||
|
return Response.json(await r.json(), {
|
||||||
|
status: r.status,
|
||||||
|
headers: r.headers
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
url.pathname = '/authorize'
|
url.pathname = '/authorize'
|
||||||
|
|
||||||
@@ -54,7 +75,7 @@ export async function action({ request, context }: Route.ActionArgs) {
|
|||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: httpStatus.INTERNAL_SERVER })
|
return Response.json({}, { status: httpStatus.INTERNAL_SERVER })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +83,35 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
const { register, handleSubmit, formState } = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema)
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { username: '', password: '' }
|
||||||
})
|
})
|
||||||
|
const { control, handleSubmit, formState, setError } = form
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
const onSubmit = async (data: Schema) => {
|
||||||
await fetcher.submit(data, { method: 'post' })
|
await fetcher.submit(data, { method: 'post' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetcher.state === 'idle' && fetcher.data) {
|
||||||
|
const message = fetcher.data?.message
|
||||||
|
|
||||||
|
switch (message) {
|
||||||
|
case 'User not found':
|
||||||
|
return setError('username', {
|
||||||
|
message: 'Usuário não encontrado',
|
||||||
|
type: 'manual'
|
||||||
|
})
|
||||||
|
case 'Invalid credentials':
|
||||||
|
return setError('password', {
|
||||||
|
message: 'A senha está incorreta',
|
||||||
|
type: 'manual'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetcher.state, fetcher.data])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full max-w-xs grid gap-6">
|
<div className="w-full max-w-xs grid gap-6">
|
||||||
@@ -79,6 +121,7 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="text-center space-y-1.5">
|
<div className="text-center space-y-1.5">
|
||||||
@@ -94,14 +137,27 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<FormField
|
||||||
<Label htmlFor="email">Email ou CPF</Label>
|
control={control}
|
||||||
<Input id="email" {...register('username')} />
|
name="username"
|
||||||
</div>
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email ou CPF</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<FormField
|
||||||
<div className="flex items-center">
|
control={control}
|
||||||
<Label htmlFor="password">Senha</Label>
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex">
|
||||||
|
<FormLabel>Senha</FormLabel>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -110,13 +166,9 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
Esqueceu sua senha?
|
Esqueceu sua senha?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<FormControl>
|
||||||
<Input
|
<Input type={show ? 'text' : 'password'} {...field} />
|
||||||
id="password"
|
</FormControl>
|
||||||
type={show ? 'text' : 'password'}
|
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showPassword"
|
id="showPassword"
|
||||||
@@ -125,7 +177,10 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="showPassword">Mostrar senha</Label>
|
<Label htmlFor="showPassword">Mostrar senha</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -139,6 +194,7 @@ export default function Index({}: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
<p className="text-white/50 text-xs text-center">
|
<p className="text-white/50 text-xs text-center">
|
||||||
Ao fazer login, você concorda com nossa{' '}
|
Ao fazer login, você concorda com nossa{' '}
|
||||||
|
|||||||
20
id.saladeaula.digital/client/package-lock.json
generated
20
id.saladeaula.digital/client/package-lock.json
generated
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "id-saladeaula-digital",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "client",
|
"name": "id-saladeaula-digital",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
|
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.0.12",
|
"@cloudflare/vite-plugin": "^1.0.12",
|
||||||
@@ -1168,9 +1168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||||
"integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==",
|
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/utils": "^0.3.0"
|
"@standard-schema/utils": "^0.3.0"
|
||||||
@@ -5800,9 +5800,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.0.17",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||||
"integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==",
|
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
|
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.0.12",
|
"@cloudflare/vite-plugin": "^1.0.12",
|
||||||
|
|||||||
Reference in New Issue
Block a user