Initial commit: MetisClass - Plataforma Educacional

This commit is contained in:
bigtux
2026-02-10 15:46:24 -03:00
commit 52a2bc8fe1
60 changed files with 14300 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

35
public/favicon.svg Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

40
public/logo-dark.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

40
public/logo-light.svg Normal file
View 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
View 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
View 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
View 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

View 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>
);
}

View 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>
);
}

View 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">
tem uma conta?{" "}
<Link href="/login" className="text-primary-400 hover:text-primary-300 font-medium">
Entrar
</Link>
</p>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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"
/>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

159
src/app/globals.css Normal file
View 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
View 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
View 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 />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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 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>
);
}

View 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 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}