add list to catalog

This commit is contained in:
2025-12-21 00:30:29 -03:00
parent fed47b09ea
commit f35253a8d4
10 changed files with 510 additions and 172 deletions

View File

@@ -0,0 +1,3 @@
export default function Route() {
return <>...</>
}

View File

@@ -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
}

View File

@@ -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'
})

View File

@@ -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>
)
}

View File

@@ -1,50 +1,23 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import Fuse from 'fuse.js' import { Grid2X2Icon, ListIcon } from 'lucide-react'
import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
import { Suspense, useMemo } from 'react' import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router' import { Await, useSearchParams } from 'react-router'
import { SearchForm } from '@repo/ui/components/search-form' import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' 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 { Kbd } from '@repo/ui/components/ui/kbd'
import { cn } from '@repo/ui/lib/utils'
import { cloudflareContext } from '@repo/auth/context' import { cloudflareContext } from '@repo/auth/context'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { request as req } from '@repo/util/request' 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 type { CustomPricing, Course } from './data'
import { Currency } from '@repo/ui/components/currency' import { List } from './list'
import { Grid } from './grid'
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) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Catálogo de cursos' }] 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) { export default function Route({ loaderData: { data } }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('s') as string const search = searchParams.get('s') as string
const view = searchParams.get('view') || 'grid'
return ( return (
<Suspense fallback={<Skeleton />}> <Suspense fallback={<Skeleton />}>
@@ -88,6 +62,10 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
<Await resolve={data}> <Await resolve={data}>
{([{ hits }, { items }]) => { {([{ hits }, { items }]) => {
const hits_ = hits.filter(
({ metadata__unit_price = 0 }) => metadata__unit_price > 0
) as Course[]
return ( return (
<> <>
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5"> <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} defaultValue={search}
onChange={(search) => { onChange={(search) => {
setSearchParams({ s: String(search) }) setSearchParams((searchParams) => {
searchParams.set('s', String(search))
return searchParams
})
}} }}
/> />
</div> </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> </div>
<List {view === 'list' ? (
search={search} <List search={search} hits={hits_} customPricing={items} />
hits={hits as Course[]} ) : (
customPricing={items} <Grid search={search} hits={hits_} customPricing={items} />
/> )}
</> </>
) )
}} }}
@@ -119,132 +120,3 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</Suspense> </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'
})

View File

@@ -72,13 +72,13 @@ export default function Route({}: Route.ComponentProps) {
useEffect(() => { useEffect(() => {
if (fetcher.data?.ok) { if (fetcher.data?.ok) {
toast.success('O colaborador foi atualizado.') toast.success('O colaborador(a) foi atualizado(a).')
return return
} }
switch (fetcher.data?.error?.type) { switch (fetcher.data?.error?.type) {
case 'RateLimitExceededError': case 'RateLimitExceededError':
toast.error('Seu limite diário de atualizações foi atingido.') toast.error('Limite diário de atualizações atingido.')
return return
case 'CPFConflictError': case 'CPFConflictError':
setError('cpf', { message: 'CPF já está em uso' }) setError('cpf', { message: 'CPF já está em uso' })

56
package-lock.json generated
View File

@@ -3499,6 +3499,60 @@
} }
} }
}, },
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@@ -7441,6 +7495,8 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",

View File

@@ -32,6 +32,8 @@
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",

View File

@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }