Files
saladeaula.digital/apps/saladeaula.digital/app/routes/settings/emails/index.tsx
2025-11-28 11:41:06 -03:00

289 lines
8.4 KiB
TypeScript

import type { Route } from './+types/index'
import {
Suspense,
type ComponentProps,
type MouseEvent,
createContext,
use
} from 'react'
import { Await, useRevalidator } from 'react-router'
import { EllipsisIcon, CircleXIcon, SendIcon } from 'lucide-react'
import { useRequest, useToggle } from 'ahooks'
import { toast } from 'sonner'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { userContext } from '@repo/auth/context'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@repo/ui/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { request as req } from '@repo/util/request'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button'
import { useOutletContext } from 'react-router'
import type { User as AuthUser } from '@repo/auth/auth'
import type { User } from '@repo/ui/routes/users/data'
import {
Item,
ItemActions,
ItemContent,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Badge } from '@repo/ui/components/ui/badge'
import { type Email } from './data'
import { Add } from './add'
import { Primary } from './primary'
const ActionMenuContext = createContext<Email | null>(null)
export async function loader({ request, context }: Route.LoaderArgs) {
const user = context.get(userContext) as AuthUser
const data = req({
url: `/users/${user.sub}/emails`,
request,
context
}).then((r) => r.json() as Promise<{ items: Email[] }>)
return { data }
}
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
return (
<div className="space-y-2.5">
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ items = [] }) => (
<>
<Card>
<CardHeader>
<CardTitle className="text-lg">Emails</CardTitle>
<CardDescription>
Você pode associar vários emails a sua conta. É possível
usar qualquer email para recuperar a senha, mas apenas o
email principal receberá as mensagens.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
{items.map(({ sk, email_primary, email_verified }) => {
const [, email] = sk.split('#') as [string, string]
return (
<Item key={email} variant="outline">
<ItemContent>
<ItemTitle>
{email}{' '}
{email_primary && (
<Badge
variant="outline"
className="border-blue-500 text-blue-400 lowercase text-xs"
>
Principal
</Badge>
)}
{email_verified ? (
<Badge
variant="outline"
className="border-green-600 text-green-500 lowercase text-xs"
>
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>
<Add />
</CardContent>
</Card>
<Primary items={items} />
</>
)}
</Await>
</Suspense>
</div>
)
}
function ActionMenu() {
const { revalidate } = useRevalidator()
const onSuccess = () => {
revalidate()
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<ResendItem onSuccess={onSuccess} />
<RemoveItem onSuccess={onSuccess} />
</DropdownMenuContent>
</DropdownMenu>
)
}
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
onSuccess?: () => void
}
function ResendItem({ onSuccess }: ItemProps) {
const { user } = useOutletContext() as { user: User }
const { email, email_verified } = use(ActionMenuContext) as Email
const { runAsync, loading } = useRequest(
async () => {
const r = await fetch(
`/api/users/${user.id}/emails/${email}/request-verification`,
{
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' })
}
)
if (!r.ok) {
throw await r.json()
}
},
{
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) {
const { user } = useOutletContext() as { user: User }
const { email, email_primary } = use(ActionMenuContext) as Email
const [open, { set: setOpen }] = useToggle(false)
const { runAsync, loading } = useRequest(
async () => {
const r = await fetch(`/api/users/${user.id}/emails/${email}`, {
method: 'DELETE',
headers: new Headers({ 'Content-Type': 'application/json' })
})
if (!r.ok) {
throw await r.json()
}
},
{
manual: true
}
)
const cancel = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
try {
await runAsync()
toast.info(`O endereço de email ${email} foi removido.`)
onSuccess?.()
} catch (err) {
// @ts-ignore
if (err?.type === 'EmailConflictError') {
toast.error(`O endereço de email ${email} não pode ser removido.`)
}
}
setOpen(false)
}
if (email_primary) {
return null
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
{...props}
>
<CircleXIcon /> Remover
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">remove permanentemente</span> o seu
endereço de email.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}