279 lines
7.2 KiB
TypeScript
279 lines
7.2 KiB
TypeScript
import type { Route } from './+types'
|
|
|
|
import Fuse from 'fuse.js'
|
|
import {
|
|
BanIcon,
|
|
CircleCheckIcon,
|
|
CircleIcon,
|
|
CircleOffIcon,
|
|
CirclePlusIcon,
|
|
CircleXIcon,
|
|
EllipsisIcon,
|
|
FileBadgeIcon,
|
|
TimerIcon,
|
|
type LucideIcon
|
|
} from 'lucide-react'
|
|
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
|
|
import { Suspense, useMemo } from 'react'
|
|
import { Await, NavLink, useSearchParams } from 'react-router'
|
|
|
|
import type { User } from '@repo/auth/auth'
|
|
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,
|
|
CardAction
|
|
} from '@repo/ui/components/ui/card'
|
|
import { Kbd } from '@repo/ui/components/ui/kbd'
|
|
import { Progress } from '@repo/ui/components/ui/progress'
|
|
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
|
|
scormset?: string
|
|
}
|
|
|
|
export type Enrollment = {
|
|
id: string
|
|
course: Course
|
|
status: number
|
|
progress: number
|
|
}
|
|
|
|
export function meta({}: Route.MetaArgs) {
|
|
return [{ title: 'Meus cursos' }]
|
|
}
|
|
|
|
export async function loader({ request, context }: Route.ActionArgs) {
|
|
const user = context.get(userContext) as User
|
|
const { searchParams } = new URL(request.url)
|
|
const status = searchParams.getAll('status') || []
|
|
let builder = new MeiliSearchFilterBuilder().where('user.id', '=', user.sub)
|
|
|
|
if (status.length) {
|
|
builder = builder.where('status', 'in', status)
|
|
}
|
|
|
|
const enrollments = createSearch({
|
|
index: 'betaeducacao-prod-enrollments',
|
|
filter: builder.build(),
|
|
sort: ['created_at:desc'],
|
|
hitsPerPage: 100,
|
|
env: context.cloudflare.env
|
|
})
|
|
|
|
return {
|
|
data: enrollments
|
|
}
|
|
}
|
|
|
|
export default function Component({
|
|
loaderData: { data }
|
|
}: Route.ComponentProps) {
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const term = searchParams.get('term') as string
|
|
|
|
return (
|
|
<Container className="space-y-4">
|
|
<Suspense fallback={<Skeleton />}>
|
|
<div className="space-y-0.5 mb-8">
|
|
<h1 className="text-2xl font-bold tracking-tight">Meus cursos</h1>
|
|
<p className="text-muted-foreground">
|
|
Aqui você encontra todos os cursos em que está matriculado,
|
|
organizados em um só lugar. Acompanhe seu progresso e continue de
|
|
onde parou.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2.5">
|
|
<div className="w-full xl:w-1/3">
|
|
<SearchForm
|
|
defaultValue={term || ''}
|
|
placeholder={
|
|
<>
|
|
Digite <Kbd>/</Kbd> para pesquisar
|
|
</>
|
|
}
|
|
onChange={(value) =>
|
|
setSearchParams((searchParams) => {
|
|
searchParams.set('term', String(value))
|
|
return searchParams
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<FacetedFilter
|
|
icon={CirclePlusIcon}
|
|
value={searchParams.getAll('status')}
|
|
onChange={(statuses) => {
|
|
setSearchParams((searchParams) => {
|
|
searchParams.delete('status')
|
|
|
|
if (statuses.length) {
|
|
statuses.forEach((s) =>
|
|
searchParams.has('status', s)
|
|
? null
|
|
: searchParams.append('status', s)
|
|
)
|
|
}
|
|
|
|
return searchParams
|
|
})
|
|
}}
|
|
title="Status"
|
|
options={Object.entries(statuses).map(([key, value]) => ({
|
|
value: key,
|
|
...value
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
<Await resolve={data}>
|
|
{({ hits = [] }) => <List term={term} hits={hits as Enrollment[]} />}
|
|
</Await>
|
|
</Suspense>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
|
|
const fuse = useMemo(() => {
|
|
return new Fuse(hits, {
|
|
keys: ['course.name'],
|
|
threshold: 0.3,
|
|
includeMatches: true
|
|
})
|
|
}, [hits])
|
|
|
|
const hits_ = useMemo(() => {
|
|
if (!term) {
|
|
return hits
|
|
}
|
|
|
|
return fuse.search(term).map(({ item }) => item)
|
|
}, [term, fuse, hits])
|
|
|
|
if (hits_.length === 0) {
|
|
return (
|
|
<Empty className="border border-dashed">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<BanIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>Nada encontrado</EmptyTitle>
|
|
<EmptyDescription>
|
|
Nenhum resultado para <mark>{term}</mark>.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid lg:grid-cols-4 gap-5">
|
|
{hits_.map((props: Enrollment, idx) => {
|
|
return <Enrollment key={idx} {...props} />
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Enrollment({ id, course, progress }: Enrollment) {
|
|
return (
|
|
<NavLink
|
|
to={course?.scormset ? `/player/${id}` : '/konviva'}
|
|
className="hover:scale-105 transition"
|
|
>
|
|
<Card className="overflow-hidden relative h-96">
|
|
<CardHeader className="z-1 relative">
|
|
<CardTitle className="text-xl/6">{course.name}</CardTitle>
|
|
<CardAction>
|
|
<ActionMenu />
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
|
|
<Progress value={progress} />
|
|
<span className="text-xs">{progress}%</span>
|
|
</CardFooter>
|
|
<img
|
|
src={placeholder}
|
|
alt={course.name}
|
|
className="absolute bottom-0 opacity-75"
|
|
/>
|
|
</Card>
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
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 }
|
|
> = {
|
|
PENDING: {
|
|
icon: CircleIcon,
|
|
label: 'Não iniciado'
|
|
},
|
|
IN_PROGRESS: {
|
|
icon: TimerIcon,
|
|
color: 'text-blue-400 [&_svg]:text-blue-500',
|
|
label: 'Em andamento'
|
|
},
|
|
COMPLETED: {
|
|
icon: CircleCheckIcon,
|
|
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
|
|
label: 'Concluído'
|
|
},
|
|
FAILED: {
|
|
icon: CircleXIcon,
|
|
color: 'text-red-400 [&_svg]:text-red-500',
|
|
label: 'Reprovado'
|
|
},
|
|
CANCELED: {
|
|
icon: CircleOffIcon,
|
|
color: 'text-orange-400 [&_svg]:text-orange-500',
|
|
label: 'Cancelado'
|
|
}
|
|
}
|