download cert to uyser

This commit is contained in:
2025-12-01 14:49:54 -03:00
parent 8d312893fa
commit f3e3d9f8c2
9 changed files with 218 additions and 112 deletions

View File

@@ -199,19 +199,7 @@ function RemoveDedupItem({
}
}
const getDaysRemaining = () => {
if (!lock?.ttl) return null
return new Date(lock.ttl * 1000).toLocaleString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit',
year: '2-digit'
})
}
const daysRemaining = getDaysRemaining()
const daysRemaining = lock?.ttl ? getDaysRemaining(lock.ttl) : null
return (
<AlertDialog open={open} onOpenChange={setOpen}>
@@ -254,6 +242,16 @@ function RemoveDedupItem({
)
}
const getDaysRemaining = (ttl: number) => {
return new Date(ttl * 1000).toLocaleString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit',
year: '2-digit'
})
}
function CancelItem({
id,
cancelPolicy,

View File

@@ -42,16 +42,18 @@ export async function loader({ context, request }: Route.LoaderArgs) {
builder = builder.where(field, 'between', [from_, to])
}
const enrollments = createSearch({
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: [sort],
query,
page,
hitsPerPage,
env: context.cloudflare.env
})
return {
data: createSearch({
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: [sort],
query,
page,
hitsPerPage,
env: context.cloudflare.env
})
data: enrollments
}
}

View File

@@ -3,18 +3,15 @@ import type { Route } from './+types'
import Fuse from 'fuse.js'
import {
BanIcon,
CircleCheckIcon,
CircleIcon,
CircleOffIcon,
CirclePlusIcon,
CircleXIcon,
EllipsisIcon,
FileBadgeIcon,
TimerIcon,
type LucideIcon
PresentationIcon,
HelpCircleIcon
} from 'lucide-react'
import { useRequest } from 'ahooks'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useMemo } from 'react'
import { Suspense, useMemo, type ComponentProps } from 'react'
import { Await, NavLink, useSearchParams } from 'react-router'
import type { User } from '@repo/auth/auth'
@@ -22,12 +19,12 @@ 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 { cn } from '@repo/ui/lib/utils'
import { Badge } from '@repo/ui/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import {
@@ -39,31 +36,25 @@ import {
} from '@repo/ui/components/ui/empty'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
CardAction
CardAction,
CardContent
} 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 { Button } from '@repo/ui/components/ui/button'
import {
statuses,
labels,
type Enrollment
} from '@repo/ui/routes/enrollments/data'
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
}
import { Spinner } from '@repo/ui/components/ui/spinner'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Meus cursos' }]
@@ -203,23 +194,42 @@ function List({ term, hits = [] }: { term: string; hits: Enrollment[] }) {
)
}
function Enrollment({ id, course, progress }: Enrollment) {
function Enrollment(enrollment: Enrollment) {
const { id, course, progress, status } = enrollment
const disabled = ['CANCELED', 'FAILED'].includes(status)
return (
<NavLink
to={course?.scormset ? `/player/${id}` : '/konviva'}
className="hover:scale-105 transition"
onClick={(e) => {
if (disabled) {
e.preventDefault()
}
if ((e.target as HTMLElement).closest('[role="menu"]')) {
e.preventDefault()
}
}}
>
<Card className="overflow-hidden relative h-96">
<Card
aria-disabled={disabled}
className="overflow-hidden relative h-96 hover:scale-105
has-data-[state=open]:scale-105 transition
aria-disabled:border-dashed"
>
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{course.name}</CardTitle>
<CardAction>
<ActionMenu />
<ActionMenu disabled={disabled} {...enrollment} />
</CardAction>
<Status status={status} />
</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}
@@ -230,49 +240,86 @@ function Enrollment({ id, course, progress }: Enrollment) {
)
}
function ActionMenu() {
function ActionMenu({
id,
course,
cert,
disabled
}: Enrollment & { disabled?: boolean }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
disabled={disabled}
>
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={true}>
<FileBadgeIcon /> Baixar certificado
</DropdownMenuItem>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<ContinueItem id={id} course={course} />
<DownloadItem id={id} disabled={!cert} />
</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'
}
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
id: string
onSuccess?: () => void
}
function DownloadItem({ id, onSuccess, ...props }: ItemProps) {
const { runAsync, loading } = useRequest(
async () => {
return await fetch(`/api/enrollments/${id}/download`)
},
{
manual: true
}
)
const download = async (e: Event) => {
e.preventDefault()
try {
const r = await runAsync()
const { presigned_url } = (await r.json()) as {
presigned_url: string
}
window.open(presigned_url, '_blank')
onSuccess?.()
} catch {}
}
return (
<DropdownMenuItem onSelect={download} {...props}>
{loading ? <Spinner /> : <FileBadgeIcon />} Baixar certificado
</DropdownMenuItem>
)
}
function ContinueItem({ id, course }: ItemProps & { course: any }) {
return (
<DropdownMenuItem asChild>
<NavLink to={course?.scormset ? `/player/${id}` : '/konviva'}>
<PresentationIcon /> Continuar curso
</NavLink>
</DropdownMenuItem>
)
}
function Status({ status: s }: { status: string }) {
const status = labels[s] ?? s
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return (
<Badge variant="outline" className={cn(color, 'bg-card px-1.5')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}

View File

@@ -56,10 +56,10 @@ const navMain = [
title: 'Meus cursos',
url: '/'
},
{
title: 'Certificados',
url: '/certs'
},
// {
// title: 'Certificados',
// url: '/certs'
// },
{
title: 'Histórico de compras',
url: '/history'
@@ -78,7 +78,7 @@ export default function Component({
}, [flash])
return (
<div className="relative flex flex-col flex-1 min-w-0 h-full">
<div className="relative flex flex-col flex-1 min-w-0 h-screen overflow-y-auto">
<header
className="bg-background/15 backdrop-blur-sm
px-4 py-2 lg:py-4 sticky top-0 z-5"

View File

@@ -47,6 +47,7 @@ import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Badge } from '@repo/ui/components/ui/badge'
@@ -92,8 +93,8 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
return (
<Item key={email} variant="outline">
<ItemContent>
<ItemTitle>
{email}{' '}
<ItemTitle>{email}</ItemTitle>
<ItemDescription className="flex gap-1">
{email_primary && (
<Badge
variant="outline"
@@ -117,7 +118,7 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
Não verificado
</Badge>
)}
</ItemTitle>
</ItemDescription>
</ItemContent>
<ItemActions>
<ActionMenuContext

View File

@@ -78,10 +78,10 @@ export default function Route({}: Route.ComponentProps) {
{user?.rate_limit_exceeded && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Limite de atualizações excedido</AlertTitle>
<AlertTitle>Limite diário de atualizações atingido.</AlertTitle>
<AlertDescription>
Nova tentativa disponível a partir de{' '}
{remainingTime(user.rate_limit_exceeded.ttl)}
Tente novamente a partir de{' '}
{getDaysRemaining(user.rate_limit_exceeded.ttl)}
</AlertDescription>
</Alert>
)}
@@ -179,7 +179,7 @@ export default function Route({}: Route.ComponentProps) {
)
}
function remainingTime(ttl: number) {
function getDaysRemaining(ttl: number) {
const date = new Date(ttl * 1000)
const day = date.toLocaleDateString('pt-BR', {