add email

This commit is contained in:
2025-11-28 09:11:16 -03:00
parent 2467798855
commit c8e323163d
10 changed files with 228 additions and 51 deletions

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/api'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/certs'
import { Link } from 'react-router' import { Link } from 'react-router'

View File

@@ -1,5 +1,7 @@
import type { Route } from './+types/konviva'
import { redirect } from 'react-router' import { redirect } from 'react-router'
import type { Route } from './+types'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'

View File

@@ -3,8 +3,8 @@ import type { Route } from './+types/layout'
import { useToggle } from 'ahooks' import { useToggle } from 'ahooks'
import { MenuIcon } from 'lucide-react' import { MenuIcon } from 'lucide-react'
import { Link, NavLink, Outlet } from 'react-router' import { Link, NavLink, Outlet } from 'react-router'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth' import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode' import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'

View File

@@ -1,4 +1,4 @@
import type { Route } from '../+types' import type { Route } from './+types/orders'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper' import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Await, Link } from 'react-router' import { Await, Link } from 'react-router'

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/player'
import { HttpMethod, request as req } from '@repo/util/request' import { HttpMethod, request as req } from '@repo/util/request'
import { useFetcher } from 'react-router' import { useFetcher } from 'react-router'

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/proxy'
// Mapping of extensions to MIME types // Mapping of extensions to MIME types
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {

View File

@@ -0,0 +1,101 @@
import { useForm } from 'react-hook-form'
import { useRequest } from 'ahooks'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { Button } from '@repo/ui/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useOutletContext } from 'react-router'
import type { User } from '@repo/ui/routes/users/data'
import { useRevalidator } from 'react-router'
const formSchema = z.object({
email: z.email('Email inválido').trim().toLowerCase()
})
type Schema = z.infer<typeof formSchema>
export function AddEmail() {
const { revalidate } = useRevalidator()
const { user } = useOutletContext() as { user: User }
const { runAsync } = useRequest(
async (data) => {
const r = await fetch(`/api/users/${user.id}/emails`, {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data)
})
if (!r.ok) {
throw await r.json()
}
},
{ manual: true }
)
const form = useForm({ resolver: zodResolver(formSchema) })
const { handleSubmit, control, formState, reset } = form
const onSubmit = async ({ email }: Schema) => {
try {
await runAsync({ email })
reset()
revalidate()
toast.success(
`Enviamos um email de verificação para ${email}. Por favor, siga as instruções que constam nele.`
)
} catch (err) {
// @ts-ignore
if (err?.type === 'EmailConflictError') {
toast.error(`O endereço de email ${email} já está em uso.`)
}
}
}
return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset
disabled={formState.isSubmitting}
className="space-y-2.5 lg:w-2/3"
>
<h2 className="font-semibold">Adicionar email</h2>
<div className="flex gap-2.5">
<FormField
control={control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input placeholder="seu@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="relative overflow-hidden">
{formState.isSubmitting && (
<div className="absolute inset-0 bg-lime-500 flex items-center justify-center">
<Spinner />
</div>
)}
Adicionar
</Button>
</div>
</fieldset>
</form>
</Form>
)
}

View File

@@ -49,9 +49,11 @@ import {
Item, Item,
ItemActions, ItemActions,
ItemContent, ItemContent,
ItemDescription,
ItemTitle ItemTitle
} from '@repo/ui/components/ui/item' } from '@repo/ui/components/ui/item'
import { AddEmail } from './add-email'
import { Badge } from '@repo/ui/components/ui/badge'
import { useRevalidator } from 'react-router'
type Email = { type Email = {
sk: string sk: string
@@ -75,45 +77,83 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export default function Route({ loaderData: { data } }: Route.ComponentProps) { export default function Route({ loaderData: { data } }: Route.ComponentProps) {
return ( return (
<Suspense fallback={<Skeleton />}> <div className="space-y-2.5">
<Await resolve={data}> <Suspense fallback={<Skeleton />}>
{({ items = [] }) => ( <Await resolve={data}>
<Card> {({ items = [] }) => (
<CardHeader> <Card>
<CardTitle className="text-lg">Emails</CardTitle> <CardHeader>
<CardDescription> <CardTitle className="text-lg">Emails</CardTitle>
Podem ser associados vários emails a sua conta. É possível usar <CardDescription>
qualquer email para recuperar a senha, mas apenas o email Você pode associar vários emails a sua conta. É possível usar
principal receberá as mensagens. qualquer email para recuperar a senha, mas apenas o email
</CardDescription> principal receberá as mensagens.
</CardHeader> </CardDescription>
<CardContent className="flex flex-col gap-2.5"> </CardHeader>
{items.map(({ sk, ...props }) => { <CardContent className="flex flex-col gap-2.5">
const [, email] = sk.split('#') as [string, string] {items.map(({ sk, email_primary, email_verified }) => {
const [, email] = sk.split('#') as [string, string]
return ( return (
<Item key={email} variant="outline"> <Item key={email} variant="outline">
<ItemContent> <ItemContent>
<ItemTitle>{email}</ItemTitle> <ItemTitle>
<ItemDescription>...</ItemDescription> {email}{' '}
</ItemContent> {email_primary && (
<ItemActions> <Badge
<ActionMenuContext value={{ ...props, sk, email }}> variant="outline"
<ActionMenu /> className="border-blue-500 text-blue-400 lowercase text-xs"
</ActionMenuContext> >
</ItemActions> Principal
</Item> </Badge>
) )}
})} {email_verified ? (
</CardContent> <Badge
</Card> variant="outline"
)} className="border-green-600 text-green-500 lowercase text-xs"
</Await> >
</Suspense> Verificado
</Badge>
) : (
<Badge
variant="outline"
className="border-yellow-600 text-yellow-500 lowercase text-xs"
>
Não verificado
</Badge>
)}
</ItemTitle>
</ItemContent>
<ItemActions>
<ActionMenuContext
value={{ sk, email, email_primary, email_verified }}
>
<ActionMenu />
</ActionMenuContext>
</ItemActions>
</Item>
)
})}
</CardContent>
<CardContent>
<AddEmail />
</CardContent>
</Card>
)}
</Await>
</Suspense>
</div>
) )
} }
function ActionMenu() { function ActionMenu() {
const { revalidate } = useRevalidator()
const onSuccess = () => {
revalidate()
}
return ( return (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -122,10 +162,8 @@ function ActionMenu() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer"> <DropdownMenuContent align="end" className="*:cursor-pointer">
<DropdownMenuItem> <ResendItem onSuccess={onSuccess} />
<SendIcon /> Reenviar email de verificação <RemoveItem onSuccess={onSuccess} />
</DropdownMenuItem>
<RemoveItem />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
@@ -135,9 +173,40 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
onSuccess?: () => void onSuccess?: () => void
} }
function ResendItem({ onSuccess }: ItemProps) {
const { email, email_verified } = use(ActionMenuContext) as Email
const { runAsync, loading } = useRequest(
async () => {
return await new Promise((r) => setTimeout(r, 3000))
},
{
manual: true
}
)
const resend = async (e: Event) => {
e.preventDefault()
try {
await runAsync()
toast.success(
`Enviamos um email de verificação para ${email}. Por favor, siga as instruções contidas nele.`
)
onSuccess?.()
} catch {}
}
return (
<DropdownMenuItem onSelect={resend} disabled={email_verified}>
{loading ? <Spinner /> : <SendIcon />} Reenviar email de verificação
</DropdownMenuItem>
)
}
function RemoveItem({ onSuccess, ...props }: ItemProps) { function RemoveItem({ onSuccess, ...props }: ItemProps) {
const { user } = useOutletContext() as { user: User } const { user } = useOutletContext() as { user: User }
const { email } = use(ActionMenuContext) as Email const { email, email_primary } = use(ActionMenuContext) as Email
const [open, { set: setOpen }] = useToggle(false) const [open, { set: setOpen }] = useToggle(false)
const { runAsync, loading } = useRequest( const { runAsync, loading } = useRequest(
async () => { async () => {
@@ -160,15 +229,20 @@ function RemoveItem({ onSuccess, ...props }: ItemProps) {
try { try {
await runAsync() await runAsync()
toast.success('O email foi removido') toast.info(`O endereço de email ${email} foi removido.`)
onSuccess?.() onSuccess?.()
setOpen(false)
} catch (err) { } catch (err) {
// @ts-ignore // @ts-ignore
if (err?.type === 'EmailConflictError') { if (err?.type === 'EmailConflictError') {
toast.error('O email não pode ser removido') toast.error(`O endereço de email ${email} não pode ser removido.`)
} }
} }
setOpen(false)
}
if (email_primary) {
return null
} }
return ( return (

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types/emails' import type { Route } from './+types/password'
export default function Route({}: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
return <></> return <></>