Files
saladeaula.digital/apps/admin.saladeaula.digital/app/routes/_.$orgid.courses._index/route.tsx
2025-11-05 16:26:01 -03:00

237 lines
5.6 KiB
TypeScript

import type { Route } from './+types'
import Fuse from 'fuse.js'
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 { createSearch } from '@/lib/meili'
import { request as req } from '@/lib/request'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
Card,
CardFooter,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { cn } from '@repo/ui/lib/utils'
type Cert = {
exp_interval: number
}
type Course = {
id: string
name: string
access_period: string
cert: Cert
metadata__unit_price?: number
}
type CustomPricing = {
sk: string
unit_price: number
}
export function meta({}: Route.MetaArgs) {
return [{ title: 'Catálogo de cursos' }]
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const courses = createSearch({
index: 'saladeaula_courses',
sort: ['created_at:desc'],
filter: 'draft NOT EXISTS',
hitsPerPage: 100,
env: context.cloudflare.env
})
const customPricing = req({
url: `/orgs/${params.orgid}/custompricing`,
context,
request
}).then((r) => r.json())
return {
data: Promise.all([courses, customPricing])
}
}
export default function Route({ loaderData: { data } }) {
const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Catálogo de cursos
</h1>
<p className="text-muted-foreground">
Explore o catálogo de cursos disponíveis para sua empresa.
</p>
</div>
<Await resolve={data}>
{([{ hits }, { items }]) => {
return (
<>
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5">
<div className="2xl:w-92">
<SearchForm
placeholder={
<>
Pressione <Kbd className="border font-mono">/</Kbd> para
filtrar...
</>
}
defaultValue={term}
onChange={(term) => {
setSearchParams({ term })
}}
/>
</div>
</div>
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
<List term={term} hits={hits} customPricing={items} />
</div>
</>
)
}}
</Await>
</Suspense>
)
}
function List({
term,
hits = [],
customPricing = []
}: {
term: string
hits: Course[]
customPricing: CustomPricing[]
}) {
const fuse = useMemo(() => {
return new Fuse(hits, {
keys: ['name'],
threshold: 0.3,
includeMatches: true
})
}, [hits])
const hits_ = useMemo(() => {
if (!term) {
return hits
}
return fuse.search(term).map(({ item }) => item)
}, [term, fuse, hits])
const customPricingMap = new Map(
customPricing.map((x) => {
const [, courseId] = x.sk.split('#')
return [courseId, x.unit_price]
})
)
if (hits_.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{term}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return hits_.map((props: Course, idx) => {
return (
<Course
key={idx}
custom_pricing={customPricingMap.get(props.id)}
{...props}
/>
)
})
}
function Course({
name,
access_period,
cert,
metadata__unit_price,
custom_pricing
}: Course & { custom_pricing?: number }) {
return (
<Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{name}</CardTitle>
</CardHeader>
<CardFooter className="text-gray-300 text-sm absolute z-1 bottom-6 w-full flex gap-1.5">
<ul className="flex gap-2.5">
<li className="flex gap-0.5 items-center">
<LaptopIcon className="size-4" />
<span>{access_period}d</span>
</li>
{cert?.exp_interval && (
<li className="flex gap-0.5 items-center">
<AwardIcon className="size-4" />
<span>{cert.exp_interval}d</span>
</li>
)}
{metadata__unit_price && (
<>
<li className="flex gap-1.5">
<span
className={cn({
'line-through text-muted-foreground': custom_pricing
})}
>
{currency.format(metadata__unit_price)}
</span>
{custom_pricing && (
<span>{currency.format(custom_pricing)}</span>
)}
</li>
</>
)}
</ul>
</CardFooter>
<img
src={placeholder}
alt={name}
className="absolute bottom-0 opacity-75"
/>
</Card>
)
}
const currency = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
})