add lookup

This commit is contained in:
2025-12-01 22:27:14 -03:00
parent f3e3d9f8c2
commit 8eb5427af4
22 changed files with 548 additions and 133 deletions

View File

@@ -5,8 +5,6 @@ import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router'
import placeholder from '@/assets/placeholder.webp'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
@@ -27,6 +25,8 @@ import { cn } from '@repo/ui/lib/utils'
import { createSearch } from '@repo/util/meili'
import { request as req } from '@repo/util/request'
import placeholder from '@/assets/placeholder.webp'
type Cert = {
exp_interval: number
}
@@ -61,7 +61,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
url: `/orgs/${params.orgid}/custom-pricing`,
context,
request
}).then((r) => r.json())
}).then((r) => r.json() as Promise<{ items: CustomPricing[] }>)
return {
data: Promise.all([courses, customPricing])
@@ -70,7 +70,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string
const s = searchParams.get('s') as string
return (
<Suspense fallback={<Skeleton />}>
@@ -96,17 +96,15 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
pesquisar
</>
}
defaultValue={term}
onChange={(term) => {
setSearchParams({ term })
defaultValue={s}
onChange={(s) => {
setSearchParams({ s: String(s) })
}}
/>
</div>
</div>
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
<List term={term} hits={hits} customPricing={items} />
</div>
<List s={s} hits={hits as Course[]} customPricing={items} />
</>
)
}}
@@ -116,11 +114,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
}
function List({
term,
s,
hits = [],
customPricing = []
}: {
term: string
s: string
hits: Course[]
customPricing: CustomPricing[]
}) {
@@ -133,12 +131,12 @@ function List({
}, [hits])
const hits_ = useMemo(() => {
if (!term) {
if (!s) {
return hits
}
return fuse.search(term).map(({ item }) => item)
}, [term, fuse, hits])
return fuse.search(s).map(({ item }) => item)
}, [s, fuse, hits])
const customPricingMap = new Map(
customPricing.map((x) => {
@@ -149,29 +147,35 @@ function List({
if (hits_.length === 0) {
return (
<Empty>
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{term}</mark>.
Nenhum resultado para <mark>{s}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return hits_.map((props: Course, idx) => {
return (
<Course
key={idx}
custom_pricing={customPricingMap.get(props.id)}
{...props}
/>
)
})
return (
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
{hits_
.filter(({ metadata__unit_price = 0 }) => metadata__unit_price > 0)
.map((props: Course, idx) => {
return (
<Course
key={idx}
custom_pricing={customPricingMap.get(props.id)}
{...props}
/>
)
})}
</div>
)
}
function Course({

View File

@@ -177,7 +177,7 @@ export default function Route({}: Route.ComponentProps) {
/>
</FormControl>
<FormLabel className="cursor-pointer">
Usar um email fornecido pela plataforma.
Usar email gerado pela plataforma
</FormLabel>
</FormItem>
)}

View File

@@ -8,7 +8,9 @@ import {
export default [
layout('routes/layout.tsx', [
index('routes/index.tsx'),
route('/signup', 'routes/signup.tsx'),
layout('routes/register/layout.tsx', [
route('/register', 'routes/register/index.tsx')
]),
route('/forgot', 'routes/forgot.tsx'),
route('/deny', 'routes/deny.tsx')
]),

View File

@@ -136,7 +136,7 @@ export default function Index({}: Route.ComponentProps) {
<p className="text-white/50 text-sm">
Não tem uma senha?{' '}
<Link
to="/signup"
to="/register"
className="font-medium text-white hover:underline"
>
Criar senha
@@ -183,6 +183,7 @@ export default function Index({}: Route.ComponentProps) {
<FormControl>
<Input
type={show ? 'text' : 'password'}
autoComplete="false"
placeholder="••••••••"
{...field}
/>

View File

@@ -0,0 +1,97 @@
import { PatternFormat } from 'react-number-format'
import { useRequest } from 'ahooks'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Button } from '@repo/ui/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cpf, type RegisterContextProps, type User } from './data'
import { RegisterContext } from './data'
import { use } from 'react'
const formSchema = z.object({
cpf: cpf
})
type Schema = z.infer<typeof formSchema>
export function Cpf() {
const { setUser } = use(RegisterContext) as RegisterContextProps
const form = useForm({
resolver: zodResolver(formSchema)
})
const { control, handleSubmit, formState } = form
const { runAsync } = useRequest(
async ({ cpf }) => {
return await fetch(`/lookup?cpf=${cpf}`, {
method: 'GET',
headers: new Headers({ 'Content-Type': 'application/json' })
})
},
{ manual: true }
)
const onSubmit = async ({ cpf }: Schema) => {
const r = await runAsync({ cpf })
const user = (await r.json()) as any
setUser({ cpf, ...user })
}
return (
<>
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset disabled={formState.isSubmitting} className="grid gap-6">
<FormField
control={control}
name="cpf"
defaultValue=""
render={({ field: { ref, onChange, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
autoFocus={true}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full cursor-pointer relative overflow-hidden"
>
{formState.isSubmitting && (
<div className="absolute bg-lime-500 inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
Continuar
</Button>
</fieldset>
</form>
</Form>
</>
)
}

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { createContext } from 'react'
const isName = (name: string) => name && name.includes(' ')
export type User = {
id?: string | null
cpf: string
name: string
email: string
}
export const cpf = z
.string()
.nonempty('Digite seu CPF')
.refine(isValidCPF, 'Deve ser um CPF válido')
export const formSchema = z.object({
name: z
.string()
.trim()
.nonempty('Digite seu nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.email('Digite seu email'),
password: z
.string()
.nonempty('Digite sua senha')
.min(6, 'Deve ter no mínimo 6 caracteres'),
cpf: cpf
})
export type Schema = z.infer<typeof formSchema>
export type RegisterContextProps = {
user: User | null
setUser: (user: User) => void
}
export const RegisterContext = createContext<RegisterContextProps | null>(null)

View File

@@ -1,13 +1,10 @@
import type { Route } from './+types'
import type { Route } from '../+types'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { useState, createContext, type ReactNode, use } from 'react'
import { useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { z } from 'zod'
import logo from '@repo/ui/components/logo2.svg'
import { Button } from '@repo/ui/components/ui/button'
import { Checkbox } from '@repo/ui/components/ui/checkbox'
import {
@@ -21,20 +18,8 @@ import {
import { Input } from '@repo/ui/components/ui/input'
import { Label } from '@repo/ui/components/ui/label'
const schema = z.object({
name: z.string().trim().nonempty('Digite seu nome'),
email: z.email('Digite seu email'),
password: z
.string()
.nonempty('Digite sua senha')
.min(6, 'Deve ter no mínimo 6 caracteres'),
cpf: z
.string()
.nonempty('Digite seu CPF')
.refine(isValidCPF, 'Deve ser um CPF válido')
})
type Schema = z.infer<typeof schema>
import { Cpf } from './cpf'
import { formSchema, type Schema, RegisterContext, type User } from './data'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Criar conta · EDUSEG®' }]
@@ -42,8 +27,9 @@ export function meta({}: Route.MetaArgs) {
export default function Signup({}: Route.ComponentProps) {
const [show, setShow] = useState(false)
const [user, setUser] = useState<User | null>(null)
const form = useForm({
resolver: zodResolver(schema)
resolver: zodResolver(formSchema)
})
const { control, handleSubmit, formState } = form
@@ -51,34 +37,17 @@ export default function Signup({}: Route.ComponentProps) {
console.log(data)
}
console.log(user)
return (
<>
<div className="space-y-6">
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />
</div>
</div>
<div className="text-center space-y-1.5">
<h1 className="text-2xl font-semibold font-display text-balance">
Criar conta
</h1>
<p className="text-white/50 text-sm">
tem uma conta?{' '}
<Link to="/" className="font-medium text-white hover:underline">
Faça login
</Link>
.
</p>
</div>
<RegisterContext value={{ user, setUser }}>
{user ? (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-6">
<FormField
control={control}
name="name"
defaultValue=""
defaultValue={user?.name}
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
@@ -93,6 +62,7 @@ export default function Signup({}: Route.ComponentProps) {
<FormField
control={control}
name="email"
defaultValue={user?.email}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
@@ -107,12 +77,22 @@ export default function Signup({}: Route.ComponentProps) {
<FormField
control={control}
name="cpf"
defaultValue=""
render={({ field }) => (
defaultValue={user.cpf}
render={({ field: { ref, onChange, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<Input placeholder="___.___.___-__" {...field} />
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -130,6 +110,7 @@ export default function Signup({}: Route.ComponentProps) {
<Input
type={show ? 'text' : 'password'}
placeholder="••••••••"
autoComplete="false"
{...field}
/>
</FormControl>
@@ -155,19 +136,9 @@ export default function Signup({}: Route.ComponentProps) {
</Button>
</form>
</Form>
<p className="text-white/50 text-xs text-center">
Ao fazer login, você concorda com nossa{' '}
<a
href="//eduseg.com.br/politica"
target="_blank"
className="underline hover:no-underline"
>
política de privacidade
</a>
.
</p>
</div>
</>
) : (
<Cpf />
)}
</RegisterContext>
)
}

View File

@@ -0,0 +1,50 @@
import type { Route } from './+types/index'
import { Outlet, Link } from 'react-router'
import logo from '@repo/ui/components/logo2.svg'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Criar conta · EDUSEG®' }]
}
export default function Layout() {
return (
<>
<div className="space-y-6">
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />
</div>
</div>
<div className="text-center space-y-1.5">
<h1 className="text-2xl font-semibold font-display text-balance">
Criar conta
</h1>
<p className="text-white/50 text-sm">
tem uma conta?{' '}
<Link to="/" className="font-medium text-white hover:underline">
Faça login
</Link>
.
</p>
</div>
<Outlet />
<p className="text-white/50 text-xs text-center">
Ao cadastrar, você concorda com nossa{' '}
<a
href="//eduseg.com.br/politica"
target="_blank"
className="underline hover:no-underline"
>
política de privacidade
</a>
.
</p>
</div>
</>
)
}

View File

@@ -9,8 +9,8 @@ async function proxy({
request,
context
}: Route.ActionArgs): Promise<Response> {
const pathname = new URL(request.url).pathname
const url = new URL(pathname, context.cloudflare.env.ISSUER_URL)
const { pathname, search } = new URL(request.url)
const url = new URL(pathname + search, context.cloudflare.env.ISSUER_URL)
const headers = new Headers(request.headers)
const shouldCache =

View File

@@ -87,7 +87,7 @@ export default function Component({
loaderData: { data }
}: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string
const s = searchParams.get('s') as string
return (
<Container className="space-y-4">
@@ -104,7 +104,7 @@ export default function Component({
<div className="flex gap-2.5">
<div className="w-full xl:w-1/3">
<SearchForm
defaultValue={term || ''}
defaultValue={s || ''}
placeholder={
<>
Digite <Kbd>/</Kbd> para pesquisar
@@ -112,7 +112,7 @@ export default function Component({
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('term', String(value))
searchParams.set('s', String(value))
return searchParams
})
}
@@ -145,14 +145,14 @@ export default function Component({
</div>
<Await resolve={data}>
{({ hits = [] }) => <List term={term} hits={hits as Enrollment[]} />}
{({ hits = [] }) => <List s={s} hits={hits as Enrollment[]} />}
</Await>
</Suspense>
</Container>
)
}
function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
function List({ s, hits = [] }: { s: string; hits: Enrollment[] }) {
const fuse = useMemo(() => {
return new Fuse(hits, {
keys: ['course.name'],
@@ -162,12 +162,12 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
}, [hits])
const hits_ = useMemo(() => {
if (!term) {
if (!s) {
return hits
}
return fuse.search(term).map(({ item }) => item)
}, [term, fuse, hits])
return fuse.search(s).map(({ item }) => item)
}, [s, fuse, hits])
if (hits_.length === 0) {
return (
@@ -178,7 +178,7 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{term}</mark>.
Nenhum resultado para <mark>{s}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>