download cert to uyser
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user