Commit 4c58c646 authored by devteam's avatar devteam

Merge branch 'dev' into 'staging'

fix: add local upload

See merge request !1
parents 92238f07 d5b25004
Pipeline #19012 failed with stage
in 1 minute and 31 seconds
# syntax=docker.io/docker/dockerfile:1
# Etapa 1: build da aplicação
FROM node:22-alpine AS builder
FROM node:22-alpine AS base
# INSTALAÇÃO DE DEPENDENCIAS
FROM base AS deps
RUN apk add --no-cache libc6-compat
# Diretório de trabalho dentro do container
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Copia os arquivos de dependência
COPY package.json package-lock.json ./
# Instala as dependências
RUN npm ci
# REBUILD CODIGO APENAS QUANDO PRECISA
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copia o restante dos arquivos
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Build da aplicação Next.js
RUN npm run build
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Etapa 2: imagem final para produção
FROM node:22-alpine
# IMAGEM PARA PRODUCAO, COPIA TODOS OS ARQUIVOS DO REBUILD E EXECUTA
FROM base AS runner
# Diretório de trabalho
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copia os arquivos necessários do builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
#USER nextjs
# Garante que a pasta imgs exista
RUN mkdir -p ./public/imgs
# Expõe a porta padrão
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
\ No newline at end of file
# Comando para iniciar a aplicação
CMD ["npm", "start"]
\ No newline at end of file
version: '3.9'
services:
nextjs-app:
build: .
ports:
- "3000:3000"
volumes:
- ./imgs:/app/public/imgs
environment:
- NODE_ENV=production
\ No newline at end of file
// next.config.js
module.exports = {
// ... rest of the configuration.
output: "standalone",
};
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: `${process.env.NEXT_PUBLIC_CLOUDFLARE_URL_PUBLIC_BUCKET}`,
port: '',
pathname: '**',
},
],
},
}
module.exports = nextConfig
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
This source diff could not be displayed because it is too large. You can view the blob instead.
import { api } from '@/lib/axios'
export type AccountsProps = {
id: string
name: string
email: string
}
export async function getAccounts() {
const accounts = await api.get<AccountsProps[]>('/accounts')
return accounts
}
export async function getAccountsId(id: string) {
const account = await api.get<AccountsProps>(`/accounts/${id}`)
return account
}
import { api } from '@/lib/axios'
export type AreasProps = {
id?: string
name: string
desktopBanner: string | null
mobileBanner: string | null
}
export async function createAreas({
name,
desktopBanner,
mobileBanner,
}: AreasProps) {
const area = await api.post<AreasProps[]>('/areas', {
name,
desktopBanner,
mobileBanner,
})
return area
}
export async function editArea({
id,
name,
desktopBanner,
mobileBanner,
}: AreasProps) {
const area = await api.put<AreasProps[]>(`/areas/${id}`, {
name,
desktopBanner,
mobileBanner,
})
return area
}
export async function getAreas() {
const areas = await api.get<AreasProps[]>('/areas')
return areas
}
export async function getAreasId(id: string) {
const area = await api.get<AreasProps>(`/areas/${id}`)
return area
}
export async function deleteArea(id: string) {
const area = await api.delete(`/areas/${id}`)
return area
}
import { api } from '@/lib/axios'
export type AudiencesProps = {
id: string
name: string
}
export async function createAudiences({ name }: AudiencesProps) {
const audiences = await api.post<AudiencesProps>('/audiences', {
name,
})
return audiences
}
export async function getAudiences() {
const audiences = await api.get<AudiencesProps[]>('/audiences')
return audiences
}
export async function getAudienceId(id: string) {
const audiences = await api.get<AudiencesProps>(`/audiences/${id}`)
return audiences
}
export async function editAudienceId(data: AudiencesProps) {
const audiences = await api.put<AudiencesProps>(`/audiences/${data.id}`, {
name: data.name,
})
return audiences
}
export async function deleteAudience(id: string) {
const audiences = await api.delete(`/audiences/${id}`)
return audiences
}
import { api } from '@/lib/axios'
export type BannersProps = {
id: string
name: string
desktopBanner?: string
mobileBanner?: string
}
export async function getBanners() {
const banners = await api.get<BannersProps[]>('/banners')
return banners
}
import { api } from '@/lib/axios'
export type CategoriesProps = {
id: string
name: string
}
export async function createCategories({ name }: CategoriesProps) {
const categories = await api.post<CategoriesProps>('/categories', {
name,
})
return categories
}
export async function getCategories() {
const categories = await api.get<CategoriesProps[]>('/categories')
return categories
}
export async function getCategoryId(id: string) {
const categories = await api.get<CategoriesProps>(`/categories/${id}`)
return categories
}
export async function editCategoryId(data: CategoriesProps) {
const categories = await api.put<CategoriesProps>(`/categories/${data.id}`, {
name: data.name,
})
return categories
}
export async function deleteCategory(id: string) {
const categories = await api.delete(`/categories/${id}`)
return categories
}
import { api } from '@/lib/axios'
import { AreasProps } from './areas'
import { AudiencesProps } from './audiences'
import { CategoriesProps } from './categories'
import { ModulesProps } from './modules'
export type CourseIdProps = {
id?: string
name: string
area: AreasProps
audience: AudiencesProps
category: CategoriesProps
description: string
desktopBanner: string
mobileBanner: string
startDate?: string
endDate?: string
professors: string[]
workload: number
modules: {
courseId: string
module: ModulesProps
moduleId: string
}[]
}
export type CoursesProps = {
id?: string
name: string
description: string
desktopBanner: string | null
mobileBanner: string | null
startDate?: string
endDate?: string
professors: string[]
workload: number
areaId: string
audienceId: string
categoryId: string
area?: {
id: string
name: string
}
audience?: {
id: string
name: string
}
category?: {
id: string
name: string
}
moduleIds: string[]
}
export async function createCourses(data: CoursesProps) {
const course = await api.post<CoursesProps[]>('/courses', data)
return course
}
export async function getCourses() {
const courses = await api.get<CoursesProps[]>('/courses')
return courses
}
export async function getCourseId(id: string) {
const course = await api.get<CourseIdProps>(`/courses/${id}`)
return course
}
export async function editCourseId(data: CoursesProps) {
const course = await api.put<CoursesProps>(`/courses/${data.id}`, data)
return course
}
export async function deleteCourse(id: string) {
const course = await api.delete(`/courses/${id}`)
return course
}
import { api } from '@/lib/axios'
export type SignInProps = {
email: string
password: string
}
export type SignInResponse = {
access_token: string
user: {
id: string
name: string
email: string
}
}
export async function login(data: SignInProps) {
const token = await api.post<SignInResponse>('/login', data)
return token
}
import { api } from '@/lib/axios'
export type ModulesProps = {
id: string
name: string
description: string
}
export async function createModules({ name, description }: ModulesProps) {
const modules = await api.post<ModulesProps>('/modules', {
name,
description,
})
return modules
}
export async function editModule({ id, name, description }: ModulesProps) {
const modules = await api.patch<ModulesProps[]>(`/modules/${id}`, {
name,
description,
})
return modules
}
export async function getModules() {
const modules = await api.get<ModulesProps[]>('/modules')
return modules
}
export async function getModulesId(id: string) {
const modules = await api.get<ModulesProps>(`/modules/${id}`)
return modules
}
export async function deleteModule(id: string) {
const modules = await api.delete(`/modules/${id}`)
return modules
}
import { Search } from 'lucide-react'
'use client'
import Link from 'next/link'
import { About } from '@/components/about'
......@@ -6,14 +7,16 @@ import { Banner } from '@/components/banner'
import { CarouselComponent } from '@/components/corousel-component'
import { CourseCategory } from '@/components/course-category'
import { Differences } from '@/components/differences'
import { InputMui } from '@/components/mui/inputs'
import { NavLinkCategory } from '@/components/nav-link-category'
import { SignUp } from '@/components/sign-up'
import { SkeletonSerachParams } from '@/components/skeleton-serach-params'
import { Button } from '@/components/ui/button'
import { Suspense } from 'react'
// import { useForm } from 'react-hook-form'
export default function Home() {
// const { register } = useForm()
return (
<>
<div className="absolute h-[200px] w-full bg-gradient-to-b from-gray-900 from-5% z-10" />
......@@ -24,22 +27,25 @@ export default function Home() {
<Suspense fallback={<SkeletonSerachParams />}>
<NavLinkCategory />
</Suspense>
<div className="flex justify-center items-center w-full md:w-1/2 mx-auto mb-8">
{/* <div className="flex justify-center items-center w-full md:w-1/2 mx-auto mb-8">
<InputMui
label="O que você quer aprender hoje?"
variant="standard"
type="text"
className="w-full #fafafa"
className="w-full"
themeColor="#fafafa"
name="learned-today"
register={register}
/>
<Search size={24} />
</div>
</div> */}
<CarouselComponent />
<Button
variant="secondary"
className="uppercase mx-auto my-8"
asChild
>
<Link href="#">Ver todos os cursos</Link>
<Link href="/estudantes">Ver todos os cursos</Link>
</Button>
</div>
<CourseCategory />
......
'use client'
import { AreasProps, deleteArea, editArea, getAreasId } from '@/api/areas'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import InputFile from '@/components/input-file'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import ModalDialog from '@/components/modal-dialog'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { AlertDialog, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { TextField } from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
export default function AreaId() {
const [desktopBanner, setDesktopBanner] = useState<File | null>(null)
const [mobileBanner, setMobileBanner] = useState<File | null>(null)
const params = useParams<{ id: string }>()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AreasProps>()
const router = useRouter()
const queryClient = useQueryClient()
const { data: area, isLoading } = useQuery({
queryKey: ['areas', params.id],
queryFn: () => getAreasId(params.id),
enabled: !!params.id,
})
const mutationEdit = useMutation({
mutationFn: editArea,
onSuccess: () => {
toast.success('Área editada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['areas'] })
router.push('/admin')
},
})
const mutationDelete = useMutation({
mutationFn: deleteArea,
onSuccess: () => {
toast.success('Área deletada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['areas'] })
router.push('/admin')
},
})
async function onSubmit(data: AreasProps) {
const urlDesktop = await handleUpload(desktopBanner)
const urlMobile = await handleUpload(mobileBanner)
const dataArea = {
...data,
id: params.id,
desktopBanner: urlDesktop,
mobileBanner: urlMobile,
}
mutationEdit.mutateAsync(dataArea)
}
const handleUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
return data.url
}
function handleDeleteArea() {
mutationDelete.mutate(params.id)
}
if (isLoading) {
return (
<div className="flex w-full h-[500px] items-center justify-center">
<LoadingSpinIcon />
<span>Loading...</span>
</div>
)
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Área" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-green-700 text-3xl font-bold">Editar Área</h1>
<Button variant="third" className="uppercase">
Salvar como rascunho
</Button>
<Button variant="secondary" className="uppercase">
<h1 className="text-3xl font-bold">Editar Área</h1>
<Button
variant="secondary"
type="submit"
className="uppercase rounded-sm"
disabled={isLoading}
>
{mutationEdit.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
<Button
variant="ghost"
className="uppercase flex items-center gap-2 text-orange-100 hover:text-orange-100"
<AlertDialog>
<>
<AlertDialogTrigger
className="uppercase flex items-center gap-2 text-orange-100 border-none hover:bg-orange-100/10 hover:text-orange-100 h-10 px-5 rounded-sm"
type="button"
>
<span>Apagar área</span>
<Trash />
</Button>
</AlertDialogTrigger>
<ModalDialog
title="Tem certeza que deseja deletar a área?"
description={`Ao clicar em continue você irá deletar a área ${area?.data.name}`}
handleClick={handleDeleteArea}
/>
</>
</AlertDialog>
</div>
<div className="w-full">
<div className="w-full mt-16">
<TextField
label="Nome da área"
variant="standard"
type="text"
defaultValue={area?.data.name}
{...register('name')}
variant="standard"
className="flex"
sx={StyledInputs({ color: '#26AAA7' })}
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
<div className="flex justify-between flex-wrap gap-6">
<div className="flex justify-between flex-wrap gap-6 py-6">
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Desktop</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto w-full"
<h6
className={cn(
'text-xl text-purple-100',
errors.desktopBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Banner Desktop
</h6>
<span className="mt-4">Dimensões recomendadas: 1512 x 300</span>
<span>Formatos aceitos: .png, .jpg, .jpeg</span>
<InputFile
id="desktopBanner"
label={!desktopBanner ? 'Atualizar banner' : desktopBanner.name}
onChange={(e) => setDesktopBanner(e.target.files?.[0] || null)}
/>
</div>
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Mobile</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto w-full"
<h6
className={cn(
'text-xl text-purple-100',
errors.mobileBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Banner Mobile
</h6>
<span className="mt-4">Dimensões recomendadas: 390 x 300</span>
<span>Formatos aceitos: .png, .jpg, .jpeg</span>
<InputFile
id="mobileBanner"
label={!mobileBanner ? 'Atualizar banner' : mobileBanner.name}
onChange={(e) => setMobileBanner(e.target.files?.[0] || null)}
/>
</div>
</div>
</div>
</form>
</section>
)
}
'use client'
import { AreasProps, createAreas } from '@/api/areas'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import InputFile from '@/components/input-file'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { TextField } from '@mui/material'
import { Trash } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
const FormSchema = z.object({
name: z.string().trim().min(3, { message: 'Nome obrigatório' }),
})
export default function Area() {
const [desktopBanner, setDesktopBanner] = useState<File | null>(null)
const [mobileBanner, setMobileBanner] = useState<File | null>(null)
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<AreasProps>({
resolver: zodResolver(FormSchema),
})
const router = useRouter()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createAreas,
onSuccess: () => {
toast.success('Área criada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['areas'] })
router.push('/admin')
},
})
async function onSubmit(data: AreasProps) {
if (!desktopBanner) {
setError('desktopBanner', {
type: 'manual',
message: 'Foto desktop obrigatória!',
})
return
}
if (!mobileBanner) {
setError('mobileBanner', {
type: 'manual',
message: 'Foto mobile obrigatória!',
})
return
}
const urlDesktop = await handleUpload(desktopBanner)
const urlMobile = await handleUpload(mobileBanner)
const dataArea = {
...data,
desktopBanner: urlDesktop,
mobileBanner: urlMobile,
}
mutation.mutateAsync(dataArea)
}
const handleUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
return data.url
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Área" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-green-700 text-3xl font-bold">Criar Área</h1>
<Button variant="third" className="uppercase">
Salvar como rascunho
</Button>
<Button variant="secondary" className="uppercase">
<h1 className="text-3xl font-bold">Criar Área</h1>
<Button variant="secondary" className="uppercase rounded-sm">
{mutation.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
<Button
variant="ghost"
className="uppercase flex items-center gap-2 text-orange-100 hover:text-orange-100"
>
<span>Apagar área</span>
<Trash />
</Button>
</div>
<div className="w-full">
<div className="w-full mt-16">
<TextField
label="Nome da área"
variant="standard"
type="text"
className="flex"
sx={StyledInputs({ color: '#26AAA7' })}
{...register('name')}
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
<div className="flex justify-between flex-wrap gap-6">
<div className="flex justify-between flex-wrap gap-6 py-6">
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Desktop</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto w-full"
<h6
className={cn(
'text-xl text-purple-100',
errors.desktopBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Banner Desktop
</h6>
<span className="mt-4">Dimensões recomendadas: 1512 x 300</span>
<span>Formatos aceitos: .png, .jpg, .jpeg</span>
<InputFile
id="desktopBanner"
label={!desktopBanner ? 'Adicionar banner' : desktopBanner.name}
onChange={(e) => setDesktopBanner(e.target.files?.[0] || null)}
/>
</div>
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Mobile</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto w-full"
<h6
className={cn(
'text-xl text-purple-100',
errors.mobileBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Banner Mobile
</h6>
<span className="mt-4">Dimensões recomendadas: 390 x 300</span>
<span>Formatos aceitos: .png, .jpg, .jpeg</span>
<InputFile
id="mobileBanner"
label={!mobileBanner ? 'Adicionar banner' : mobileBanner.name}
onChange={(e) => setMobileBanner(e.target.files?.[0] || null)}
/>
</div>
</div>
</div>
</form>
</section>
)
}
'use client'
import {
AudiencesProps,
deleteAudience,
editAudienceId,
getAudienceId,
} from '@/api/audiences'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import ModalDialog from '@/components/modal-dialog'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { AlertDialog, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { TextField } from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
export default function AudienceId() {
const params = useParams<{ id: string }>()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AudiencesProps>()
const router = useRouter()
const queryClient = useQueryClient()
const { data: audience, isLoading } = useQuery({
queryKey: ['audiences', params.id],
queryFn: () => getAudienceId(params.id),
enabled: !!params.id,
})
const mutationEditAudience = useMutation({
mutationFn: editAudienceId,
onSuccess: () => {
toast.success('Audiência auterada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['audiences'] })
router.push('/admin')
},
})
const mutationDeleteAudience = useMutation({
mutationFn: deleteAudience,
onSuccess: () => {
toast.success('Audiência criada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['audiences'] })
router.push('/admin')
},
})
function handleDeleteAudience() {
mutationDeleteAudience.mutate(params.id)
}
async function onSubmit(data: AudiencesProps) {
const body = {
id: params.id,
name: data.name,
}
mutationEditAudience.mutateAsync(body)
}
if (isLoading) {
return (
<div className="flex w-full h-[500px] items-center justify-center">
<LoadingSpinIcon />
<span>Loading...</span>
</div>
)
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Audiência" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-3xl font-bold">Editar Audiência</h1>
<Button
variant="secondary"
type="submit"
className="uppercase rounded-sm"
disabled={isLoading}
>
{mutationEditAudience.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
<AlertDialog>
<>
<AlertDialogTrigger
className="uppercase flex items-center gap-2 text-orange-100 border-none hover:bg-orange-100/10 hover:text-orange-100 h-10 px-5 rounded-sm"
type="button"
>
<span>Apagar audiência</span>
<Trash />
</AlertDialogTrigger>
<ModalDialog
title="Tem certeza que deseja deletar a audiência?"
description={`Ao clicar em continue você irá deletar a audiência ${audience?.data.name}`}
handleClick={handleDeleteAudience}
/>
</>
</AlertDialog>
</div>
<div className="w-full mt-16">
<TextField
label="Nome da audiência"
type="text"
defaultValue={audience?.data.name}
{...register('name')}
variant="standard"
className="flex"
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
</div>
</form>
</section>
)
}
'use client'
import { AudiencesProps, createAudiences } from '@/api/audiences'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { Button } from '@/components/ui/button'
import { zodResolver } from '@hookform/resolvers/zod'
import { TextField } from '@mui/material'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
const FormSchema = z.object({
name: z.string().trim().min(3, { message: 'Nome obrigatório' }),
})
export default function Audience() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AudiencesProps>({
resolver: zodResolver(FormSchema),
})
const router = useRouter()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createAudiences,
onSuccess: () => {
toast.success('Audiência criada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['audiences'] })
router.push('/admin')
},
})
async function onSubmit(data: AudiencesProps) {
mutation.mutateAsync(data)
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Audiência" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-3xl font-bold">Criar Audiência</h1>
<Button variant="secondary" className="uppercase rounded-sm">
{mutation.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
</div>
<div className="w-full mt-16">
<TextField
label="Nome da audiência"
variant="standard"
type="text"
className="flex"
{...register('name')}
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
</div>
</form>
</section>
)
}
'use client'
import {
CategoriesProps,
deleteCategory,
editCategoryId,
getCategoryId,
} from '@/api/categories'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import ModalDialog from '@/components/modal-dialog'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { AlertDialog, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { TextField } from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
export default function AudienceId() {
const params = useParams<{ id: string }>()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CategoriesProps>()
const router = useRouter()
const queryClient = useQueryClient()
const { data: categorie, isLoading } = useQuery({
queryKey: ['categories', params.id],
queryFn: () => getCategoryId(params.id),
enabled: !!params.id,
})
const mutationEditAudience = useMutation({
mutationFn: editCategoryId,
onSuccess: () => {
toast.success('Categoria auterada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['categories'] })
router.push('/admin')
},
})
const mutationDeleteAudience = useMutation({
mutationFn: deleteCategory,
onSuccess: () => {
toast.success('Categoria criada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['categories'] })
router.push('/admin')
},
})
function handleDeleteAudience() {
mutationDeleteAudience.mutate(params.id)
}
async function onSubmit(data: CategoriesProps) {
const body = {
id: params.id,
name: data.name,
}
mutationEditAudience.mutateAsync(body)
}
if (isLoading) {
return (
<div className="flex w-full h-[500px] items-center justify-center">
<LoadingSpinIcon />
<span>Loading...</span>
</div>
)
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Categoria" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-3xl font-bold">Editar Categoria</h1>
<Button
variant="secondary"
type="submit"
className="uppercase rounded-sm"
disabled={isLoading}
>
{mutationEditAudience.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
<AlertDialog>
<>
<AlertDialogTrigger
className="uppercase flex items-center gap-2 text-orange-100 border-none hover:bg-orange-100/10 hover:text-orange-100 h-10 px-5 rounded-sm"
type="button"
>
<span>Apagar categoria</span>
<Trash />
</AlertDialogTrigger>
<ModalDialog
title="Tem certeza que deseja deletar a categoria?"
description={`Ao clicar em continue você irá deletar a categoria ${categorie?.data.name}`}
handleClick={handleDeleteAudience}
/>
</>
</AlertDialog>
</div>
<div className="w-full mt-16">
<TextField
label="Nome da categoria"
type="text"
defaultValue={categorie?.data.name}
{...register('name')}
variant="standard"
className="flex"
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
</div>
</form>
</section>
)
}
'use client'
import { CategoriesProps, createCategories } from '@/api/categories'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { Button } from '@/components/ui/button'
import { zodResolver } from '@hookform/resolvers/zod'
import { TextField } from '@mui/material'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
const FormSchema = z.object({
name: z.string().trim().min(3, { message: 'Nome obrigatório' }),
})
export default function Category() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CategoriesProps>({
resolver: zodResolver(FormSchema),
})
const router = useRouter()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createCategories,
onSuccess: () => {
toast.success('Categoria criada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['categories'] })
router.push('/admin')
},
})
async function onSubmit(data: CategoriesProps) {
mutation.mutateAsync(data)
}
return (
<section className="container py-10">
<BreadcrumbComponent page="Categoria" />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-3xl font-bold">Criar Categoria</h1>
<Button variant="secondary" className="uppercase rounded-sm">
{mutation.isPending && <LoadingSpinIcon />}
Salvar e publicar
</Button>
</div>
<div className="w-full mt-16">
<TextField
label="Nome da categoria"
variant="standard"
type="text"
className="flex"
{...register('name')}
sx={StyledInputs(
errors.name ? { color: '#dc2626' } : { color: '#26AAA7' },
)}
/>
</div>
</form>
</section>
)
}
'use client'
import { InputMui } from '@/components/mui/inputs'
import { getAreas } from '@/api/areas'
import { getAudiences } from '@/api/audiences'
import { getCategories } from '@/api/categories'
import { editCourseId, getCourseId } from '@/api/courses'
import { createModules, getModules, ModulesProps } from '@/api/modules'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import InputFile from '@/components/input-file'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Label } from '@/components/ui/label'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { MenuItem } from '@mui/material'
import { format } from 'date-fns'
import { CalendarIcon, PlusIcon, Trash } from 'lucide-react'
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Box, Chip, MenuItem, TextField } from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { CalendarIcon, Trash2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
const FormSchema = z.object({
name: z.string().trim().min(3, { message: 'Título obrigatório' }),
description: z.string().trim().min(3, { message: 'Descrição obrigatória' }),
desktopBanner: z.any(),
mobileBanner: z.any(),
startDate: z.date().optional(),
endDate: z.date().optional(),
professors: z.string().array(),
workload: z.string().trim().min(1, { message: 'Horas do curso necessário' }),
areaId: z.string().trim().min(1, { message: 'Selecione a área' }),
audienceId: z.string(),
categoryId: z.string(),
moduleIds: z.string().array(),
})
export default function Curso() {
const params = useParams<{ id: string }>()
const [checkedStart, setCheckedStart] = useState<boolean>(false)
const [checkedEnd, setCheckedEnd] = useState<boolean>(false)
const [dateStart, setDateStart] = useState<Date>()
const [dateEnd, setDateEnd] = useState<Date>()
const [inputValue, setInputValue] = useState<string>('')
const [desktopBanner, setDesktopBanner] = useState<File | null>(null)
const [mobileBanner, setMobileBanner] = useState<File | null>(null)
const router = useRouter()
const queryClient = useQueryClient()
const { register, handleSubmit } = useForm<ModulesProps>()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
professors: [],
moduleIds: [],
desktopBanner: '',
mobileBanner: '',
},
})
const { data: course, isLoading } = useQuery({
queryKey: ['course', params.id],
queryFn: () => getCourseId(params.id),
enabled: !!params.id,
})
const formatDate = (dateString: string) => {
const date = parseISO(dateString)
const utcDate = toZonedTime(date, 'UTC')
return utcDate
}
useEffect(() => {
if (course && course.data.startDate) {
setCheckedStart(true)
onCheckedStart(!!course.data.startDate)
}
if (course && course.data.endDate) {
setCheckedEnd(true)
onCheckedEnd(!!course.data.endDate)
}
}, [course])
useEffect(() => {
if (!course?.data) return
const { modules, professors, category, audience, startDate, endDate } =
course.data
if (modules) {
const existingModuleIds = modules.map((mod) => mod.module.id)
form.setValue('moduleIds', existingModuleIds)
}
if (professors) {
const existingTeachers = professors.map((teacher) => teacher)
form.setValue('professors', existingTeachers)
}
if (category) {
form.setValue('categoryId', category.id)
}
if (audience) {
form.setValue('audienceId', audience.id)
}
if (startDate) {
const date = formatDate(startDate)
form.setValue('startDate', date)
setDateStart(date)
}
if (endDate) {
const date = formatDate(endDate)
form.setValue('endDate', date)
setDateEnd(date)
}
}, [course, form])
const values = form.watch()
const { data: areas } = useQuery({
queryKey: ['areas'],
queryFn: getAreas,
})
const { data: modules } = useQuery({
queryKey: ['modules'],
queryFn: getModules,
})
const { data: audiences, isLoading: audiencesLoading } = useQuery({
queryKey: ['audiences'],
queryFn: getAudiences,
})
const { data: categories, isLoading: categoriesLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
})
const mutationEdit = useMutation({
mutationFn: editCourseId,
onSuccess: () => {
toast.success('Curso editada com sucesso!')
queryClient.invalidateQueries({ queryKey: ['courses'] })
router.push('/admin')
},
onError: (error) => {
if (error instanceof AxiosError) {
const { message } = error.response?.data
toast.error(`${message}`)
}
},
})
function onCheckedStart(checkedStart: boolean) {
setCheckedStart(checkedStart)
}
function onCheckedEnd(checkedEnd: boolean) {
setCheckedEnd(checkedEnd)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && inputValue.trim()) {
event.preventDefault()
form.setValue('professors', [...values.professors, inputValue.trim()])
setInputValue('')
}
}
const handleDeleteTeacher = (professorToDelete: string) => {
form.setValue(
'professors',
values.professors.filter((str) => str !== professorToDelete),
)
}
const mutation = useMutation({
mutationFn: createModules,
onSuccess: () => {
toast.success('Módulo criado com sucesso!')
queryClient.invalidateQueries({ queryKey: ['modules'] })
},
})
const selectedModuleIds = form.watch('moduleIds') || []
async function addNewModuler(data: ModulesProps) {
const response = await mutation.mutateAsync(data)
const responseModuleId = response.data.id
form.setValue('moduleIds', [...selectedModuleIds, responseModuleId])
}
const selectedModules = modules?.data.filter((module) =>
selectedModuleIds.includes(module.id),
)
const handleSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedId = event.target.value
if (selectedId && !selectedModuleIds.includes(selectedId)) {
form.setValue('moduleIds', [...selectedModuleIds, selectedId])
}
}
const handleDeleteModule = (idToRemove: string) => {
const updatedIds = selectedModuleIds.filter((id) => id !== idToRemove)
const options = [
{ label: 'The Godfather', value: 1 },
{ label: 'Pulp Fiction', value: 2 },
]
form.setValue('moduleIds', updatedIds)
}
export default function EditCourse() {
const [date, setDate] = useState<Date>()
async function onSubmit(data: z.infer<typeof FormSchema>) {
if (!dateStart) {
form.setError('startDate', {
type: 'manual',
message: 'Data de início obrigatória!',
})
return
}
if (!dateEnd) {
form.setError('endDate', {
type: 'manual',
message: 'Data de fim obrigatória!',
})
return
}
const urlCapaDesktop = await handleUpload(desktopBanner)
const urlCapaMobile = await handleUpload(mobileBanner)
const body = {
...data,
id: params.id,
startDate: dateStart ? format(dateStart, 'yyyy-MM-dd') : '',
endDate: dateEnd ? format(dateEnd, 'yyyy-MM-dd') : '',
desktopBanner: urlCapaDesktop,
mobileBanner: urlCapaMobile,
workload: Number(data.workload),
}
mutationEdit.mutate(body)
}
const handleUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
return data.url
}
if (isLoading) {
return <p>Carregando...</p>
}
return (
<section className="container py-10">
<form>
<BreadcrumbComponent page="Curso" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-green-700 text-3xl font-bold">Editar curso</h1>
<Button variant="third" className="uppercase">
Salvar como rascunho
</Button>
<Button variant="secondary" className="uppercase">
<h1 className="text-3xl font-bold">Criar curso</h1>
<Button type="submit" variant="secondary" className="uppercase">
Salvar e publicar
</Button>
<Button
variant="ghost"
className="uppercase flex items-center gap-2 text-orange-100 hover:text-orange-100"
>
<span>Apagar curso</span>
<Trash />
</Button>
</div>
<div className="flex w-full mt-10">
<div className="flex-1 flex flex-col border-r border-gray-50 pr-6 space-y-4">
......@@ -50,81 +301,186 @@ export default function EditCourse() {
Informações sobre o curso
</h6>
<InputMui label="Nome do curso" variant="standard" type="text" />
<TextField
label="Nome do curso"
variant="standard"
type="text"
{...form.register('name')}
defaultValue={course?.data.name}
sx={StyledInputs(
form.formState.errors.name
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<InputMui type="text" variant="standard" label="Área" select>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
<TextField
type="text"
variant="standard"
label="Área"
select
defaultValue={course?.data.area.id || ''}
{...form.register('areaId')}
sx={StyledInputs(
form.formState.errors.areaId
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
>
<MenuItem value="" className="hidden">
Selecione a área
</MenuItem>
{areas?.data.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</InputMui>
</TextField>
<InputMui
<TextField
label="Descrição do Curso"
variant="standard"
type="text"
defaultValue={course?.data.description}
multiline
rows={4}
{...form.register('description')}
sx={StyledInputs(
form.formState.errors.description
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<InputMui
label="Nome dos(as) Professores(as):"
<Controller
control={form.control}
name="professors"
render={({ field }) => (
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<TextField
label="Digite os nomes dos professores e pressione Enter"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
variant="standard"
type="text"
sx={StyledInputs({ color: '#26AAA7' })}
/>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{field.value.map((str, index) => (
<Chip
key={index}
label={str}
onDelete={() => handleDeleteTeacher(str)}
variant="outlined"
color="secondary"
sx={{ color: 'white' }}
/>
))}
</Box>
</Box>
)}
/>
<div className="flex justify-between flex-wrap gap-6">
<div className="flex justify-between flex-wrap gap-6 py-6">
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Desktop</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto md:w-[436px]"
<h6
className={cn(
'text-xl text-purple-100',
form.formState.errors.desktopBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Capa do curso desktop
</h6>
<span className="mt-4">
Dimensões recomendadas: 348 x 464
</span>
<span>Formatos aceitos: .png, .jpg</span>
<InputFile
id="desktopBanner"
label={
!desktopBanner ? 'Atualizar capa' : desktopBanner.name
}
onChange={(e) =>
setDesktopBanner(e.target.files?.[0] || null)
}
/>
</div>
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Mobile</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto md:w-[436px]"
<h6
className={cn(
'text-xl text-purple-100',
form.formState.errors.mobileBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Capa do curso mobile
</h6>
<span className="mt-4">
Dimensões recomendadas: 348 x 464
</span>
<span>Formatos aceitos: .png, .jpg</span>
<InputFile
id="mobileBanner"
label={!mobileBanner ? 'Atualizar capa' : mobileBanner.name}
onChange={(e) =>
setMobileBanner(e.target.files?.[0] || null)
}
/>
</div>
</div>
<Separator className="!mt-10 !mb-4" />
<h6 className="text-xl text-purple-100 mb-4">Horas</h6>
<TextField
label="Total de horas do curso"
variant="standard"
defaultValue={course?.data.workload}
type="number"
{...form.register('workload')}
sx={StyledInputs(
form.formState.errors.workload
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<Separator className="!mt-10 !mb-4" />
<h6 className="text-xl text-purple-100 mb-4">Datas</h6>
<div className="flex flex-col space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Checkbox id="start" />
<Checkbox
id="start"
onCheckedChange={onCheckedStart}
checked={checkedStart}
/>
<label
htmlFor="start"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
form.formState.errors.startDate && 'text-red-600',
)}
>
Definir data de início
</label>
</div>
{checkedStart && (
<div className="flex flex-col">
<Label>Data de início</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'ghost'}
type="button"
className={cn(
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0',
!date && 'text-muted-foreground',
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0 border-green-400',
!dateStart && 'text-muted-foreground',
)}
>
{date ? (
format(date, 'dd/MM/yyyy')
{dateStart ? (
format(dateStart, 'dd/MM/yyyy')
) : (
<span className="text-gray-600">MM/DD/YYYY</span>
)}
......@@ -134,37 +490,52 @@ export default function EditCourse() {
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
selected={dateStart}
onSelect={(date) => {
if (date) {
setDateStart(date)
form.setValue('startDate', date)
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Checkbox id="closure" />
<Checkbox
id="end"
onCheckedChange={onCheckedEnd}
checked={checkedEnd}
/>
<label
htmlFor="closure"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
form.formState.errors.endDate && 'text-red-600',
)}
>
Definir data de encerramento
</label>
</div>
{checkedEnd && (
<div className="flex flex-col">
<Label>Data de encerramento</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'ghost'}
type="button"
className={cn(
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0',
!date && 'text-muted-foreground',
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0 border-green-400',
!dateEnd && 'text-muted-foreground',
)}
>
{date ? (
format(date, 'dd/MM/yyyy')
{dateEnd ? (
format(dateEnd, 'dd/MM/yyyy')
) : (
<span className="text-gray-600">MM/DD/YYYY</span>
)}
......@@ -174,13 +545,19 @@ export default function EditCourse() {
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
selected={dateEnd}
onSelect={(date) => {
if (date) {
setDateEnd(date)
form.setValue('endDate', date)
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
......@@ -188,89 +565,146 @@ export default function EditCourse() {
<h6 className="text-xl text-purple-100 mb-4">Módulos</h6>
<TextField
type="text"
variant="standard"
label="Selecione os módulos do curso"
select
defaultValue={''}
value=""
onChange={handleSelect}
// {...form.register('moduleIds')}
sx={StyledInputs(
form.formState.errors.moduleIds
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
>
<MenuItem value="" className="hidden">
Selecione os módulos
</MenuItem>
{modules?.data.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
{!modules?.data.length && <p>Não há módulo criado!</p>}
</TextField>
{selectedModules?.map((item) => (
<div
key={item.id}
className="w-full flex justify-between bg-green-800/30 py-2 px-4 rounded-sm"
>
<p>{item.name}</p>
<Trash2
className="cursor-pointer text-red-400 hover:text-red-100"
onClick={() => handleDeleteModule(item.id)}
/>
</div>
))}
<div className="flex flex-col p-4 gap-6 border border-gray-100">
<InputMui label="Título" variant="standard" type="text" />
<TextField
label="Título"
variant="standard"
type="text"
sx={StyledInputs({ color: '#26AAA7' })}
{...register('name')}
/>
<InputMui
<TextField
label="Descrição"
variant="standard"
type="text"
multiline
rows={4}
sx={StyledInputs({ color: '#26AAA7' })}
{...register('description')}
/>
</div>
<Button
variant="third"
type="button"
className="uppercase w-52 flex items-center gap-2 !mt-8"
onClick={handleSubmit(addNewModuler)}
>
<PlusIcon size={20} /> <span>Adicionar Módulo</span>
<span>Criar novo Módulo</span>
</Button>
</div>
<div className="w-[380px] pl-6 flex flex-col gap-4">
<h6 className="text-xl text-purple-100 mb-4">
Qual o público alvo?
</h6>
{audiencesLoading && <LoadingSpinIcon className="ml-10" />}
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
<FormField
control={form.control}
name="audienceId"
render={({ field }) => (
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex flex-col space-y-4"
>
Estudante
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Profissionais
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
{audiences?.data.map((audience) => (
<FormItem
className="flex items-center space-x-3 space-y-0"
key={audience.id}
>
Empresas
</label>
<FormControl>
<RadioGroupItem value={audience.id} />
</FormControl>
<FormLabel className="font-normal">
{audience.name}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
)}
/>
</div>
<h6 className="text-xl text-purple-100 my-4">
Qual o tipo de curso?
</h6>
{categoriesLoading && <LoadingSpinIcon className="ml-10" />}
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormControl>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="flex flex-col space-y-4"
>
Curso rápido
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
{categories?.data.map((category) => (
<FormItem
className="flex items-center space-x-3 space-y-0"
key={category.id}
>
Curso de aprofundamento
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Curso Corporativo
</label>
<FormControl>
<RadioGroupItem value={category.id} />
</FormControl>
<FormLabel className="font-normal">
{category.name}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</section>
)
}
import { StyledInputs } from '@/components/mui/styled-inputs'
import { TextField } from '@mui/material'
import { useForm } from 'react-hook-form'
type FormModulesProps = {
index?: number
remove?: (index: number | number[]) => void
}
export function FormNewModules({ index, remove }: FormModulesProps) {
const { register } = useForm()
return (
<div className="flex flex-col p-4 gap-6 border border-gray-100">
<TextField
label="Título"
variant="standard"
type="text"
sx={StyledInputs({ color: '#26AAA7' })}
{...register(`newModule.${index}.name`)}
/>
<TextField
label="Descrição"
variant="standard"
type="text"
multiline
rows={4}
sx={StyledInputs({ color: '#26AAA7' })}
{...register(`newModule.${index}.description`)}
/>
</div>
)
}
'use client'
import { InputMui } from '@/components/mui/inputs'
import { getAreas } from '@/api/areas'
import { getAudiences } from '@/api/audiences'
import { getCategories } from '@/api/categories'
import { createCourses } from '@/api/courses'
import { createModules, getModules, ModulesProps } from '@/api/modules'
import BreadcrumbComponent from '@/components/breadcrumb-component'
import InputFile from '@/components/input-file'
import { LoadingSpinIcon } from '@/components/loading-spin-icon'
import { StyledInputs } from '@/components/mui/styled-inputs'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Label } from '@/components/ui/label'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { MenuItem } from '@mui/material'
import { zodResolver } from '@hookform/resolvers/zod'
import { Box, Chip, MenuItem, TextField } from '@mui/material'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { format } from 'date-fns'
import { CalendarIcon, PlusIcon, Trash } from 'lucide-react'
import { CalendarIcon, Trash2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
const options = [
{ label: 'The Godfather', value: 1 },
{ label: 'Pulp Fiction', value: 2 },
]
const FormSchema = z.object({
name: z.string().trim().min(3, { message: 'Título obrigatório' }),
description: z.string().trim().min(3, { message: 'Descrição obrigatória' }),
desktopBanner: z.string().nullable(),
mobileBanner: z.string().nullable(),
startDate: z.date().optional(),
endDate: z.date().optional(),
professors: z.string().array(),
workload: z.string().trim().min(1, { message: 'Horas do curso necessário' }),
areaId: z.string().trim().min(1, { message: 'Selecione a área' }),
audienceId: z.string(),
categoryId: z.string(),
moduleIds: z.string().array(),
})
export default function Curso() {
const [date, setDate] = useState<Date>()
const [checkedStart, setCheckedStart] = useState<boolean>(false)
const [checkedEnd, setCheckedEnd] = useState<boolean>(false)
const [dateStart, setDateStart] = useState<Date>()
const [dateEnd, setDateEnd] = useState<Date>()
const [inputValue, setInputValue] = useState<string>('')
const [desktopBanner, setDesktopBanner] = useState<File | null>(null)
const [mobileBanner, setMobileBanner] = useState<File | null>(null)
const router = useRouter()
const queryClient = useQueryClient()
const { register, handleSubmit } = useForm<ModulesProps>()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
areaId: '',
professors: [],
moduleIds: [],
desktopBanner: null,
mobileBanner: null,
},
})
const values = form.watch()
const { data: areas } = useQuery({
queryKey: ['areas'],
queryFn: getAreas,
})
const { data: modules } = useQuery({
queryKey: ['modules'],
queryFn: getModules,
})
const { data: audiences, isLoading: audiencesLoading } = useQuery({
queryKey: ['audiences'],
queryFn: getAudiences,
})
const { data: categories, isLoading: categoriesLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
})
function onCheckedStart(checkedStart: boolean) {
setCheckedStart(checkedStart)
}
function onCheckedEnd(checkedEnd: boolean) {
setCheckedEnd(checkedEnd)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && inputValue.trim()) {
event.preventDefault()
form.setValue('professors', [...values.professors, inputValue.trim()])
setInputValue('')
}
}
const handleDelete = (professorToDelete: string) => {
form.setValue(
'professors',
values.professors.filter((str) => str !== professorToDelete),
)
}
const mutation = useMutation({
mutationFn: createModules,
onSuccess: () => {
toast.success('Módulo criado com sucesso!')
queryClient.invalidateQueries({ queryKey: ['modules'] })
},
})
const selectedModuleIds = form.watch('moduleIds') || []
async function addNewModuler(data: ModulesProps) {
const response = await mutation.mutateAsync(data)
const responseModuleId = response.data.id
form.setValue('moduleIds', [...selectedModuleIds, responseModuleId])
}
const selectedModules = modules?.data.filter((module) =>
selectedModuleIds.includes(module.id),
)
const handleSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedId = event.target.value
if (selectedId && !selectedModuleIds.includes(selectedId)) {
form.setValue('moduleIds', [...selectedModuleIds, selectedId])
}
}
const handleDeleteModule = (idToRemove: string) => {
const updatedIds = selectedModuleIds.filter((id) => id !== idToRemove)
form.setValue('moduleIds', updatedIds)
}
const mutationCreateCourse = useMutation({
mutationFn: createCourses,
onSuccess: () => {
toast.success('Curso criado com sucesso!')
queryClient.invalidateQueries({ queryKey: ['courses'] })
router.push('/admin')
},
onError: (error) => {
if (error instanceof AxiosError) {
const { message } = error.response?.data
toast.error(`${message}`)
}
},
})
async function onSubmit(data: z.infer<typeof FormSchema>) {
if (!desktopBanner) {
form.setError('desktopBanner', {
type: 'manual',
message: 'Foto desktop obrigatória!',
})
return
}
if (!mobileBanner) {
form.setError('mobileBanner', {
type: 'manual',
message: 'Foto mobile obrigatória!',
})
return
}
if (!dateStart) {
form.setError('startDate', {
type: 'manual',
message: 'Data de início obrigatória!',
})
return
}
if (!dateEnd) {
form.setError('endDate', {
type: 'manual',
message: 'Data de fim obrigatória!',
})
return
}
const urlCapaDesktop = await handleUpload(desktopBanner)
const urlCapaMobile = await handleUpload(mobileBanner)
const body = {
...data,
startDate: dateStart ? format(dateStart, 'yyyy-MM-dd') : '',
endDate: dateEnd ? format(dateEnd, 'yyyy-MM-dd') : '',
desktopBanner: urlCapaDesktop,
mobileBanner: urlCapaMobile,
workload: Number(data.workload),
}
mutationCreateCourse.mutateAsync(body)
}
const handleUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await res.json()
return data.url
}
return (
<section className="container py-10">
<form>
<BreadcrumbComponent page="Curso" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex items-center gap-6">
<h1 className="text-green-700 text-3xl font-bold">Criar curso</h1>
<Button variant="third" className="uppercase">
Salvar como rascunho
</Button>
<Button variant="secondary" className="uppercase">
<h1 className="text-3xl font-bold">Criar curso</h1>
<Button type="submit" variant="secondary" className="uppercase">
Salvar e publicar
</Button>
<Button
variant="ghost"
className="uppercase flex items-center gap-2 text-orange-100 hover:text-orange-100"
>
<span>Apagar curso</span>
<Trash />
</Button>
</div>
<div className="flex w-full mt-10">
<div className="flex-1 flex flex-col border-r border-gray-50 pr-6 space-y-4">
......@@ -50,81 +250,179 @@ export default function Curso() {
Informações sobre o curso
</h6>
<InputMui label="Nome do curso" variant="standard" type="text" />
<TextField
label="Nome do curso"
variant="standard"
type="text"
{...form.register('name')}
sx={StyledInputs(
form.formState.errors.name
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<InputMui type="text" variant="standard" label="Área" select>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
<TextField
type="text"
variant="standard"
label="Área"
select
defaultValue={''}
{...form.register('areaId')}
sx={StyledInputs(
form.formState.errors.areaId
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
>
<MenuItem value="" className="hidden">
Selecione a área
</MenuItem>
{areas?.data.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
</InputMui>
</TextField>
<InputMui
<TextField
label="Descrição do Curso"
variant="standard"
type="text"
multiline
rows={4}
{...form.register('description')}
sx={StyledInputs(
form.formState.errors.description
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<InputMui
label="Nome dos(as) Professores(as):"
<Controller
control={form.control}
name="professors"
render={({ field }) => (
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<TextField
label="Digite os nomes dos professores e pressione Enter"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
variant="standard"
type="text"
sx={StyledInputs({ color: '#26AAA7' })}
/>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{field.value.map((str, index) => (
<Chip
key={index}
label={str}
onDelete={() => handleDelete(str)}
variant="outlined"
color="secondary"
sx={{ color: 'white' }}
/>
))}
</Box>
</Box>
)}
/>
<div className="flex justify-between flex-wrap gap-6">
<div className="flex justify-between flex-wrap gap-6 py-6">
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Desktop</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto md:w-[436px]"
<h6
className={cn(
'text-xl text-purple-100',
form.formState.errors.desktopBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Capa do curso desktop
</h6>
<span className="mt-4">
Dimensões recomendadas: 348 x 464
</span>
<span>Formatos aceitos: .png, .jpg</span>
<InputFile
id="desktopBanner"
label={
!desktopBanner ? 'Adicionar capa' : desktopBanner.name
}
onChange={(e) =>
setDesktopBanner(e.target.files?.[0] || null)
}
/>
</div>
<div className="flex flex-col flex-1 mt-6">
<h6 className="text-xl text-purple-100">Banner Mobile</h6>
<span className="mt-4">Dimensões recomendadas: </span>
<span>Formatos aceitos: .png, .jpg</span>
<Button
variant="third"
className="border border-dotted border-green-400 rounded-sm mt-6 mx-auto md:w-[436px]"
<h6
className={cn(
'text-xl text-purple-100',
form.formState.errors.mobileBanner && 'text-red-600',
)}
>
Adicionar banner
</Button>
Capa do curso mobile
</h6>
<span className="mt-4">
Dimensões recomendadas: 348 x 464
</span>
<span>Formatos aceitos: .png, .jpg</span>
<InputFile
id="mobileBanner"
label={!mobileBanner ? 'Adicionar capa' : mobileBanner.name}
onChange={(e) =>
setMobileBanner(e.target.files?.[0] || null)
}
/>
</div>
</div>
<Separator className="!mt-10 !mb-4" />
<h6 className="text-xl text-purple-100 mb-4">Horas</h6>
<TextField
label="Total de horas do curso"
variant="standard"
type="number"
{...form.register('workload')}
sx={StyledInputs(
form.formState.errors.workload
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
/>
<Separator className="!mt-10 !mb-4" />
<h6 className="text-xl text-purple-100 mb-4">Datas</h6>
<div className="flex flex-col space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Checkbox id="start" />
<Checkbox id="start" onCheckedChange={onCheckedStart} />
<label
htmlFor="start"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
form.formState.errors.startDate && 'text-red-600',
)}
>
Definir data de início
</label>
</div>
{checkedStart && (
<div className="flex flex-col">
<Label>Data de início</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'ghost'}
type="button"
className={cn(
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0',
!date && 'text-muted-foreground',
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0 border-green-400',
!dateStart && 'text-muted-foreground',
)}
>
{date ? (
format(date, 'dd/MM/yyyy')
{dateStart ? (
format(dateStart, 'dd/MM/yyyy')
) : (
<span className="text-gray-600">MM/DD/YYYY</span>
)}
......@@ -134,37 +432,44 @@ export default function Curso() {
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
selected={dateStart}
onSelect={setDateStart}
initialFocus
{...form.register('startDate')}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Checkbox id="closure" />
<Checkbox id="end" onCheckedChange={onCheckedEnd} />
<label
htmlFor="closure"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
form.formState.errors.endDate && 'text-red-600',
)}
>
Definir data de encerramento
</label>
</div>
{checkedEnd && (
<div className="flex flex-col">
<Label>Data de encerramento</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={'ghost'}
type="button"
className={cn(
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0',
!date && 'text-muted-foreground',
'lg:w-[450px] justify-start text-left font-normal border-b rounded-none pl-0 border-green-400',
!dateEnd && 'text-muted-foreground',
)}
>
{date ? (
format(date, 'dd/MM/yyyy')
{dateEnd ? (
format(dateEnd, 'dd/MM/yyyy')
) : (
<span className="text-gray-600">MM/DD/YYYY</span>
)}
......@@ -174,13 +479,15 @@ export default function Curso() {
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
selected={dateEnd}
onSelect={setDateEnd}
initialFocus
{...form.register('endDate')}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
......@@ -188,89 +495,146 @@ export default function Curso() {
<h6 className="text-xl text-purple-100 mb-4">Módulos</h6>
<TextField
type="text"
variant="standard"
label="Selecione os módulos do curso"
select
defaultValue={''}
value=""
onChange={handleSelect}
// {...form.register('moduleIds')}
sx={StyledInputs(
form.formState.errors.moduleIds
? { color: '#dc2626' }
: { color: '#26AAA7' },
)}
>
<MenuItem value="" className="hidden">
Selecione os módulos
</MenuItem>
{modules?.data.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.name}
</MenuItem>
))}
{!modules?.data.length && <p>Não há módulo criado!</p>}
</TextField>
{selectedModules?.map((item) => (
<div
key={item.id}
className="w-full flex justify-between bg-green-800/30 py-2 px-4 rounded-sm"
>
<p>{item.name}</p>
<Trash2
className="cursor-pointer text-red-400 hover:text-red-100"
onClick={() => handleDeleteModule(item.id)}
/>
</div>
))}
<div className="flex flex-col p-4 gap-6 border border-gray-100">
<InputMui label="Título" variant="standard" type="text" />
<TextField
label="Título"
variant="standard"
type="text"
sx={StyledInputs({ color: '#26AAA7' })}
{...register('name')}
/>
<InputMui
<TextField
label="Descrição"
variant="standard"
type="text"
multiline
rows={4}
sx={StyledInputs({ color: '#26AAA7' })}
{...register('description')}
/>
</div>
<Button
variant="third"
type="button"
className="uppercase w-52 flex items-center gap-2 !mt-8"
onClick={handleSubmit(addNewModuler)}
>
<PlusIcon size={20} /> <span>Adicionar Módulo</span>
<span>Criar novo Módulo</span>
</Button>
</div>
<div className="w-[380px] pl-6 flex flex-col gap-4">
<h6 className="text-xl text-purple-100 mb-4">
Qual o público alvo?
</h6>
{audiencesLoading && <LoadingSpinIcon className="ml-10" />}
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Estudante
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
<FormField
control={form.control}
name="audienceId"
render={({ field }) => (
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-4"
>
Profissionais
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
{audiences?.data.map((audience) => (
<FormItem
className="flex items-center space-x-3 space-y-0"
key={audience.id}
>
Empresas
</label>
<FormControl>
<RadioGroupItem value={audience.id} />
</FormControl>
<FormLabel className="font-normal">
{audience.name}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
)}
/>
</div>
<h6 className="text-xl text-purple-100 my-4">
Qual o tipo de curso?
</h6>
{categoriesLoading && <LoadingSpinIcon className="ml-10" />}
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-4"
>
Curso rápido
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
{categories?.data.map((category) => (
<FormItem
className="flex items-center space-x-3 space-y-0"
key={category.id}
>
Curso de aprofundamento
</label>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox id="terms" />
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Curso Corporativo
</label>
<FormControl>
<RadioGroupItem value={category.id} />
</FormControl>
<FormLabel className="font-normal">
{category.name}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</section>
)
}
'use client'
import { getAreas } from '@/api/areas'
import { getAudiences } from '@/api/audiences'
import { getCategories } from '@/api/categories'
import { getCourses } from '@/api/courses'
import { Card } from '@/components/card'
import { InputMui } from '@/components/mui/inputs'
import { Button } from '@/components/ui/button'
import { CoursesCard } from '@/utils/courses-array'
import { AuthContext } from '@/contexts/auth-context'
import { useQuery } from '@tanstack/react-query'
import { Pencil, Search } from 'lucide-react'
import Link from 'next/link'
const areas = [
{
id: '1',
title: 'Educação',
},
{
id: '2',
title: 'Negócios',
},
{
id: '3',
title: 'Comunicação',
},
{
id: '4',
title: 'Saúde',
},
{
id: '5',
title: 'Tecnologia',
},
{
id: '6',
title: 'Teologia',
},
]
const banners = [
{
title: 'Banner Home',
},
{
title: 'CTAs',
},
]
import { useContext, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
export default function Admin() {
const { register, watch } = useForm()
const { user } = useContext(AuthContext)
const search = watch('search', '')
const [debouncedSearch, setDebouncedSearch] = useState('')
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(search)
}, 500)
return () => clearTimeout(handler)
}, [search])
const {
data: areas,
error,
isLoading: areasLoading,
isError: areasIsError,
} = useQuery({ queryKey: ['areas'], queryFn: getAreas })
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: getCategories,
})
const {
data: audiences,
isLoading: audiencesLoading,
isError: audiencesIsError,
} = useQuery({
queryKey: ['audiences'],
queryFn: getAudiences,
})
const {
data: courses,
isLoading: coursesLoading,
isError: coursesIsError,
} = useQuery({
queryKey: ['courses'],
queryFn: getCourses,
})
const filteredCourses = courses?.data.filter((course) =>
course.name.toLowerCase().includes(debouncedSearch.toLowerCase()),
)
if (areasLoading || coursesLoading || audiencesLoading) {
return <span>Loading...</span>
}
if (areasIsError || audiencesIsError || coursesIsError) {
return <span>Error: {error?.message}</span>
}
return (
<section className="container">
<h1 className="text-green-400 text-2xl">Bem vindo, João da Silva,</h1>
<h1 className="text-green-400 text-2xl">Bem vindo, {user?.name}!</h1>
<div className="flex h-auto mb-20">
<aside className="mt-10 w-[348px]">
<h2 className="text-purple-50 text-2xl">Áreas</h2>
......@@ -55,13 +84,35 @@ export default function Admin() {
<nav className="my-6">
<ul className="space-y-4">
{areas.map((area) => (
<li key={area.title} className="border-b border-green-400">
{areas?.data.map((area) => (
<li key={area.id} className="border-b border-green-400">
<Link
href={`/admin/area/${area.id}`}
className="flex justify-between py-2 hover:bg-green-400/10"
>
<span>{area.title}</span>
<span>{area.name}</span>
<Pencil className="text-green-400" />
</Link>
</li>
))}
</ul>
</nav>
<h2 className="text-purple-50 text-2xl mt-10">Audiência</h2>
<Button className="uppercase mt-6" asChild>
<Link href="admin/audience">+ Adicionar novo</Link>
</Button>
<nav className="my-6">
<ul className="space-y-4">
{audiences?.data.map((audience) => (
<li key={audience.id} className="border-b border-green-400">
<Link
href={`/admin/audience/${audience.id}`}
className="flex justify-between py-2 hover:bg-green-400/10"
>
<span>{audience.name}</span>
<Pencil className="text-green-400" />
</Link>
</li>
......@@ -69,16 +120,23 @@ export default function Admin() {
</ul>
</nav>
<h2 className="text-purple-50 text-2xl mt-10">Banners</h2>
<h2 className="text-purple-50 text-2xl mt-10">Categoria</h2>
<Button className="uppercase mt-6" asChild>
<Link href="admin/category">+ Adicionar novo</Link>
</Button>
<nav className="my-6">
<ul className="space-y-4">
{banners.map((banner) => (
<li
key={banner.title}
className="flex justify-between border-b border-green-400 py-2"
{categories?.data.map((category) => (
<li key={category.id} className="border-b border-green-400">
<Link
href={`/admin/category/${category.id}`}
className="flex justify-between py-2 hover:bg-green-400/10"
>
<Link href="#">{banner.title}</Link>
<span>{category.name}</span>
<Pencil className="text-green-400" />
</Link>
</li>
))}
</ul>
......@@ -96,19 +154,29 @@ export default function Admin() {
type="text"
variant="standard"
className="w-full"
themeColor="#fafafa"
name="search"
register={register}
/>
<Search size={24} className="-ml-6" />
</div>
{courses?.data.length === 0 ? (
<p className="text-yellow-100">Não há curso cadastrado!</p>
) : (
<div className="flex flex-wrap justify-around gap-4">
{CoursesCard.map((course) => (
{filteredCourses?.map((course) => (
<Link
key={course.id}
className="lowercase scale-100 hover:scale-105 duration-300"
href={`admin/curso/${course.id}`}
>
<div className="border border-gray-100 pb-4 rounded-lg overflow-hidden">
<Card.Image image={course.image.src} width={240} height={320}>
<Card.Title title={course.title} />
<Card.Image
image={course.mobileBanner || ''}
width={240}
height={320}
>
<Card.Title title={course.name} />
</Card.Image>
<Button
variant="secondary"
......@@ -120,7 +188,11 @@ export default function Admin() {
</div>
</Link>
))}
{filteredCourses?.length === 0 && (
<p className="text-yellow-100">Nenhum curso encontrado!</p>
)}
</div>
)}
</div>
</div>
</section>
......
import { NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
export async function POST(req: Request) {
try {
const { fullname, email, subject, message } = await req.json()
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT),
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
})
const mailOptions = {
from: email,
to: process.env.EMAIL_SEVENPRO,
subject: `Nova dúvida de ${fullname}: ${subject}`,
text: message,
html: `<p><strong>Nome:</strong> ${fullname}</p><p><strong>Assunto:</strong> ${subject}</p><p><strong>Mensagem:</strong></p><p>${message}</p>`,
}
await transporter.sendMail(mailOptions)
return NextResponse.json(
{ message: 'E-mail enviado com sucesso!' },
{ status: 200 },
)
} catch (error) {
return NextResponse.json(
{ error: 'Erro ao enviar o e-mail.' },
{ status: 500 },
)
}
}
import { writeFile } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
try {
const arrayBuffer = await file.arrayBuffer()
const buffer = new Uint8Array(arrayBuffer)
const fileName = `${Date.now()}-${file.name}`
const filePath = path.join(process.cwd(), 'public', 'imgs', fileName)
await writeFile(filePath, buffer)
const fileUrl = `/imgs/${fileName}` // caminho relativo acessível pela tag <img src=...>
return NextResponse.json({ url: fileUrl })
} catch (error) {
console.error('Upload failed:', error)
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
}
}
'use client'
import { InputMui } from '@/components/mui/inputs'
import { Button } from '@/components/ui/button'
import { zodResolver } from '@hookform/resolvers/zod'
import { MenuItem } from '@mui/material'
import { Clock4, Mail, Smartphone } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
const matters = [
{
......@@ -22,7 +28,57 @@ const matters = [
},
]
type DataEmail = {
fullname: string
email: string
message: string
subject: string
}
const schema = z
.object({
fullname: z.string().min(5, { message: 'Digite seu nome completo' }),
email: z.string().email({ message: 'E-mail inválido' }),
message: z
.string()
.min(5, { message: 'Mensagem deve ter no mínimo 5 caracteres' }),
subject: z.string().min(2, { message: 'Selecione uma assunto' }),
})
.required()
export default function Contact() {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<DataEmail>({
resolver: zodResolver(schema),
})
const [status, setStatus] = useState('')
async function onSubmit(data: DataEmail) {
setStatus('Enviando...')
try {
const response = await fetch('/api/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (response.ok) {
setStatus('E-mail enviado com sucesso!')
reset()
} else {
setStatus('Erro ao enviar o e-mail.')
}
} catch (error) {
setStatus('Erro ao enviar o e-mail.')
}
}
return (
<main className="container flex flex-col justify-center items-center py-20">
<h1 className="text-center font-normal md:font-extrabold text-green-800 text-2xl md:text-4xl">
......@@ -36,15 +92,15 @@ export default function Contact() {
<ul className="flex flex-col gap-4 mt-6">
<li className="flex items-center gap-4">
<Clock4 className="text-green-800" />
<span>De XXh à XXh</span>
<span>De segunda à sexta das 08h às 18h</span>
</li>
<li className="flex items-center gap-4">
<Smartphone className="text-green-800" />
<span>Telefones: (XX) XXXX-XXXX</span>
<span>Telefones: (44) 9725-0427</span>
</li>
<li className="flex items-center gap-4">
<Mail className="text-green-800" />
<span>E-mail: sevenpro@sevenpro.com.br</span>
<span>E-mail: id.sevenpro@gmail.com</span>
</li>
</ul>
</div>
......@@ -52,23 +108,59 @@ export default function Contact() {
<h2 className="text-green-800 text-xl md:text-2xl">
Envie uma mensagem
</h2>
<form action="" className="mt-6 flex flex-col gap-6">
<InputMui type="text" variant="outlined" label="Nome completo" />
<form
onSubmit={handleSubmit(onSubmit)}
className="mt-6 flex flex-col gap-6"
>
<InputMui
type="text"
variant="outlined"
label="Nome completo"
name="fullname"
register={register}
/>
{errors.fullname?.message && (
<p className="text-red-600">{errors.fullname?.message}</p>
)}
<InputMui
type="text"
variant="outlined"
label="E-mail"
name="email"
register={register}
/>
{errors.email?.message && (
<p className="text-red-600">{errors.email?.message}</p>
)}
<InputMui
type="text"
variant="outlined"
label="Mensagem"
multiline
rows={4}
name="message"
register={register}
/>
<InputMui type="text" variant="outlined" label="Assunto" select>
{errors.message?.message && (
<p className="text-red-600">{errors.message?.message}</p>
)}
<InputMui
type="text"
variant="outlined"
label="Assunto"
select
name="subject"
register={register}
>
{matters.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</InputMui>
{errors.subject?.message && (
<p className="text-red-600">{errors.subject?.message}</p>
)}
<div className="flex">
<Button type="submit" className="w-auto">
......@@ -76,6 +168,7 @@ export default function Contact() {
</Button>
</div>
</form>
{status && <p className="mt-4 text-sm text-white">{status}</p>}
</div>
</div>
</main>
......
'use client'
import Image from 'next/image'
import { getCourseId } from '@/api/courses'
import { BannerCategory } from '@/components/banner-category'
import { CarouselComponent } from '@/components/corousel-component'
import { CourseDetails } from '@/components/course-details'
......@@ -11,19 +14,35 @@ import {
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import imageCapa from '../../../../public/images/banner.png'
export default function CoursePage() {
const params = useParams<{ courseId: string }>()
const { data: course, isLoading } = useQuery({
queryKey: ['course', params.courseId],
queryFn: () => getCourseId(params.courseId),
enabled: !!params.courseId,
})
if (isLoading) {
return <p>Carregando...</p>
}
return (
<>
<BannerCategory />
<BannerCategory data={course?.data.area.name} />
<div className="flex flex-col md:flex-row px-6 pt-20 gap-6">
<div className="flex flex-col gap-7">
<h1 className="block md:hidden text-green-400 text-3xl font-thin">
História Contemporânea dos Estados Unidos
{course?.data.name}
</h1>
<Image
src={imageCapa}
src={course?.data.mobileBanner || imageCapa}
width={500}
height={500}
className="min-w-[348px] h-[464px] object-cover rounded-lg"
alt="Imagem de capa do curso"
/>
......@@ -33,43 +52,43 @@ export default function CoursePage() {
</div>
<div className="flex flex-col gap-6">
<h1 className="hidden md:block text-green-400 text-4xl font-extrabold">
História Contemporânea dos Estados Unidos
{course?.data.name}
</h1>
<div className="flex flex-col-reverse lg:flex-row gap-6 w-full">
<div className="flex flex-col lg:w-1/2">
<h3 className="font-bold">O que você vai aprender</h3>
<p>
Este curso levará você por uma jornada desde os tumultuados anos
de 1970 até os dias atuais, explorando os impactos decisivos dos
governos de Bush, Obama, Trump e Biden. Descubra os
momentos-chave que moldaram a história contemporânea dos Estados
Unidos, desde a resiliência diante de crises até as mudanças
geopolíticas e sociais. Vamos desvendar os bastidores dos
eventos que marcaram época, proporcionando uma compreensão
profunda e contextualizada da evolução norte-americana.
Prepare-se para uma experiência de aprendizado envolvente e
reveladora, onde o passado ilumina o presente, moldando o
futuro.
</p>
<p>{course?.data.description}</p>
<h3 className="font-bold mt-6">Com quem você vai aprender</h3>
<p>Professor João Silva</p>
{course?.data.professors &&
course?.data.professors.length > 0 && (
<p>{course?.data.professors.join(', ')}</p>
)}
</div>
<div className="lg:w-1/2">
<CourseDetails />
<CourseDetails data={course?.data} />
</div>
</div>
<Button variant="secondary" className="uppercase block md:hidden">
Quero este curso
</Button>
<span className="font-bold mt-4 block md:hidden">Saiba mais:</span>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>Módulos</AccordionTrigger>
{course?.data.modules
.sort((a, b) => a.module.name.localeCompare(b.module.name))
.map((module) => (
<Accordion
type="single"
collapsible
className="w-full"
key={module.module.id}
>
<AccordionItem value={module.module.id}>
<AccordionTrigger>{module.module.name}</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
{module.module.description}
</AccordionContent>
</AccordionItem>
</Accordion>
))}
</div>
</div>
<div className="my-10 px-6">
......
'use client'
import { getCourses } from '@/api/courses'
import { About } from '@/components/about'
import { BannerCategory } from '@/components/banner-category'
import { Card } from '@/components/card'
import { Differences } from '@/components/differences'
import { NavLinkCategory } from '@/components/nav-link-category'
import { PaginationComponent } from '@/components/pagination-component'
import SearchFilter from '@/components/search-filter'
import { SignUp } from '@/components/sign-up'
import { SkeletonSerachParams } from '@/components/skeleton-serach-params'
import { CoursesCard } from '@/utils/courses-array'
import { formatDate } from '@/utils/formatDate'
import { useQuery } from '@tanstack/react-query'
import { Calendar, Clock4, User } from 'lucide-react'
import { usePathname, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
export default function Companies() {
const path = usePathname()
const searchParams = useSearchParams()
const category = searchParams.get('area')
const { data: courses, isLoading: coursesLoading } = useQuery({
queryKey: ['courses'],
queryFn: getCourses,
})
const filterCoursesAudience = courses?.data.filter((item) =>
path.toLowerCase().includes(item?.audience?.name.toLowerCase() as string),
)
const filterCoursesAudiencesAndArea = filterCoursesAudience?.filter((area) =>
category?.includes(area?.area?.name.toLowerCase() as string),
)
if (coursesLoading) {
return <p>Carregando...</p>
}
return (
<main>
<Suspense fallback={<SkeletonSerachParams />}>
<BannerCategory />
<NavLinkCategory />
</Suspense>
<SearchFilter />
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 grid-rows-3 lg:grid-rows-2 gap-4 md:gap-6 p-6">
{CoursesCard.map((course) => (
{/* <SearchFilter /> */}
<div className="grid rounded-none grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 grid-rows-3 lg:grid-rows-2 gap-4 md:gap-6 p-6">
{!category &&
filterCoursesAudience &&
filterCoursesAudience.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`curso/${course.id}`}>
<Card.Image image={course.image.src} width={273} height={365}>
<Card.Title title={course.title} />
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content description={course.hours}>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.category}>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content description={course.calender}>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
))}
{category &&
filterCoursesAudiencesAndArea &&
filterCoursesAudiencesAndArea.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
<div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
))}
{category && filterCoursesAudiencesAndArea?.length === 0 && (
<p>
Não há cursos para a área <b>{category}</b> com audiência{' '}
<b>{path.replace('/', '')}</b>
</p>
)}
{!category && filterCoursesAudience?.length === 0 && (
<p>
Não há cursos para a audiência <b>{path.replace('/', '')}</b>
</p>
)}
</div>
{/* <div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
</div> */}
<Differences />
<SignUp />
<About.Root>
......
'use client'
import { getCourses } from '@/api/courses'
import { About } from '@/components/about'
import { BannerCategory } from '@/components/banner-category'
import { Card } from '@/components/card'
import { Differences } from '@/components/differences'
import { NavLinkCategory } from '@/components/nav-link-category'
import { PaginationComponent } from '@/components/pagination-component'
import SearchFilter from '@/components/search-filter'
import { SignUp } from '@/components/sign-up'
import { SkeletonSerachParams } from '@/components/skeleton-serach-params'
import { CoursesCard } from '@/utils/courses-array'
import { formatDate } from '@/utils/formatDate'
import { useQuery } from '@tanstack/react-query'
import { Calendar, Clock4, User } from 'lucide-react'
import { usePathname, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
export default function Students() {
const path = usePathname()
const searchParams = useSearchParams()
const category = searchParams.get('area')
const { data: courses, isLoading: coursesLoading } = useQuery({
queryKey: ['courses'],
queryFn: getCourses,
})
const filterCoursesAudience = courses?.data.filter((item) =>
path.toLowerCase().includes(item?.audience?.name.toLowerCase() as string),
)
const filterCoursesAudiencesAndArea = filterCoursesAudience?.filter((area) =>
category?.includes(area?.area?.name.toLowerCase() as string),
)
if (coursesLoading) {
return <p>Carregando...</p>
}
return (
<main>
<Suspense fallback={<SkeletonSerachParams />}>
<BannerCategory />
<NavLinkCategory />
</Suspense>
<SearchFilter />
{/* <SearchFilter /> */}
<div className="grid rounded-none grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 grid-rows-3 lg:grid-rows-2 gap-4 md:gap-6 p-6">
{CoursesCard.map((course) => (
{!category &&
filterCoursesAudience &&
filterCoursesAudience.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`curso/${course.id}`}>
<Card.Image image={course.image.src} width={273} height={365}>
<Card.Title title={course.title} />
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content description={course.hours}>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.category}>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content description={course.calender}>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
))}
{category &&
filterCoursesAudiencesAndArea &&
filterCoursesAudiencesAndArea.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
<div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
))}
{category && filterCoursesAudiencesAndArea?.length === 0 && (
<p>
Não há cursos para a área <b>{category}</b> com audiência{' '}
<b>{path.replace('/', '')}</b>
</p>
)}
{!category && filterCoursesAudience?.length === 0 && (
<p>
Não há cursos para a audiência <b>{path.replace('/', '')}</b>
</p>
)}
</div>
{/* <div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
</div> */}
<Differences />
<SignUp />
<About.Root>
......
......@@ -5,6 +5,10 @@ import '@/styles/globals.css'
import { StyledEngineProvider } from '@mui/material'
import type { Metadata } from 'next'
import { Poppins } from 'next/font/google'
import { Toaster } from 'sonner'
import { AuthProvider } from '@/contexts/auth-context'
import ReactQueryProvider from '@/providers/react-query'
const poppins = Poppins({ subsets: ['latin'], weight: ['300', '400', '800'] })
......@@ -21,6 +25,7 @@ export default function RootLayout({
return (
<html lang="pt-BR">
<body className={`antialiased ${poppins.className}`}>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
......@@ -28,11 +33,15 @@ export default function RootLayout({
disableTransitionOnChange
>
<StyledEngineProvider injectFirst>
<ReactQueryProvider>
<Toaster richColors />
<Header />
{children}
<Footer />
</ReactQueryProvider>
</StyledEngineProvider>
</ThemeProvider>
</AuthProvider>
</body>
</html>
)
......
'use client'
import { InputMui } from '@/components/mui/inputs'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { AuthContext } from '@/contexts/auth-context'
import { AxiosError } from 'axios'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useContext } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import globo from '../../../public/images/globo.svg'
type FormLogin = {
email: string
password: string
}
export default function Login() {
const { handleSubmit, register } = useForm<FormLogin>()
const { signIn } = useContext(AuthContext)
const router = useRouter()
const onSubmit = async (data: FormLogin) => {
try {
await signIn(data)
router.push('/admin')
} catch (error) {
if (error instanceof AxiosError) {
const { message } = error.response?.data
toast.error(`${message}`)
}
}
}
return (
<main>
<section className=" container flex flex-col justify-center items-center space-y-10 h-[723px]">
......@@ -20,15 +48,27 @@ export default function Login() {
Acesso exclusivo para usuários cadastrados.
</h2>
<form
// onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)}
className="w-full md:w-[600px] flex flex-col gap-8 order-4"
>
<div className="flex flex-col space-y-6">
<div className="flex flex-col space-y-4">
<InputMui label="E-mail" variant="outlined" type="email" />
<InputMui label="Senha" variant="outlined" type="password" />
<InputMui
label="E-mail"
variant="outlined"
type="email"
name="email"
register={register}
/>
<InputMui
label="Senha"
variant="outlined"
type="password"
name="password"
register={register}
/>
</div>
<div className="flex items-center space-x-2">
{/* <div className="flex items-center space-x-2">
<Checkbox id="remember" />
<label
htmlFor="remember"
......@@ -36,9 +76,10 @@ export default function Login() {
>
Lembrar-me
</label>
</div>
<Button className="rounded-sm" asChild>
<Link href="/admin">Entrar</Link>
</div> */}
<Button className="rounded-sm" type="submit">
{/* <Link href="/admin">Entrar</Link> */}
Entrar
</Button>
<Button
variant={'link'}
......
'use client'
import { getCourses } from '@/api/courses'
import { About } from '@/components/about'
import { BannerCategory } from '@/components/banner-category'
import { Card } from '@/components/card'
import { Differences } from '@/components/differences'
import { NavLinkCategory } from '@/components/nav-link-category'
import { PaginationComponent } from '@/components/pagination-component'
import SearchFilter from '@/components/search-filter'
import { SignUp } from '@/components/sign-up'
import { SkeletonSerachParams } from '@/components/skeleton-serach-params'
import { CoursesCard } from '@/utils/courses-array'
import { formatDate } from '@/utils/formatDate'
import { useQuery } from '@tanstack/react-query'
import { Calendar, Clock4, User } from 'lucide-react'
import { usePathname, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
export default function Professionals() {
const path = usePathname()
const searchParams = useSearchParams()
const category = searchParams.get('area')
const { data: courses, isLoading: coursesLoading } = useQuery({
queryKey: ['courses'],
queryFn: getCourses,
})
const filterCoursesAudience = courses?.data.filter((item) =>
path.toLowerCase().includes(item?.audience?.name.toLowerCase() as string),
)
const filterCoursesAudiencesAndArea = filterCoursesAudience?.filter((area) =>
category?.includes(area?.area?.name.toLowerCase() as string),
)
if (coursesLoading) {
return <p>Carregando...</p>
}
return (
<main>
<Suspense fallback={<SkeletonSerachParams />}>
<BannerCategory />
<NavLinkCategory />
</Suspense>
<SearchFilter />
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 grid-rows-3 lg:grid-rows-2 gap-4 md:gap-6 p-6">
{CoursesCard.map((course) => (
{/* <SearchFilter /> */}
<div className="grid rounded-none grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 grid-rows-3 lg:grid-rows-2 gap-4 md:gap-6 p-6">
{!category &&
filterCoursesAudience &&
filterCoursesAudience.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`curso/${course.id}`}>
<Card.Image image={course.image.src} width={273} height={365}>
<Card.Title title={course.title} />
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content description={course.hours}>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.category}>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content description={course.calender}>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
))}
{category &&
filterCoursesAudiencesAndArea &&
filterCoursesAudiencesAndArea.map((course) => (
<div className="flex justify-center" key={course.id}>
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner as string}
width={273}
height={365}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content
description={`${course.workload} hrs / ${course.category?.name}`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.audience?.name as string}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content
description={formatDate(course.startDate) as string}
>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</div>
<div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
))}
{category && filterCoursesAudiencesAndArea?.length === 0 && (
<p>
Não há cursos para a área <b>{category}</b> com audiência{' '}
<b>{path.replace('/', '')}</b>
</p>
)}
{!category && filterCoursesAudience?.length === 0 && (
<p>
Não há cursos para a audiência <b>{path.replace('/', '')}</b>
</p>
)}
</div>
{/* <div className="my-6">
<PaginationComponent pageIndex={0} totalCount={105} perPage={10} />
</div> */}
<Differences />
<SignUp />
<About.Root>
......
......@@ -2,76 +2,49 @@
import Image from 'next/image'
import { useSearchParams } from 'next/navigation'
import bannerComunicacao from '../../public/images/banners/comunicacao.jpg'
import bannerEducacao from '../../public/images/banners/educacao.jpg'
import bannerNegocios from '../../public/images/banners/negocios.jpg'
import bannerSaude from '../../public/images/banners/saude.jpg'
import bannerStudants from '../../public/images/banners/students_banner.jpg'
import bannerTecnologia from '../../public/images/banners/tecnologia.jpg'
import bannerTeologia from '../../public/images/banners/teologia.jpg'
import bannerComunicacaoMobile from '../../public/images/banners/comunicacao-mobile.jpg'
import bannerEducacaoMobile from '../../public/images/banners/educacao-mobile.jpg'
import bannerNegociosMobile from '../../public/images/banners/negocios-mobile.jpg'
import bannerSaudeMobile from '../../public/images/banners/saude-mobile.jpg'
import bannerTecnologiaMobile from '../../public/images/banners/tecnologia-mobile.jpg'
import bannerTeologiaMobile from '../../public/images/banners/teologia-mobile.jpg'
import { getAreas } from '@/api/areas'
import { useQuery } from '@tanstack/react-query'
export function BannerCategory() {
export function BannerCategory(areaBanner: { data?: string | undefined }) {
const searchParams = useSearchParams()
const category = searchParams.get('categoria')
const category = searchParams.get('area')
const { data: areas } = useQuery({ queryKey: ['areas'], queryFn: getAreas })
function desktopBannerCategory() {
switch (category) {
case 'tecnologia':
return bannerTecnologia
case 'negocios':
return bannerNegocios
case 'comunicacao':
return bannerComunicacao
case 'educacao':
return bannerEducacao
case 'saude':
return bannerSaude
case 'teologia':
return bannerTeologia
default:
return bannerStudants
}
}
const bannerImage = areas?.data.find(
(item) => item.name.toLowerCase() === category?.toLowerCase(),
)
function mobileBannerCategory() {
switch (category) {
case 'tecnologia':
return bannerTecnologiaMobile
case 'negocios':
return bannerNegociosMobile
case 'comunicacao':
return bannerComunicacaoMobile
case 'educacao':
return bannerEducacaoMobile
case 'saude':
return bannerSaudeMobile
case 'teologia':
return bannerTeologiaMobile
default:
return bannerStudants
}
}
const bannerImageArea = areas?.data.find(
(item) => item.name.toLowerCase() === areaBanner?.data?.toLowerCase(),
)
return (
<div className="h-[300px] flex items-center justify-end">
<Image
alt="banner"
className="hidden md:flex w-full h-full object-cover"
src={desktopBannerCategory()}
width={100}
height={100}
src={
bannerImage?.desktopBanner ||
bannerImageArea?.desktopBanner ||
bannerStudants
}
unoptimized
/>
<Image
alt="banner"
className="flex md:hidden w-full h-full object-cover"
src={mobileBannerCategory()}
width={100}
height={100}
src={
bannerImage?.mobileBanner ||
bannerImageArea?.mobileBanner ||
bannerStudants
}
unoptimized
/>
</div>
......
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import Link from 'next/link'
type BreadcrumbProps = {
page: string
}
export default function BreadcrumbComponent({ page }: BreadcrumbProps) {
return (
<Breadcrumb className="my-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/admin">Admin</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{page}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
......@@ -10,7 +10,7 @@ type CardImageProps = {
export function CardImage({ children, image, width, height }: CardImageProps) {
return (
<div className={`relative w-[${width}px] `}>
<div className={`relative w-[${width}px]`}>
<Image
src={image}
width={width}
......
'use client'
import Link from 'next/link'
import { ReactNode } from 'react'
......@@ -9,10 +11,12 @@ type CardRootProps = {
export function CardRoot({ children, link }: CardRootProps) {
return (
<Link
className="lowercase transform scale-95 hover:scale-100 transition-transform duration-300 drop-shadow"
className="lowercase transform scale-95 hover:scale-100 transition-transform duration-300 drop-shadow "
href={link}
>
<div className={`border border-gray-100 pl-0 rounded-lg max-w-[273px] `}>
<div
className={`border border-gray-100 pl-0 rounded-lg max-w-[273px] h-full`}
>
{children}
</div>
</Link>
......
'use client'
import { Calendar, Clock4, User } from 'lucide-react'
import { CoursesCard } from '@/utils/courses-array'
import { getCourses } from '@/api/courses'
import { useQuery } from '@tanstack/react-query'
import { Card } from './card'
import {
Carousel,
......@@ -11,32 +14,47 @@ import {
} from './ui/carousel'
export function CarouselComponent() {
const { data: courses } = useQuery({
queryKey: ['courses'],
queryFn: getCourses,
})
return (
<section>
<Carousel>
{courses ? (
<CarouselContent>
{CoursesCard.map((course) => (
{courses.data.slice(0, 10).map((course) => (
<CarouselItem
key={course.id}
className="sm:basis-1/2 md:basis-1/3 lg:basis-1/4 xl:basis-1/5 flex justify-center"
>
<Card.Root link={`curso/${course.id}`}>
<Card.Image image={course.image.src} width={240} height={320}>
<Card.Title title={course.title} />
<Card.Root link={`/curso/${course.id}`}>
<Card.Image
image={course.mobileBanner || ''}
width={240}
height={320}
>
<Card.Title title={course.name} />
</Card.Image>
<Card.Content description={course.hours}>
<Card.Content
description={`${String(course.workload)} horas`}
>
<Card.Icon icon={Clock4} />
</Card.Content>
<Card.Content description={course.category}>
<Card.Content description={course?.audience?.name || ''}>
<Card.Icon icon={User} />
</Card.Content>
<Card.Content description={course.calender}>
<Card.Content description={course?.category?.name || ''}>
<Card.Icon icon={Calendar} />
</Card.Content>
</Card.Root>
</CarouselItem>
))}
</CarouselContent>
) : (
<p>Não há cursos disponíveis</p>
)}
<CarouselPrevious />
<CarouselNext />
</Carousel>
......
import { Calendar, Clock4, DollarSign, UserRound } from 'lucide-react'
import { CourseIdProps } from '@/api/courses'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Calendar, Clock4, UserRound } from 'lucide-react'
type CourseProps = {
data?: CourseIdProps
}
export function CourseDetails(course: CourseProps) {
const formatDate = (dateString: string | undefined) => {
if (!dateString) return
const date = parseISO(dateString)
const utcDate = toZonedTime(date, 'UTC')
return format(utcDate, 'dd/MM/yyyy')
}
export function CourseDetails() {
return (
<ul className="flex flex-col gap-8">
<li className="flex items-center gap-4">
......@@ -11,7 +26,9 @@ export function CourseDetails() {
</div>
<div className="flex flex-col gap-2">
<span className="font-thin">Duração:</span>
<span className="text-xl">30 Hrs/Curso Rápido</span>
<span className="text-xl">
{course.data?.workload.toString()} Hrs/{course.data?.category.name}
</span>
</div>
</li>
<li className="flex items-center gap-4">
......@@ -22,7 +39,7 @@ export function CourseDetails() {
</div>
<div className="flex flex-col gap-2">
<span className="font-thin">Indicado para:</span>
<span className="text-xl">Estudantes</span>
<span className="text-xl">{course.data?.audience.name}</span>
</div>
</li>
<li className="flex items-center gap-4">
......@@ -33,10 +50,10 @@ export function CourseDetails() {
</div>
<div className="flex flex-col gap-2">
<span className="font-thin">Início:</span>
<span className="text-xl">Imediato</span>
<span className="text-xl">{formatDate(course.data?.startDate)}</span>
</div>
</li>
<li className="flex items-center gap-4">
{/* <li className="flex items-center gap-4">
<div className="rounded-full w-[60px] h-[60px] flex items-center justify-center bg-gradient-to-r from-green-700 to-green-50">
<div className="rounded-full flex items-center justify-center w-[57px] h-[57px] dark:bg-gray-900 bg-gray-50">
<DollarSign size={35} />
......@@ -46,7 +63,7 @@ export function CourseDetails() {
<span className="font-thin">Investimento:</span>
<span className="text-xl">R$200,00</span>
</div>
</li>
</li> */}
</ul>
)
}
import { forwardRef } from 'react'
interface InputFileProps extends React.InputHTMLAttributes<HTMLInputElement> {
id: string
values?: string
label: string
}
const InputFile = forwardRef<HTMLInputElement, InputFileProps>(
({ id, values, label, ...inputProps }, ref) => {
return (
<label
htmlFor={id}
className="flex justify-center items-center border border-dotted rounded-sm border-green-400 bg-green-400/10 cursor-pointer hover:bg-green-400/5 text-green-400 text-sm h-10 mt-6"
>
{values || label}
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg"
className="hidden"
ref={ref}
{...inputProps}
/>
</label>
)
},
)
InputFile.displayName = 'InputFile'
export default InputFile
import { cn } from '@/lib/utils'
type SpinProps = {
className?: React.HTMLProps<HTMLElement>['className']
}
export function LoadingSpinIcon({ className }: SpinProps) {
return (
<svg
className={cn('animate-spin -ml-1 mr-3 h-5 w-5 text-white', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)
}
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog'
import { Button } from './ui/button'
type AlertDialogProps = {
title: string
description: string
handleClick: () => void
}
export default function ModalDialog({
title,
description,
handleClick,
}: AlertDialogProps) {
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<Button
onClick={handleClick}
variant="outline"
className="text-gray-50 bg-red-500 hover:bg-red-500/80"
>
Continue
</Button>
</AlertDialogFooter>
</AlertDialogContent>
)
}
'use client'
import { TextField } from '@mui/material'
import { StyledInputs } from './mui/styled-inputs'
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog'
import { Button } from './ui/button'
type AlertDialogProps = {
title: string
description: string
value?: string
handleClick: () => void
handleClickDelete?: () => void
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export default function ModalInputs({
title,
description,
value,
handleClick,
handleClickDelete,
onChange,
}: AlertDialogProps) {
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<TextField
label="Nome"
type="text"
value={value}
onChange={onChange}
variant="standard"
className="flex"
sx={StyledInputs({ color: '#26AAA7' })}
/>
<AlertDialogFooter className="mt-4 w-full flex !justify-between">
{handleClickDelete && (
<Button
onClick={handleClickDelete}
variant="outline"
className="text-gray-50 bg-red-500 hover:bg-red-500/80"
>
Deletar
</Button>
)}
<div className='flex gap-4'>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<Button
onClick={handleClick}
variant="outline"
className="text-gray-50 bg-green-500 hover:bg-green-500/80"
>
Salvar
</Button>
</div>
</AlertDialogFooter>
</AlertDialogContent>
)
}
......@@ -3,28 +3,36 @@
import { useThemeClient } from '@/hooks/useThemeClient'
import { TextField, TextFieldProps } from '@mui/material'
import { ReactNode } from 'react'
import { FieldValues, Path, UseFormRegister } from 'react-hook-form'
type InputMuiProps = TextFieldProps & {
type InputMuiProps<T extends FieldValues> = TextFieldProps & {
label: string
variant: 'standard' | 'filled' | 'outlined'
themeColor?: string
children?: ReactNode
name: Path<T>
register: UseFormRegister<T>
}
export function InputMui({
export const InputMui = <T extends FieldValues>({
label,
variant,
children,
...props
}: InputMuiProps) {
themeColor = '#fafafa',
register,
name,
...rest
}: InputMuiProps<T>) => {
const themeConfig = useThemeClient()
const color = themeConfig === 'dark' ? '#fafafa' : '#3C3C3C'
const color = themeConfig === 'dark' ? themeColor : '#3C3C3C'
return (
<TextField
label={label}
variant={variant}
{...props}
{...register(name)}
{...rest}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
......@@ -44,10 +52,10 @@ export function InputMui({
color: `${color}`,
},
'& input': {
color: `${color}`,
color: `#fafafa`,
},
'& .MuiInputBase-input': {
color: `${color}`,
color: `#fafafa`,
},
'& .MuiInput-underline:before': {
borderBottomColor: color,
......
'use client'
import { getAreas } from '@/api/areas'
import { useQuery } from '@tanstack/react-query'
import { NavLinkSearchParams } from './nav-link-search-params'
export function NavLinkCategory() {
const { data: areas } = useQuery({ queryKey: ['areas'], queryFn: getAreas })
return (
<nav className="container my-10">
<ul className="flex flex-wrap justify-center items-center gap-8">
<li>
<NavLinkSearchParams
variant="default"
href="tecnologia"
className="uppercase"
>
Tecnologia
</NavLinkSearchParams>
</li>
<li>
<NavLinkSearchParams
variant="default"
href="negocios"
className="uppercase"
>
Negócios
</NavLinkSearchParams>
</li>
<li>
<NavLinkSearchParams
variant="default"
href="saude"
className="uppercase"
>
Saúde
</NavLinkSearchParams>
</li>
<li>
<NavLinkSearchParams
variant="default"
href="educacao"
className="uppercase"
>
Educação
</NavLinkSearchParams>
</li>
<li>
<NavLinkSearchParams
variant="default"
href="comunicacao"
className="uppercase"
>
Comunicação
</NavLinkSearchParams>
</li>
<li>
{areas?.data.map((area) => (
<li key={area.id}>
<NavLinkSearchParams
variant="default"
href="teologia"
href={area.name.toLowerCase()}
className="uppercase"
>
Teologia
{area.name}
</NavLinkSearchParams>
</li>
))}
</ul>
</nav>
)
......
......@@ -2,7 +2,7 @@
import Link, { LinkProps } from 'next/link'
import { useSearchParams } from 'next/navigation'
import { usePathname, useSearchParams } from 'next/navigation'
import { Button } from './ui/button'
interface NavLinkProps extends LinkProps {
......@@ -23,16 +23,25 @@ interface NavLinkProps extends LinkProps {
export function NavLinkSearchParams(props: NavLinkProps) {
const searchParams = useSearchParams()
const category = searchParams.get('categoria')
const pathname = usePathname()
const path = pathname
const area = searchParams.get('area')
return (
<Button
data-current={category === props.href}
data-current={area === props.href}
className={`data-[current=true]:font-semibold rounded-2xl ${props.className}`}
variant={category === props.href ? 'third' : props.variant}
variant={area === props.href ? 'third' : props.variant}
asChild
>
<Link href={{ query: { categoria: props.href as string } }}>
<Link
href={{
pathname: `${path === '/' ? '/estudantes' : path}`,
query: { area: props.href as string },
}}
>
{props.children}
</Link>
</Button>
......
'use client'
import { Search } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { InputMui } from './mui/inputs'
import { Label } from './ui/label'
import {
......@@ -11,6 +14,8 @@ import {
} from './ui/select'
export default function SearchFilter() {
const { register } = useForm()
return (
<div className="container grid md:grid-cols-2 gap-8 items-end mb-8">
<div className="flex items-center max-w-[712px] flex-1">
......@@ -19,6 +24,8 @@ export default function SearchFilter() {
variant="standard"
type="text"
className="w-full #fafafa"
name="learned-today"
register={register}
/>
<Search size={24} className="-ml-6" />
</div>
......
......@@ -2,24 +2,69 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TextField } from '@mui/material'
import { zodResolver } from '@hookform/resolvers/zod'
import Image from 'next/image'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import signUpImage from '../../public/images/sign-up.png'
import { StyledInputs } from './mui/styled-inputs'
import { InputMui } from './mui/inputs'
type FormSignUpTypes = {
name: string
fullname: string
email: string
whatsapp: string
interest: string
area: string
course: string
}
const schema = z
.object({
fullname: z.string().min(5, { message: 'Digite seu nome completo' }),
email: z.string().email({ message: 'E-mail inválido' }),
whatsapp: z
.string()
.min(10, { message: 'Digite seu whatsapp com o DDD' })
.max(13, { message: 'Digite apenas o número do seu whatsapp' }),
area: z.string().min(2, { message: 'Informe a área' }),
course: z.string().min(2, { message: 'Informe o curso' }),
})
.required()
export function SignUp() {
const { handleSubmit } = useForm<FormSignUpTypes>()
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormSignUpTypes>({
resolver: zodResolver(schema),
})
const [status, setStatus] = useState('')
const onSubmit: SubmitHandler<FormSignUpTypes> = (data) => console.log(data)
async function onSubmit(data: FormSignUpTypes) {
setStatus('Enviando...')
try {
const response = await fetch('/api/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (response.ok) {
toast.success('E-mail enviado com sucesso!')
setStatus('Inscrever')
reset()
} else {
toast.error('Falha ao enviar E-mail!')
setStatus('Inscrever')
}
} catch (error) {
setStatus('Inscrever')
}
}
return (
<section className="w-full bg-gradient-to-r from-green-700 to-green-50 pb-6 md:py-6 md:pb-0">
......@@ -39,42 +84,62 @@ export function SignUp() {
onSubmit={handleSubmit(onSubmit)}
className="w-full md:w-[600px] flex flex-col gap-8 order-4"
>
<TextField
label="Nome"
variant="standard"
<InputMui
type="text"
sx={StyledInputs({ color: '#fafafa' })}
/>
<TextField
label="Whatsapp"
variant="standard"
type="number"
sx={StyledInputs({ color: '#fafafa' })}
label="Nome completo"
name="fullname"
register={register}
/>
<TextField
label="Área de interesse"
{errors.fullname?.message && (
<p className="text-red-600">{errors.fullname?.message}</p>
)}
<InputMui
type="number"
variant="standard"
type="text"
sx={StyledInputs({ color: '#fafafa' })}
label="Whatsapp"
name="whatsapp"
register={register}
/>
<TextField
label="E-mail"
variant="standard"
{errors.whatsapp?.message && (
<p className="text-red-600">{errors.whatsapp?.message}</p>
)}
<InputMui
type="text"
sx={StyledInputs({ color: '#fafafa' })}
variant="standard"
label="Área de interesse"
name="area"
register={register}
/>
<TextField
label="Curso desejado"
{errors.area?.message && (
<p className="text-red-600">{errors.area?.message}</p>
)}
<InputMui
type="text"
variant="standard"
label="E-mail"
name="email"
register={register}
/>
{errors.email?.message && (
<p className="text-red-600">{errors.email?.message}</p>
)}
<InputMui
type="text"
sx={StyledInputs({ color: '#fafafa' })}
variant="standard"
label="Curso desejado"
name="course"
register={register}
/>
{errors.course?.message && (
<p className="text-red-600">{errors.course?.message}</p>
)}
<Button
type="submit"
variant="secondary"
className="uppercase max-w-32 mx-auto md:mb-6"
>
Inscrever
{status || 'Inscrever'}
</Button>
</form>
</div>
......
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
import { cva, VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const inputVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-ring focus-visible:border-b-2 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none disabled:cursor-not-allowed',
{
variants: {
variant: {
default:
'flex h-10 w-full border border-t-0 border-x-0 border-gray-300 px-2 py-2 text-md text-white bg-transparent file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-300',
secondary:
'text-gray-50 bg-gradient-to-r from-purple-100 to-purple-200 hover:bg-purple-200 hover:opacity-75',
file: 'border border-dotted rounded-sm border-green-400 bg-green-400/10 cursor-pointer hover:bg-green-400/5 text-green-400 text-sm h-10',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost:
'dark:hover:bg-white/10 dark:hover:text-gray-50 hover:bg-gray-900/10',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
label?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
({ className, variant, type, label, ...props }, ref) => {
return (
<label>
<span className="text-sm text-gray-400">{label}</span>
<input
type={type}
className={cn(
'flex h-10 w-full border border-t-0 border-x-0 border-gray-300 px-2 py-2 text-md text-white ile:border-0 bg-transparent file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-300 focus-visible:outline-none focus-visible:border-b-white focus-visible:border-b-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
className={cn(inputVariants({ variant, className }))}
ref={ref}
{...props}
/>
</label>
)
},
)
Input.displayName = 'Input'
export { Input }
export { Input, inputVariants }
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
'use client'
// import { useRouter } from 'next/router'
import { parseCookies, setCookie } from 'nookies'
import { createContext, useEffect, useState } from 'react'
import { getAccountsId } from '@/api/accounts'
import { login } from '@/api/login'
import { api } from '@/lib/axios'
import { useRouter } from 'next/navigation'
type User = {
id: string
name: string
email: string
} | null
type SignInData = {
email: string
password: string
}
type AuthContextType = {
isAuthenticated: boolean
user: User
signIn: (data: SignInData) => Promise<void>
}
export const AuthContext = createContext({} as AuthContextType)
export function AuthProvider({ children }) {
const [user, setUser] = useState<User | null>(null)
const router = useRouter()
const isAuthenticated = !!user
useEffect(() => {
const { 'sevenpro-token': token } = parseCookies()
const { 'sevenpro-user': userCookie } = parseCookies()
if (token && userCookie) {
const { id } = JSON.parse(userCookie)
getAccountsId(id).then((response) => {
setUser(response.data)
})
}
}, [])
async function signIn({ email, password }: SignInData) {
const { data } = await login({
email,
password,
})
const token = data.access_token
const user = data.user
setCookie(undefined, 'sevenpro-token', token, {
maxAge: 60 * 60 * 1, // 1 hour
})
setCookie(undefined, 'sevenpro-user', JSON.stringify(user), {
maxAge: 60 * 60 * 1, // 1 hour
})
api.defaults.headers.Authorization = `Bearer ${token}`
setUser(user)
router.push('/admin')
}
return (
<AuthContext.Provider value={{ user, isAuthenticated, signIn }}>
{children}
</AuthContext.Provider>
)
}
// import * as z from 'zod'
// const envSchema = z.object({
// NEXT_PUBLIC_URL_API: z.string().url(),
// NEXT_PUBLIC_CLOUDFLARE_ACCESS_ENDPOINT: z.string().url(),
// NEXT_PUBLIC_CLOUDFLARE_ACCESS_TOKEN_VALUE: z.string(),
// NEXT_PUBLIC_CLOUDFLARE_ACCESS_KEY_ID: z.string(),
// NEXT_PUBLIC_CLOUDFLARE_SECRET_ACCESS_KEY: z.string(),
// NEXT_PUBLIC_CLOUDFLARE_ACCESS_ACCOUNT_ID: z.string(),
// })
// export const env = envSchema.parse(process.env)
import axios from 'axios'
// import Router from 'next/router'
import { destroyCookie, parseCookies } from 'nookies'
const { 'sevenpro-token': token } = parseCookies()
export const api = axios.create({
baseURL: '/api',
baseURL: process.env.NEXT_PUBLIC_URL_API,
})
if (token) {
api.defaults.headers.Authorization = `Bearer ${token}`
}
// api.interceptors.request.use(
// (config) => {
// return config
// },
// (error) => {
// return Promise.reject(error)
// },
// )
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response && error.response.status === 401) {
// Remove o token inválido
destroyCookie(null, 'sevenpro-token')
// Redireciona para a página de login
// Router.push('/login')
}
return Promise.reject(error)
},
)
import { S3Client } from '@aws-sdk/client-s3'
export const s3 = new S3Client({
region: 'auto',
endpoint: process.env.NEXT_PUBLIC_CLOUDFLARE_ACCESS_ENDPOINT,
credentials: {
accessKeyId: process.env.NEXT_PUBLIC_CLOUDFLARE_ACCESS_KEY_ID!,
secretAccessKey: process.env.NEXT_PUBLIC_CLOUDFLARE_SECRET_ACCESS_KEY!,
},
})
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('sevenpro-token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*'],
}
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode } from 'react'
type ReactQueryProps = {
children: ReactNode
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
})
export default function ReactQueryProvider({ children }: ReactQueryProps) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind components;
@tailwind utilities;
@layer base {
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0, 0%, 25%;
......@@ -64,13 +64,13 @@
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
}
@layer base {
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
\ No newline at end of file
}
import { format, parseISO } from "date-fns"
import { toZonedTime } from "date-fns-tz"
export const formatDate = (dateString: string | undefined) => {
if (!dateString) return
const date = parseISO(dateString)
const utcDate = toZonedTime(date, 'UTC')
return format(utcDate, 'dd/MM/yyyy')
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment