237 lines
5.7 KiB
TypeScript
237 lines
5.7 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 { 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'
|
|
import { createSearch } from '@repo/util/meili'
|
|
import { request as req } from '@repo/util/request'
|
|
|
|
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}/custom-pricing`,
|
|
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={
|
|
<>
|
|
Digite <Kbd className="border font-mono">/</Kbd> para
|
|
pesquisar
|
|
</>
|
|
}
|
|
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'
|
|
})
|