Initial commit: MetisClass - Plataforma Educacional
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9532
package-lock.json
generated
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "metisclass",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.72.1",
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.31.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"openai": "^6.17.0",
|
||||
"prisma": "^7.3.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
35
public/favicon.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="32" cy="32" r="30" fill="#0a0f1a"/>
|
||||
|
||||
<!-- Owl Body -->
|
||||
<ellipse cx="32" cy="38" rx="16" ry="18" fill="#10B981"/>
|
||||
|
||||
<!-- Owl Face -->
|
||||
<ellipse cx="32" cy="36" rx="13" ry="11" fill="#0D9668"/>
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle cx="26" cy="34" r="5" fill="#0a0f1a"/>
|
||||
<circle cx="38" cy="34" r="5" fill="#0a0f1a"/>
|
||||
<circle cx="26" cy="34" r="3.5" fill="#F59E0B"/>
|
||||
<circle cx="38" cy="34" r="3.5" fill="#F59E0B"/>
|
||||
<circle cx="26" cy="33" r="1.5" fill="#0a0f1a"/>
|
||||
<circle cx="38" cy="33" r="1.5" fill="#0a0f1a"/>
|
||||
<circle cx="27" cy="32.5" r="0.8" fill="white"/>
|
||||
<circle cx="39" cy="32.5" r="0.8" fill="white"/>
|
||||
|
||||
<!-- Beak -->
|
||||
<path d="M30 39 L32 43 L34 39 Z" fill="#F59E0B"/>
|
||||
|
||||
<!-- Graduation Cap -->
|
||||
<rect x="18" y="16" width="28" height="3" fill="#1f2937"/>
|
||||
<polygon points="32,8 16,18 32,22 48,18" fill="#1f2937"/>
|
||||
<circle cx="32" cy="15" r="2" fill="#F59E0B"/>
|
||||
<!-- Tassel -->
|
||||
<line x1="46" y1="17" x2="50" y2="25" stroke="#F59E0B" stroke-width="2"/>
|
||||
<circle cx="50" cy="27" r="2.5" fill="#F59E0B"/>
|
||||
|
||||
<!-- Ears/Feathers -->
|
||||
<path d="M20 26 L15 14 L24 22 Z" fill="#10B981"/>
|
||||
<path d="M44 26 L49 14 L40 22 Z" fill="#10B981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
40
public/logo-dark.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg width="200" height="60" viewBox="0 0 200 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Owl Body -->
|
||||
<ellipse cx="30" cy="35" rx="18" ry="20" fill="#10B981"/>
|
||||
|
||||
<!-- Owl Face -->
|
||||
<ellipse cx="30" cy="32" rx="14" ry="12" fill="#0D9668"/>
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle cx="24" cy="30" r="6" fill="#0a0f1a"/>
|
||||
<circle cx="36" cy="30" r="6" fill="#0a0f1a"/>
|
||||
<circle cx="24" cy="30" r="4" fill="#F59E0B"/>
|
||||
<circle cx="36" cy="30" r="4" fill="#F59E0B"/>
|
||||
<circle cx="24" cy="29" r="2" fill="#0a0f1a"/>
|
||||
<circle cx="36" cy="29" r="2" fill="#0a0f1a"/>
|
||||
<circle cx="25" cy="28" r="1" fill="white"/>
|
||||
<circle cx="37" cy="28" r="1" fill="white"/>
|
||||
|
||||
<!-- Beak -->
|
||||
<path d="M28 36 L30 40 L32 36 Z" fill="#F59E0B"/>
|
||||
|
||||
<!-- Graduation Cap -->
|
||||
<rect x="16" y="12" width="28" height="3" fill="#1f2937"/>
|
||||
<polygon points="30,5 14,15 30,18 46,15" fill="#1f2937"/>
|
||||
<circle cx="30" cy="12" r="2" fill="#F59E0B"/>
|
||||
<!-- Tassel -->
|
||||
<line x1="44" y1="14" x2="48" y2="22" stroke="#F59E0B" stroke-width="2"/>
|
||||
<circle cx="48" cy="24" r="3" fill="#F59E0B"/>
|
||||
|
||||
<!-- Ears/Feathers -->
|
||||
<path d="M16 22 L12 10 L20 18 Z" fill="#10B981"/>
|
||||
<path d="M44 22 L48 10 L40 18 Z" fill="#10B981"/>
|
||||
|
||||
<!-- Text: Metis -->
|
||||
<text x="58" y="32" font-family="Outfit, sans-serif" font-size="22" font-weight="700" fill="#E2E8F0">Metis</text>
|
||||
<!-- Text: Class -->
|
||||
<text x="116" y="32" font-family="Outfit, sans-serif" font-size="22" font-weight="700" fill="#10B981">Class</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="58" y="48" font-family="Outfit, sans-serif" font-size="10" fill="#6B7280">Ensino inteligente com IA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
40
public/logo-light.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg width="200" height="60" viewBox="0 0 200 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Owl Body -->
|
||||
<ellipse cx="30" cy="35" rx="18" ry="20" fill="#10B981"/>
|
||||
|
||||
<!-- Owl Face -->
|
||||
<ellipse cx="30" cy="32" rx="14" ry="12" fill="#0D9668"/>
|
||||
|
||||
<!-- Eyes -->
|
||||
<circle cx="24" cy="30" r="6" fill="white"/>
|
||||
<circle cx="36" cy="30" r="6" fill="white"/>
|
||||
<circle cx="24" cy="30" r="4" fill="#F59E0B"/>
|
||||
<circle cx="36" cy="30" r="4" fill="#F59E0B"/>
|
||||
<circle cx="24" cy="29" r="2" fill="#1f2937"/>
|
||||
<circle cx="36" cy="29" r="2" fill="#1f2937"/>
|
||||
<circle cx="25" cy="28" r="1" fill="white"/>
|
||||
<circle cx="37" cy="28" r="1" fill="white"/>
|
||||
|
||||
<!-- Beak -->
|
||||
<path d="M28 36 L30 40 L32 36 Z" fill="#F59E0B"/>
|
||||
|
||||
<!-- Graduation Cap -->
|
||||
<rect x="16" y="12" width="28" height="3" fill="#1f2937"/>
|
||||
<polygon points="30,5 14,15 30,18 46,15" fill="#1f2937"/>
|
||||
<circle cx="30" cy="12" r="2" fill="#F59E0B"/>
|
||||
<!-- Tassel -->
|
||||
<line x1="44" y1="14" x2="48" y2="22" stroke="#F59E0B" stroke-width="2"/>
|
||||
<circle cx="48" cy="24" r="3" fill="#F59E0B"/>
|
||||
|
||||
<!-- Ears/Feathers -->
|
||||
<path d="M16 22 L12 10 L20 18 Z" fill="#10B981"/>
|
||||
<path d="M44 22 L48 10 L40 18 Z" fill="#10B981"/>
|
||||
|
||||
<!-- Text: Metis -->
|
||||
<text x="58" y="32" font-family="Outfit, sans-serif" font-size="22" font-weight="700" fill="#1f2937">Metis</text>
|
||||
<!-- Text: Class -->
|
||||
<text x="116" y="32" font-family="Outfit, sans-serif" font-size="22" font-weight="700" fill="#10B981">Class</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="58" y="48" font-family="Outfit, sans-serif" font-size="10" fill="#6B7280">Ensino inteligente com IA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
198
src/app/(auth)/login/LoginForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Mail, Lock, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
|
||||
const error = searchParams.get("error");
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState(error || "");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setErrorMessage(result.error);
|
||||
} else {
|
||||
router.push(callbackUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage("Ocorreu um erro. Tente novamente.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
signIn("google", { callbackUrl });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950 px-4">
|
||||
{/* Background effects */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-gold-500/10 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo-dark.svg"
|
||||
alt="MetisClass"
|
||||
width={200}
|
||||
height={45}
|
||||
className="mx-auto h-10 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold text-white">Bem-vindo de volta!</h1>
|
||||
<p className="mt-2 text-dark-400">Entre na sua conta para continuar</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-dark-900/50 border border-dark-700/50 rounded-2xl p-8">
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Sign In */}
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
className="w-full flex items-center justify-center gap-3 bg-white text-dark-900 py-3 px-4 rounded-xl font-medium hover:bg-dark-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continuar com Google
|
||||
</button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-dark-700"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-dark-900/50 text-dark-400">ou</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input pl-12"
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pl-12 pr-12"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="rounded border-dark-600 bg-dark-800 text-primary-500" />
|
||||
<span className="text-sm text-dark-400">Lembrar de mim</span>
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-sm text-primary-400 hover:text-primary-300">
|
||||
Esqueceu a senha?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Entrando...
|
||||
</>
|
||||
) : (
|
||||
"Entrar"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="mt-6 text-center text-dark-400">
|
||||
Não tem uma conta?{" "}
|
||||
<Link href="/register" className="text-primary-400 hover:text-primary-300 font-medium">
|
||||
Criar conta grátis
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginSkeleton />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950 px-4">
|
||||
<div className="animate-pulse w-full max-w-md">
|
||||
<div className="h-10 w-48 bg-dark-800 rounded mx-auto mb-8" />
|
||||
<div className="bg-dark-900/50 border border-dark-700/50 rounded-2xl p-8 space-y-4">
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/app/(auth)/register/RegisterForm.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Mail, Lock, User, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage("As senhas não coincidem");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setErrorMessage("A senha deve ter pelo menos 6 caracteres");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setErrorMessage(data.error || "Erro ao criar conta");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto sign in after registration
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setErrorMessage(result.error);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage("Ocorreu um erro. Tente novamente.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
signIn("google", { callbackUrl: "/dashboard" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950 px-4 py-12">
|
||||
{/* Background effects */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-gold-500/10 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo-dark.svg"
|
||||
alt="MetisClass"
|
||||
width={200}
|
||||
height={45}
|
||||
className="mx-auto h-10 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold text-white">Criar conta grátis</h1>
|
||||
<p className="mt-2 text-dark-400">Comece a criar conteúdo incrível com IA</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-dark-900/50 border border-dark-700/50 rounded-2xl p-8">
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Sign In */}
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
className="w-full flex items-center justify-center gap-3 bg-white text-dark-900 py-3 px-4 rounded-xl font-medium hover:bg-dark-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continuar com Google
|
||||
</button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-dark-700"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-dark-900/50 text-dark-400">ou</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Nome completo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input pl-12"
|
||||
placeholder="Seu nome"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input pl-12"
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input pl-12 pr-12"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Confirmar senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-dark-400" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="input pl-12"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
className="mt-1 rounded border-dark-600 bg-dark-800 text-primary-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-dark-400">
|
||||
Eu concordo com os{" "}
|
||||
<Link href="/terms" className="text-primary-400 hover:text-primary-300">
|
||||
Termos de Uso
|
||||
</Link>{" "}
|
||||
e{" "}
|
||||
<Link href="/privacy" className="text-primary-400 hover:text-primary-300">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Criando conta...
|
||||
</>
|
||||
) : (
|
||||
"Criar conta grátis"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Login link */}
|
||||
<p className="mt-6 text-center text-dark-400">
|
||||
Já tem uma conta?{" "}
|
||||
<Link href="/login" className="text-primary-400 hover:text-primary-300 font-medium">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense fallback={<RegisterSkeleton />}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-950 px-4">
|
||||
<div className="animate-pulse w-full max-w-md">
|
||||
<div className="h-10 w-48 bg-dark-800 rounded mx-auto mb-8" />
|
||||
<div className="bg-dark-900/50 border border-dark-700/50 rounded-2xl p-8 space-y-4">
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
<div className="h-12 bg-dark-800 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
src/app/(dashboard)/dashboard/crossword/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PuzzleIcon, Sparkles, Loader2, Download, RefreshCw } from "lucide-react";
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
const subjects = [
|
||||
"Matemática", "Português", "História", "Geografia", "Ciências",
|
||||
"Física", "Química", "Biologia", "Inglês", "Filosofia",
|
||||
];
|
||||
|
||||
const grades = [
|
||||
"1º ao 5º ano - Fundamental I",
|
||||
"6º ao 9º ano - Fundamental II",
|
||||
"Ensino Médio",
|
||||
];
|
||||
|
||||
interface CrosswordWord {
|
||||
word: string;
|
||||
clue: string;
|
||||
row: number;
|
||||
col: number;
|
||||
direction: "across" | "down";
|
||||
number: number;
|
||||
}
|
||||
|
||||
export default function CrosswordPage() {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [grade, setGrade] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [wordCount, setWordCount] = useState("8");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [words, setWords] = useState<CrosswordWord[]>([]);
|
||||
const [grid, setGrid] = useState<string[][]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const generateCrossword = async () => {
|
||||
if (!subject || !grade || !topic) {
|
||||
setError("Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError("");
|
||||
setWords([]);
|
||||
setGrid([]);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate/crossword", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subject, grade, topic, wordCount: parseInt(wordCount) }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Erro ao gerar palavras cruzadas");
|
||||
|
||||
setWords(data.words);
|
||||
setGrid(data.grid);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = () => {
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 15;
|
||||
|
||||
// Header
|
||||
doc.setFillColor(124, 58, 237);
|
||||
doc.rect(0, 0, pageWidth, 12, "F");
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("MetisClass", margin, 8);
|
||||
doc.setFontSize(8);
|
||||
doc.text("metisclass.com.br", pageWidth - margin - 30, 8);
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
// Title
|
||||
doc.setFontSize(16);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Palavras Cruzadas", margin, 25);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(`${subject} - ${topic}`, margin, 32);
|
||||
|
||||
// Draw grid
|
||||
const cellSize = 8;
|
||||
const gridStartX = margin;
|
||||
const gridStartY = 40;
|
||||
|
||||
doc.setFontSize(7);
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
for (let j = 0; j < grid[i].length; j++) {
|
||||
const x = gridStartX + j * cellSize;
|
||||
const y = gridStartY + i * cellSize;
|
||||
|
||||
if (grid[i][j] !== " ") {
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(x, y, cellSize, cellSize, "FD");
|
||||
|
||||
// Check if this is a starting position
|
||||
const wordStart = words.find(w => w.row === i && w.col === j);
|
||||
if (wordStart) {
|
||||
doc.setFontSize(5);
|
||||
doc.text(wordStart.number.toString(), x + 0.5, y + 2.5);
|
||||
doc.setFontSize(7);
|
||||
}
|
||||
} else {
|
||||
doc.setFillColor(30, 30, 30);
|
||||
doc.rect(x, y, cellSize, cellSize, "F");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clues
|
||||
let y = gridStartY + grid.length * cellSize + 15;
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("HORIZONTAIS", margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const acrossWords = words.filter(w => w.direction === "across").sort((a, b) => a.number - b.number);
|
||||
for (const word of acrossWords) {
|
||||
const text = `${word.number}. ${word.clue}`;
|
||||
const lines = doc.splitTextToSize(text, pageWidth - margin * 2);
|
||||
doc.text(lines, margin, y);
|
||||
y += lines.length * 4 + 2;
|
||||
}
|
||||
|
||||
y += 5;
|
||||
doc.setFontSize(11);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("VERTICAIS", margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const downWords = words.filter(w => w.direction === "down").sort((a, b) => a.number - b.number);
|
||||
for (const word of downWords) {
|
||||
const text = `${word.number}. ${word.clue}`;
|
||||
const lines = doc.splitTextToSize(text, pageWidth - margin * 2);
|
||||
doc.text(lines, margin, y);
|
||||
y += lines.length * 4 + 2;
|
||||
}
|
||||
|
||||
// Footer
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text("Gerado por MetisClass - metisclass.com.br", margin, pageHeight - 10);
|
||||
|
||||
// Answer key on second page
|
||||
doc.addPage();
|
||||
|
||||
// Header
|
||||
doc.setFillColor(124, 58, 237);
|
||||
doc.rect(0, 0, pageWidth, 12, "F");
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("MetisClass - Gabarito", margin, 8);
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.setFontSize(14);
|
||||
doc.text("Gabarito", margin, 25);
|
||||
|
||||
y = 35;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Horizontais:", margin, y);
|
||||
y += 6;
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const word of acrossWords) {
|
||||
doc.text(`${word.number}. ${word.word.toUpperCase()}`, margin, y);
|
||||
y += 5;
|
||||
}
|
||||
|
||||
y += 5;
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Verticais:", margin, y);
|
||||
y += 6;
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const word of downWords) {
|
||||
doc.text(`${word.number}. ${word.word.toUpperCase()}`, margin, y);
|
||||
y += 5;
|
||||
}
|
||||
|
||||
doc.save(`palavras-cruzadas-${topic.toLowerCase().replace(/\s+/g, "-")}.pdf`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary-500/10 rounded-xl">
|
||||
<PuzzleIcon className="h-8 w-8 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Gerador de Palavras Cruzadas</h1>
|
||||
<p className="text-dark-400">Crie atividades lúdicas para seus alunos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Form */}
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Configurações</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Disciplina *</label>
|
||||
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{subjects.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Nível *</label>
|
||||
<select value={grade} onChange={(e) => setGrade(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{grades.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Tema *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Sistema Solar, Verbos, Revolução Francesa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Número de palavras</label>
|
||||
<select value={wordCount} onChange={(e) => setWordCount(e.target.value)} className="input">
|
||||
{[6, 8, 10, 12].map((n) => <option key={n} value={n}>{n} palavras</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={generateCrossword} disabled={isGenerating} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
{isGenerating ? (
|
||||
<><Loader2 className="h-5 w-5 animate-spin" />Gerando...</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5" />Gerar Palavras Cruzadas</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Resultado</h2>
|
||||
{words.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={generateCrossword} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg" title="Regenerar">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
</button>
|
||||
<button onClick={downloadPDF} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg" title="Download PDF">
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{grid.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Grid */}
|
||||
<div className="overflow-auto">
|
||||
<div className="inline-block">
|
||||
{grid.map((row, i) => (
|
||||
<div key={i} className="flex">
|
||||
{row.map((cell, j) => {
|
||||
const wordStart = words.find(w => w.row === i && w.col === j);
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={`w-8 h-8 border border-dark-600 flex items-center justify-center text-sm font-bold relative ${
|
||||
cell === " " ? "bg-dark-800" : "bg-dark-700"
|
||||
}`}
|
||||
>
|
||||
{wordStart && (
|
||||
<span className="absolute top-0 left-0.5 text-[8px] text-primary-400">
|
||||
{wordStart.number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clues */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-2">Horizontais</h3>
|
||||
<ul className="space-y-1 text-dark-300">
|
||||
{words.filter(w => w.direction === "across").sort((a, b) => a.number - b.number).map((w) => (
|
||||
<li key={w.number}><span className="text-primary-400">{w.number}.</span> {w.clue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-2">Verticais</h3>
|
||||
<ul className="space-y-1 text-dark-300">
|
||||
{words.filter(w => w.direction === "down").sort((a, b) => a.number - b.number).map((w) => (
|
||||
<li key={w.number}><span className="text-primary-400">{w.number}.</span> {w.clue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 bg-dark-800 rounded-full flex items-center justify-center mb-4">
|
||||
<PuzzleIcon className="h-8 w-8 text-dark-500" />
|
||||
</div>
|
||||
<p className="text-dark-400 max-w-sm">
|
||||
Configure os parâmetros e clique em "Gerar Palavras Cruzadas".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/documents/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon } from "@/components/dashboard/ComingSoon";
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Meus Documentos"
|
||||
description="Organize e acesse todos os conteúdos que você criou. Planos de aula, provas, atividades - tudo em um só lugar."
|
||||
iconName="folder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/enem/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon } from "@/components/dashboard/ComingSoon";
|
||||
|
||||
export default function EnemPage() {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Banco de Questões ENEM"
|
||||
description="Acesse milhares de questões oficiais do ENEM organizadas por ano, área e competência. Ideal para preparação e simulados."
|
||||
iconName="graduation"
|
||||
/>
|
||||
);
|
||||
}
|
||||
232
src/app/(dashboard)/dashboard/exams/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Sparkles, Loader2, Copy, Download, Check } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const subjects = [
|
||||
"Matemática", "Português", "História", "Geografia", "Ciências",
|
||||
"Física", "Química", "Biologia", "Inglês", "Filosofia", "Sociologia",
|
||||
];
|
||||
|
||||
const grades = [
|
||||
"6º ano - Fundamental", "7º ano - Fundamental", "8º ano - Fundamental", "9º ano - Fundamental",
|
||||
"1º ano - Médio", "2º ano - Médio", "3º ano - Médio",
|
||||
];
|
||||
|
||||
const questionTypes = [
|
||||
{ id: "multiple", label: "Múltipla escolha" },
|
||||
{ id: "truefalse", label: "Verdadeiro ou Falso" },
|
||||
{ id: "essay", label: "Dissertativa" },
|
||||
{ id: "fill", label: "Completar lacunas" },
|
||||
];
|
||||
|
||||
const difficulties = ["Fácil", "Médio", "Difícil", "Misto"];
|
||||
|
||||
export default function ExamsPage() {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [grade, setGrade] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [questionCount, setQuestionCount] = useState("10");
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>(["multiple"]);
|
||||
const [difficulty, setDifficulty] = useState("Médio");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [result, setResult] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const toggleType = (id: string) => {
|
||||
setSelectedTypes(prev =>
|
||||
prev.includes(id) ? prev.filter(t => t !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!subject || !grade || !topic) {
|
||||
setError("Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError("");
|
||||
setResult("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "EXAM",
|
||||
subject,
|
||||
grade,
|
||||
topic,
|
||||
questionCount,
|
||||
questionTypes: selectedTypes.map(id =>
|
||||
questionTypes.find(t => t.id === id)?.label
|
||||
).join(", "),
|
||||
difficulty,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Erro ao gerar prova");
|
||||
}
|
||||
|
||||
setResult(data.content);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([result], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `prova-${topic.toLowerCase().replace(/\s+/g, "-")}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary-500/10 rounded-xl">
|
||||
<FileText className="h-8 w-8 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Gerador de Provas & Listas</h1>
|
||||
<p className="text-dark-400">Crie avaliações personalizadas com gabarito</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Form */}
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Configurações</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Disciplina *</label>
|
||||
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{subjects.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Ano/Série *</label>
|
||||
<select value={grade} onChange={(e) => setGrade(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{grades.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Tema/Conteúdo *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Equações do 2º grau"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Número de questões</label>
|
||||
<select value={questionCount} onChange={(e) => setQuestionCount(e.target.value)} className="input">
|
||||
{[5, 10, 15, 20].map((n) => <option key={n} value={n}>{n} questões</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Tipos de questões</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{questionTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
type="button"
|
||||
onClick={() => toggleType(type.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedTypes.includes(type.id)
|
||||
? "bg-primary-500/20 text-primary-400 border border-primary-500/30"
|
||||
: "bg-dark-800 text-dark-400 border border-dark-700 hover:border-dark-600"
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Dificuldade</label>
|
||||
<select value={difficulty} onChange={(e) => setDifficulty(e.target.value)} className="input">
|
||||
{difficulties.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleGenerate} disabled={isGenerating} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
{isGenerating ? (
|
||||
<><Loader2 className="h-5 w-5 animate-spin" />Gerando prova...</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5" />Gerar Prova</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Resultado</h2>
|
||||
{result && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleCopy} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors">
|
||||
{copied ? <Check className="h-5 w-5 text-primary-400" /> : <Copy className="h-5 w-5" />}
|
||||
</button>
|
||||
<button onClick={handleDownload} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none overflow-auto max-h-[600px]">
|
||||
<ReactMarkdown>{result}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 bg-dark-800 rounded-full flex items-center justify-center mb-4">
|
||||
<FileText className="h-8 w-8 text-dark-500" />
|
||||
</div>
|
||||
<p className="text-dark-400 max-w-sm">
|
||||
Configure sua prova e clique em "Gerar Prova" para criar sua avaliação.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
src/app/(dashboard)/dashboard/games/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Gamepad2, Sparkles, Loader2, Trophy, CheckCircle, XCircle, RotateCcw } from "lucide-react";
|
||||
|
||||
const subjects = [
|
||||
"Matemática", "Português", "História", "Geografia", "Ciências",
|
||||
"Física", "Química", "Biologia", "Inglês", "Filosofia",
|
||||
];
|
||||
|
||||
const grades = [
|
||||
"1º ao 5º ano - Fundamental I",
|
||||
"6º ao 9º ano - Fundamental II",
|
||||
"Ensino Médio",
|
||||
];
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
options: string[];
|
||||
correct: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export default function GamesPage() {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [grade, setGrade] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [questionCount, setQuestionCount] = useState("5");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [answers, setAnswers] = useState<(number | null)[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
|
||||
const generateQuiz = async () => {
|
||||
if (!subject || !grade || !topic) {
|
||||
setError("Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError("");
|
||||
setQuestions([]);
|
||||
setCurrentQuestion(0);
|
||||
setScore(0);
|
||||
setAnswers([]);
|
||||
setGameStarted(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subject, grade, topic, questionCount: parseInt(questionCount) }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Erro ao gerar quiz");
|
||||
|
||||
setQuestions(data.questions);
|
||||
setAnswers(new Array(data.questions.length).fill(null));
|
||||
setGameStarted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = (index: number) => {
|
||||
if (selectedAnswer !== null) return;
|
||||
|
||||
setSelectedAnswer(index);
|
||||
const newAnswers = [...answers];
|
||||
newAnswers[currentQuestion] = index;
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (index === questions[currentQuestion].correct) {
|
||||
setScore(score + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (currentQuestion < questions.length - 1) {
|
||||
setCurrentQuestion(currentQuestion + 1);
|
||||
setSelectedAnswer(null);
|
||||
} else {
|
||||
setShowResult(true);
|
||||
}
|
||||
};
|
||||
|
||||
const restartQuiz = () => {
|
||||
setCurrentQuestion(0);
|
||||
setSelectedAnswer(null);
|
||||
setShowResult(false);
|
||||
setScore(0);
|
||||
setAnswers(new Array(questions.length).fill(null));
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
setQuestions([]);
|
||||
setGameStarted(false);
|
||||
setShowResult(false);
|
||||
setCurrentQuestion(0);
|
||||
setScore(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-accent-500/10 rounded-xl">
|
||||
<Gamepad2 className="h-8 w-8 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Quiz Interativo</h1>
|
||||
<p className="text-dark-400">Crie quizzes educativos para seus alunos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!gameStarted ? (
|
||||
<div className="card space-y-6 max-w-xl mx-auto">
|
||||
<h2 className="text-lg font-semibold text-white">Configurar Quiz</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Disciplina *</label>
|
||||
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{subjects.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Nível *</label>
|
||||
<select value={grade} onChange={(e) => setGrade(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{grades.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Tema *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Segunda Guerra Mundial, Frações..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Número de perguntas</label>
|
||||
<select value={questionCount} onChange={(e) => setQuestionCount(e.target.value)} className="input">
|
||||
{[5, 10, 15, 20].map((n) => <option key={n} value={n}>{n} perguntas</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={generateQuiz} disabled={isGenerating} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
{isGenerating ? (
|
||||
<><Loader2 className="h-5 w-5 animate-spin" />Gerando Quiz...</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5" />Criar Quiz</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : showResult ? (
|
||||
<div className="card text-center space-y-6 max-w-xl mx-auto">
|
||||
<div className="w-20 h-20 bg-accent-500/20 rounded-full flex items-center justify-center mx-auto">
|
||||
<Trophy className="h-10 w-10 text-accent-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Quiz Finalizado!</h2>
|
||||
<p className="text-4xl font-bold text-primary-400">
|
||||
{score}/{questions.length}
|
||||
</p>
|
||||
<p className="text-dark-400 mt-2">
|
||||
{score === questions.length ? "Perfeito! 🎉" :
|
||||
score >= questions.length * 0.7 ? "Muito bem! 👏" :
|
||||
score >= questions.length * 0.5 ? "Bom trabalho! 💪" : "Continue estudando! 📚"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={restartQuiz} className="btn-secondary flex items-center gap-2">
|
||||
<RotateCcw className="h-4 w-4" /> Refazer
|
||||
</button>
|
||||
<button onClick={resetAll} className="btn-primary">Novo Quiz</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-400">Pergunta {currentQuestion + 1} de {questions.length}</span>
|
||||
<span className="text-primary-400 font-medium">Pontuação: {score}</span>
|
||||
</div>
|
||||
<div className="w-full bg-dark-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${((currentQuestion + 1) / questions.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<div className="card">
|
||||
<h3 className="text-xl font-semibold text-white mb-6">
|
||||
{questions[currentQuestion]?.question}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{questions[currentQuestion]?.options.map((option, index) => {
|
||||
const isSelected = selectedAnswer === index;
|
||||
const isCorrect = index === questions[currentQuestion].correct;
|
||||
const showFeedback = selectedAnswer !== null;
|
||||
|
||||
let bgClass = "bg-dark-800 hover:bg-dark-700 border-dark-700";
|
||||
if (showFeedback) {
|
||||
if (isCorrect) {
|
||||
bgClass = "bg-green-500/20 border-green-500/50";
|
||||
} else if (isSelected && !isCorrect) {
|
||||
bgClass = "bg-red-500/20 border-red-500/50";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleAnswer(index)}
|
||||
disabled={selectedAnswer !== null}
|
||||
className={`w-full p-4 rounded-xl border text-left transition-all flex items-center gap-3 ${bgClass}`}
|
||||
>
|
||||
<span className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center text-sm font-medium">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className="flex-1">{option}</span>
|
||||
{showFeedback && isCorrect && <CheckCircle className="h-5 w-5 text-green-400" />}
|
||||
{showFeedback && isSelected && !isCorrect && <XCircle className="h-5 w-5 text-red-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedAnswer !== null && (
|
||||
<div className="mt-6 p-4 bg-dark-800/50 rounded-xl">
|
||||
<p className="text-sm text-dark-300">
|
||||
<span className="font-medium text-white">Explicação:</span> {questions[currentQuestion].explanation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAnswer !== null && (
|
||||
<button onClick={nextQuestion} className="btn-primary w-full">
|
||||
{currentQuestion < questions.length - 1 ? "Próxima Pergunta" : "Ver Resultado"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/help/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon } from "@/components/dashboard/ComingSoon";
|
||||
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Central de Ajuda"
|
||||
description="Tutoriais, FAQ e suporte para aproveitar ao máximo o MetisClass. Estamos aqui para ajudar!"
|
||||
iconName="help"
|
||||
/>
|
||||
);
|
||||
}
|
||||
355
src/app/(dashboard)/dashboard/lesson-plan/page.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { BookOpen, Sparkles, Loader2, Copy, Download, Check, FileText, File } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { jsPDF } from "jspdf";
|
||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from "docx";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
const subjects = [
|
||||
"Matemática", "Português", "História", "Geografia", "Ciências",
|
||||
"Física", "Química", "Biologia", "Inglês", "Espanhol",
|
||||
"Artes", "Educação Física", "Filosofia", "Sociologia",
|
||||
];
|
||||
|
||||
const grades = [
|
||||
"1º ano - Ensino Fundamental", "2º ano - Ensino Fundamental",
|
||||
"3º ano - Ensino Fundamental", "4º ano - Ensino Fundamental",
|
||||
"5º ano - Ensino Fundamental", "6º ano - Ensino Fundamental",
|
||||
"7º ano - Ensino Fundamental", "8º ano - Ensino Fundamental",
|
||||
"9º ano - Ensino Fundamental", "1º ano - Ensino Médio",
|
||||
"2º ano - Ensino Médio", "3º ano - Ensino Médio",
|
||||
];
|
||||
|
||||
const durations = ["45 minutos", "50 minutos", "1 hora", "1h30", "2 horas", "2 aulas", "3 aulas"];
|
||||
|
||||
export default function LessonPlanPage() {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [grade, setGrade] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [duration, setDuration] = useState("");
|
||||
const [additionalInfo, setAdditionalInfo] = useState("");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [result, setResult] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resultRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!subject || !grade || !topic || !duration) {
|
||||
setError("Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError("");
|
||||
setResult("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "LESSON_PLAN",
|
||||
subject, grade, topic, duration, additionalInfo,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Erro ao gerar plano de aula");
|
||||
setResult(data.content);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownloadMD = () => {
|
||||
const blob = new Blob([result], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `plano-de-aula-${topic.toLowerCase().replace(/\s+/g, "-")}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
const doc = new jsPDF();
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 20;
|
||||
const maxWidth = pageWidth - margin * 2;
|
||||
|
||||
// Header with MetisClass brand
|
||||
doc.setFillColor(124, 58, 237); // primary-700
|
||||
doc.rect(0, 0, pageWidth, 12, "F");
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text("MetisClass", margin, 8);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text("metisclass.com.br", pageWidth - margin - 30, 8);
|
||||
|
||||
// Reset text color
|
||||
doc.setTextColor(0, 0, 0);
|
||||
|
||||
// Title
|
||||
doc.setFontSize(18);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Plano de Aula", margin, 28);
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(`${subject} - ${grade}`, margin, 38);
|
||||
doc.text(`Tema: ${topic}`, margin, 46);
|
||||
doc.text(`Duração: ${duration}`, margin, 54);
|
||||
|
||||
// Footer function
|
||||
const addFooter = (pageNum: number) => {
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(`Gerado por MetisClass - metisclass.com.br`, margin, pageHeight - 10);
|
||||
doc.text(`Página ${pageNum}`, pageWidth - margin - 15, pageHeight - 10);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
};
|
||||
|
||||
let pageNum = 1;
|
||||
addFooter(pageNum);
|
||||
|
||||
// Content
|
||||
let y = 68;
|
||||
const lines = result.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (y > 270) {
|
||||
doc.addPage();
|
||||
pageNum++;
|
||||
addFooter(pageNum);
|
||||
y = 20;
|
||||
}
|
||||
|
||||
if (line.startsWith("## ")) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(line.replace("## ", ""), margin, y);
|
||||
y += 8;
|
||||
} else if (line.startsWith("### ")) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(line.replace("### ", ""), margin, y);
|
||||
y += 7;
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const text = "• " + line.substring(2);
|
||||
const splitText = doc.splitTextToSize(text, maxWidth - 5);
|
||||
doc.text(splitText, margin + 5, y);
|
||||
y += splitText.length * 5;
|
||||
} else if (line.trim()) {
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const splitText = doc.splitTextToSize(line.replace(/\*\*/g, ""), maxWidth);
|
||||
doc.text(splitText, margin, y);
|
||||
y += splitText.length * 5;
|
||||
} else {
|
||||
y += 3;
|
||||
}
|
||||
}
|
||||
|
||||
doc.save(`plano-de-aula-${topic.toLowerCase().replace(/\s+/g, "-")}.pdf`);
|
||||
};
|
||||
|
||||
const handleDownloadDOCX = async () => {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
// Title
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: "Plano de Aula", bold: true, size: 36 })],
|
||||
heading: HeadingLevel.TITLE,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `${subject} - ${grade}`, size: 24 })],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Tema: ${topic}`, size: 24 })],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Duração: ${duration}`, size: 24 })],
|
||||
}),
|
||||
new Paragraph({ children: [] })
|
||||
);
|
||||
|
||||
// Content
|
||||
const lines = result.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
paragraphs.push(new Paragraph({
|
||||
children: [new TextRun({ text: line.replace("## ", ""), bold: true, size: 28 })],
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}));
|
||||
} else if (line.startsWith("### ")) {
|
||||
paragraphs.push(new Paragraph({
|
||||
children: [new TextRun({ text: line.replace("### ", ""), bold: true, size: 24 })],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}));
|
||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
paragraphs.push(new Paragraph({
|
||||
children: [new TextRun({ text: line.substring(2), size: 22 })],
|
||||
bullet: { level: 0 },
|
||||
}));
|
||||
} else if (line.trim()) {
|
||||
// Handle bold text
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/);
|
||||
const runs = parts.map(part => {
|
||||
if (part.startsWith("**") && part.endsWith("**")) {
|
||||
return new TextRun({ text: part.slice(2, -2), bold: true, size: 22 });
|
||||
}
|
||||
return new TextRun({ text: part, size: 22 });
|
||||
});
|
||||
paragraphs.push(new Paragraph({ children: runs }));
|
||||
} else {
|
||||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ properties: {}, children: paragraphs }],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
saveAs(blob, `plano-de-aula-${topic.toLowerCase().replace(/\s+/g, "-")}.docx`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary-500/10 rounded-xl">
|
||||
<BookOpen className="h-8 w-8 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Gerador de Plano de Aula</h1>
|
||||
<p className="text-dark-400">Crie planos completos e alinhados à BNCC em segundos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Form */}
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Configurações</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Disciplina *</label>
|
||||
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{subjects.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Ano/Série *</label>
|
||||
<select value={grade} onChange={(e) => setGrade(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{grades.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Tema da Aula *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Frações equivalentes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Duração *</label>
|
||||
<select value={duration} onChange={(e) => setDuration(e.target.value)} className="input">
|
||||
<option value="">Selecione...</option>
|
||||
{durations.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">Informações adicionais (opcional)</label>
|
||||
<textarea
|
||||
value={additionalInfo}
|
||||
onChange={(e) => setAdditionalInfo(e.target.value)}
|
||||
className="input min-h-[100px]"
|
||||
placeholder="Ex: Turma com 30 alunos, alguns com dificuldades..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleGenerate} disabled={isGenerating} className="btn-primary w-full flex items-center justify-center gap-2">
|
||||
{isGenerating ? (
|
||||
<><Loader2 className="h-5 w-5 animate-spin" />Gerando plano de aula...</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5" />Gerar Plano de Aula</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Resultado</h2>
|
||||
{result && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={handleCopy} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors" title="Copiar">
|
||||
{copied ? <Check className="h-5 w-5 text-primary-400" /> : <Copy className="h-5 w-5" />}
|
||||
</button>
|
||||
<button onClick={handleDownloadMD} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors" title="Download Markdown">
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<button onClick={handleDownloadPDF} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors" title="Download PDF">
|
||||
<FileText className="h-5 w-5" />
|
||||
</button>
|
||||
<button onClick={handleDownloadDOCX} className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors" title="Download Word">
|
||||
<File className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div ref={resultRef} className="prose prose-invert prose-sm max-w-none overflow-auto max-h-[600px]">
|
||||
<ReactMarkdown>{result}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-16 h-16 bg-dark-800 rounded-full flex items-center justify-center mb-4">
|
||||
<BookOpen className="h-8 w-8 text-dark-500" />
|
||||
</div>
|
||||
<p className="text-dark-400 max-w-sm">
|
||||
Preencha as informações ao lado e clique em "Gerar Plano de Aula" para criar seu conteúdo.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import {
|
||||
BookOpen,
|
||||
FileText,
|
||||
PuzzleIcon,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "Plano de Aula",
|
||||
description: "Crie planos completos alinhados à BNCC",
|
||||
href: "/dashboard/lesson-plan",
|
||||
icon: BookOpen,
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
name: "Provas & Listas",
|
||||
description: "Gere provas e exercícios personalizados",
|
||||
href: "/dashboard/exams",
|
||||
icon: FileText,
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
name: "Palavras Cruzadas",
|
||||
description: "Crie atividades lúdicas para seus alunos",
|
||||
href: "/dashboard/crossword",
|
||||
icon: PuzzleIcon,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
name: "Jogos Interativos",
|
||||
description: "Quiz, memória e muito mais",
|
||||
href: "/dashboard/games",
|
||||
icon: Gamepad2,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
name: "Banco ENEM",
|
||||
description: "Questões oficiais por ano e área",
|
||||
href: "/dashboard/enem",
|
||||
icon: GraduationCap,
|
||||
color: "primary",
|
||||
},
|
||||
];
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Olá, {user?.name?.split(" ")[0] || "Professor"}! 👋
|
||||
</h1>
|
||||
<p className="mt-1 text-dark-400">
|
||||
O que vamos criar hoje?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-primary-500/10 border border-primary-500/20 rounded-xl px-4 py-2">
|
||||
<Sparkles className="h-5 w-5 text-primary-400" />
|
||||
<span className="text-sm font-medium text-primary-400">
|
||||
{user?.credits || 10} créditos disponíveis
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-500/10 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">0</p>
|
||||
<p className="text-sm text-dark-400">Conteúdos criados</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/10 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">0h</p>
|
||||
<p className="text-sm text-dark-400">Tempo economizado</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-500/10 rounded-lg">
|
||||
<GraduationCap className="h-5 w-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">0</p>
|
||||
<p className="text-sm text-dark-400">Questões ENEM salvas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Ferramentas</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{tools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const isGold = tool.color === "accent";
|
||||
return (
|
||||
<Link
|
||||
key={tool.name}
|
||||
href={tool.href}
|
||||
className="card-interactive group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`p-3 rounded-xl ${
|
||||
isGold ? "bg-accent-500/10" : "bg-primary-500/10"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-6 w-6 ${
|
||||
isGold ? "text-accent-400" : "text-primary-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-white group-hover:text-primary-400 transition-colors">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-dark-500 group-hover:text-primary-400 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Atividade Recente</h2>
|
||||
<div className="card">
|
||||
<div className="text-center py-8">
|
||||
<div className="mx-auto w-12 h-12 bg-dark-800 rounded-full flex items-center justify-center mb-4">
|
||||
<Sparkles className="h-6 w-6 text-dark-500" />
|
||||
</div>
|
||||
<p className="text-dark-400 mb-4">
|
||||
Você ainda não criou nenhum conteúdo.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/lesson-plan"
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Criar primeiro plano de aula
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon } from "@/components/dashboard/ComingSoon";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Meu Perfil"
|
||||
description="Gerencie suas informações pessoais, foto de perfil e dados da conta."
|
||||
iconName="user"
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
src/app/(dashboard)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ComingSoon } from "@/components/dashboard/ComingSoon";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<ComingSoon
|
||||
title="Configurações"
|
||||
description="Personalize sua experiência no MetisClass. Configure notificações, preferências de geração e muito mais."
|
||||
iconName="settings"
|
||||
/>
|
||||
);
|
||||
}
|
||||
128
src/app/(dashboard)/dashboard/upgrade/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Sparkles, Zap, Crown } from "lucide-react";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Gratuito",
|
||||
price: "R$ 0",
|
||||
period: "para sempre",
|
||||
description: "Perfeito para experimentar",
|
||||
features: [
|
||||
"10 gerações por mês",
|
||||
"Planos de aula básicos",
|
||||
"Provas simples",
|
||||
"Suporte por email",
|
||||
],
|
||||
cta: "Plano atual",
|
||||
current: true,
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "R$ 29",
|
||||
period: "/mês",
|
||||
description: "Para professores dedicados",
|
||||
features: [
|
||||
"Gerações ilimitadas",
|
||||
"Todos os tipos de conteúdo",
|
||||
"Banco de questões ENEM",
|
||||
"Jogos interativos",
|
||||
"Exportação em PDF/Word",
|
||||
"Suporte prioritário",
|
||||
],
|
||||
cta: "Fazer upgrade",
|
||||
popular: true,
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
name: "Escola",
|
||||
price: "R$ 199",
|
||||
period: "/mês",
|
||||
description: "Para instituições",
|
||||
features: [
|
||||
"Tudo do Pro",
|
||||
"Até 20 professores",
|
||||
"Painel administrativo",
|
||||
"Relatórios de uso",
|
||||
"API de integração",
|
||||
"Suporte dedicado",
|
||||
],
|
||||
cta: "Falar com vendas",
|
||||
icon: Crown,
|
||||
},
|
||||
];
|
||||
|
||||
export default function UpgradePage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Escolha seu plano</h1>
|
||||
<p className="text-dark-400">Desbloqueie todo o potencial do MetisClass</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{plans.map((plan) => {
|
||||
const Icon = plan.icon;
|
||||
return (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={`card relative ${
|
||||
plan.popular ? "border-primary-500/50 ring-1 ring-primary-500/20" : ""
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="bg-primary-500 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
Mais popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center mx-auto mb-4 ${
|
||||
plan.popular ? "bg-primary-500/20" : "bg-dark-800"
|
||||
}`}>
|
||||
<Icon className={`h-6 w-6 ${plan.popular ? "text-primary-400" : "text-dark-400"}`} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">{plan.name}</h3>
|
||||
<p className="text-dark-400 text-sm">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<span className="text-4xl font-bold text-white">{plan.price}</span>
|
||||
<span className="text-dark-400">{plan.period}</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-primary-400 shrink-0" />
|
||||
<span className="text-dark-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className={`w-full py-3 rounded-xl font-medium transition-all ${
|
||||
plan.current
|
||||
? "bg-dark-800 text-dark-400 cursor-default"
|
||||
: plan.popular
|
||||
? "btn-primary"
|
||||
: "btn-secondary"
|
||||
}`}
|
||||
disabled={plan.current}
|
||||
>
|
||||
{plan.cta}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-dark-400">
|
||||
<p>Todos os planos incluem garantia de 7 dias. Cancele quando quiser.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardSidebar } from "@/components/dashboard/Sidebar";
|
||||
import { DashboardHeader } from "@/components/dashboard/Header";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950">
|
||||
<DashboardSidebar />
|
||||
<div className="lg:pl-64">
|
||||
<DashboardHeader user={session.user} />
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
63
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { name, email, password } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!name || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nome, email e senha são obrigatórios" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "A senha deve ter pelo menos 6 caracteres" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "Este email já está em uso" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Register error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro interno do servidor" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
214
src/app/api/generate/crossword/route.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { generateWithGemini } from "@/lib/gemini";
|
||||
|
||||
interface WordData {
|
||||
word: string;
|
||||
clue: string;
|
||||
}
|
||||
|
||||
interface PlacedWord {
|
||||
word: string;
|
||||
clue: string;
|
||||
row: number;
|
||||
col: number;
|
||||
direction: "across" | "down";
|
||||
number: number;
|
||||
}
|
||||
|
||||
function createCrosswordGrid(words: WordData[]): { grid: string[][]; placedWords: PlacedWord[] } {
|
||||
const gridSize = 15;
|
||||
const grid: string[][] = Array(gridSize).fill(null).map(() => Array(gridSize).fill(" "));
|
||||
const placedWords: PlacedWord[] = [];
|
||||
|
||||
const sortedWords = [...words].sort((a, b) => b.word.length - a.word.length);
|
||||
|
||||
let wordNumber = 1;
|
||||
|
||||
for (let i = 0; i < sortedWords.length; i++) {
|
||||
const { word, clue } = sortedWords[i];
|
||||
const upperWord = word.toUpperCase().replace(/[^A-ZÁÉÍÓÚÀÈÌÒÙÂÊÎÔÛÃÕÇ]/g, "");
|
||||
|
||||
if (upperWord.length < 2) continue;
|
||||
|
||||
let placed = false;
|
||||
|
||||
if (placedWords.length === 0) {
|
||||
const row = Math.floor(gridSize / 2);
|
||||
const col = Math.floor((gridSize - upperWord.length) / 2);
|
||||
|
||||
for (let j = 0; j < upperWord.length; j++) {
|
||||
grid[row][col + j] = upperWord[j];
|
||||
}
|
||||
|
||||
placedWords.push({ word: upperWord, clue, row, col, direction: "across", number: wordNumber++ });
|
||||
placed = true;
|
||||
} else {
|
||||
for (const placedWord of placedWords) {
|
||||
if (placed) break;
|
||||
|
||||
for (let pi = 0; pi < placedWord.word.length && !placed; pi++) {
|
||||
for (let wi = 0; wi < upperWord.length && !placed; wi++) {
|
||||
if (placedWord.word[pi] === upperWord[wi]) {
|
||||
let newRow: number, newCol: number;
|
||||
const newDirection: "across" | "down" = placedWord.direction === "across" ? "down" : "across";
|
||||
|
||||
if (placedWord.direction === "across") {
|
||||
newRow = placedWord.row - wi;
|
||||
newCol = placedWord.col + pi;
|
||||
} else {
|
||||
newRow = placedWord.row + pi;
|
||||
newCol = placedWord.col - wi;
|
||||
}
|
||||
|
||||
let canPlace = true;
|
||||
for (let j = 0; j < upperWord.length && canPlace; j++) {
|
||||
const r = newDirection === "across" ? newRow : newRow + j;
|
||||
const c = newDirection === "across" ? newCol + j : newCol;
|
||||
|
||||
if (r < 0 || r >= gridSize || c < 0 || c >= gridSize) {
|
||||
canPlace = false;
|
||||
} else if (grid[r][c] !== " " && grid[r][c] !== upperWord[j]) {
|
||||
canPlace = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (canPlace) {
|
||||
for (let j = 0; j < upperWord.length; j++) {
|
||||
const r = newDirection === "across" ? newRow : newRow + j;
|
||||
const c = newDirection === "across" ? newCol + j : newCol;
|
||||
grid[r][c] = upperWord[j];
|
||||
}
|
||||
|
||||
placedWords.push({ word: upperWord, clue, row: newRow, col: newCol, direction: newDirection, number: wordNumber++ });
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let minRow = gridSize, maxRow = 0, minCol = gridSize, maxCol = 0;
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
if (grid[i][j] !== " ") {
|
||||
minRow = Math.min(minRow, i);
|
||||
maxRow = Math.max(maxRow, i);
|
||||
minCol = Math.min(minCol, j);
|
||||
maxCol = Math.max(maxCol, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedGrid = grid.slice(minRow, maxRow + 1).map(row => row.slice(minCol, maxCol + 1));
|
||||
|
||||
for (const word of placedWords) {
|
||||
word.row -= minRow;
|
||||
word.col -= minCol;
|
||||
}
|
||||
|
||||
placedWords.sort((a, b) => {
|
||||
if (a.row !== b.row) return a.row - b.row;
|
||||
return a.col - b.col;
|
||||
});
|
||||
|
||||
let num = 1;
|
||||
const numbered = new Set<string>();
|
||||
for (const word of placedWords) {
|
||||
const key = `${word.row},${word.col}`;
|
||||
if (!numbered.has(key)) {
|
||||
word.number = num++;
|
||||
numbered.add(key);
|
||||
} else {
|
||||
const existing = placedWords.find(w => w.row === word.row && w.col === word.col && w !== word);
|
||||
if (existing) word.number = existing.number;
|
||||
}
|
||||
}
|
||||
|
||||
return { grid: trimmedGrid, placedWords };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { credits: true, plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.credits <= 0 && user.plan === "FREE") {
|
||||
return NextResponse.json({ error: "Créditos insuficientes" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { subject, grade, topic, wordCount } = await request.json();
|
||||
|
||||
const prompt = `Gere ${wordCount} palavras relacionadas ao tema "${topic}" para uma atividade de palavras cruzadas de ${subject} para alunos do ${grade}.
|
||||
|
||||
Para cada palavra, forneça uma dica/definição educativa.
|
||||
|
||||
IMPORTANTE:
|
||||
- Use palavras em PORTUGUÊS
|
||||
- Palavras devem ter entre 4 e 12 letras
|
||||
- Dicas devem ser claras e educativas
|
||||
- Evite palavras muito difíceis para o nível
|
||||
|
||||
Responda APENAS em JSON válido neste formato:
|
||||
[
|
||||
{"word": "PALAVRA", "clue": "Dica para a palavra"},
|
||||
{"word": "EXEMPLO", "clue": "Dica para exemplo"}
|
||||
]`;
|
||||
|
||||
const content = await generateWithGemini(prompt, "Você é um professor especialista em criar atividades educativas. Responda apenas com JSON válido.");
|
||||
|
||||
let wordsData: WordData[];
|
||||
try {
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
wordsData = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
|
||||
} catch {
|
||||
wordsData = [];
|
||||
}
|
||||
|
||||
if (wordsData.length === 0) {
|
||||
throw new Error("Não foi possível gerar as palavras");
|
||||
}
|
||||
|
||||
const { grid, placedWords } = createCrosswordGrid(wordsData);
|
||||
|
||||
await prisma.generation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: "CROSSWORD",
|
||||
title: topic,
|
||||
content: JSON.parse(JSON.stringify({ words: placedWords, grid })),
|
||||
prompt,
|
||||
model: "gemini-2.0-flash",
|
||||
tokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.plan === "FREE") {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ words: placedWords, grid });
|
||||
} catch (error: any) {
|
||||
console.error("Crossword generation error:", error);
|
||||
return NextResponse.json({ error: error.message || "Erro ao gerar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
92
src/app/api/generate/quiz/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { generateWithGemini } from "@/lib/gemini";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { credits: true, plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.credits <= 0 && user.plan === "FREE") {
|
||||
return NextResponse.json({ error: "Créditos insuficientes" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { subject, grade, topic, questionCount } = await request.json();
|
||||
|
||||
const prompt = `Crie um quiz educativo com ${questionCount} perguntas sobre "${topic}" para a disciplina de ${subject}, nível ${grade}.
|
||||
|
||||
Cada pergunta deve ter:
|
||||
- Uma pergunta clara e objetiva
|
||||
- 4 alternativas (A, B, C, D)
|
||||
- Indicação de qual é a correta (índice 0-3)
|
||||
- Uma breve explicação da resposta
|
||||
|
||||
IMPORTANTE:
|
||||
- Perguntas em português brasileiro
|
||||
- Adequadas ao nível escolar
|
||||
- Variadas em dificuldade
|
||||
- Educativas e precisas
|
||||
|
||||
Responda APENAS em JSON válido neste formato:
|
||||
[
|
||||
{
|
||||
"question": "Pergunta aqui?",
|
||||
"options": ["Opção A", "Opção B", "Opção C", "Opção D"],
|
||||
"correct": 0,
|
||||
"explanation": "Explicação da resposta correta"
|
||||
}
|
||||
]`;
|
||||
|
||||
const content = await generateWithGemini(prompt, "Você é um professor especialista em criar quizzes educativos. Responda apenas com JSON válido.");
|
||||
|
||||
let questions;
|
||||
try {
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
questions = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
|
||||
} catch {
|
||||
questions = [];
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
throw new Error("Não foi possível gerar o quiz");
|
||||
}
|
||||
|
||||
await prisma.generation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: "QUIZ",
|
||||
title: topic,
|
||||
content: JSON.parse(JSON.stringify({ questions })),
|
||||
prompt,
|
||||
model: "gemini-2.0-flash",
|
||||
tokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.plan === "FREE") {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ questions });
|
||||
} catch (error: any) {
|
||||
console.error("Quiz generation error:", error);
|
||||
return NextResponse.json({ error: error.message || "Erro ao gerar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
145
src/app/api/generate/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { generateWithGemini } from "@/lib/gemini";
|
||||
|
||||
const GENERATION_PROMPTS = {
|
||||
LESSON_PLAN: `Você é um especialista em educação brasileira. Crie um plano de aula completo e detalhado seguindo a estrutura abaixo:
|
||||
|
||||
## Dados da Aula
|
||||
- **Disciplina:** {subject}
|
||||
- **Ano/Série:** {grade}
|
||||
- **Tema:** {topic}
|
||||
- **Duração:** {duration}
|
||||
|
||||
## Estrutura do Plano
|
||||
|
||||
### 1. Objetivos de Aprendizagem
|
||||
- Objetivo geral
|
||||
- Objetivos específicos (3-5)
|
||||
- Competências da BNCC relacionadas
|
||||
|
||||
### 2. Conteúdos
|
||||
- Conceituais
|
||||
- Procedimentais
|
||||
- Atitudinais
|
||||
|
||||
### 3. Metodologia
|
||||
- Abordagem pedagógica
|
||||
- Estratégias de ensino
|
||||
- Recursos didáticos necessários
|
||||
|
||||
### 4. Desenvolvimento da Aula
|
||||
- Momento inicial (aquecimento/motivação)
|
||||
- Desenvolvimento (atividades principais)
|
||||
- Fechamento (síntese/avaliação)
|
||||
|
||||
### 5. Avaliação
|
||||
- Critérios de avaliação
|
||||
- Instrumentos avaliativos
|
||||
- Indicadores de aprendizagem
|
||||
|
||||
### 6. Referências e Materiais de Apoio
|
||||
|
||||
Seja detalhado, prático e alinhado às diretrizes da BNCC. Use linguagem clara e objetiva.`,
|
||||
|
||||
EXAM: `Você é um especialista em avaliação educacional. Crie uma prova/lista de exercícios seguindo os parâmetros:
|
||||
|
||||
- **Disciplina:** {subject}
|
||||
- **Ano/Série:** {grade}
|
||||
- **Tema:** {topic}
|
||||
- **Número de questões:** {questionCount}
|
||||
- **Tipos de questões:** {questionTypes}
|
||||
- **Nível de dificuldade:** {difficulty}
|
||||
|
||||
Para cada questão, inclua:
|
||||
- Enunciado claro e objetivo
|
||||
- Alternativas (se múltipla escolha)
|
||||
- Gabarito
|
||||
- Habilidade BNCC relacionada
|
||||
|
||||
Varie os tipos de questões e níveis de dificuldade progressivamente.`,
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { credits: true, plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.credits <= 0 && user.plan === "FREE") {
|
||||
return NextResponse.json(
|
||||
{ error: "Créditos insuficientes. Faça upgrade para continuar." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, ...params } = body;
|
||||
|
||||
if (!type || !GENERATION_PROMPTS[type as keyof typeof GENERATION_PROMPTS]) {
|
||||
return NextResponse.json({ error: "Tipo de geração inválido" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
let prompt = GENERATION_PROMPTS[type as keyof typeof GENERATION_PROMPTS];
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
prompt = prompt.replace(`{${key}}`, value as string);
|
||||
});
|
||||
|
||||
// Call Gemini API
|
||||
const content = await generateWithGemini(
|
||||
prompt,
|
||||
"Você é um assistente especializado em educação brasileira, ajudando professores a criar conteúdos pedagógicos de alta qualidade."
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
throw new Error("Resposta vazia da IA");
|
||||
}
|
||||
|
||||
// Save generation
|
||||
const generation = await prisma.generation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: type as any,
|
||||
title: params.topic || params.title || "Sem título",
|
||||
content: { text: content },
|
||||
prompt: prompt,
|
||||
model: "gemini-2.0-flash",
|
||||
tokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Deduct credits (only for FREE plan)
|
||||
if (user.plan === "FREE") {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: generation.id,
|
||||
content: content,
|
||||
tokens: 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Generation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || "Erro ao gerar conteúdo" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
159
src/app/globals.css
Normal file
@@ -0,0 +1,159 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Primary - Violet */
|
||||
--color-primary-50: #faf5ff;
|
||||
--color-primary-100: #f3e8ff;
|
||||
--color-primary-200: #e9d5ff;
|
||||
--color-primary-300: #d8b4fe;
|
||||
--color-primary-400: #c084fc;
|
||||
--color-primary-500: #a855f7;
|
||||
--color-primary-600: #9333ea;
|
||||
--color-primary-700: #7c3aed;
|
||||
--color-primary-800: #6b21a8;
|
||||
--color-primary-900: #581c87;
|
||||
--color-primary-950: #3b0764;
|
||||
|
||||
/* Accent - Amber/Gold */
|
||||
--color-accent-50: #fffbeb;
|
||||
--color-accent-100: #fef3c7;
|
||||
--color-accent-200: #fde68a;
|
||||
--color-accent-300: #fcd34d;
|
||||
--color-accent-400: #fbbf24;
|
||||
--color-accent-500: #f59e0b;
|
||||
--color-accent-600: #d97706;
|
||||
--color-accent-700: #b45309;
|
||||
|
||||
/* Dark theme */
|
||||
--color-dark-50: #f8fafc;
|
||||
--color-dark-100: #f1f5f9;
|
||||
--color-dark-200: #e2e8f0;
|
||||
--color-dark-300: #cbd5e1;
|
||||
--color-dark-400: #94a3b8;
|
||||
--color-dark-500: #64748b;
|
||||
--color-dark-600: #475569;
|
||||
--color-dark-700: #334155;
|
||||
--color-dark-800: #1e293b;
|
||||
--color-dark-900: #0f172a;
|
||||
--color-dark-950: #020617;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-dark-950 text-dark-100 antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-primary-500/30 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3
|
||||
bg-gradient-to-r from-primary-600 to-primary-700
|
||||
text-white font-semibold rounded-xl
|
||||
hover:from-primary-500 hover:to-primary-600
|
||||
transition-all duration-200
|
||||
shadow-lg shadow-primary-500/25
|
||||
hover:shadow-xl hover:shadow-primary-500/30
|
||||
hover:-translate-y-0.5
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3
|
||||
bg-dark-800 text-white font-semibold rounded-xl
|
||||
border border-dark-700
|
||||
hover:bg-dark-700 hover:border-dark-600
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
@apply inline-flex items-center justify-center px-6 py-3
|
||||
bg-gradient-to-r from-accent-500 to-accent-600
|
||||
text-dark-900 font-semibold rounded-xl
|
||||
hover:from-accent-400 hover:to-accent-500
|
||||
transition-all duration-200
|
||||
shadow-lg shadow-accent-500/25;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-dark-900/50 border border-dark-700/50 rounded-2xl p-6
|
||||
backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@apply bg-dark-900/50 border border-dark-700/50 rounded-2xl p-6
|
||||
backdrop-blur-sm
|
||||
hover:border-primary-500/30 hover:bg-dark-800/50
|
||||
transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-3 bg-dark-800/50 border border-dark-700 rounded-xl
|
||||
text-white placeholder:text-dark-400
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-400 via-primary-300 to-accent-400
|
||||
bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.section-padding {
|
||||
@apply px-4 sm:px-6 lg:px-8 py-16 sm:py-24;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-dark-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-dark-700 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-dark-600;
|
||||
}
|
||||
|
||||
/* Prose styles for markdown content */
|
||||
.prose {
|
||||
@apply text-dark-200;
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 {
|
||||
@apply text-white font-semibold;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
@apply text-primary-400 hover:text-primary-300;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply bg-dark-800 px-1.5 py-0.5 rounded text-primary-300;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply bg-dark-800 border border-dark-700 rounded-xl;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
@apply text-dark-300;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
@apply text-white;
|
||||
}
|
||||
62
src/app/layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MetisClass | Ensino Inteligente com IA",
|
||||
description: "Plataforma de IA para professores. Crie planos de aula, provas, exercícios e atividades interativas em segundos.",
|
||||
keywords: ["educação", "IA", "professor", "plano de aula", "provas", "exercícios", "ENEM", "BNCC"],
|
||||
authors: [{ name: "MetisClass" }],
|
||||
openGraph: {
|
||||
title: "MetisClass | Ensino Inteligente com IA",
|
||||
description: "Crie planos de aula, provas e atividades interativas com IA em segundos.",
|
||||
url: "https://metisclass.com.br",
|
||||
siteName: "MetisClass",
|
||||
images: [
|
||||
{
|
||||
url: "/logo-dark.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "MetisClass",
|
||||
},
|
||||
],
|
||||
locale: "pt_BR",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "MetisClass | Ensino Inteligente com IA",
|
||||
description: "Crie planos de aula, provas e atividades interativas com IA em segundos.",
|
||||
images: ["/logo-dark.png"],
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/favicon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR" className="dark">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-dark-950 text-white min-h-screen`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
21
src/app/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Hero } from "@/components/landing/Hero";
|
||||
import { Features } from "@/components/landing/Features";
|
||||
import { Pricing } from "@/components/landing/Pricing";
|
||||
import { Testimonials } from "@/components/landing/Testimonials";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/components/dashboard/ComingSoon.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, PuzzleIcon, Gamepad2, GraduationCap, FolderOpen, Settings, User, HelpCircle } from "lucide-react";
|
||||
|
||||
const icons = {
|
||||
puzzle: PuzzleIcon,
|
||||
gamepad: Gamepad2,
|
||||
graduation: GraduationCap,
|
||||
folder: FolderOpen,
|
||||
settings: Settings,
|
||||
user: User,
|
||||
help: HelpCircle,
|
||||
};
|
||||
|
||||
interface ComingSoonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
iconName: keyof typeof icons;
|
||||
}
|
||||
|
||||
export function ComingSoon({ title, description, iconName }: ComingSoonProps) {
|
||||
const Icon = icons[iconName];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center py-16">
|
||||
<div className="w-20 h-20 bg-primary-500/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Icon className="h-10 w-10 text-primary-400" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-2">{title}</h1>
|
||||
<p className="text-dark-400 mb-8 max-w-md mx-auto">{description}</p>
|
||||
|
||||
<div className="bg-dark-800/50 border border-dark-700 rounded-xl p-6 max-w-sm mx-auto">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Bell className="h-5 w-5 text-accent-400" />
|
||||
<span className="font-medium text-white">Quer ser avisado?</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-4">
|
||||
Deixe seu email para receber uma notificação quando esta funcionalidade estiver disponível.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
className="input flex-1 text-sm"
|
||||
/>
|
||||
<button className="btn-primary text-sm px-4">
|
||||
Avisar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/components/dashboard/Header.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Menu,
|
||||
Bell,
|
||||
Search,
|
||||
ChevronDown,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
user: any;
|
||||
}
|
||||
|
||||
export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-dark-800 bg-dark-900/80 backdrop-blur-sm px-4 sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden -m-2.5 p-2.5 text-dark-400"
|
||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-6 w-px bg-dark-700 lg:hidden" />
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
{/* Search */}
|
||||
<form className="relative flex flex-1" action="#" method="GET">
|
||||
<Search className="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-dark-400 ml-3" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar..."
|
||||
className="block h-full w-full bg-dark-800/50 border-0 py-0 pl-10 pr-3 text-white placeholder:text-dark-400 focus:ring-0 sm:text-sm rounded-xl"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
{/* Credits badge */}
|
||||
<div className="hidden sm:flex items-center gap-2 bg-primary-500/10 border border-primary-500/20 rounded-full px-3 py-1.5">
|
||||
<Sparkles className="h-4 w-4 text-primary-400" />
|
||||
<span className="text-sm font-medium text-primary-400">
|
||||
{user?.credits || 0} créditos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Ver notificações</span>
|
||||
<Bell className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="hidden lg:block lg:h-6 lg:w-px lg:bg-dark-700" />
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-x-3 -m-1.5 p-1.5"
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
>
|
||||
<span className="sr-only">Abrir menu do usuário</span>
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full bg-dark-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-sm font-bold text-white">
|
||||
{user?.name?.charAt(0) || user?.email?.charAt(0) || "U"}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden lg:flex lg:items-center">
|
||||
<span className="text-sm font-semibold text-white" aria-hidden="true">
|
||||
{user?.name || "Usuário"}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-dark-400" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-xl bg-dark-800 border border-dark-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-dark-200 hover:bg-dark-700 hover:text-white"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Meu Perfil
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-dark-200 hover:bg-dark-700 hover:text-white"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configurações
|
||||
</Link>
|
||||
<hr className="my-1 border-dark-700" />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
137
src/components/dashboard/Sidebar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
FileText,
|
||||
PuzzleIcon,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Plano de Aula", href: "/dashboard/lesson-plan", icon: BookOpen },
|
||||
{ name: "Provas & Listas", href: "/dashboard/exams", icon: FileText },
|
||||
{ name: "Palavras Cruzadas", href: "/dashboard/crossword", icon: PuzzleIcon },
|
||||
{ name: "Jogos", href: "/dashboard/games", icon: Gamepad2 },
|
||||
{ name: "Banco ENEM", href: "/dashboard/enem", icon: GraduationCap },
|
||||
{ name: "Meus Documentos", href: "/dashboard/documents", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const secondaryNavigation = [
|
||||
{ name: "Configurações", href: "/dashboard/settings", icon: Settings },
|
||||
{ name: "Ajuda", href: "/dashboard/help", icon: HelpCircle },
|
||||
];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-dark-900 border-r border-dark-800 px-6 pb-4">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<Link href="/dashboard">
|
||||
<Image
|
||||
src="/logo-dark.svg"
|
||||
alt="MetisClass"
|
||||
width={150}
|
||||
height={34}
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`group flex gap-x-3 rounded-xl p-3 text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? "bg-primary-500/10 text-primary-400"
|
||||
: "text-dark-300 hover:text-white hover:bg-dark-800"
|
||||
}`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-5 w-5 shrink-0 ${
|
||||
isActive ? "text-primary-400" : "text-dark-400 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{/* Pro Badge */}
|
||||
<li>
|
||||
<div className="bg-gradient-to-r from-primary-500/10 to-accent-500/10 border border-primary-500/20 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="h-5 w-5 text-accent-400" />
|
||||
<span className="text-sm font-semibold text-white">Upgrade Pro</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-300 mb-3">
|
||||
Desbloqueie gerações ilimitadas e mais recursos
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/upgrade"
|
||||
className="block text-center text-sm font-medium bg-gradient-to-r from-primary-500 to-primary-600 text-white py-2 rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all"
|
||||
>
|
||||
Ver planos
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* Secondary Navigation */}
|
||||
<li className="mt-auto">
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{secondaryNavigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`group flex gap-x-3 rounded-xl p-3 text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? "bg-primary-500/10 text-primary-400"
|
||||
: "text-dark-300 hover:text-white hover:bg-dark-800"
|
||||
}`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-5 w-5 shrink-0 ${
|
||||
isActive ? "text-primary-400" : "text-dark-400 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
src/components/landing/Features.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
BookOpen,
|
||||
FileText,
|
||||
PuzzleIcon,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
FileDown,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Planos de Aula",
|
||||
description:
|
||||
"Gere planos de aula completos e alinhados à BNCC em segundos. Inclui objetivos, metodologia, recursos e avaliação.",
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Provas & Listas",
|
||||
description:
|
||||
"Crie provas e listas de exercícios personalizadas por nível de dificuldade, assunto e formato de questão.",
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
icon: PuzzleIcon,
|
||||
title: "Palavras Cruzadas",
|
||||
description:
|
||||
"Gere palavras cruzadas e caça-palavras educativos automaticamente a partir do conteúdo que você definir.",
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
icon: Gamepad2,
|
||||
title: "Jogos Interativos",
|
||||
description:
|
||||
"Quiz, jogos de memória, verdadeiro ou falso e muito mais. Engaje seus alunos com atividades gamificadas.",
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
title: "Banco ENEM",
|
||||
description:
|
||||
"Acesse questões oficiais do ENEM com filtros por ano, área do conhecimento e habilidade.",
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
icon: FileDown,
|
||||
title: "Exportação Completa",
|
||||
description:
|
||||
"Exporte para PDF, Word, Google Slides e PowerPoint. Pronto para imprimir ou projetar.",
|
||||
color: "accent",
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="relative py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-dark-900" />
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{/* Section header */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 bg-primary-500/10 border border-primary-500/20 rounded-full px-4 py-2 mb-6"
|
||||
>
|
||||
<Zap className="w-4 h-4 text-primary-400" />
|
||||
<span className="text-sm font-medium text-primary-400">
|
||||
Recursos Poderosos
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-6"
|
||||
>
|
||||
<span className="text-white">Tudo que você precisa</span>
|
||||
<br />
|
||||
<span className="gradient-text">em um só lugar</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-lg text-dark-300 max-w-2xl mx-auto"
|
||||
>
|
||||
Ferramentas inteligentes que economizam horas do seu tempo e
|
||||
transformam a forma como você prepara suas aulas.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Features grid */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
const isGold = feature.color === "accent";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
variants={itemVariants}
|
||||
className="card-interactive group"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-all duration-300 ${
|
||||
isGold
|
||||
? "bg-accent-500/10 group-hover:bg-accent-500/20"
|
||||
: "bg-primary-500/10 group-hover:bg-primary-500/20"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-6 h-6 ${
|
||||
isGold ? "text-accent-400" : "text-primary-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-dark-300 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="text-center mt-16"
|
||||
>
|
||||
<a href="/register" className="btn-primary inline-flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Experimente Grátis
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
164
src/components/landing/Hero.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { Sparkles, BookOpen, FileText, Gamepad2, ArrowRight } from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{ icon: BookOpen, label: "Planos de Aula" },
|
||||
{ icon: FileText, label: "Provas & Listas" },
|
||||
{ icon: Gamepad2, label: "Jogos Interativos" },
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-16">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-500/10 rounded-full blur-3xl" />
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 bg-[url('/patterns/grid.svg')] opacity-5" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center">
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 bg-primary-500/10 border border-primary-500/20 rounded-full px-4 py-2 mb-8"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-primary-400" />
|
||||
<span className="text-sm font-medium text-primary-400">
|
||||
Powered by AI • Claude & GPT
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-4xl sm:text-5xl lg:text-7xl font-bold tracking-tight mb-6"
|
||||
>
|
||||
<span className="text-white">Ensino </span>
|
||||
<span className="gradient-text">Inteligente</span>
|
||||
<br />
|
||||
<span className="text-white">com </span>
|
||||
<span className="gradient-text-accent">IA</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-lg sm:text-xl text-dark-300 max-w-2xl mx-auto mb-8"
|
||||
>
|
||||
Crie planos de aula, provas, exercícios e atividades interativas em segundos.
|
||||
A plataforma que todo professor merece. 🦉
|
||||
</motion.p>
|
||||
|
||||
{/* Feature pills */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex flex-wrap justify-center gap-3 mb-10"
|
||||
>
|
||||
{features.map(({ icon: Icon, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-2 bg-dark-800/50 border border-dark-700/50 rounded-full px-4 py-2"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-primary-400" />
|
||||
<span className="text-sm text-dark-200">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
>
|
||||
<Link href="/register" className="btn-primary flex items-center gap-2 text-lg px-8 py-4">
|
||||
Começar Grátis
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
<Link href="#demo" className="btn-secondary flex items-center gap-2 text-lg px-8 py-4">
|
||||
Ver Demonstração
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Social proof */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-12 flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="flex -space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 border-2 border-dark-900 flex items-center justify-center text-xs font-bold"
|
||||
>
|
||||
{String.fromCharCode(64 + i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-dark-400 text-sm">
|
||||
<span className="text-white font-semibold">+5.000</span> professores já usam
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Hero Image/Demo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.5 }}
|
||||
className="mt-16 relative"
|
||||
>
|
||||
<div className="relative mx-auto max-w-5xl">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-primary-500/20 via-transparent to-accent-500/20 rounded-3xl blur-2xl" />
|
||||
|
||||
{/* Dashboard preview */}
|
||||
<div className="relative bg-dark-900 border border-dark-700 rounded-2xl overflow-hidden shadow-2xl">
|
||||
{/* Browser chrome */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-dark-800 border-b border-dark-700">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="bg-dark-700 rounded-lg px-4 py-1 text-xs text-dark-400">
|
||||
metisclass.com.br/dashboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard content placeholder */}
|
||||
<div className="p-8 min-h-[400px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-gradient-to-br from-primary-400 to-primary-600 rounded-2xl flex items-center justify-center">
|
||||
<Sparkles className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<p className="text-dark-400">Preview do Dashboard em breve</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
232
src/components/landing/Pricing.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Check, Sparkles, Zap, Crown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Grátis",
|
||||
price: "R$ 0",
|
||||
period: "para sempre",
|
||||
description: "Para começar a explorar a plataforma",
|
||||
icon: Sparkles,
|
||||
features: [
|
||||
"10 gerações por mês",
|
||||
"Planos de aula básicos",
|
||||
"Provas simples",
|
||||
"Exportação PDF",
|
||||
"Suporte por email",
|
||||
],
|
||||
cta: "Começar Grátis",
|
||||
href: "/register",
|
||||
popular: false,
|
||||
color: "dark",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "R$ 29",
|
||||
period: "/mês",
|
||||
description: "Para professores que querem mais",
|
||||
icon: Zap,
|
||||
features: [
|
||||
"Gerações ilimitadas",
|
||||
"Todos os tipos de conteúdo",
|
||||
"Banco de questões ENEM",
|
||||
"Exportação completa (PDF, Word, Slides)",
|
||||
"Jogos interativos",
|
||||
"Templates personalizados",
|
||||
"Suporte prioritário",
|
||||
],
|
||||
cta: "Assinar Pro",
|
||||
href: "/register?plan=pro",
|
||||
popular: true,
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
name: "Escola",
|
||||
price: "R$ 99",
|
||||
period: "/mês",
|
||||
description: "Para escolas e coordenações",
|
||||
icon: Crown,
|
||||
features: [
|
||||
"Tudo do Pro",
|
||||
"Até 20 professores",
|
||||
"Painel administrativo",
|
||||
"Relatórios de uso",
|
||||
"API de integração",
|
||||
"Treinamento incluso",
|
||||
"Suporte dedicado",
|
||||
],
|
||||
cta: "Falar com Vendas",
|
||||
href: "/contact?plan=school",
|
||||
popular: false,
|
||||
color: "accent",
|
||||
},
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<section id="pricing" className="relative py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-dark-950" />
|
||||
<div className="absolute top-1/2 left-1/4 w-96 h-96 bg-primary-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-96 h-96 bg-accent-500/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{/* Section header */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 bg-accent-500/10 border border-accent-500/20 rounded-full px-4 py-2 mb-6"
|
||||
>
|
||||
<Crown className="w-4 h-4 text-accent-400" />
|
||||
<span className="text-sm font-medium text-accent-400">
|
||||
Planos Acessíveis
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-6"
|
||||
>
|
||||
<span className="text-white">Preços que cabem</span>
|
||||
<br />
|
||||
<span className="gradient-text-accent">no seu bolso</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-lg text-dark-300 max-w-2xl mx-auto"
|
||||
>
|
||||
Comece grátis e faça upgrade quando precisar.
|
||||
Sem compromisso, cancele quando quiser.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Pricing cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{plans.map((plan, index) => {
|
||||
const Icon = plan.icon;
|
||||
const isPopular = plan.popular;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={plan.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className={`relative rounded-2xl p-8 ${
|
||||
isPopular
|
||||
? "bg-gradient-to-b from-primary-500/10 to-dark-900 border-2 border-primary-500/30 scale-105"
|
||||
: "bg-dark-900/50 border border-dark-700/50"
|
||||
}`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
{isPopular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<div className="bg-gradient-to-r from-primary-500 to-primary-600 text-white text-sm font-semibold px-4 py-1 rounded-full">
|
||||
Mais Popular
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan icon */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center mb-4 ${
|
||||
plan.color === "primary"
|
||||
? "bg-primary-500/20"
|
||||
: plan.color === "accent"
|
||||
? "bg-accent-500/20"
|
||||
: "bg-dark-700"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-6 h-6 ${
|
||||
plan.color === "primary"
|
||||
? "text-primary-400"
|
||||
: plan.color === "accent"
|
||||
? "text-accent-400"
|
||||
: "text-dark-300"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plan name */}
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{plan.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-dark-400">{plan.period}</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-dark-400 text-sm mb-6">{plan.description}</p>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-3 mb-8">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<Check
|
||||
className={`w-5 h-5 flex-shrink-0 mt-0.5 ${
|
||||
plan.color === "primary"
|
||||
? "text-primary-400"
|
||||
: plan.color === "accent"
|
||||
? "text-accent-400"
|
||||
: "text-dark-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-dark-200 text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={plan.href}
|
||||
className={`block text-center py-3 px-6 rounded-xl font-semibold transition-all duration-200 ${
|
||||
isPopular
|
||||
? "btn-primary"
|
||||
: plan.color === "accent"
|
||||
? "btn-accent"
|
||||
: "btn-secondary"
|
||||
}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Money back guarantee */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<p className="text-dark-400 text-sm">
|
||||
💰 Garantia de 7 dias. Se não gostar, devolvemos seu dinheiro.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
154
src/components/landing/Testimonials.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Star, Quote } from "lucide-react";
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: "Maria Silva",
|
||||
role: "Professora de Matemática",
|
||||
school: "Colégio São Paulo",
|
||||
content:
|
||||
"O MetisClass revolucionou minha forma de preparar aulas. O que antes levava horas, agora faço em minutos. A qualidade do conteúdo gerado é impressionante!",
|
||||
avatar: "MS",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "João Santos",
|
||||
role: "Professor de História",
|
||||
school: "Escola Municipal Centro",
|
||||
content:
|
||||
"Finalmente uma ferramenta que entende as necessidades do professor brasileiro. O banco de questões ENEM é fantástico!",
|
||||
avatar: "JS",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Ana Paula",
|
||||
role: "Coordenadora Pedagógica",
|
||||
school: "Rede de Ensino XYZ",
|
||||
content:
|
||||
"Implementamos o MetisClass em toda nossa rede e a produtividade dos professores aumentou significativamente. Recomendo demais!",
|
||||
avatar: "AP",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Carlos Mendes",
|
||||
role: "Professor de Português",
|
||||
school: "Instituto Federal",
|
||||
content:
|
||||
"Os jogos interativos são incríveis! Meus alunos adoram e eu economizo muito tempo na preparação. Vale cada centavo.",
|
||||
avatar: "CM",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Fernanda Lima",
|
||||
role: "Professora de Ciências",
|
||||
school: "Colégio Estadual Norte",
|
||||
content:
|
||||
"A interface é super intuitiva e o dark mode é maravilhoso. Uso todo dia e não consigo mais viver sem!",
|
||||
avatar: "FL",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Roberto Costa",
|
||||
role: "Professor de Geografia",
|
||||
school: "Escola Particular Sul",
|
||||
content:
|
||||
"O suporte é excepcional e as atualizações são constantes. Você sente que a equipe realmente ouve os professores.",
|
||||
avatar: "RC",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section id="testimonials" className="relative py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-dark-900" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{/* Section header */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 bg-primary-500/10 border border-primary-500/20 rounded-full px-4 py-2 mb-6"
|
||||
>
|
||||
<Star className="w-4 h-4 text-primary-400 fill-primary-400" />
|
||||
<span className="text-sm font-medium text-primary-400">
|
||||
+5.000 Professores Felizes
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-6"
|
||||
>
|
||||
<span className="text-white">Amado por </span>
|
||||
<span className="gradient-text">educadores</span>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-lg text-dark-300 max-w-2xl mx-auto"
|
||||
>
|
||||
Veja o que professores de todo o Brasil estão dizendo sobre o MetisClass
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Testimonials grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<motion.div
|
||||
key={testimonial.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="card group"
|
||||
>
|
||||
{/* Quote icon */}
|
||||
<Quote className="w-8 h-8 text-primary-500/20 mb-4" />
|
||||
|
||||
{/* Rating */}
|
||||
<div className="flex gap-1 mb-4">
|
||||
{Array.from({ length: testimonial.rating }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className="w-4 h-4 text-accent-400 fill-accent-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-dark-200 mb-6 leading-relaxed">
|
||||
"{testimonial.content}"
|
||||
</p>
|
||||
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-dark-700/50">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-sm font-bold text-white">
|
||||
{testimonial.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">{testimonial.name}</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{testimonial.role} • {testimonial.school}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
147
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Instagram, Twitter, Linkedin, Youtube, Mail } from "lucide-react";
|
||||
|
||||
const navigation = {
|
||||
produto: [
|
||||
{ name: "Recursos", href: "#features" },
|
||||
{ name: "Preços", href: "#pricing" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Changelog", href: "/changelog" },
|
||||
],
|
||||
suporte: [
|
||||
{ name: "Central de Ajuda", href: "/help" },
|
||||
{ name: "Documentação", href: "/docs" },
|
||||
{ name: "Comunidade", href: "/community" },
|
||||
{ name: "Contato", href: "/contact" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Privacidade", href: "/privacy" },
|
||||
{ name: "Termos de Uso", href: "/terms" },
|
||||
{ name: "Cookies", href: "/cookies" },
|
||||
],
|
||||
social: [
|
||||
{ name: "Instagram", href: "https://instagram.com/metisclass", icon: Instagram },
|
||||
{ name: "Twitter", href: "https://twitter.com/metisclass", icon: Twitter },
|
||||
{ name: "LinkedIn", href: "https://linkedin.com/company/metisclass", icon: Linkedin },
|
||||
{ name: "YouTube", href: "https://youtube.com/@metisclass", icon: Youtube },
|
||||
],
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-dark-950 border-t border-dark-800">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8">
|
||||
{/* Brand column */}
|
||||
<div className="lg:col-span-2">
|
||||
<Link href="/" className="inline-block mb-4">
|
||||
<Image
|
||||
src="/logo-dark.svg"
|
||||
alt="MetisClass"
|
||||
width={160}
|
||||
height={36}
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-dark-400 text-sm mb-6 max-w-sm">
|
||||
Plataforma de IA para professores. Crie planos de aula, provas,
|
||||
exercícios e atividades interativas em segundos.
|
||||
</p>
|
||||
|
||||
{/* Newsletter */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Seu email"
|
||||
className="input flex-1 text-sm py-2"
|
||||
/>
|
||||
<button className="btn-primary py-2 px-4 text-sm">
|
||||
Inscrever
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Produto */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Produto</h3>
|
||||
<ul className="space-y-3">
|
||||
{navigation.produto.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Suporte */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Suporte</h3>
|
||||
<ul className="space-y-3">
|
||||
{navigation.suporte.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Legal</h3>
|
||||
<ul className="space-y-3">
|
||||
{navigation.legal.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-sm text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="mt-12 pt-8 border-t border-dark-800 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-dark-400">
|
||||
© {new Date().getFullYear()} MetisClass. Todos os direitos reservados.
|
||||
</p>
|
||||
|
||||
{/* Social links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{navigation.social.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">{item.name}</span>
|
||||
<Icon className="w-5 h-5" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
109
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Menu, X, ChevronDown } from "lucide-react";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Recursos", href: "#features" },
|
||||
{ name: "Preços", href: "#pricing" },
|
||||
{ name: "Depoimentos", href: "#testimonials" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 glass-dark">
|
||||
<nav className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" aria-label="Top">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/logo-dark.svg"
|
||||
alt="MetisClass"
|
||||
width={180}
|
||||
height={40}
|
||||
className="h-9 w-auto"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex md:items-center md:gap-8">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-sm font-medium text-dark-300 hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="hidden md:flex md:items-center md:gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-dark-300 hover:text-white transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
<Link href="/register" className="btn-primary text-sm py-2 px-4">
|
||||
Começar Grátis
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="text-dark-300 hover:text-white"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-dark-800">
|
||||
<div className="flex flex-col gap-4">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="text-base font-medium text-dark-300 hover:text-white transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<hr className="border-dark-800" />
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-base font-medium text-dark-300 hover:text-white transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
<Link href="/register" className="btn-primary text-center">
|
||||
Começar Grátis
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
117
src/lib/auth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "./prisma";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma) as any,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login",
|
||||
newUser: "/register",
|
||||
},
|
||||
cookies: {
|
||||
pkceCodeVerifier: {
|
||||
name: "next-auth.pkce.code_verifier",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
state: {
|
||||
name: "next-auth.state",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Senha", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("Email e senha são obrigatórios");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new Error("Usuário não encontrado");
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Senha incorreta");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
|
||||
if (trigger === "update" && session) {
|
||||
token = { ...token, ...session };
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
|
||||
// Fetch fresh user data
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
plan: true,
|
||||
credits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (dbUser) {
|
||||
session.user = {
|
||||
...session.user,
|
||||
...dbUser,
|
||||
};
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
20
src/lib/gemini.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_AI_API_KEY || "");
|
||||
|
||||
export const geminiFlash = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
||||
export const geminiPro = genAI.getGenerativeModel({ model: "gemini-1.5-pro" });
|
||||
|
||||
export async function generateWithGemini(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const model = geminiFlash;
|
||||
|
||||
const fullPrompt = systemPrompt
|
||||
? `${systemPrompt}\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
const result = await model.generateContent(fullPrompt);
|
||||
const response = await result.response;
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export default genAI;
|
||||
15
src/lib/prisma.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
export default prisma;
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
29
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DefaultSession, DefaultUser } from "next-auth";
|
||||
import { Role, Plan } from "@prisma/client";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
role?: Role;
|
||||
plan?: Plan;
|
||||
credits?: number;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User extends DefaultUser {
|
||||
id: string;
|
||||
role?: Role;
|
||||
plan?: Plan;
|
||||
credits?: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id: string;
|
||||
role?: Role;
|
||||
plan?: Plan;
|
||||
credits?: number;
|
||||
}
|
||||
}
|
||||
87
tailwind.config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Primary - Violet/Purple
|
||||
primary: {
|
||||
50: "#faf5ff",
|
||||
100: "#f3e8ff",
|
||||
200: "#e9d5ff",
|
||||
300: "#d8b4fe",
|
||||
400: "#c084fc",
|
||||
500: "#a855f7",
|
||||
600: "#9333ea",
|
||||
700: "#7c3aed",
|
||||
800: "#6b21a8",
|
||||
900: "#581c87",
|
||||
950: "#3b0764",
|
||||
},
|
||||
// Accent - Amber/Gold for CTAs
|
||||
accent: {
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
},
|
||||
// Dark theme
|
||||
dark: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||
display: ["var(--font-cal-sans)", "var(--font-inter)", "system-ui", "sans-serif"],
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"hero-pattern": "url('/patterns/hero-bg.svg')",
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-out",
|
||||
"slide-up": "slideUp 0.5s ease-out",
|
||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
float: "float 6s ease-in-out infinite",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { opacity: "0", transform: "translateY(20px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-10px)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||