add list to catalog
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default function Route() {
|
||||
return <>...</>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type Cert = {
|
||||
exp_interval: number
|
||||
}
|
||||
|
||||
export type Course = {
|
||||
id: string
|
||||
name: string
|
||||
access_period: string
|
||||
cert: Cert
|
||||
metadata__unit_price?: number
|
||||
}
|
||||
|
||||
export type CustomPricing = {
|
||||
sk: string
|
||||
unit_price: number
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
import { Currency } from '@repo/ui/components/currency'
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle
|
||||
} from '@repo/ui/components/ui/empty'
|
||||
|
||||
import type { Course, CustomPricing } from './data'
|
||||
import placeholder from '@/assets/placeholder.webp'
|
||||
|
||||
export function Grid({
|
||||
search,
|
||||
hits = [],
|
||||
customPricing = []
|
||||
}: {
|
||||
search: string
|
||||
hits: Course[]
|
||||
customPricing: CustomPricing[]
|
||||
}) {
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(hits, {
|
||||
keys: ['name'],
|
||||
threshold: 0.3,
|
||||
includeMatches: true
|
||||
})
|
||||
}, [hits])
|
||||
|
||||
const hits_ = useMemo(() => {
|
||||
if (!search) {
|
||||
return hits
|
||||
}
|
||||
|
||||
return fuse.search(search).map(({ item }) => item)
|
||||
}, [search, 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 className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<BanIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nada encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Nenhum resultado para <mark>{search}</mark>.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{hits_.map((props: Course, idx) => {
|
||||
return (
|
||||
<Course
|
||||
key={idx}
|
||||
custom_pricing={customPricingMap.get(props.id)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
'font-bold': !custom_pricing
|
||||
})}
|
||||
>
|
||||
<Currency>{metadata__unit_price}</Currency>
|
||||
</span>
|
||||
|
||||
{custom_pricing && (
|
||||
<span className="font-bold">
|
||||
<Currency>{custom_pricing}</Currency>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</CardFooter>
|
||||
|
||||
<img
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
className="absolute bottom-0 dark:opacity-75"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const currency = new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@repo/ui/components/ui/table'
|
||||
|
||||
import type { Course, CustomPricing } from './data'
|
||||
import { Currency } from '@repo/ui/components/currency'
|
||||
import { Card, CardContent } from '@repo/ui/components/ui/card'
|
||||
|
||||
export function List({
|
||||
search,
|
||||
hits = [],
|
||||
customPricing = []
|
||||
}: {
|
||||
search: string
|
||||
hits: Course[]
|
||||
customPricing: CustomPricing[]
|
||||
}) {
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(hits, {
|
||||
keys: ['name'],
|
||||
threshold: 0.3,
|
||||
includeMatches: true
|
||||
})
|
||||
}, [hits])
|
||||
|
||||
const hits_ = useMemo(() => {
|
||||
if (!search) {
|
||||
return hits
|
||||
}
|
||||
|
||||
return fuse.search(search).map(({ item }) => item)
|
||||
}, [search, fuse, hits])
|
||||
|
||||
const customPricingMap = new Map(
|
||||
customPricing.map((x) => {
|
||||
const [, courseId] = x.sk.split('#')
|
||||
return [courseId, x.unit_price]
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableHead>Curso</TableHead>
|
||||
<TableHead>Tempo de acesso</TableHead>
|
||||
<TableHead>Validade do cert.</TableHead>
|
||||
<TableHead>Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hits_.map(
|
||||
(
|
||||
{ id, name, access_period, cert, metadata__unit_price },
|
||||
index
|
||||
) => {
|
||||
const custom_pricing = customPricingMap.get(id)
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell>{access_period} dias</TableCell>
|
||||
<TableCell>
|
||||
{cert?.exp_interval ? (
|
||||
<>{cert.exp_interval} dias</>
|
||||
) : (
|
||||
<>N/A</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{metadata__unit_price ? (
|
||||
<>
|
||||
{custom_pricing ? (
|
||||
<Currency>{custom_pricing}</Currency>
|
||||
) : (
|
||||
<Currency>{metadata__unit_price}</Currency>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>N/A</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{hits_.length === 0 && (
|
||||
<TableRow className="pointer-events-none">
|
||||
<TableCell colSpan={4} className="h-20 text-center">
|
||||
Nenhum resultado para <mark>{search}</mark>.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +1,23 @@
|
||||
import type { Route } from './+types/route'
|
||||
|
||||
import Fuse from 'fuse.js'
|
||||
import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
|
||||
import { Grid2X2Icon, ListIcon } from 'lucide-react'
|
||||
import { Suspense, useMemo } from 'react'
|
||||
import { Await, useSearchParams } from 'react-router'
|
||||
|
||||
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 { cloudflareContext } from '@repo/auth/context'
|
||||
import { createSearch } from '@repo/util/meili'
|
||||
import { request as req } from '@repo/util/request'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem
|
||||
} from '@repo/ui/components/ui/toggle-group'
|
||||
|
||||
import placeholder from '@/assets/placeholder.webp'
|
||||
import { Currency } from '@repo/ui/components/currency'
|
||||
|
||||
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
|
||||
}
|
||||
import type { CustomPricing, Course } from './data'
|
||||
import { List } from './list'
|
||||
import { Grid } from './grid'
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Catálogo de cursos' }]
|
||||
@@ -74,6 +47,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
|
||||
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const search = searchParams.get('s') as string
|
||||
const view = searchParams.get('view') || 'grid'
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
@@ -88,6 +62,10 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
|
||||
<Await resolve={data}>
|
||||
{([{ hits }, { items }]) => {
|
||||
const hits_ = hits.filter(
|
||||
({ metadata__unit_price = 0 }) => metadata__unit_price > 0
|
||||
) as Course[]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5">
|
||||
@@ -101,17 +79,40 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
}
|
||||
defaultValue={search}
|
||||
onChange={(search) => {
|
||||
setSearchParams({ s: String(search) })
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set('s', String(search))
|
||||
return searchParams
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={view}
|
||||
className="**:cursor-pointer"
|
||||
onValueChange={(value) => {
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set('view', value)
|
||||
return searchParams
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="list">
|
||||
<ListIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="grid">
|
||||
<Grid2X2Icon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<List
|
||||
search={search}
|
||||
hits={hits as Course[]}
|
||||
customPricing={items}
|
||||
/>
|
||||
{view === 'list' ? (
|
||||
<List search={search} hits={hits_} customPricing={items} />
|
||||
) : (
|
||||
<Grid search={search} hits={hits_} customPricing={items} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
@@ -119,132 +120,3 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function List({
|
||||
search,
|
||||
hits = [],
|
||||
customPricing = []
|
||||
}: {
|
||||
search: string
|
||||
hits: Course[]
|
||||
customPricing: CustomPricing[]
|
||||
}) {
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(hits, {
|
||||
keys: ['name'],
|
||||
threshold: 0.3,
|
||||
includeMatches: true
|
||||
})
|
||||
}, [hits])
|
||||
|
||||
const hits_ = useMemo(() => {
|
||||
if (!search) {
|
||||
return hits
|
||||
}
|
||||
|
||||
return fuse.search(search).map(({ item }) => item)
|
||||
}, [search, 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 className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<BanIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nada encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Nenhum resultado para <mark>{search}</mark>.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{hits_
|
||||
.filter(({ metadata__unit_price = 0 }) => metadata__unit_price > 0)
|
||||
.map((props: Course, idx) => {
|
||||
return (
|
||||
<Course
|
||||
key={idx}
|
||||
custom_pricing={customPricingMap.get(props.id)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
'font-bold': !custom_pricing
|
||||
})}
|
||||
>
|
||||
<Currency>{metadata__unit_price}</Currency>
|
||||
</span>
|
||||
|
||||
{custom_pricing && (
|
||||
<span className="font-bold">
|
||||
<Currency>{custom_pricing}</Currency>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</CardFooter>
|
||||
|
||||
<img
|
||||
src={placeholder}
|
||||
alt={name}
|
||||
className="absolute bottom-0 dark:opacity-75"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const currency = new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
})
|
||||
|
||||
@@ -72,13 +72,13 @@ export default function Route({}: Route.ComponentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.ok) {
|
||||
toast.success('O colaborador foi atualizado.')
|
||||
toast.success('O colaborador(a) foi atualizado(a).')
|
||||
return
|
||||
}
|
||||
|
||||
switch (fetcher.data?.error?.type) {
|
||||
case 'RateLimitExceededError':
|
||||
toast.error('Seu limite diário de atualizações foi atingido.')
|
||||
toast.error('Limite diário de atualizações atingido.')
|
||||
return
|
||||
case 'CPFConflictError':
|
||||
setError('cpf', { message: 'CPF já está em uso' })
|
||||
|
||||
Reference in New Issue
Block a user