add notification

This commit is contained in:
2025-11-27 20:41:29 -03:00
parent ab7e4ea38b
commit 2467798855
19 changed files with 560 additions and 80 deletions

View File

@@ -3,10 +3,10 @@
import {
BookCopyIcon,
CalendarClockIcon,
DollarSign,
DollarSignIcon,
FileBadgeIcon,
GraduationCap,
LayoutDashboard,
LayoutDashboardIcon,
ShieldUserIcon,
UploadIcon,
UsersIcon
@@ -26,12 +26,12 @@ const data = {
{
title: 'Visão geral',
url: '/main',
icon: LayoutDashboard
icon: LayoutDashboardIcon
},
{
title: 'Histórico de compras',
url: '/orders',
icon: DollarSign
icon: DollarSignIcon
}
],
navUser: [

View File

@@ -0,0 +1,27 @@
import { BellIcon } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import { Button } from '@repo/ui/components/ui/button'
export function Notification() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="link"
size="icon"
className="cursor-pointer text-muted-foreground"
>
<BellIcon className="size-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<>Notificações</>
</PopoverContent>
</Popover>
)
}

View File

@@ -26,7 +26,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger
} from '@repo/ui/components/ui/alert-dialog'
import { Button } from '@repo/ui/components/ui/button'
import {
DropdownMenu,
@@ -58,24 +57,24 @@ export const columns: ColumnDef<Enrollment>[] = [
}
]
async function getEnrollment(id: string) {
const r = await fetch(`/~/api/enrollments/${id}`, {
method: 'GET'
})
await new Promise((r) => setTimeout(r, 150))
return (await r.json()) as {
cancel_policy?: any
lock?: { hash: string }
}
}
function ActionMenu({ row }: { row: any }) {
const [open, { set: setOpen }] = useToggle(false)
const cert = row.original?.cert
const { data, loading, run, refresh } = useRequest(getEnrollment, {
manual: true
})
const { data, loading, runAsync, refresh } = useRequest(
async () => {
const r = await fetch(`/~/api/enrollments/${row.id}`, {
method: 'GET'
})
return (await r.json()) as {
cancel_policy?: any
lock?: { hash: string }
}
},
{
manual: true
}
)
const onSuccess = () => {
refresh()
@@ -90,7 +89,7 @@ function ActionMenu({ row }: { row: any }) {
onOpenChange={(open) => {
setOpen(open)
if (data) return
run(row.id)
runAsync()
}}
>
<DropdownMenuTrigger asChild>
@@ -140,22 +139,27 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
}
function DownloadItem({ id, onSuccess, ...props }: ItemProps) {
const [loading, { set }] = useToggle(false)
const { runAsync, loading } = useRequest(
async () => {
return await fetch(`/~/api/enrollments/${id}/download`)
},
{
manual: true
}
)
const download = async (e: Event) => {
e.preventDefault()
set(true)
const r = await fetch(`/~/api/enrollments/${id}/download`, {
method: 'GET'
})
if (r.ok) {
const { presigned_url } = (await r.json()) as { presigned_url: string }
try {
const r = await runAsync()
const { presigned_url } = (await r.json()) as {
presigned_url: string
}
window.open(presigned_url, '_blank')
set(false)
onSuccess?.()
}
} catch {}
}
return (
@@ -299,7 +303,7 @@ function CancelItem({
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">
cancelar permanentemente a matrícula
cancela permanentemente a matrícula
</span>{' '}
deste colaborador.
</AlertDialogDescription>

View File

@@ -20,6 +20,7 @@ import { Toaster } from '@repo/ui/components/ui/sonner'
import { request as req } from '@repo/util/request'
import { AppSidebar } from '@/components/app-sidebar'
import { Notification } from '@/components/notification'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
@@ -78,6 +79,7 @@ export default function Route({ loaderData }: Route.ComponentProps) {
<ThemedImage className="max-md:hidden" />
<div className="ml-auto flex gap-2.5 items-center">
<Notification />
<ModeToggle />
<NavUser user={user} />
</div>

View File

@@ -17,7 +17,8 @@ export default [
]),
route('konviva', 'routes/konviva.ts'),
route('player/:id', 'routes/player.tsx'),
route('proxy/*', 'routes/proxy.tsx')
route('proxy/*', 'routes/proxy.tsx'),
route('api/*', 'routes/api.ts')
]),
route('logout', 'routes/auth/logout.ts'),
route('login', 'routes/auth/login.ts')

View File

@@ -1,12 +1,5 @@
import type { Route } from './+types'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import Fuse from 'fuse.js'
import {
BanIcon,
@@ -15,6 +8,8 @@ import {
CircleOffIcon,
CirclePlusIcon,
CircleXIcon,
EllipsisIcon,
FileBadgeIcon,
TimerIcon,
type LucideIcon
} from 'lucide-react'
@@ -27,12 +22,28 @@ import { userContext } from '@repo/auth/context'
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
CardTitle,
CardAction
} from '@repo/ui/components/ui/card'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Progress } from '@repo/ui/components/ui/progress'
@@ -40,6 +51,7 @@ import { createSearch } from '@repo/util/meili'
import placeholder from '@/assets/placeholder.webp'
import { Container } from '@/components/container'
import { Button } from '@repo/ui/components/ui/button'
type Course = {
name: string
@@ -200,8 +212,10 @@ function Enrollment({ id, course, progress }: Enrollment) {
<Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{course.name}</CardTitle>
<CardAction>
<ActionMenu />
</CardAction>
</CardHeader>
<CardContent className="z-1"></CardContent>
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
<Progress value={progress} />
<span className="text-xs">{progress}%</span>
@@ -216,6 +230,23 @@ function Enrollment({ id, course, progress }: Enrollment) {
)
}
function ActionMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={true}>
<FileBadgeIcon /> Baixar certificado
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const statuses: Record<
string,
{ icon: LucideIcon; color?: string; label: string }

View File

@@ -1,12 +1,12 @@
import type { Route } from './+types'
import type { Route } from './+types/layout'
import { useToggle } from 'ahooks'
import { MenuIcon } from 'lucide-react'
import { Link, NavLink, Outlet } from 'react-router'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
import { NavUser } from '@repo/ui/components/nav-user'
import { Button } from '@repo/ui/components/ui/button'
@@ -22,11 +22,12 @@ import {
SheetTitle,
SheetTrigger
} from '@repo/ui/components/ui/sheet'
import type { User } from '@repo/auth/auth'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ context }: Route.ActionArgs) {
const user = context.get(userContext)
const user = context.get(userContext) as User
return Response.json({ user })
}
@@ -45,8 +46,9 @@ const navMain = [
}
]
export default function Component({ loaderData }: Route.ComponentProps) {
const { user } = loaderData
export default function Component({
loaderData: { user }
}: Route.ComponentProps) {
const [isOpen, { toggle }] = useToggle()
return (
@@ -119,6 +121,12 @@ export default function Component({ loaderData }: Route.ComponentProps) {
</header>
<Outlet />
<Toaster
position="top-center"
richColors={true}
duration={Infinity}
closeButton={true}
/>
</div>
)
}

View File

@@ -1,8 +1,19 @@
import type { Route } from './+types/emails'
import { Suspense } from 'react'
import { Await } from 'react-router'
import {
Suspense,
type ComponentProps,
type MouseEvent,
createContext,
use
} from 'react'
import { Await } 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,
@@ -11,17 +22,53 @@ import {
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import type { User } from '@repo/auth/auth'
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,
ItemDescription,
ItemTitle
} from '@repo/ui/components/ui/item'
type Email = {
sk: string
email: string
email_verified: boolean
email_primary: boolean
}
const ActionMenuContext = createContext<Email | null>(null)
export async function loader({ request, context }: Route.LoaderArgs) {
const user = context.get(userContext) as User
const user = context.get(userContext) as AuthUser
const data = req({
url: `/users/${user.sub}/emails`,
request,
context
}).then((r) => r.json())
}).then((r) => r.json() as Promise<{ items: Email[] }>)
return { data }
}
@@ -40,13 +87,24 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
principal receberá as mensagens.
</CardDescription>
</CardHeader>
<CardContent>
<ul>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return <li key={idx}>{email}</li>
})}
</ul>
<CardContent className="flex flex-col gap-2.5">
{items.map(({ sk, ...props }) => {
const [, email] = sk.split('#') as [string, string]
return (
<Item key={email} variant="outline">
<ItemContent>
<ItemTitle>{email}</ItemTitle>
<ItemDescription>...</ItemDescription>
</ItemContent>
<ItemActions>
<ActionMenuContext value={{ ...props, sk, email }}>
<ActionMenu />
</ActionMenuContext>
</ItemActions>
</Item>
)
})}
</CardContent>
</Card>
)}
@@ -54,3 +112,94 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</Suspense>
)
}
function ActionMenu() {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<DropdownMenuItem>
<SendIcon /> Reenviar email de verificação
</DropdownMenuItem>
<RemoveItem />
</DropdownMenuContent>
</DropdownMenu>
)
}
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
onSuccess?: () => void
}
function RemoveItem({ onSuccess, ...props }: ItemProps) {
const { user } = useOutletContext() as { user: User }
const { email } = 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.success('O email foi removido')
onSuccess?.()
setOpen(false)
} catch (err) {
// @ts-ignore
if (err?.type === 'EmailConflictError') {
toast.error('O email não pode ser removido')
}
}
}
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>
)
}