Initial commit: CMS completo com gerenciamento de leads e personalização de tema

This commit is contained in:
Erik
2025-11-26 14:09:21 -03:00
commit aaa1709e41
106 changed files with 26268 additions and 0 deletions

23
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Dependências de produção (imagens)
node_modules/
.next/
# Arquivos de ambiente locais
.env*.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Sistema operacional
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Git
.git/
.gitignore

43
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

57
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar arquivos de dependências
COPY package*.json ./
COPY prisma ./prisma/
# Instalar dependências
RUN npm ci
# Copiar código fonte
COPY . .
# Gerar Prisma Client
RUN npx prisma generate
# Build da aplicação
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Criar usuário não-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copiar arquivos necessários do builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Copiar script de entrypoint
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# Configurar permissões
RUN chown -R nextjs:nodejs /app
# Mudar para usuário não-root
USER nextjs
# Expor porta
EXPOSE 3000
# Definir variável de ambiente
ENV PORT=3000
ENV NODE_ENV=production
# Comando de inicialização
CMD ["/app/docker-entrypoint.sh"]

36
frontend/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.

View File

@@ -0,0 +1,23 @@
#!/bin/sh
set -e
echo "🚀 Iniciando aplicação..."
# Aguardar PostgreSQL estar pronto
echo "⏳ Aguardando PostgreSQL..."
until node -e "require('net').createConnection(5432, 'postgres').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"; do
sleep 2
done
echo "✅ PostgreSQL pronto!"
# Executar migrations
echo "🔄 Executando migrations do Prisma..."
npx prisma generate
npx prisma db push --skip-generate
echo "✅ Migrations executadas!"
# Iniciar aplicação
echo "🎉 Iniciando Next.js..."
exec node server.js

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;

10
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
images: {
domains: ['localhost', 'images.unsplash.com'],
},
};
export default nextConfig;

7473
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.6",
"next": "16.0.4",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"prisma": "^5.22.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"remixicon": "^4.7.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.4",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

View File

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

View File

@@ -0,0 +1,71 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Modelo de Usuário (para o Painel Admin)
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modelo de Projeto
model Project {
id String @id @default(cuid())
title String
category String
client String?
status String @default("Em andamento") // "Em andamento", "Concluído"
completionDate DateTime?
description String? @db.Text
coverImage String?
galleryImages String[]
featured Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modelo de Serviço
model Service {
id String @id @default(cuid())
title String
icon String
shortDescription String?
fullDescription String? @db.Text
active Boolean @default(true)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modelo de Mensagem (Contato)
model Message {
id String @id @default(cuid())
name String
email String
subject String
message String @db.Text
status String @default("Nova") // "Nova", "Lida", "Respondida"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Modelo de Conteúdo de Página (para textos editáveis)
model PageContent {
id String @id @default(cuid())
slug String @unique // "home", "sobre", "contato"
content Json
updatedAt DateTime @updatedAt
}

32
frontend/prisma/seed.js Normal file
View File

@@ -0,0 +1,32 @@
const { PrismaClient } = require('../src/generated/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
const email = 'admin@occto.com';
const password = 'admin'; // Senha inicial simples
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.upsert({
where: { email },
update: {},
create: {
email,
name: 'Admin Occto',
password: hashedPassword,
},
});
console.log({ user });
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

208
frontend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,208 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Iniciando seed de dados iniciais...\n');
// 1. Criar usuário admin
const email = 'admin@occto.com';
const password = 'admin';
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.upsert({
where: { email },
update: {},
create: {
email,
name: 'Admin Occto',
password: hashedPassword,
},
});
console.log('✅ Usuário admin criado:', user.email);
// 2. Seed para página Home
const homePage = await prisma.pageContent.upsert({
where: { slug: 'home' },
update: {},
create: {
slug: 'home',
content: {
hero: {
title: 'Engenharia de Excelência para Seus Projetos',
subtitle: 'Soluções completas em engenharia veicular, mecânica e segurança do trabalho com mais de 15 anos de experiência.',
buttonText: 'Conheça Nossos Serviços'
},
features: {
pretitle: 'Por que nos escolher',
title: 'Nossos Diferenciais',
items: [
{
icon: 'ri-shield-star-line',
title: 'Qualidade Garantida',
description: 'Processos certificados e equipe altamente qualificada para garantir excelência em cada projeto.'
},
{
icon: 'ri-settings-4-line',
title: 'Soluções Personalizadas',
description: 'Atendimento sob medida para as necessidades específicas de cada cliente e projeto.'
},
{
icon: 'ri-truck-line',
title: 'Especialização Veicular',
description: 'Expertise consolidada em engenharia automotiva e de frotas, com foco em segurança e eficiência.'
}
]
},
services: {
pretitle: 'Nossos Serviços',
title: 'O Que Fazemos',
items: [
{
icon: 'ri-draft-line',
title: 'Projetos Técnicos',
description: 'Desenvolvimento de projetos de engenharia com documentação completa e aprovações necessárias.'
},
{
icon: 'ri-file-paper-2-line',
title: 'Laudos e Perícias',
description: 'Emissão de laudos técnicos e realização de perícias especializadas em engenharia.'
},
{
icon: 'ri-alert-line',
title: 'Segurança do Trabalho',
description: 'Implementação de normas e procedimentos de segurança conforme legislação vigente.'
},
{
icon: 'ri-truck-fill',
title: 'Engenharia Veicular',
description: 'Modificações, adaptações e adequações de veículos comerciais e especiais.'
}
]
},
about: {
pretitle: 'Conheça a OCCTO',
title: 'Sobre Nós',
description: 'Com mais de 15 anos de experiência, a OCCTO Engenharia se consolidou como referência em soluções de engenharia para o setor automotivo e industrial. Nossa equipe de especialistas está comprometida em entregar projetos de excelência, sempre priorizando segurança, qualidade e inovação.',
highlights: [
'Mais de 500 clientes atendidos',
'Equipe técnica altamente qualificada',
'Parceiro oficial de grandes empresas'
]
},
testimonials: {
pretitle: 'Depoimentos',
title: 'O Que Dizem Nossos Clientes',
items: [
{
name: 'Ricardo Mendes',
role: 'Gerente de Frota - Coca-Cola',
text: 'Excelente trabalho! A OCCTO realizou todas as adequações da nossa frota com qualidade e dentro do prazo. Equipe muito profissional.'
},
{
name: 'Fernanda Costa',
role: 'Diretora de Operações - Log Express',
text: 'Parceria de confiança. Os projetos são sempre bem elaborados e atendem todas as nossas necessidades técnicas e regulatórias.'
},
{
name: 'Paulo Oliveira',
role: 'Engenheiro Chefe - AutoTrans',
text: 'Conhecimento técnico incomparável. A consultoria da OCCTO foi fundamental para otimizar nossos processos e garantir conformidade total.'
}
]
},
stats: {
clients: '500+',
projects: '1200+',
years: '15'
},
cta: {
title: 'Pronto para tirar seu projeto do papel?',
text: 'Entre em contato com nossa equipe de especialistas e solicite um orçamento sem compromisso.',
button: 'Fale Conosco'
}
}
}
});
console.log('✅ Página Home criada:', homePage.slug);
// 3. Seed para página Sobre
const sobrePage = await prisma.pageContent.upsert({
where: { slug: 'sobre' },
update: {},
create: {
slug: 'sobre',
content: {
hero: {
title: 'Sobre a OCCTO Engenharia',
subtitle: 'Excelência técnica e comprometimento com resultados desde 2009'
},
mission: {
title: 'Nossa Missão',
text: 'Fornecer soluções de engenharia de alta qualidade, garantindo segurança, eficiência e conformidade em todos os projetos que realizamos.'
},
vision: {
title: 'Nossa Visão',
text: 'Ser referência nacional em engenharia veicular e segurança do trabalho, reconhecidos pela excelência técnica e inovação.'
},
values: {
title: 'Nossos Valores',
items: [
'Compromisso com a qualidade',
'Ética profissional',
'Inovação constante',
'Respeito ao cliente',
'Segurança em primeiro lugar'
]
}
}
}
});
console.log('✅ Página Sobre criada:', sobrePage.slug);
// 4. Seed para página Contato
const contatoPage = await prisma.pageContent.upsert({
where: { slug: 'contato' },
update: {},
create: {
slug: 'contato',
content: {
hero: {
title: 'Entre em Contato',
subtitle: 'Nossa equipe está pronta para atender suas necessidades'
},
info: {
address: {
street: 'Rua Exemplo, 123',
neighborhood: 'Centro',
city: 'São Paulo',
state: 'SP',
zip: '01234-567'
},
phone: '(11) 9999-9999',
email: 'contato@occto.com.br',
hours: 'Segunda a Sexta: 8h às 18h'
}
}
}
});
console.log('✅ Página Contato criada:', contatoPage.slug);
console.log('\n🎉 Seed concluído com sucesso!');
console.log('📊 Total: 1 usuário + 3 páginas criadas\n');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

1
frontend/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

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

1
frontend/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

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

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,316 @@
"use client";
import { useLanguage } from "@/contexts/LanguageContext";
import { useToast } from "@/contexts/ToastContext";
import { useState, useEffect } from "react";
interface ContactInfo {
icon: string;
title: string;
description: string;
link: string;
linkText: string;
}
interface ContactContent {
hero: {
pretitle: string;
title: string;
subtitle: string;
};
info: {
title: string;
subtitle: string;
description: string;
items: ContactInfo[];
};
}
export default function ContatoPage() {
const { t } = useLanguage();
const { success, error: showError } = useToast();
const [content, setContent] = useState<ContactContent | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
email: '',
subject: '',
message: ''
});
useEffect(() => {
fetchContent();
}, []);
const fetchContent = async () => {
try {
const response = await fetch('/api/pages/contact');
if (response.ok) {
const data = await response.json();
if (data.content) {
setContent(data.content);
}
}
} catch (error) {
console.error('Erro ao carregar conteúdo:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (!response.ok) throw new Error('Erro ao enviar mensagem');
// Limpar formulário
setFormData({
name: '',
phone: '',
email: '',
subject: '',
message: ''
});
success('Mensagem enviada com sucesso! Entraremos em contato em breve.');
} catch (error) {
showError('Erro ao enviar mensagem. Tente novamente.');
} finally {
setSubmitting(false);
}
};
// Valores padrão caso não tenha conteúdo salvo
const hero = content?.hero || {
pretitle: t('contact.info.pretitle'),
title: t('contact.hero.title'),
subtitle: t('contact.hero.subtitle')
};
const info = content?.info || {
title: t('contact.info.title'),
subtitle: t('contact.info.subtitle'),
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
items: [
{
icon: 'ri-whatsapp-line',
title: t('contact.info.phone.title'),
description: t('contact.info.whatsapp.desc'),
link: 'https://wa.me/5527999999999',
linkText: '(27) 99999-9999'
},
{
icon: 'ri-mail-send-line',
title: t('contact.info.email.title'),
description: t('contact.info.email.desc'),
link: 'mailto:contato@octto.com.br',
linkText: 'contato@octto.com.br'
},
{
icon: 'ri-map-pin-line',
title: t('contact.info.address.title'),
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
link: 'https://maps.google.com',
linkText: 'Ver no mapa'
}
]
};
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-linear-to-r from-black/80 to-black/40 z-10"></div>
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
<div className="container mx-auto px-4 relative z-20">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1 mb-6">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
<span className="text-sm font-bold text-primary uppercase tracking-wider">{hero.pretitle}</span>
</div>
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">{hero.title}</h1>
<p className="text-xl text-gray-300 max-w-2xl leading-relaxed">
{hero.subtitle}
</p>
</div>
</div>
</section>
<section className="py-20 bg-white dark:bg-secondary relative">
{/* Decorative Elements */}
<div className="absolute top-0 right-0 w-1/3 h-full bg-gray-50 dark:bg-white/5 -z-10 hidden lg:block"></div>
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
{/* Informações de Contato */}
<div className="lg:col-span-5 space-y-12">
<div>
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{info.title}</h2>
<h3 className="text-3xl md:text-4xl font-bold font-headline text-secondary dark:text-white mb-6">{info.subtitle}</h3>
<p className="text-gray-600 dark:text-gray-400 text-lg leading-relaxed">
{info.description}
</p>
</div>
<div className="space-y-6">
{info.items.map((item, index) => (
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
<div className="flex items-start gap-5">
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm group-hover:scale-110 transition-transform duration-300">
<i className={`${item.icon} text-3xl`}></i>
</div>
<div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
<a href={item.link} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
{item.linkText} <i className="ri-arrow-right-line"></i>
</a>
</div>
</div>
</div>
))}
</div>
</div>
{/* Formulário */}
<div className="lg:col-span-7">
<div className="bg-white dark:bg-secondary p-8 md:p-10 rounded-3xl shadow-xl border border-gray-100 dark:border-white/10 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10"></div>
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-8 relative z-10">{t('contact.form.title')}</h3>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="group">
<label htmlFor="nome" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.name')}</label>
<div className="relative">
<i className="ri-user-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
<input
type="text"
id="nome"
required
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder={t('contact.form.name.placeholder')}
/>
</div>
</div>
<div className="group">
<label htmlFor="telefone" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.phone')}</label>
<div className="relative">
<i className="ri-phone-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
<input
type="tel"
id="telefone"
value={formData.phone}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="(00) 00000-0000"
/>
</div>
</div>
</div>
<div className="group">
<label htmlFor="email" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.email')}</label>
<div className="relative">
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder={t('contact.form.email.placeholder')}
/>
</div>
</div>
<div className="group">
<label htmlFor="assunto" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.subject')}</label>
<div className="relative">
<i className="ri-file-list-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors"></i>
<select
id="assunto"
value={formData.subject}
onChange={(e) => setFormData({...formData, subject: e.target.value})}
className="w-full pl-11 pr-10 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
>
<option value="">{t('contact.form.subject.select')}</option>
<option value="orcamento">{t('contact.form.subject.quote')}</option>
<option value="duvida">{t('contact.form.subject.doubt')}</option>
<option value="parceria">{t('contact.form.subject.partnership')}</option>
<option value="trabalhe">{t('contact.form.subject.other')}</option>
</select>
<i className="ri-arrow-down-s-line absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"></i>
</div>
</div>
<div className="group">
<label htmlFor="mensagem" className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 group-focus-within:text-primary transition-colors">{t('contact.form.message')}</label>
<div className="relative">
<i className="ri-message-2-line absolute left-4 top-6 text-gray-400 group-focus-within:text-primary transition-colors"></i>
<textarea
id="mensagem"
required
value={formData.message}
onChange={(e) => setFormData({...formData, message: e.target.value})}
className="w-full pl-11 pr-4 py-3.5 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl h-40 text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
placeholder={t('contact.form.message.placeholder')}
></textarea>
</div>
</div>
<button
type="submit"
disabled={submitting}
className="mt-4 w-full bg-primary text-white py-4 rounded-xl font-bold hover:bg-orange-600 transition-all shadow-lg hover:shadow-primary/30 flex items-center justify-center gap-2 group disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
<span>Enviando...</span>
</>
) : (
<>
<span>{t('contact.form.submit')}</span>
<i className="ri-send-plane-fill group-hover:translate-x-1 transition-transform"></i>
</>
)}
</button>
</form>
</div>
</div>
</div>
</div>
</section>
{/* Map Section */}
<section className="h-[400px] w-full bg-gray-200 dark:bg-white/5 relative grayscale hover:grayscale-0 transition-all duration-700">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3741.447687667888!2d-40.29799692398269!3d-20.32313498115656!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0xb817d0a5b5b5b5%3A0x5b5b5b5b5b5b5b5b!2sAv.%20Nossa%20Sra.%20da%20Penha%2C%20Vit%C3%B3ria%20-%20ES!5e0!3m2!1spt-BR!2sbr!4v1700000000000!5m2!1spt-BR!2sbr"
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="absolute inset-0"
></iframe>
<div className="absolute inset-0 bg-primary/10 pointer-events-none"></div>
</section>
</main>
);
}

View File

@@ -0,0 +1,22 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import CookieConsent from "@/components/CookieConsent";
import WhatsAppButton from "@/components/WhatsAppButton";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<div className="grow">
{children}
</div>
<Footer />
<CookieConsent />
<WhatsAppButton />
</>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import Link from "next/link";
import { useLanguage } from "@/contexts/LanguageContext";
import { usePageContent } from "@/hooks/usePageContent";
export default function Home() {
const { t } = useLanguage();
const { content, loading } = usePageContent('home');
// Usar conteúdo personalizado do banco ou fallback para traduções
const hero = content?.hero || {
title: t('home.hero.title'),
subtitle: t('home.hero.subtitle'),
buttonText: t('home.hero.cta_primary')
};
const features = content?.features || {
pretitle: t('home.features.pretitle'),
title: t('home.features.title'),
items: [
{ icon: 'ri-shield-star-line', title: t('home.features.1.title'), description: t('home.features.1.desc') },
{ icon: 'ri-settings-4-line', title: t('home.features.2.title'), description: t('home.features.2.desc') },
{ icon: 'ri-truck-line', title: t('home.features.3.title'), description: t('home.features.3.desc') }
]
};
const services = content?.services || {
pretitle: t('home.services.pretitle'),
title: t('home.services.title'),
items: [
{ icon: 'ri-draft-line', title: t('home.services.1.title'), description: t('home.services.1.desc') },
{ icon: 'ri-file-paper-2-line', title: t('home.services.2.title'), description: t('home.services.2.desc') },
{ icon: 'ri-alert-line', title: t('home.services.3.title'), description: t('home.services.3.desc') },
{ icon: 'ri-truck-fill', title: t('home.services.4.title'), description: t('home.services.4.desc') }
]
};
const about = content?.about || {
pretitle: t('home.about.pretitle'),
title: t('home.about.title'),
description: t('home.about.desc'),
highlights: [
t('home.about.list.1'),
t('home.about.list.2'),
t('home.about.list.3')
]
};
const testimonials = content?.testimonials || {
pretitle: t('home.testimonials.pretitle'),
title: t('home.testimonials.title'),
items: [
{ name: 'Ricardo Mendes', role: t('home.testimonials.1.role'), text: t('home.testimonials.1.text') },
{ name: 'Fernanda Costa', role: t('home.testimonials.2.role'), text: t('home.testimonials.2.text') },
{ name: 'Paulo Oliveira', role: t('home.testimonials.3.role'), text: t('home.testimonials.3.text') }
]
};
const stats = content?.stats || {
clients: '500+',
projects: '1200+',
years: '15'
};
const cta = content?.cta || {
title: t('home.cta.title'),
text: t('home.cta.desc'),
button: t('home.cta.button')
};
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
<section className="relative h-[600px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10"></div>
{/* Placeholder for Hero Image - Industrial/Truck context */}
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581094288338-2314dddb7ece?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
<div className="container mx-auto px-4 relative z-20">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-3 bg-white/10 backdrop-blur-md border border-white/20 rounded-full px-5 py-2 mb-8 hover:bg-white/20 transition-colors cursor-default">
<i className="ri-verified-badge-fill text-primary text-xl"></i>
<span className="text-sm font-bold tracking-wider uppercase text-white">{t('home.hero.badge')} <span className="text-primary">Coca-Cola</span></span>
</div>
<h1 className="text-5xl md:text-6xl font-bold font-headline mb-6 leading-tight">
{hero.title}
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl">
{hero.subtitle}
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/contato" className="px-8 py-4 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors text-center">
{hero.buttonText}
</Link>
<Link href="/projetos" className="px-8 py-4 border-2 border-white text-white rounded-lg font-bold hover:bg-white hover:text-secondary transition-colors text-center">
{t('home.hero.cta_secondary')}
</Link>
</div>
</div>
</div>
</section>
{/* Features Section - Por que nos escolher */}
<section className="py-20 bg-white dark:bg-secondary">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{features.pretitle}</h2>
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{features.title}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.items.map((feature, index) => (
<div key={index} className="p-8 bg-gray-50 dark:bg-white/5 rounded-xl hover:shadow-lg transition-shadow border border-gray-100 dark:border-white/10 group">
<div className="w-14 h-14 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-6 group-hover:bg-primary group-hover:text-white transition-colors">
<i className={`${feature.icon} text-3xl`}></i>
</div>
<h3 className="text-2xl font-bold font-headline mb-4 text-secondary dark:text-white">{feature.title}</h3>
<p className="text-gray-600 dark:text-gray-400">
{feature.description}
</p>
</div>
))}
</div>
</div>
</section>
{/* Services Section */}
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{services.pretitle}</h2>
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{services.title}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{services.items.map((service, index) => (
<div key={index} className="bg-white dark:bg-secondary p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow border-b-4 border-transparent hover:border-primary">
<i className={`${service.icon} text-4xl text-primary mb-4 block`}></i>
<h4 className="text-xl font-bold font-headline mb-2 text-secondary dark:text-white">{service.title}</h4>
<p className="text-gray-600 dark:text-gray-400 text-sm">{service.description}</p>
</div>
))}
</div>
<div className="text-center mt-12">
<Link href="/servicos" className="text-primary font-bold hover:text-secondary dark:hover:text-white transition-colors inline-flex items-center gap-2">
{t('home.services.link')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
</section>
{/* About Preview */}
<section className="py-20 bg-secondary text-white">
<div className="container mx-auto px-4 flex flex-col md:flex-row items-center gap-12">
<div className="w-full md:w-1/2 hidden md:block">
<div className="relative h-[400px] w-full rounded-xl overflow-hidden">
{/* Placeholder for About Image - Engineer inspecting */}
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
</div>
</div>
<div className="w-full md:w-1/2">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{about.pretitle}</h2>
<h3 className="text-4xl font-bold font-headline mb-6">{about.title}</h3>
<p className="text-gray-400 mb-6 text-lg">
{about.description}
</p>
<ul className="space-y-4 mb-8">
{about.highlights.map((highlight, index) => (
<li key={index} className="flex items-center gap-3">
<i className="ri-check-double-line text-primary text-xl"></i>
<span>{highlight}</span>
</li>
))}
</ul>
<Link href="/sobre" className="text-primary font-bold hover:text-white transition-colors flex items-center gap-2">
{t('home.about.link')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
</section>
{/* Latest Projects Section */}
<section className="py-20 bg-white dark:bg-secondary">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 gap-4">
<div>
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('home.projects.pretitle')}</h2>
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{t('home.projects.title')}</h3>
</div>
<Link href="/projetos" className="px-6 py-3 border border-secondary dark:border-white text-secondary dark:text-white rounded-lg font-bold hover:bg-secondary hover:text-white dark:hover:bg-white dark:hover:text-secondary transition-colors">
{t('home.projects.link')}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{ img: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.1.title'), cat: t('home.projects.1.cat') },
{ img: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.2.title'), cat: t('home.projects.2.cat') },
{ img: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop", title: t('home.projects.3.title'), cat: t('home.projects.3.cat') }
].map((project, index) => (
<div key={index} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.img}')` }}></div>
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent opacity-80 group-hover:opacity-90 transition-opacity"></div>
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform">
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.cat}</span>
<h3 className="text-2xl font-bold font-headline text-white mb-2">{project.title}</h3>
<div className="h-0 group-hover:h-auto overflow-hidden transition-all">
<span className="text-white/80 text-sm flex items-center gap-2 mt-4">
{t('home.projects.view_details')} <i className="ri-arrow-right-line"></i>
</span>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* Testimonials Section */}
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{testimonials.pretitle}</h2>
<h3 className="text-4xl font-bold font-headline text-secondary dark:text-white">{testimonials.title}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonials.items.map((testimonial, index) => (
<div key={index} className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border border-gray-100 dark:border-white/10 relative">
<i className="ri-double-quotes-l text-4xl text-primary/20 absolute top-6 left-6"></i>
<p className="text-gray-600 dark:text-gray-300 mb-6 relative z-10 pt-6 italic">"{testimonial.text}"</p>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-200 dark:bg-white/10 rounded-full flex items-center justify-center text-gray-400">
<i className="ri-user-line text-xl"></i>
</div>
<div>
<h4 className="font-bold font-headline text-secondary dark:text-white">{testimonial.name}</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{testimonial.role}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 bg-primary">
<div className="container mx-auto px-4 text-center">
<h2 className="text-4xl font-bold font-headline text-white mb-6">{cta.title}</h2>
<p className="text-white/90 text-xl mb-8 max-w-2xl mx-auto">
{cta.text}
</p>
<Link href="/contato" className="inline-block px-10 py-4 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors shadow-lg">
{cta.button}
</Link>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,55 @@
export default function PrivacyPolicy() {
return (
<main className="py-20 bg-white">
<div className="container mx-auto px-4 max-w-4xl">
<h1 className="text-4xl font-bold font-headline text-secondary mb-8">Política de Privacidade</h1>
<div className="prose prose-lg text-gray-600">
<p className="mb-6">
A Octto Engenharia valoriza a privacidade de seus usuários e clientes. Esta Política de Privacidade descreve como coletamos, usamos e protegemos suas informações pessoais ao utilizar nosso site e serviços.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">1. Coleta de Informações</h2>
<p className="mb-4">
Coletamos informações que você nos fornece diretamente, como quando preenche nosso formulário de contato, solicita um orçamento ou se inscreve em nossa newsletter. As informações podem incluir nome, e-mail, telefone e detalhes sobre sua empresa ou projeto.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">2. Uso das Informações</h2>
<p className="mb-4">
Utilizamos as informações coletadas para:
</p>
<ul className="list-disc pl-6 mb-6 space-y-2">
<li>Responder a suas consultas e solicitações de orçamento;</li>
<li>Fornecer informações sobre nossos serviços de engenharia e laudos técnicos;</li>
<li>Melhorar a experiência do usuário em nosso site;</li>
<li>Cumprir obrigações legais e regulatórias.</li>
</ul>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">3. Proteção de Dados</h2>
<p className="mb-4">
Adotamos medidas de segurança técnicas e organizacionais adequadas para proteger seus dados pessoais contra acesso não autorizado, alteração, divulgação ou destruição.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">4. Compartilhamento de Informações</h2>
<p className="mb-4">
Não vendemos, trocamos ou transferimos suas informações pessoais para terceiros, exceto quando necessário para a prestação de nossos serviços (ex: parceiros técnicos envolvidos em um projeto específico) ou quando exigido por lei.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">5. Cookies</h2>
<p className="mb-4">
Nosso site pode utilizar cookies para melhorar a navegação e entender como os visitantes interagem com nosso conteúdo. Você pode desativar os cookies nas configurações do seu navegador, se preferir.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">6. Contato</h2>
<p className="mb-4">
Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato conosco através do e-mail: contato@octto.com.br.
</p>
<p className="text-sm text-gray-500 mt-12">
Última atualização: Novembro de 2025.
</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,157 @@
import Link from "next/link";
import { notFound } from "next/navigation";
// Mock data - same as in the main projects page
// In a real app, this would come from a database or API
const projects = [
{
id: 1,
title: "Engenharia de Adequação - Frota Coca-Cola",
category: "Engenharia Veicular",
location: "Vitória, ES",
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança.",
details: "Desenvolvimento completo do projeto de engenharia para adequação de frota de distribuição de bebidas. O escopo incluiu o cálculo estrutural para rebaixamento de carrocerias, instalação de sistemas de proteção lateral e traseira conforme resoluções do CONTRAN, e homologação junto aos órgãos competentes. O projeto resultou em aumento de 15% na capacidade de carga e total conformidade normativa.",
features: ["Cálculo Estrutural", "Homologação DENATRAN", "Segurança Operacional", "Adequação de Carroceria"]
},
{
id: 2,
title: "Laudo de Guindaste Articulado",
category: "Inspeção Técnica",
location: "Serra, ES",
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural.",
details: "Realização de inspeção detalhada em guindaste articulado (Munck) com capacidade de 45 toneladas. Foram realizados ensaios não destrutivos (líquido penetrante) em pontos críticos de solda, verificação do sistema hidráulico, testes de carga estática e dinâmica conforme NR-11. O laudo técnico atestou a integridade do equipamento para operação segura.",
features: ["Ensaio Não Destrutivo", "Teste de Carga", "Verificação Hidráulica", "ART de Inspeção"]
},
{
id: 3,
title: "Projeto de Dispositivo de Içamento",
category: "Projeto Mecânico",
location: "Aracruz, ES",
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária.",
details: "Projeto mecânico de um Spreader (balancim) automático para içamento de contêineres de 20 e 40 pés. O dispositivo foi projetado para suportar cargas de até 30 toneladas, com sistema de travamento twist-lock automático. Entregamos o projeto completo em 3D, desenhos de fabricação, memorial de cálculo e manual de operação.",
features: ["Modelagem 3D", "Cálculo de Elementos Finitos", "Detalhamento de Fabricação", "Manual de Operação"]
},
{
id: 4,
title: "Certificação NR-12 - Parque Industrial",
category: "Laudos",
location: "Linhares, ES",
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12.",
details: "Consultoria completa para adequação à NR-12 em parque fabril. Realizamos o inventário de 120 máquinas, análise de risco (HRN), projeto de proteções mecânicas e sistemas de segurança eletrônica. Acompanhamos a implementação e emitimos os laudos de validação final, garantindo a segurança dos operadores.",
features: ["Análise de Risco", "Projeto de Proteções", "Sistemas de Segurança", "Laudo de Validação"]
},
{
id: 5,
title: "Homologação de Plataforma Elevatória",
category: "Engenharia Veicular",
location: "Viana, ES",
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana.",
details: "Assessoria técnica para fabricante de plataformas elevatórias veiculares. Realizamos os cálculos de estabilidade, testes de tombamento e resistência estrutural necessários para a obtenção do CAT (Certificado de Adequação à Legislação de Trânsito). O equipamento foi homologado com sucesso para uso em veículos urbanos de carga.",
features: ["Cálculo de Estabilidade", "Teste de Tombamento", "Dossiê Técnico", "Homologação INMETRO/DENATRAN"]
},
{
id: 6,
title: "Projeto de Linha de Vida para Caminhões",
category: "Segurança do Trabalho",
location: "Cariacica, ES",
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga.",
details: "Projeto e instalação de sistema de linha de vida rígida sobre estrutura metálica para proteção de quedas durante o enlonamento de caminhões. O sistema permite que o operador trabalhe com segurança em toda a extensão da carroceria. Fornecimento de projeto, ART e treinamento de uso para a equipe.",
features: ["Projeto Estrutural", "Sistema de Ancoragem", "Treinamento NR-35", "ART de Instalação"]
}
];
export default function ProjectDetails({ params }: { params: { id: string } }) {
const project = projects.find((p) => p.id === parseInt(params.id));
if (!project) {
notFound();
}
return (
<main>
{/* Hero Section */}
<section className="relative h-[500px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10"></div>
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url('${project.image}')` }}
></div>
<div className="container mx-auto px-4 relative z-20">
<span className="inline-block px-3 py-1 bg-primary text-white text-sm font-bold rounded-md mb-4 uppercase tracking-wider">
{project.category}
</span>
<h1 className="text-4xl md:text-6xl font-bold font-headline mb-4 leading-tight max-w-4xl">
{project.title}
</h1>
<div className="flex items-center gap-2 text-gray-300 text-lg">
<i className="ri-map-pin-line text-primary"></i>
<span>{project.location}</span>
</div>
</div>
</section>
{/* Content Section */}
<section className="py-20 bg-white">
<div className="container mx-auto px-4">
<div className="flex flex-col lg:flex-row gap-16">
{/* Main Content */}
<div className="lg:w-2/3">
<h2 className="text-3xl font-bold font-headline text-secondary mb-6">Sobre o Projeto</h2>
<p className="text-gray-600 text-lg leading-relaxed mb-8">
{project.details}
</p>
<h3 className="text-2xl font-bold font-headline text-secondary mb-6">Escopo Técnico</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{project.features.map((feature, index) => (
<div key={index} className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-100">
<i className="ri-checkbox-circle-line text-primary text-xl"></i>
<span className="font-medium text-gray-700">{feature}</span>
</div>
))}
</div>
</div>
{/* Sidebar */}
<div className="lg:w-1/3">
<div className="bg-gray-50 p-8 rounded-xl border border-gray-100 sticky top-24">
<h3 className="text-xl font-bold font-headline text-secondary mb-6">Ficha Técnica</h3>
<ul className="space-y-4 mb-8">
<li className="flex justify-between border-b border-gray-200 pb-3">
<span className="text-gray-500">Cliente</span>
<span className="font-medium text-secondary">Confidencial</span>
</li>
<li className="flex justify-between border-b border-gray-200 pb-3">
<span className="text-gray-500">Categoria</span>
<span className="font-medium text-secondary">{project.category}</span>
</li>
<li className="flex justify-between border-b border-gray-200 pb-3">
<span className="text-gray-500">Local</span>
<span className="font-medium text-secondary">{project.location}</span>
</li>
<li className="flex justify-between border-b border-gray-200 pb-3">
<span className="text-gray-500">Ano</span>
<span className="font-medium text-secondary">2024</span>
</li>
</ul>
<Link href="/contato" className="block w-full py-4 bg-primary text-white text-center rounded-lg font-bold hover:bg-orange-600 transition-colors">
Solicitar Orçamento Similar
</Link>
<Link href="/projetos" className="block w-full py-4 mt-4 border border-gray-300 text-gray-600 text-center rounded-lg font-bold hover:bg-gray-100 transition-colors">
Voltar para Projetos
</Link>
</div>
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useLanguage } from "@/contexts/LanguageContext";
export default function ProjetosPage() {
const { t } = useLanguage();
// Placeholder data - will be replaced by database content
const projects = [
{
id: 1,
title: t('home.projects.1.title'),
category: t('home.projects.1.cat'),
location: "Vitória, ES",
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
description: "Projeto de adequação técnica de 50 caminhões para instalação de carrocerias especiais e sistemas de segurança."
},
{
id: 2,
title: t('home.projects.2.title'),
category: t('home.projects.2.cat'),
location: "Serra, ES",
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
description: "Inspeção completa e emissão de laudo técnico para guindaste de 45 toneladas, com testes de carga e verificação estrutural."
},
{
id: 3,
title: t('home.projects.3.title'),
category: t('home.projects.3.cat'),
location: "Aracruz, ES",
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
description: "Desenvolvimento e cálculo estrutural de Spreader para movimentação de contêineres em área portuária."
},
{
id: 4,
title: t('home.projects.4.title'),
category: t('home.projects.4.cat'),
location: "Linhares, ES",
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
description: "Inventário e adequação de segurança de 120 máquinas operatrizes conforme norma regulamentadora NR-12."
},
{
id: 5,
title: t('home.projects.5.title'),
category: t('home.projects.5.cat'),
location: "Viana, ES",
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
description: "Processo completo de homologação e certificação de plataformas elevatórias para distribuição urbana."
},
{
id: 6,
title: t('home.projects.6.title'),
category: t('home.projects.6.cat'),
location: "Cariacica, ES",
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
description: "Projeto e instalação de sistema de linha de vida para proteção contra quedas em operações de carga e descarga."
}
];
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10"></div>
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
<div className="container mx-auto px-4 relative z-20">
<h1 className="text-5xl font-bold font-headline mb-4">{t('projects.hero.title')}</h1>
<p className="text-xl text-gray-300 max-w-2xl">
{t('projects.hero.subtitle')}
</p>
</div>
</section>
{/* Projects Grid */}
<section className="py-20 bg-white dark:bg-secondary">
<div className="container mx-auto px-4">
{/* Filters (Placeholder) */}
<div className="flex flex-wrap gap-4 mb-12 justify-center">
<button className="px-6 py-2 bg-primary text-white rounded-full font-bold shadow-md">{t('projects.filter.all')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.implements')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.mechanical')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filter.reports')}</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project) => (
<div key={project.id} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col">
<div className="relative h-64 overflow-hidden">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
{project.category}
</div>
</div>
<div className="p-6 grow flex flex-col">
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">{project.title}</h3>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
<i className="ri-map-pin-line"></i>
<span>{project.location}</span>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
{project.description}
</p>
<Link href={`/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
{t('projects.card.details')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
))}
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import Link from "next/link";
import { useLanguage } from "@/contexts/LanguageContext";
export default function ServicosPage() {
const { t } = useLanguage();
const services = [
{
icon: "ri-draft-line",
title: t('home.services.1.title'),
description: t('home.services.1.desc'),
features: ["Projeto Mecânico 3D", "Cálculo Estrutural", "Dispositivos Especiais", "Homologação de Equipamentos"]
},
{
icon: "ri-truck-line",
title: t('home.features.3.title'),
description: t('home.features.3.desc'),
features: ["Projeto de Instalação", "Estudo de Estabilidade", "Adequação de Carrocerias", "Regularização Veicular"]
},
{
icon: "ri-file-paper-2-line",
title: t('home.services.2.title'),
description: t('home.services.2.desc'),
features: ["Laudos de Munck/Guindaste", "Inspeção de Segurança", "Teste de Carga", "Certificação de Equipamentos"]
},
{
icon: "ri-tools-fill",
title: "Consultoria Técnica",
description: "Assessoria especializada para adequação de frotas, planos de Rigging e supervisão de manutenção de equipamentos de carga.",
features: ["Plano de Rigging", "Supervisão de Manutenção", "Consultoria em Normas", "Treinamento Operacional"]
}
];
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10"></div>
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
<div className="container mx-auto px-4 relative z-20">
<h1 className="text-5xl font-bold font-headline mb-4">{t('services.hero.title')}</h1>
<p className="text-xl text-gray-300 max-w-2xl">
{t('services.hero.subtitle')}
</p>
</div>
</section>
{/* Services List */}
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{services.map((service, index) => (
<div key={index} className="group bg-white dark:bg-secondary rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 dark:border-white/10 flex flex-col relative">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<i className={`${service.icon} text-9xl text-primary`}></i>
</div>
<div className="p-8 pb-0 relative z-10">
<div className="flex justify-between items-start mb-6">
<div className="w-16 h-16 bg-primary/10 rounded-xl flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors duration-300 shadow-sm">
<i className={`${service.icon} text-3xl`}></i>
</div>
<span className="text-5xl font-bold text-gray-100 dark:text-white/10 font-headline select-none">0{index + 1}</span>
</div>
<h3 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-4 group-hover:text-primary transition-colors">{service.title}</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed mb-8">
{service.description}
</p>
</div>
<div className="mt-auto bg-gray-50/50 dark:bg-white/5 p-8 border-t border-gray-100 dark:border-white/10 backdrop-blur-sm">
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<span className="w-8 h-px bg-primary"></span>
{t('services.scope')}
</h4>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-y-3 gap-x-4">
{service.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<i className="ri-checkbox-circle-fill text-primary/80"></i>
{feature}
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16 bg-primary text-white text-center">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold font-headline mb-6">{t('services.cta.title')}</h2>
<Link href="/contato" className="inline-block px-8 py-3 bg-white text-primary rounded-lg font-bold hover:bg-gray-100 transition-colors">
{t('services.cta.button')}
</Link>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import Image from "next/image";
import { useLanguage } from "@/contexts/LanguageContext";
export default function SobrePage() {
const { t } = useLanguage();
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
<section className="relative h-[400px] flex items-center bg-secondary text-white overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10"></div>
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center"></div>
<div className="container mx-auto px-4 relative z-20">
<h1 className="text-5xl font-bold font-headline mb-4">{t('about.hero.title')}</h1>
<p className="text-xl text-gray-300 max-w-2xl">
{t('about.hero.subtitle')}
</p>
</div>
</section>
{/* História e Missão */}
<section className="py-20 bg-white dark:bg-secondary">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row gap-12 items-center">
<div className="w-full md:w-1/2">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.history.title')}</h2>
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{t('about.history.subtitle')}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
{t('about.history.p1')}
</p>
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
{t('about.history.p2')}
</p>
</div>
<div className="w-full md:w-1/2 grid grid-cols-2 gap-4">
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
</div>
<div className="h-64 rounded-xl bg-gray-200 dark:bg-white/10 overflow-hidden relative mt-8">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=1000&auto=format&fit=crop')] bg-cover bg-center"></div>
</div>
</div>
</div>
</div>
</section>
{/* Valores */}
<section className="py-20 bg-gray-50 dark:bg-[#121212]">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{t('about.values.title')}</h2>
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{t('about.values.subtitle')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
<i className="ri-medal-line text-2xl"></i>
</div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.quality.title')}</h4>
<p className="text-gray-600 dark:text-gray-400">{t('about.values.quality.desc')}</p>
</div>
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
<i className="ri-shake-hands-line text-2xl"></i>
</div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.transparency.title')}</h4>
<p className="text-gray-600 dark:text-gray-400">{t('about.values.transparency.desc')}</p>
</div>
<div className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
<i className="ri-leaf-line text-2xl"></i>
</div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{t('about.values.sustainability.title')}</h4>
<p className="text-gray-600 dark:text-gray-400">{t('about.values.sustainability.desc')}</p>
</div>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,49 @@
export default function TermsOfUse() {
return (
<main className="py-20 bg-white">
<div className="container mx-auto px-4 max-w-4xl">
<h1 className="text-4xl font-bold font-headline text-secondary mb-8">Termos de Uso</h1>
<div className="prose prose-lg text-gray-600">
<p className="mb-6">
Bem-vindo ao site da Octto Engenharia. Ao acessar e utilizar este site, você concorda em cumprir e estar vinculado aos seguintes Termos de Uso. Se você não concordar com qualquer parte destes termos, por favor, não utilize nosso site.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">1. Uso do Site</h2>
<p className="mb-4">
O conteúdo deste site é apenas para fins informativos gerais sobre nossos serviços de engenharia mecânica, laudos e projetos. Reservamo-nos o direito de alterar ou descontinuar qualquer aspecto do site a qualquer momento.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">2. Propriedade Intelectual</h2>
<p className="mb-4">
Todo o conteúdo presente neste site, incluindo textos, gráficos, logotipos, ícones, imagens e software, é propriedade da Octto Engenharia ou de seus fornecedores de conteúdo e é protegido pelas leis de direitos autorais do Brasil e internacionais.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">3. Limitação de Responsabilidade</h2>
<p className="mb-4">
A Octto Engenharia não se responsabiliza por quaisquer danos diretos, indiretos, incidentais ou consequenciais resultantes do uso ou da incapacidade de uso deste site ou de qualquer informação nele contida. As informações técnicas fornecidas no site não substituem a consulta profissional e a emissão de laudos técnicos específicos para cada caso.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">4. Links para Terceiros</h2>
<p className="mb-4">
Nosso site pode conter links para sites de terceiros. Estes links são fornecidos apenas para sua conveniência. A Octto Engenharia não tem controle sobre o conteúdo desses sites e não assume responsabilidade por eles.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">5. Alterações nos Termos</h2>
<p className="mb-4">
Podemos revisar estes Termos de Uso a qualquer momento. Ao utilizar este site, você concorda em ficar vinculado à versão atual desses Termos de Uso.
</p>
<h2 className="text-2xl font-bold font-headline text-secondary mt-8 mb-4">6. Legislação Aplicável</h2>
<p className="mb-4">
Estes termos são regidos e interpretados de acordo com as leis da República Federativa do Brasil. Qualquer disputa relacionada a estes termos será submetida à jurisdição exclusiva dos tribunais competentes.
</p>
<p className="text-sm text-gray-500 mt-12">
Última atualização: Novembro de 2025.
</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Erro ao fazer login');
setLoading(false);
return;
}
// Redirecionar para o admin
router.push('/admin');
router.refresh();
} catch (err) {
setError('Erro ao conectar com o servidor');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-[#121212] px-4">
<div className="max-w-md w-full bg-white dark:bg-secondary rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-white/10">
<div className="p-8 md:p-10">
<div className="text-center mb-10">
<Link href="/" className="inline-flex items-center gap-3 group mb-6">
<i className="ri-building-2-fill text-4xl text-primary"></i>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</div>
</Link>
<h1 className="text-2xl font-bold font-headline text-secondary dark:text-white mb-2">Acesso Administrativo</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm">Entre com suas credenciais para gerenciar o site.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
<i className="ri-error-warning-line"></i>
{error}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">E-mail</label>
<div className="relative">
<i className="ri-mail-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="admin@octto.com.br"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Senha</label>
<div className="relative">
<i className="ri-lock-password-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-12 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary transition-colors cursor-pointer"
>
<i className={showPassword ? "ri-eye-off-line text-xl" : "ri-eye-line text-xl"}></i>
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<>
<i className="ri-loader-4-line animate-spin text-xl"></i>
<span>Entrando...</span>
</>
) : (
<>
<span>Acessar Painel</span>
<i className="ri-arrow-right-line"></i>
</>
)}
</button>
</form>
</div>
<div className="bg-gray-50 dark:bg-white/5 p-4 text-center border-t border-gray-100 dark:border-white/10">
<Link href="/" className="text-sm text-gray-500 hover:text-primary transition-colors flex items-center justify-center gap-2">
<i className="ri-arrow-left-line"></i> Voltar para o site
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect } from 'react';
import { useToast } from '@/contexts/ToastContext';
const PRESET_COLORS = [
{ name: 'Laranja (Padrão)', value: '#FF6B35', gradient: 'from-orange-500 to-orange-600' },
{ name: 'Azul Corporativo', value: '#2563EB', gradient: 'from-blue-600 to-blue-700' },
{ name: 'Verde Profissional', value: '#059669', gradient: 'from-emerald-600 to-emerald-700' },
{ name: 'Roxo Moderno', value: '#7C3AED', gradient: 'from-violet-600 to-violet-700' },
{ name: 'Vermelho Vibrante', value: '#DC2626', gradient: 'from-red-600 to-red-700' },
{ name: 'Azul Petróleo', value: '#0891B2', gradient: 'from-cyan-600 to-cyan-700' },
{ name: 'Rosa Criativo', value: '#DB2777', gradient: 'from-pink-600 to-pink-700' },
{ name: 'Âmbar Caloroso', value: '#D97706', gradient: 'from-amber-600 to-amber-700' },
];
export default function ConfiguracoesPage() {
const { success, error: showError } = useToast();
const [primaryColor, setPrimaryColor] = useState('#FF6B35');
const [customColor, setCustomColor] = useState('#FF6B35');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
try {
const response = await fetch('/api/config');
if (response.ok) {
const data = await response.json();
if (data.primaryColor) {
setPrimaryColor(data.primaryColor);
setCustomColor(data.primaryColor);
}
}
} catch (error) {
console.error('Erro ao carregar configurações:', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ primaryColor })
});
if (!response.ok) throw new Error('Erro ao salvar');
success('Configurações salvas com sucesso! Recarregando página...');
// Recarregar a página após 1 segundo para aplicar as mudanças
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
showError('Erro ao salvar configurações');
} finally {
setSaving(false);
}
};
const applyPreviewColor = (color: string) => {
setPrimaryColor(color);
setCustomColor(color);
// Preview temporário
document.documentElement.style.setProperty('--color-primary', color);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Configurações</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">Personalize a aparência do seu site</p>
</div>
</div>
{/* Color Settings */}
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-start gap-4 mb-6">
<div className="w-12 h-12 bg-linear-to-br from-primary to-orange-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary/30">
<i className="ri-palette-line text-2xl text-white"></i>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-1">Cor Primária</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Escolha a cor principal que representa sua marca. Ela será aplicada em botões, links e destaques.
</p>
</div>
</div>
{/* Preset Colors */}
<div className="mb-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Cores Predefinidas
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => applyPreviewColor(color.value)}
className={`group relative p-4 rounded-xl border-2 transition-all ${
primaryColor === color.value
? 'border-primary shadow-lg shadow-primary/20'
: 'border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20'
}`}
>
<div className={`w-full h-16 rounded-lg bg-linear-to-br ${color.gradient} mb-3 shadow-md group-hover:scale-105 transition-transform`}></div>
<p className="text-sm font-medium text-gray-900 dark:text-white text-center">
{color.name}
</p>
{primaryColor === color.value && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center shadow-lg">
<i className="ri-check-line text-white text-sm"></i>
</div>
)}
</button>
))}
</div>
</div>
{/* Custom Color Picker */}
<div className="border-t border-gray-200 dark:border-white/10 pt-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Cor Personalizada
</label>
<div className="flex items-center gap-4">
<div className="relative">
<input
type="color"
value={customColor}
onChange={(e) => applyPreviewColor(e.target.value)}
className="w-20 h-20 rounded-xl border-2 border-gray-200 dark:border-white/10 cursor-pointer shadow-md"
/>
</div>
<div className="flex-1">
<input
type="text"
value={customColor}
onChange={(e) => {
setCustomColor(e.target.value);
if (/^#[0-9A-F]{6}$/i.test(e.target.value)) {
applyPreviewColor(e.target.value);
}
}}
placeholder="#FF6B35"
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all font-mono"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Digite o código hexadecimal da cor (ex: #FF6B35)
</p>
</div>
</div>
</div>
{/* Preview Section */}
<div className="border-t border-gray-200 dark:border-white/10 mt-8 pt-8">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-4">
Prévia dos Elementos
</label>
<div className="bg-gray-50 dark:bg-white/5 p-6 rounded-xl space-y-4">
<button
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-all shadow-lg shadow-primary/30"
style={{ backgroundColor: primaryColor }}
>
Botão Primário
</button>
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md"
style={{ backgroundColor: primaryColor }}
>
<i className="ri-star-fill text-xl"></i>
</div>
<div>
<p className="font-bold" style={{ color: primaryColor }}>Texto em Destaque</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Exemplo de link ou texto importante</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: primaryColor }}
>
Badge
</span>
<span
className="px-3 py-1 rounded-full text-sm font-medium border-2"
style={{ borderColor: primaryColor, color: primaryColor }}
>
Outline Badge
</span>
</div>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex items-center justify-end gap-4">
<button
onClick={fetchConfig}
className="px-6 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: saving ? undefined : primaryColor }}
>
{saving ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Alterações
</>
)}
</button>
</div>
{/* Info Alert */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 flex items-start gap-3">
<i className="ri-information-line text-blue-600 dark:text-blue-400 text-xl mt-0.5"></i>
<div className="flex-1">
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium mb-1">
Aplicação Global
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
A cor primária será aplicada automaticamente em todo o site institucional e painel administrativo.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [user, setUser] = useState<{ name: string; email: string; avatar?: string | null } | null>(null);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const pathname = usePathname();
const router = useRouter();
const { success, error } = useToast();
const { confirm } = useConfirm();
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
const data = await response.json();
setUser(data.user);
}
} catch (error) {
console.error('Erro ao buscar dados do usuário:', error);
}
};
fetchUser();
}, []);
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/acesso');
} catch (error) {
console.error('Erro ao fazer logout:', error);
// Fallback: clear cookie manually
document.cookie = "auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT";
router.push('/acesso');
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch('/api/auth/avatar', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setShowAvatarModal(false);
success('Foto atualizada com sucesso!');
} else {
const errorData = await response.json();
error(errorData.error || 'Erro ao fazer upload');
}
} catch (err) {
console.error('Erro ao fazer upload:', err);
error('Erro ao fazer upload do avatar');
} finally {
setIsUploading(false);
}
};
const handleRemoveAvatar = async () => {
const confirmed = await confirm({
title: 'Remover Foto',
message: 'Deseja remover sua foto de perfil?',
confirmText: 'Remover',
cancelText: 'Cancelar',
type: 'warning',
});
if (!confirmed) return;
try {
const response = await fetch('/api/auth/avatar', { method: 'DELETE' });
if (response.ok) {
const data = await response.json();
setUser(data.user);
setShowAvatarModal(false);
success('Foto removida com sucesso!');
}
} catch (err) {
console.error('Erro ao remover avatar:', err);
error('Erro ao remover avatar');
}
};
const menuItems = [
{ icon: 'ri-dashboard-line', label: 'Dashboard', href: '/admin' },
{ icon: 'ri-briefcase-line', label: 'Projetos', href: '/admin/projetos' },
{ icon: 'ri-tools-line', label: 'Serviços', href: '/admin/servicos' },
{ icon: 'ri-pages-line', label: 'Páginas', href: '/admin/paginas' },
{ icon: 'ri-message-3-line', label: 'Mensagens', href: '/admin/mensagens' },
{ icon: 'ri-user-settings-line', label: 'Usuários', href: '/admin/usuarios' },
{ icon: 'ri-settings-3-line', label: 'Configurações', href: '/admin/configuracoes' },
];
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#121212] flex">
{/* Sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 transition-all duration-300 ${isSidebarOpen ? 'w-64' : 'w-20'} hidden md:flex flex-col`}>
<div className="h-20 flex items-center justify-center border-b border-gray-200 dark:border-white/10">
<Link href="/admin" className="flex items-center gap-3">
<i className="ri-building-2-fill text-3xl text-primary"></i>
{isSidebarOpen && (
<div className="flex items-center gap-2 animate-in fade-in duration-300">
<span className="text-xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</div>
)}
</Link>
</div>
<nav className="flex-1 py-6 px-3 space-y-2 overflow-y-auto">
{menuItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-3 rounded-xl transition-all group ${isActive ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5'}`}
>
<i className={`${item.icon} text-xl ${isActive ? 'text-white' : 'text-gray-500 dark:text-gray-400 group-hover:text-primary'}`}></i>
{isSidebarOpen && <span className="font-medium whitespace-nowrap animate-in fade-in duration-200">{item.label}</span>}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-200 dark:border-white/10">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-500 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors cursor-pointer"
>
<i className="ri-logout-box-line text-xl"></i>
{isSidebarOpen && <span className="font-medium">Sair</span>}
</button>
</div>
</aside>
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-screen transition-all duration-300 ${isSidebarOpen ? 'md:ml-64' : 'md:ml-20'}`}>
{/* Header */}
<header className="h-20 bg-white dark:bg-secondary border-b border-gray-200 dark:border-white/10 sticky top-0 z-40 px-6 flex items-center justify-between">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 flex items-center justify-center text-gray-600 dark:text-gray-300 transition-colors cursor-pointer"
>
<i className={isSidebarOpen ? "ri-menu-fold-line text-xl" : "ri-menu-unfold-line text-xl"}></i>
</button>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 pl-4 border-l border-gray-200 dark:border-white/10">
<div className="text-right hidden sm:block">
<p className="text-sm font-bold text-secondary dark:text-white">{user?.name || 'Carregando...'}</p>
<p className="text-xs text-gray-500">{user?.email || ''}</p>
</div>
<button
onClick={() => setShowAvatarModal(true)}
className="w-10 h-10 rounded-full overflow-hidden hover:ring-2 hover:ring-primary transition-all cursor-pointer"
>
{user?.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center text-gray-500 dark:text-gray-400">
<i className="ri-user-3-line text-xl"></i>
</div>
)}
</button>
</div>
</div>
</header>
{/* Page Content */}
<main className="p-6 md:p-8">
{children}
</main>
</div>
{/* Avatar Modal */}
{showAvatarModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setShowAvatarModal(false)}>
<div className="bg-white dark:bg-secondary rounded-xl p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-secondary dark:text-white">Foto de Perfil</h2>
<button onClick={() => setShowAvatarModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<i className="ri-close-line text-2xl"></i>
</button>
</div>
<div className="flex flex-col items-center gap-6">
<div className="w-32 h-32 rounded-full overflow-hidden">
{user?.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-200 dark:bg-white/10 flex items-center justify-center">
<i className="ri-user-3-line text-5xl text-gray-400"></i>
</div>
)}
</div>
<div className="flex gap-3 w-full">
<label className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-orange-600 transition-colors text-center cursor-pointer">
{isUploading ? 'Enviando...' : 'Escolher Foto'}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarUpload}
disabled={isUploading}
className="hidden"
/>
</label>
{user?.avatar && (
<button
onClick={handleRemoveAvatar}
disabled={isUploading}
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg font-medium hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
>
Remover
</button>
)}
</div>
<p className="text-xs text-gray-500 text-center">
Formatos: JPEG, PNG, WEBP Tamanho máximo: 5MB
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { useToast } from '@/contexts/ToastContext';
interface Message {
id: string;
name: string;
email: string;
subject: string;
message: string;
status: string;
createdAt: string;
}
export default function MessagesPage() {
const { success, error: showError } = useToast();
const [messages, setMessages] = useState<Message[]>([]);
const [filteredMessages, setFilteredMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
useEffect(() => {
fetchMessages();
}, []);
useEffect(() => {
filterMessages();
}, [search, statusFilter, messages]);
const fetchMessages = async () => {
try {
const response = await fetch('/api/messages');
if (response.ok) {
const data = await response.json();
setMessages(data);
} else {
showError('Erro ao carregar mensagens');
}
} catch (error) {
console.error('Erro ao carregar mensagens:', error);
showError('Erro ao carregar mensagens');
} finally {
setLoading(false);
}
};
const filterMessages = () => {
let filtered = messages;
if (statusFilter) {
filtered = filtered.filter(m => m.status === statusFilter);
}
if (search) {
const searchLower = search.toLowerCase();
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(searchLower) ||
m.email.toLowerCase().includes(searchLower) ||
m.subject.toLowerCase().includes(searchLower) ||
m.message.toLowerCase().includes(searchLower)
);
}
setFilteredMessages(filtered);
};
const updateStatus = async (id: string, status: string) => {
try {
const response = await fetch(`/api/messages/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (response.ok) {
setMessages(messages.map(m => m.id === id ? { ...m, status } : m));
if (selectedMessage?.id === id) {
setSelectedMessage({ ...selectedMessage, status });
}
success(`Status alterado para "${status}"`);
} else {
showError('Erro ao atualizar status');
}
} catch (error) {
console.error('Erro ao atualizar status:', error);
showError('Erro ao atualizar status');
}
};
const deleteMessage = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir esta mensagem?')) return;
try {
const response = await fetch(`/api/messages/${id}`, {
method: 'DELETE'
});
if (response.ok) {
setMessages(messages.filter(m => m.id !== id));
if (selectedMessage?.id === id) {
setSelectedMessage(null);
}
success('Mensagem excluída com sucesso');
} else {
showError('Erro ao excluir mensagem');
}
} catch (error) {
console.error('Erro ao deletar mensagem:', error);
showError('Erro ao excluir mensagem');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'Nova': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'Lida': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'Respondida': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
}
};
const stats = {
total: messages.length,
novas: messages.filter(m => m.status === 'Nova').length,
lidas: messages.filter(m => m.status === 'Lida').length,
respondidas: messages.filter(m => m.status === 'Respondida').length
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white">Mensagens de Contato</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">Gerencie leads e solicitações de clientes</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total</p>
<p className="text-3xl font-bold text-secondary dark:text-white mt-1">{stats.total}</p>
</div>
<div className="w-12 h-12 bg-gray-100 dark:bg-white/10 rounded-xl flex items-center justify-center">
<i className="ri-mail-line text-2xl text-gray-600 dark:text-gray-400"></i>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Novas</p>
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">{stats.novas}</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
<i className="ri-mail-unread-line text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Lidas</p>
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400 mt-1">{stats.lidas}</p>
</div>
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-xl flex items-center justify-center">
<i className="ri-mail-open-line text-2xl text-yellow-600 dark:text-yellow-400"></i>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Respondidas</p>
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{stats.respondidas}</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
<i className="ri-mail-check-line text-2xl text-green-600 dark:text-green-400"></i>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white dark:bg-secondary p-6 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="Buscar por nome, email, assunto ou mensagem..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all cursor-pointer"
>
<option value="">Todos os status</option>
<option value="Nova">Novas</option>
<option value="Lida">Lidas</option>
<option value="Respondida">Respondidas</option>
</select>
</div>
</div>
{/* Messages List */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Lista */}
<div className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<h2 className="font-bold text-gray-900 dark:text-white">
{filteredMessages.length} {filteredMessages.length === 1 ? 'mensagem' : 'mensagens'}
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-white/10 max-h-[600px] overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<i className="ri-mail-line text-4xl mb-2"></i>
<p>Nenhuma mensagem encontrada</p>
</div>
) : (
filteredMessages.map((msg) => (
<div
key={msg.id}
onClick={() => setSelectedMessage(msg)}
className={`p-4 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-white/5 ${
selectedMessage?.id === msg.id ? 'bg-primary/10 dark:bg-primary/20' : ''
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 dark:text-white truncate">{msg.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{msg.email}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${getStatusColor(msg.status)}`}>
{msg.status}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium mb-1 truncate">{msg.subject}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{format(new Date(msg.createdAt), "dd 'de' MMMM 'às' HH:mm", { locale: ptBR })}
</p>
</div>
))
)}
</div>
</div>
{/* Detalhes */}
<div className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
{selectedMessage ? (
<div className="h-full flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-white/10">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-1">{selectedMessage.name}</h2>
<a href={`mailto:${selectedMessage.email}`} className="text-primary hover:underline">
{selectedMessage.email}
</a>
</div>
<button
onClick={() => deleteMessage(selectedMessage.id)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Excluir mensagem"
>
<i className="ri-delete-bin-line text-xl"></i>
</button>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<i className="ri-calendar-line"></i>
{format(new Date(selectedMessage.createdAt), "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })}
</div>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-4">
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Assunto</label>
<p className="text-gray-900 dark:text-white font-medium">{selectedMessage.subject}</p>
</div>
<div>
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Mensagem</label>
<p className="text-gray-900 dark:text-white whitespace-pre-wrap leading-relaxed">
{selectedMessage.message}
</p>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<label className="text-sm font-bold text-gray-600 dark:text-gray-400 block mb-2">Status</label>
<div className="flex gap-2">
<button
onClick={() => updateStatus(selectedMessage.id, 'Nova')}
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
selectedMessage.status === 'Nova'
? 'bg-blue-600 text-white'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50'
}`}
>
Nova
</button>
<button
onClick={() => updateStatus(selectedMessage.id, 'Lida')}
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
selectedMessage.status === 'Lida'
? 'bg-yellow-600 text-white'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-900/50'
}`}
>
Lida
</button>
<button
onClick={() => updateStatus(selectedMessage.id, 'Respondida')}
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-colors ${
selectedMessage.status === 'Respondida'
? 'bg-green-600 text-white'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
}`}
>
Respondida
</button>
</div>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
<div className="text-center">
<i className="ri-mail-open-line text-6xl mb-4"></i>
<p>Selecione uma mensagem para ver os detalhes</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
export default function AdminDashboard() {
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Bem-vindo ao painel administrativo da Octto Engenharia.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[
{ label: 'Projetos Ativos', value: '12', icon: 'ri-briefcase-line', color: 'text-blue-500', bg: 'bg-blue-50 dark:bg-blue-900/20' },
{ label: 'Mensagens Novas', value: '5', icon: 'ri-message-3-line', color: 'text-green-500', bg: 'bg-green-50 dark:bg-green-900/20' },
{ label: 'Serviços', value: '8', icon: 'ri-tools-line', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-900/20' },
{ label: 'Visitas Hoje', value: '145', icon: 'ri-eye-line', color: 'text-purple-500', bg: 'bg-purple-50 dark:bg-purple-900/20' },
].map((stat, index) => (
<div key={index} className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm cursor-pointer hover:shadow-md transition-all">
<div className="flex items-center justify-between mb-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${stat.bg} ${stat.color}`}>
<i className={`${stat.icon} text-2xl`}></i>
</div>
<span className="text-2xl font-bold text-secondary dark:text-white">{stat.value}</span>
</div>
<h3 className="text-gray-500 dark:text-gray-400 font-medium">{stat.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-secondary dark:text-white">Últimas Mensagens</h3>
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todas</button>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-white/10 flex items-center justify-center shrink-0">
<span className="font-bold text-gray-500 dark:text-gray-400">JD</span>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<h4 className="font-bold text-secondary dark:text-white text-sm">João da Silva</h4>
<span className="text-xs text-gray-400"> 2 horas</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
Gostaria de solicitar um orçamento para adequação de frota conforme NR-12...
</p>
</div>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-secondary p-6 rounded-xl border border-gray-200 dark:border-white/10 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-secondary dark:text-white">Projetos Recentes</h3>
<button className="text-primary text-sm font-bold hover:underline cursor-pointer">Ver todos</button>
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-100 dark:hover:border-white/5 cursor-pointer">
<div className="w-16 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden">
{/* Placeholder image */}
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
</div>
<div className="flex-1">
<h4 className="font-bold text-secondary dark:text-white text-sm">Adequação Coca-Cola</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Engenharia Veicular</p>
</div>
<span className="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Concluído
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,633 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
const AVAILABLE_ICONS = [
// Pessoas e Equipe
{ value: 'ri-team-line', label: 'Equipe', category: 'pessoas' },
{ value: 'ri-user-star-line', label: 'Destaque', category: 'pessoas' },
{ value: 'ri-user-follow-line', label: 'Seguir', category: 'pessoas' },
{ value: 'ri-group-line', label: 'Grupo', category: 'pessoas' },
// Segurança
{ value: 'ri-shield-check-line', label: 'Segurança', category: 'segurança' },
{ value: 'ri-shield-star-line', label: 'Proteção Premium', category: 'segurança' },
{ value: 'ri-lock-line', label: 'Cadeado', category: 'segurança' },
{ value: 'ri-hard-hat-line', label: 'Capacete', category: 'segurança' },
// Serviços
{ value: 'ri-service-line', label: 'Atendimento', category: 'serviço' },
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'serviço' },
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'serviço' },
{ value: 'ri-settings-3-line', label: 'Engrenagem', category: 'serviço' },
// Transporte
{ value: 'ri-car-line', label: 'Veículo', category: 'transporte' },
{ value: 'ri-truck-line', label: 'Caminhão', category: 'transporte' },
{ value: 'ri-bus-line', label: 'Ônibus', category: 'transporte' },
{ value: 'ri-motorbike-line', label: 'Moto', category: 'transporte' },
// Documentos
{ value: 'ri-file-list-3-line', label: 'Documentos', category: 'documentos' },
{ value: 'ri-file-text-line', label: 'Arquivo', category: 'documentos' },
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
{ value: 'ri-contract-line', label: 'Contrato', category: 'documentos' },
// Conquistas
{ value: 'ri-award-line', label: 'Prêmio', category: 'conquista' },
{ value: 'ri-trophy-line', label: 'Troféu', category: 'conquista' },
{ value: 'ri-medal-line', label: 'Medalha', category: 'conquista' },
{ value: 'ri-vip-crown-line', label: 'Coroa', category: 'conquista' },
// Inovação
{ value: 'ri-lightbulb-line', label: 'Ideia', category: 'inovação' },
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inovação' },
{ value: 'ri-rocket-line', label: 'Foguete', category: 'inovação' },
{ value: 'ri-flask-line', label: 'Experimento', category: 'inovação' },
// Status
{ value: 'ri-checkbox-circle-line', label: 'Confirmado', category: 'status' },
{ value: 'ri-check-double-line', label: 'Verificado', category: 'status' },
{ value: 'ri-star-line', label: 'Estrela', category: 'status' },
{ value: 'ri-thumb-up-line', label: 'Aprovado', category: 'status' },
// Dados
{ value: 'ri-pie-chart-line', label: 'Gráfico Pizza', category: 'dados' },
{ value: 'ri-bar-chart-line', label: 'Gráfico Barras', category: 'dados' },
{ value: 'ri-line-chart-line', label: 'Gráfico Linha', category: 'dados' },
{ value: 'ri-dashboard-line', label: 'Dashboard', category: 'dados' },
// Performance
{ value: 'ri-speed-line', label: 'Velocidade', category: 'performance' },
{ value: 'ri-timer-line', label: 'Cronômetro', category: 'performance' },
{ value: 'ri-time-line', label: 'Relógio', category: 'performance' },
{ value: 'ri-pulse-line', label: 'Pulso', category: 'performance' },
// Negócios
{ value: 'ri-building-line', label: 'Empresa', category: 'negócios' },
{ value: 'ri-briefcase-line', label: 'Maleta', category: 'negócios' },
{ value: 'ri-money-dollar-circle-line', label: 'Dinheiro', category: 'negócios' },
{ value: 'ri-hand-coin-line', label: 'Pagamento', category: 'negócios' },
// Cálculo
{ value: 'ri-calculator-line', label: 'Calculadora', category: 'cálculo' },
{ value: 'ri-percent-line', label: 'Porcentagem', category: 'cálculo' },
{ value: 'ri-functions', label: 'Funções', category: 'cálculo' },
// Comunicação
{ value: 'ri-message-3-line', label: 'Mensagem', category: 'comunicação' },
{ value: 'ri-chat-3-line', label: 'Chat', category: 'comunicação' },
{ value: 'ri-phone-line', label: 'Telefone', category: 'comunicação' },
{ value: 'ri-mail-line', label: 'Email', category: 'comunicação' },
{ value: 'ri-whatsapp-line', label: 'WhatsApp', category: 'comunicação' },
{ value: 'ri-mail-send-line', label: 'Enviar Email', category: 'comunicação' },
// Localização
{ value: 'ri-map-pin-line', label: 'Localização', category: 'local' },
{ value: 'ri-navigation-line', label: 'Navegação', category: 'local' },
{ value: 'ri-roadster-line', label: 'Estrada', category: 'local' },
{ value: 'ri-compass-line', label: 'Bússola', category: 'local' },
];
interface IconSelectorProps {
value: string;
onChange: (icon: string) => void;
label: string;
}
function IconSelector({ value, onChange, label }: IconSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const filteredIcons = AVAILABLE_ICONS.filter(icon =>
icon.label.toLowerCase().includes(search.toLowerCase()) ||
icon.category.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</label>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all flex items-center justify-between"
>
<div className="flex items-center gap-3">
<i className={`${value} text-2xl text-primary`}></i>
<span className="text-sm">{AVAILABLE_ICONS.find(i => i.value === value)?.label || 'Selecionar ícone'}</span>
</div>
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line text-gray-400`}></i>
</button>
{isOpen && (
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl">
<div className="p-3 border-b border-gray-200 dark:border-white/10">
<div className="relative">
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar ícone..."
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:border-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="p-2 grid grid-cols-4 gap-2 max-h-64 overflow-y-auto">
{filteredIcons.map((icon) => (
<button
key={icon.value}
type="button"
onClick={() => {
onChange(icon.value);
setIsOpen(false);
setSearch('');
}}
className={`p-3 rounded-lg flex flex-col items-center gap-1 transition-all ${
value === icon.value
? 'bg-primary text-white'
: 'hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
title={icon.label}
>
<i className={`${icon.value} text-2xl`}></i>
<span className="text-[10px] text-center leading-tight">{icon.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
interface ContactInfo {
icon: string;
title: string;
description: string;
link: string;
linkText: string;
}
interface ContactContent {
hero: {
pretitle: string;
title: string;
subtitle: string;
};
info: {
title: string;
subtitle: string;
description: string;
items: ContactInfo[];
};
}
export default function EditContactPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [activeTab, setActiveTab] = useState('hero');
const { success, error: showError } = useToast();
const scrollToPreview = (sectionId: string) => {
const element = document.getElementById(`preview-${sectionId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleTabChange = (tab: string) => {
setActiveTab(tab);
setTimeout(() => scrollToPreview(tab), 100);
};
const [formData, setFormData] = useState<ContactContent>({
hero: {
pretitle: 'Fale Conosco',
title: 'Entre em Contato',
subtitle: 'Nossa equipe está pronta para atender você e transformar suas ideias em realidade.'
},
info: {
title: 'Informações de Contato',
subtitle: 'Estamos à disposição',
description: 'Estamos à disposição para atender sua empresa com a excelência técnica que seu projeto exige.',
items: [
{
icon: 'ri-whatsapp-line',
title: 'WhatsApp',
description: 'Atendimento rápido e direto',
link: 'https://wa.me/5527999999999',
linkText: '(27) 99999-9999'
},
{
icon: 'ri-mail-send-line',
title: 'E-mail',
description: 'Envie sua mensagem',
link: 'mailto:contato@octto.com.br',
linkText: 'contato@octto.com.br'
},
{
icon: 'ri-map-pin-line',
title: 'Endereço',
description: 'Av. Nossa Senhora da Penha, 1234\nSanta Lúcia, Vitória - ES\nCEP: 29056-000',
link: 'https://maps.google.com',
linkText: 'Ver no mapa'
}
]
}
});
useEffect(() => {
fetchPageContent();
}, []);
const fetchPageContent = async () => {
try {
const response = await fetch('/api/pages/contact');
if (response.ok) {
const data = await response.json();
if (data.content) {
setFormData(prevData => ({
hero: data.content.hero || prevData.hero,
info: data.content.info || prevData.info
}));
}
}
} catch (err) {
console.log('Nenhum conteúdo salvo ainda, usando padrão');
} finally {
setInitialLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/pages/contact', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: formData })
});
if (!response.ok) throw new Error('Erro ao salvar');
success('Conteúdo da página Contato atualizado com sucesso!');
} catch (err) {
showError('Erro ao salvar alterações');
} finally {
setLoading(false);
}
};
if (initialLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<>
<style jsx global>{`
main { padding: 0 !important; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
`}</style>
<div className="fixed top-20 bottom-0 left-64 right-0 flex gap-0 bg-gray-50 dark:bg-tertiary">
{/* Formulário de Edição - Coluna Esquerda 30% */}
<div className="w-[30%] shrink-0 overflow-y-auto bg-white dark:bg-secondary relative">
<div className="absolute top-0 right-0 bottom-0 w-px bg-gray-200 dark:bg-white/10"></div>
<div className="p-6 border-b border-gray-200 dark:border-white/10">
<h1 className="text-2xl font-bold">Editar Página Contato</h1>
<p className="text-sm text-muted-foreground mt-1">
Personalize informações de contato
</p>
</div>
{/* Navigation Tabs */}
<div className="relative border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<div className="flex items-center">
<button
type="button"
onClick={() => {
const container = document.getElementById('tabs-container');
if (container) container.scrollLeft -= 200;
}}
className="absolute left-0 z-10 w-10 h-full bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
>
<i className="ri-arrow-left-s-line text-xl text-gray-600 dark:text-gray-400"></i>
</button>
<div id="tabs-container" className="flex gap-2 p-4 overflow-x-auto scrollbar-hide scroll-smooth px-12">
<button
type="button"
onClick={() => handleTabChange('hero')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
activeTab === 'hero'
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
Banner
</button>
<button
type="button"
onClick={() => handleTabChange('info')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
activeTab === 'info'
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
Informações (3)
</button>
</div>
<button
type="button"
onClick={() => {
const container = document.getElementById('tabs-container');
if (container) container.scrollLeft += 200;
}}
className="absolute right-0 z-10 w-10 h-full bg-white dark:bg-secondary border-l border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
>
<i className="ri-arrow-right-s-line text-xl text-gray-600 dark:text-gray-400"></i>
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6 pb-20">
{/* Hero Section */}
{activeTab === 'hero' && (
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-layout-top-line text-primary"></i>
Banner Principal
</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
<input
type="text"
value={formData.hero.pretitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, pretitle: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
<input
type="text"
value={formData.hero.title}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
<textarea
value={formData.hero.subtitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
rows={2}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
</div>
</div>
)}
{/* Info Section */}
{activeTab === 'info' && (
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-information-line text-primary"></i>
Informações de Contato
</h2>
<div className="grid grid-cols-1 gap-6 mb-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
<input
type="text"
value={formData.info.title}
onChange={(e) => setFormData({...formData, info: {...formData.info, title: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
<input
type="text"
value={formData.info.subtitle}
onChange={(e) => setFormData({...formData, info: {...formData.info, subtitle: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
<textarea
value={formData.info.description}
onChange={(e) => setFormData({...formData, info: {...formData.info, description: e.target.value}})}
rows={2}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
</div>
<div className="space-y-6">
{formData.info.items.map((item, index) => (
<div key={index} className="p-6 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 dark:text-white">Contato {index + 1}</h3>
</div>
<div className="grid grid-cols-1 gap-4">
<IconSelector
label="Ícone"
value={item.icon}
onChange={(icon) => {
const newItems = [...formData.info.items];
newItems[index].icon = icon;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
<input
type="text"
value={item.title}
onChange={(e) => {
const newItems = [...formData.info.items];
newItems[index].title = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
<textarea
value={item.description}
onChange={(e) => {
const newItems = [...formData.info.items];
newItems[index].description = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
rows={3}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Link</label>
<input
type="text"
value={item.link}
onChange={(e) => {
const newItems = [...formData.info.items];
newItems[index].link = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
placeholder="https://..."
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texto do Link</label>
<input
type="text"
value={item.linkText}
onChange={(e) => {
const newItems = [...formData.info.items];
newItems[index].linkText = e.target.value;
setFormData({...formData, info: {...formData.info, items: newItems}});
}}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="fixed bottom-0 left-64 flex items-center justify-end gap-4 p-4 bg-white dark:bg-secondary border-t border-gray-200 dark:border-white/10 shadow-lg z-20" style={{ width: 'calc((100vw - 256px) * 0.3)' }}>
<Link
href="/admin/paginas"
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm"
>
Cancelar
</Link>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer text-sm"
>
{loading ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar
</>
)}
</button>
</div>
</form>
</div>
{/* Preview em Tempo Real - Coluna Direita Grande */}
<div className="flex-1 overflow-y-auto bg-white dark:bg-secondary">
<div className="sticky top-0 z-10 p-4 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i className="ri-eye-line text-primary"></i>
Preview em Tempo Real
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Visualização aproximada da página pública</p>
</div>
<Link
href="/contato"
target="_blank"
className="px-4 py-2 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors flex items-center gap-2 text-sm cursor-pointer"
>
<i className="ri-external-link-line"></i>
Ver Página Real
</Link>
</div>
</div>
<div className="p-8">
{/* Hero Preview */}
{activeTab === 'hero' && (
<div id="preview-hero" className="space-y-4">
<div className="inline-flex items-center gap-2 bg-primary/20 backdrop-blur-sm border border-primary/30 rounded-full px-4 py-1">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
<span className="text-sm font-bold text-primary uppercase tracking-wider">{formData.hero.pretitle}</span>
</div>
<h1 className="text-5xl font-bold font-headline text-secondary dark:text-white leading-tight">
{formData.hero.title}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl leading-relaxed">
{formData.hero.subtitle}
</p>
</div>
)}
{/* Info Preview */}
{activeTab === 'info' && (
<div id="preview-info" className="space-y-8">
<div>
<h2 className="text-primary font-bold tracking-wider uppercase mb-3">{formData.info.title}</h2>
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{formData.info.subtitle}</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg leading-relaxed">
{formData.info.description}
</p>
</div>
<div className="space-y-6">
{formData.info.items.map((item, index) => (
<div key={index} className="group bg-gray-50 dark:bg-white/5 p-6 rounded-2xl border border-gray-100 dark:border-white/10 hover:border-primary/50 transition-colors">
<div className="flex items-start gap-5">
<div className="w-14 h-14 bg-white dark:bg-white/10 rounded-xl flex items-center justify-center text-primary shadow-sm">
<i className={`${item.icon} text-3xl`}></i>
</div>
<div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2">{item.title}</h4>
<p className="text-gray-600 dark:text-gray-400 mb-3 text-sm whitespace-pre-line">{item.description}</p>
<a href={item.link} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all">
{item.linkText} <i className="ri-arrow-right-line"></i>
</a>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
"use client";
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useToast } from '@/contexts/ToastContext';
interface PageContent {
id: string;
slug: string;
content: any;
updatedAt: string;
}
export default function PagesList() {
const [pages, setPages] = useState<PageContent[]>([]);
const [loading, setLoading] = useState(true);
const { error: showError } = useToast();
const pageDefinitions = [
{
title: 'Página Inicial',
slug: 'home',
desc: 'Banner principal, textos de destaque e chamadas.',
icon: 'ri-home-4-line'
},
{
title: 'Sobre Nós',
slug: 'sobre',
desc: 'História da empresa, missão, visão e valores.',
icon: 'ri-team-line'
},
{
title: 'Contato',
slug: 'contato',
desc: 'Endereço, telefones, emails e horário de funcionamento.',
icon: 'ri-contacts-book-line'
},
];
useEffect(() => {
fetchPages();
}, []);
const fetchPages = async () => {
try {
const response = await fetch('/api/pages');
if (!response.ok) throw new Error('Erro ao carregar páginas');
const data = await response.json();
setPages(data);
} catch (err) {
showError('Erro ao carregar páginas');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Gerenciar Páginas</h1>
<p className="text-gray-500 dark:text-gray-400">Edite o conteúdo estático das páginas do site.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pageDefinitions.map((pageDef) => {
const pageData = pages.find(p => p.slug === pageDef.slug);
const lastUpdate = pageData
? new Date(pageData.updatedAt).toLocaleDateString('pt-BR')
: 'Não configurado';
return (
<div key={pageDef.slug} className="bg-white dark:bg-secondary rounded-2xl border border-gray-200 dark:border-white/10 p-6 shadow-sm hover:shadow-md transition-all group">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
<i className={pageDef.icon}></i>
</div>
<span className={`text-xs font-medium px-2 py-1 rounded-lg ${
pageData
? 'text-green-600 bg-green-100 dark:bg-green-900/30'
: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30'
}`}>
{pageData ? `Atualizado ${lastUpdate}` : 'Não configurado'}
</span>
</div>
<h3 className="text-xl font-bold text-secondary dark:text-white mb-2">{pageDef.title}</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6 h-10">{pageDef.desc}</p>
<Link
href={`/admin/paginas/${pageDef.slug}`}
className="block w-full py-3 text-center rounded-xl border border-gray-200 dark:border-white/10 font-bold text-gray-600 dark:text-gray-300 hover:bg-primary hover:text-white hover:border-primary transition-all"
>
Editar Conteúdo
</Link>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,643 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
// Ícones pré-definidos para seleção
const AVAILABLE_ICONS = [
// Pessoas e Equipe
{ value: 'ri-team-line', label: 'Equipe', category: 'pessoas' },
{ value: 'ri-user-star-line', label: 'Destaque', category: 'pessoas' },
{ value: 'ri-user-follow-line', label: 'Seguir', category: 'pessoas' },
{ value: 'ri-group-line', label: 'Grupo', category: 'pessoas' },
// Segurança
{ value: 'ri-shield-check-line', label: 'Segurança', category: 'segurança' },
{ value: 'ri-shield-star-line', label: 'Proteção Premium', category: 'segurança' },
{ value: 'ri-lock-line', label: 'Cadeado', category: 'segurança' },
{ value: 'ri-hard-hat-line', label: 'Capacete', category: 'segurança' },
// Serviços
{ value: 'ri-service-line', label: 'Atendimento', category: 'serviço' },
{ value: 'ri-customer-service-line', label: 'Suporte', category: 'serviço' },
{ value: 'ri-tools-line', label: 'Ferramentas', category: 'serviço' },
{ value: 'ri-settings-3-line', label: 'Engrenagem', category: 'serviço' },
// Transporte
{ value: 'ri-car-line', label: 'Veículo', category: 'transporte' },
{ value: 'ri-truck-line', label: 'Caminhão', category: 'transporte' },
{ value: 'ri-bus-line', label: 'Ônibus', category: 'transporte' },
{ value: 'ri-motorbike-line', label: 'Moto', category: 'transporte' },
// Documentos
{ value: 'ri-file-list-3-line', label: 'Documentos', category: 'documentos' },
{ value: 'ri-file-text-line', label: 'Arquivo', category: 'documentos' },
{ value: 'ri-clipboard-line', label: 'Prancheta', category: 'documentos' },
{ value: 'ri-contract-line', label: 'Contrato', category: 'documentos' },
// Conquistas
{ value: 'ri-award-line', label: 'Prêmio', category: 'conquista' },
{ value: 'ri-trophy-line', label: 'Troféu', category: 'conquista' },
{ value: 'ri-medal-line', label: 'Medalha', category: 'conquista' },
{ value: 'ri-vip-crown-line', label: 'Coroa', category: 'conquista' },
// Inovação
{ value: 'ri-lightbulb-line', label: 'Ideia', category: 'inovação' },
{ value: 'ri-flashlight-line', label: 'Lanterna', category: 'inovação' },
{ value: 'ri-rocket-line', label: 'Foguete', category: 'inovação' },
{ value: 'ri-flask-line', label: 'Experimento', category: 'inovação' },
// Status
{ value: 'ri-checkbox-circle-line', label: 'Confirmado', category: 'status' },
{ value: 'ri-check-double-line', label: 'Verificado', category: 'status' },
{ value: 'ri-star-line', label: 'Estrela', category: 'status' },
{ value: 'ri-thumb-up-line', label: 'Aprovado', category: 'status' },
// Dados
{ value: 'ri-pie-chart-line', label: 'Gráfico Pizza', category: 'dados' },
{ value: 'ri-bar-chart-line', label: 'Gráfico Barras', category: 'dados' },
{ value: 'ri-line-chart-line', label: 'Gráfico Linha', category: 'dados' },
{ value: 'ri-dashboard-line', label: 'Dashboard', category: 'dados' },
// Performance
{ value: 'ri-speed-line', label: 'Velocidade', category: 'performance' },
{ value: 'ri-timer-line', label: 'Cronômetro', category: 'performance' },
{ value: 'ri-time-line', label: 'Relógio', category: 'performance' },
{ value: 'ri-pulse-line', label: 'Pulso', category: 'performance' },
// Negócios
{ value: 'ri-building-line', label: 'Empresa', category: 'negócios' },
{ value: 'ri-briefcase-line', label: 'Maleta', category: 'negócios' },
{ value: 'ri-money-dollar-circle-line', label: 'Dinheiro', category: 'negócios' },
{ value: 'ri-hand-coin-line', label: 'Pagamento', category: 'negócios' },
// Cálculo
{ value: 'ri-calculator-line', label: 'Calculadora', category: 'cálculo' },
{ value: 'ri-percent-line', label: 'Porcentagem', category: 'cálculo' },
{ value: 'ri-functions', label: 'Funções', category: 'cálculo' },
// Comunicação
{ value: 'ri-message-3-line', label: 'Mensagem', category: 'comunicação' },
{ value: 'ri-chat-3-line', label: 'Chat', category: 'comunicação' },
{ value: 'ri-phone-line', label: 'Telefone', category: 'comunicação' },
{ value: 'ri-mail-line', label: 'Email', category: 'comunicação' },
// Localização
{ value: 'ri-map-pin-line', label: 'Localização', category: 'local' },
{ value: 'ri-navigation-line', label: 'Navegação', category: 'local' },
{ value: 'ri-roadster-line', label: 'Estrada', category: 'local' },
{ value: 'ri-compass-line', label: 'Bússola', category: 'local' },
];
interface IconSelectorProps {
value: string;
onChange: (icon: string) => void;
label: string;
}
function IconSelector({ value, onChange, label }: IconSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const filteredIcons = AVAILABLE_ICONS.filter(icon =>
icon.label.toLowerCase().includes(search.toLowerCase()) ||
icon.category.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="relative">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</label>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all flex items-center justify-between"
>
<div className="flex items-center gap-3">
<i className={`${value} text-2xl text-primary`}></i>
<span className="text-sm">{AVAILABLE_ICONS.find(i => i.value === value)?.label || 'Selecionar ícone'}</span>
</div>
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line text-gray-400`}></i>
</button>
{isOpen && (
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl shadow-xl">
<div className="p-3 border-b border-gray-200 dark:border-white/10">
<div className="relative">
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar ícone..."
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm focus:outline-none focus:border-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="p-2 grid grid-cols-4 gap-2 max-h-64 overflow-y-auto">
{filteredIcons.map((icon) => (
<button
key={icon.value}
type="button"
onClick={() => {
onChange(icon.value);
setIsOpen(false);
setSearch('');
}}
className={`p-3 rounded-lg flex flex-col items-center gap-1 transition-all ${
value === icon.value
? 'bg-primary text-white'
: 'hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300'
}`}
title={icon.label}
>
<i className={`${icon.value} text-2xl`}></i>
<span className="text-[10px] text-center leading-tight">{icon.label}</span>
</button>
))}
</div>
{filteredIcons.length === 0 && (
<div className="p-8 text-center text-gray-400 text-sm">
Nenhum ícone encontrado
</div>
)}
</div>
)}
</div>
);
}
interface ValueItem {
icon: string;
title: string;
description: string;
}
interface AboutContent {
hero: {
title: string;
subtitle: string;
};
history: {
title: string;
subtitle: string;
paragraph1: string;
paragraph2: string;
};
values: {
title: string;
subtitle: string;
items: ValueItem[];
};
}
export default function EditAboutPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [activeTab, setActiveTab] = useState('hero');
const { success, error: showError } = useToast();
const scrollToPreview = (sectionId: string) => {
const element = document.getElementById(`preview-${sectionId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleTabChange = (tab: string) => {
setActiveTab(tab);
setTimeout(() => scrollToPreview(tab), 100);
};
const [formData, setFormData] = useState<AboutContent>({
hero: {
title: 'Sobre a Occto Engenharia',
subtitle: 'Excelência técnica, compromisso e inovação em cada projeto.'
},
history: {
title: 'Nossa História',
subtitle: 'Tradição e Inovação em Engenharia',
paragraph1: 'Com mais de 15 anos de experiência no mercado, a Occto Engenharia se consolidou como referência em soluções técnicas. Nossa equipe multidisciplinar está preparada para atender demandas complexas com excelência e agilidade.',
paragraph2: 'Ao longo dos anos, desenvolvemos projetos que transformaram a realidade de nossos clientes, sempre pautados pela ética, responsabilidade e compromisso com a qualidade.'
},
values: {
title: 'Nossos Valores',
subtitle: 'O que nos move',
items: [
{ icon: 'ri-medal-line', title: 'Qualidade', description: 'Compromisso com a excelência em todos os nossos serviços e entregas.' },
{ icon: 'ri-shake-hands-line', title: 'Transparência', description: 'Comunicação clara e honesta em todas as etapas do projeto.' },
{ icon: 'ri-leaf-line', title: 'Sustentabilidade', description: 'Soluções que respeitam o meio ambiente e as futuras gerações.' }
]
}
});
useEffect(() => {
fetchPageContent();
}, []);
const fetchPageContent = async () => {
try {
const response = await fetch('/api/pages/about');
if (response.ok) {
const data = await response.json();
if (data.content) {
setFormData(prevData => ({
hero: data.content.hero || prevData.hero,
history: data.content.history || prevData.history,
values: data.content.values || prevData.values
}));
}
}
} catch (err) {
console.log('Nenhum conteúdo salvo ainda, usando padrão');
} finally {
setInitialLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/pages/about', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: formData })
});
if (!response.ok) throw new Error('Erro ao salvar');
success('Conteúdo da página Sobre atualizado com sucesso!');
} catch (err) {
showError('Erro ao salvar alterações');
} finally {
setLoading(false);
}
};
if (initialLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<>
<style jsx global>{`
main { padding: 0 !important; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
`}</style>
<div className="fixed top-20 bottom-0 left-64 right-0 flex gap-0 bg-gray-50 dark:bg-tertiary">
{/* Formulário de Edição - Coluna Esquerda 30% */}
<div className="w-[30%] shrink-0 overflow-y-auto bg-white dark:bg-secondary relative">
<div className="absolute top-0 right-0 bottom-0 w-px bg-gray-200 dark:bg-white/10"></div>
<div className="p-6 border-b border-gray-200 dark:border-white/10">
<h1 className="text-2xl font-bold">Editar Página Sobre</h1>
<p className="text-sm text-muted-foreground mt-1">
Personalize o conteúdo institucional
</p>
</div>
{/* Navigation Tabs */}
<div className="relative border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
<div className="flex items-center">
<button
type="button"
onClick={() => {
const container = document.getElementById('tabs-container');
if (container) container.scrollLeft -= 200;
}}
className="absolute left-0 z-10 w-10 h-full bg-white dark:bg-secondary border-r border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
>
<i className="ri-arrow-left-s-line text-xl text-gray-600 dark:text-gray-400"></i>
</button>
<div id="tabs-container" className="flex gap-2 p-4 overflow-x-auto scrollbar-hide scroll-smooth px-12">
<button
type="button"
onClick={() => handleTabChange('hero')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
activeTab === 'hero'
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
Banner
</button>
<button
type="button"
onClick={() => handleTabChange('history')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
activeTab === 'history'
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
História
</button>
<button
type="button"
onClick={() => handleTabChange('values')}
className={`px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors cursor-pointer ${
activeTab === 'values'
? 'bg-primary text-primary-foreground'
: 'bg-background hover:bg-muted'
}`}
>
Valores (3)
</button>
</div>
<button
type="button"
onClick={() => {
const container = document.getElementById('tabs-container');
if (container) container.scrollLeft += 200;
}}
className="absolute right-0 z-10 w-10 h-full bg-white dark:bg-secondary border-l border-gray-200 dark:border-white/10 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-white/5 transition-all cursor-pointer"
>
<i className="ri-arrow-right-s-line text-xl text-gray-600 dark:text-gray-400"></i>
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6 pb-20">
{/* Hero Section */}
{activeTab === 'hero' && (
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-layout-top-line text-primary"></i>
Banner Principal
</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título Principal</label>
<input
type="text"
value={formData.hero.title}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, title: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Subtítulo</label>
<textarea
value={formData.hero.subtitle}
onChange={(e) => setFormData({...formData, hero: {...formData.hero, subtitle: e.target.value}})}
rows={2}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
</div>
</div>
)}
{/* History Section */}
{activeTab === 'history' && (
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-history-line text-primary"></i>
Nossa História
</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
<input
type="text"
value={formData.history.title}
onChange={(e) => setFormData({...formData, history: {...formData.history, title: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título</label>
<input
type="text"
value={formData.history.subtitle}
onChange={(e) => setFormData({...formData, history: {...formData.history, subtitle: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Parágrafo 1</label>
<textarea
value={formData.history.paragraph1}
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph1: e.target.value}})}
rows={4}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Parágrafo 2</label>
<textarea
value={formData.history.paragraph2}
onChange={(e) => setFormData({...formData, history: {...formData.history, paragraph2: e.target.value}})}
rows={4}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
</div>
</div>
)}
{/* Values Section */}
{activeTab === 'values' && (
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-heart-line text-primary"></i>
Nossos Valores
</h2>
<div className="grid grid-cols-1 gap-6 mb-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Pré-título</label>
<input
type="text"
value={formData.values.title}
onChange={(e) => setFormData({...formData, values: {...formData.values, title: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título da Seção</label>
<input
type="text"
value={formData.values.subtitle}
onChange={(e) => setFormData({...formData, values: {...formData.values, subtitle: e.target.value}})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
</div>
<div className="space-y-6">
{formData.values.items.map((item, index) => (
<div key={index} className="p-6 bg-gray-50 dark:bg-white/5 rounded-xl border border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 dark:text-white">Valor {index + 1}</h3>
</div>
<div className="grid grid-cols-1 gap-4">
<IconSelector
label="Ícone"
value={item.icon}
onChange={(newIcon) => {
const newItems = [...formData.values.items];
newItems[index].icon = newIcon;
setFormData({...formData, values: {...formData.values, items: newItems}});
}}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Título</label>
<input
type="text"
value={item.title}
onChange={(e) => {
const newItems = [...formData.values.items];
newItems[index].title = e.target.value;
setFormData({...formData, values: {...formData.values, items: newItems}});
}}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Descrição</label>
<textarea
value={item.description}
onChange={(e) => {
const newItems = [...formData.values.items];
newItems[index].description = e.target.value;
setFormData({...formData, values: {...formData.values, items: newItems}});
}}
rows={2}
className="w-full px-4 py-3 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
></textarea>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="fixed bottom-0 left-64 flex items-center justify-end gap-4 p-4 bg-white dark:bg-secondary border-t border-gray-200 dark:border-white/10 shadow-lg z-20" style={{ width: 'calc((100vw - 256px) * 0.3)' }}>
<Link
href="/admin/paginas"
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm"
>
Cancelar
</Link>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer text-sm"
>
{loading ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar
</>
)}
</button>
</div>
</form>
</div>
{/* Preview em Tempo Real - Coluna Direita Grande */}
<div className="flex-1 overflow-y-auto bg-white dark:bg-secondary">
<div className="sticky top-0 z-10 p-4 bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i className="ri-eye-line text-primary"></i>
Preview em Tempo Real
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Visualização aproximada da página pública</p>
</div>
<Link
href="/sobre"
target="_blank"
className="px-4 py-2 bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors flex items-center gap-2 text-sm cursor-pointer"
>
<i className="ri-external-link-line"></i>
Ver Página Real
</Link>
</div>
</div>
<div className="p-8">
{/* Hero Preview */}
{activeTab === 'hero' && (
<div id="preview-hero" className="text-center space-y-4">
<h1 className="text-5xl font-bold font-headline text-secondary dark:text-white leading-tight">
{formData.hero.title}
</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
{formData.hero.subtitle}
</p>
</div>
)}
{/* History Preview */}
{activeTab === 'history' && (
<div id="preview-history" className="space-y-6">
<div>
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{formData.history.title}</h2>
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-6">{formData.history.subtitle}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
{formData.history.paragraph1}
</p>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
{formData.history.paragraph2}
</p>
</div>
</div>
)}
{/* Values Preview */}
{activeTab === 'values' && (
<div id="preview-values" className="space-y-8">
<div className="text-center">
<h2 className="text-primary font-bold tracking-wider uppercase mb-2">{formData.values.title}</h2>
<h3 className="text-3xl font-bold font-headline text-secondary dark:text-white">{formData.values.subtitle}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{formData.values.items.map((item, index) => (
<div key={index} className="bg-white dark:bg-secondary p-8 rounded-xl shadow-sm border-t-4 border-primary">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-6">
<i className={`${item.icon} text-2xl`}></i>
</div>
<h4 className="text-xl font-bold font-headline text-secondary dark:text-white mb-3">{item.title}</h4>
<p className="text-gray-600 dark:text-gray-400">{item.description}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function NewProject() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
category: '',
client: '',
status: 'active',
description: '',
date: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulate API call
setTimeout(() => {
console.log('Project data:', formData);
setLoading(false);
router.push('/admin/projetos');
}, 1500);
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin/projetos"
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
>
<i className="ri-arrow-left-line text-xl"></i>
</Link>
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Novo Projeto</h1>
<p className="text-gray-500 dark:text-gray-400">Adicione um novo projeto ao portfólio.</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Info */}
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-information-line text-primary"></i>
Informações Básicas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Projeto</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="Ex: Adequação de Frota Coca-Cola"
required
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Categoria</label>
<select
value={formData.category}
onChange={(e) => setFormData({...formData, category: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
required
>
<option value="">Selecione uma categoria</option>
<option value="veicular">Engenharia Veicular</option>
<option value="mecanica">Projetos Mecânicos</option>
<option value="laudos">Laudos e Inspeções</option>
<option value="seguranca">Segurança do Trabalho</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Cliente</label>
<input
type="text"
value={formData.client}
onChange={(e) => setFormData({...formData, client: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="Ex: Coca-Cola FEMSA"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Data de Conclusão</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({...formData, status: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
>
<option value="active">Concluído</option>
<option value="pending">Em Andamento</option>
<option value="draft">Rascunho</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Detalhada</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
rows={6}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
></textarea>
</div>
</div>
</div>
{/* Media */}
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-image-line text-primary"></i>
Mídia
</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
<div className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-8 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mx-auto mb-4">
<i className="ri-upload-cloud-2-line text-3xl"></i>
</div>
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">Clique para fazer upload ou arraste e solte</p>
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (Max. 2MB)</p>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
<i className="ri-add-line text-3xl mb-2"></i>
<span className="text-xs font-bold">Adicionar</span>
</div>
{/* Placeholders for uploaded images */}
{[1, 2].map((i) => (
<div key={i} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button type="button" className="w-8 h-8 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<i className="ri-delete-bin-line"></i>
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-4">
<Link
href="/admin/projetos"
className="px-8 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
>
Cancelar
</Link>
<button
type="submit"
disabled={loading}
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Projeto
</>
)}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from 'next/link';
export default function ProjectsList() {
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Projetos</h1>
<p className="text-gray-500 dark:text-gray-400">Gerencie os projetos exibidos no portfólio.</p>
</div>
<Link
href="/admin/projetos/novo"
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2"
>
<i className="ri-add-line text-xl"></i>
Novo Projeto
</Link>
</div>
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
{[
{ title: 'Adequação Coca-Cola', cat: 'Engenharia Veicular', client: 'Coca-Cola FEMSA', status: 'Concluído' },
{ title: 'Laudo Guindaste Articulado', cat: 'Inspeção Técnica', client: 'Logística Express', status: 'Concluído' },
{ title: 'Dispositivo de Içamento', cat: 'Projeto Mecânico', client: 'Metalúrgica ABC', status: 'Em Andamento' },
].map((project, index) => (
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
</div>
<span className="font-bold text-secondary dark:text-white">{project.title}</span>
</div>
</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.cat}</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client}</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
project.status === 'Concluído'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}`}>
{project.status}
</span>
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
<i className="ri-pencil-line"></i>
</button>
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function NewService() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
icon: 'ri-settings-3-line',
status: 'active',
shortDescription: '',
fullDescription: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulate API call
setTimeout(() => {
console.log('Service data:', formData);
setLoading(false);
router.push('/admin/servicos');
}, 1500);
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin/servicos"
className="w-10 h-10 rounded-xl bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 flex items-center justify-center text-gray-500 hover:text-primary hover:border-primary transition-colors shadow-sm"
>
<i className="ri-arrow-left-line text-xl"></i>
</Link>
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-1">Novo Serviço</h1>
<p className="text-gray-500 dark:text-gray-400">Adicione um novo serviço ao catálogo.</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="bg-white dark:bg-secondary p-8 rounded-2xl border border-gray-200 dark:border-white/10 shadow-sm">
<h2 className="text-xl font-bold text-secondary dark:text-white mb-6 flex items-center gap-2">
<i className="ri-information-line text-primary"></i>
Informações do Serviço
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Título do Serviço</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="Ex: Engenharia Veicular"
required
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Ícone (Remix Icon Class)</label>
<div className="flex gap-3">
<div className="w-12 h-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center text-xl shrink-0">
<i className={formData.icon}></i>
</div>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({...formData, icon: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="Ex: ri-truck-line"
required
/>
</div>
<p className="text-xs text-gray-500 mt-2">
Consulte a lista de ícones em <a href="https://remixicon.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">remixicon.com</a>
</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({...formData, status: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
>
<option value="active">Ativo</option>
<option value="inactive">Inativo</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Curta (Resumo)</label>
<input
type="text"
value={formData.shortDescription}
onChange={(e) => setFormData({...formData, shortDescription: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="Breve descrição para os cards da home..."
maxLength={150}
/>
<p className="text-xs text-gray-500 mt-1 text-right">{formData.shortDescription.length}/150</p>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Descrição Completa</label>
<textarea
value={formData.fullDescription}
onChange={(e) => setFormData({...formData, fullDescription: e.target.value})}
rows={6}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all resize-none"
placeholder="Detalhamento completo do serviço..."
></textarea>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-4 pt-4">
<Link
href="/admin/servicos"
className="px-8 py-3 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
>
Cancelar
</Link>
<button
type="submit"
disabled={loading}
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</>
) : (
<>
<i className="ri-save-line"></i>
Salvar Serviço
</>
)}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import Link from 'next/link';
export default function ServicesList() {
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Serviços</h1>
<p className="text-gray-500 dark:text-gray-400">Gerencie os serviços oferecidos pela empresa.</p>
</div>
<Link
href="/admin/servicos/novo"
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors shadow-lg shadow-primary/20 flex items-center gap-2"
>
<i className="ri-add-line text-xl"></i>
Novo Serviço
</Link>
</div>
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Ícone</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Título</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Descrição Curta</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
{[
{ icon: 'ri-truck-line', title: 'Engenharia Veicular', desc: 'Homologação e regularização de veículos modificados.', status: 'Ativo' },
{ icon: 'ri-tools-line', title: 'Projetos Mecânicos', desc: 'Desenvolvimento de máquinas e equipamentos industriais.', status: 'Ativo' },
{ icon: 'ri-file-list-3-line', title: 'Laudos Técnicos', desc: 'Vistorias, perícias e emissão de ART.', status: 'Ativo' },
{ icon: 'ri-shield-check-line', title: 'Segurança do Trabalho', desc: 'Consultoria em normas regulamentadoras (NRs).', status: 'Inativo' },
].map((service, index) => (
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
<td className="p-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xl">
<i className={service.icon}></i>
</div>
</td>
<td className="p-4 font-bold text-secondary dark:text-white">{service.title}</td>
<td className="p-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{service.desc}</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
service.status === 'Ativo'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
}`}>
{service.status}
</span>
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
<i className="ri-pencil-line"></i>
</button>
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,332 @@
"use client";
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
export default function EditUserPage() {
const router = useRouter();
const params = useParams();
const userId = params.id as string;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [currentAvatar, setCurrentAvatar] = useState<string | null>(null);
const { success, error: showError } = useToast();
const { confirm } = useConfirm();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
useEffect(() => {
if (userId) {
fetchUser();
}
}, [userId]);
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Erro ao buscar usuário');
}
const data = await response.json();
setFormData({
name: data.name || '',
email: data.email || '',
password: '',
confirmPassword: '',
});
setCurrentAvatar(data.avatar || null);
} catch (error) {
console.error('Erro ao carregar usuário:', error);
setError('Erro ao carregar dados do usuário');
} finally {
setLoading(false);
}
};
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = async () => {
const confirmed = await confirm({
title: 'Remover Foto',
message: 'Deseja remover a foto de perfil?',
confirmText: 'Remover',
cancelText: 'Cancelar',
type: 'warning',
});
if (!confirmed) return;
try {
const response = await fetch(`/api/users/${userId}/avatar`, {
method: 'DELETE',
});
if (response.ok) {
setCurrentAvatar(null);
setAvatarPreview(null);
setAvatarFile(null);
success('Foto removida com sucesso!');
}
} catch (error) {
console.error('Erro ao remover avatar:', error);
showError('Erro ao remover avatar');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validações
if (!formData.name || !formData.email) {
setError('Preencha todos os campos obrigatórios');
return;
}
// Se está alterando a senha
if (formData.password || formData.confirmPassword) {
if (formData.password !== formData.confirmPassword) {
setError('As senhas não coincidem');
return;
}
if (formData.password.length < 6) {
setError('A senha deve ter no mínimo 6 caracteres');
return;
}
}
setSaving(true);
try {
const payload: any = {
name: formData.name,
email: formData.email,
};
// Só envia a senha se foi preenchida
if (formData.password) {
payload.password = formData.password;
}
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Erro ao atualizar usuário');
setSaving(false);
return;
}
// Upload avatar if exists
if (avatarFile) {
try {
const avatarFormData = new FormData();
avatarFormData.append('avatar', avatarFile);
avatarFormData.append('userId', userId);
await fetch('/api/users/avatar', {
method: 'POST',
body: avatarFormData,
});
} catch (err) {
console.error('Erro ao fazer upload do avatar:', err);
}
}
router.push('/admin/usuarios');
} catch (err) {
setError('Erro ao conectar com o servidor');
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
</div>
);
}
return (
<div>
<div className="mb-8">
<Link
href="/admin/usuarios"
className="text-primary hover:text-orange-600 transition-colors inline-flex items-center gap-2 mb-4"
>
<i className="ri-arrow-left-line"></i>
Voltar para Usuários
</Link>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Editar Usuário</h1>
<p className="text-gray-600 dark:text-gray-400">Atualize as informações do usuário administrador</p>
</div>
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
<i className="ri-error-warning-line"></i>
{error}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Foto de Perfil
</label>
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 flex items-center justify-center">
{avatarPreview ? (
<img src={avatarPreview} alt="Preview" className="w-full h-full object-cover" />
) : currentAvatar ? (
<img src={currentAvatar} alt="Avatar atual" className="w-full h-full object-cover" />
) : (
<i className="ri-user-3-line text-4xl text-gray-400"></i>
)}
</div>
<div className="flex-1">
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 border border-gray-300 dark:border-white/10 rounded-lg transition-colors">
<i className="ri-upload-2-line"></i>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Alterar Foto</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
{(currentAvatar || avatarPreview) && (
<button
type="button"
onClick={handleRemoveAvatar}
className="ml-2 text-sm text-red-500 hover:text-red-600"
>
Remover
</button>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">JPEG, PNG ou WEBP (máx. 5MB)</p>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
E-mail *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
required
/>
</div>
<div className="border-t border-gray-200 dark:border-white/10 pt-6">
<h3 className="text-lg font-bold text-secondary dark:text-white mb-4">Alterar Senha (opcional)</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Deixe em branco se não quiser alterar a senha</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nova Senha
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="••••••••"
minLength={6}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Mínimo de 6 caracteres</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirmar Nova Senha
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={saving}
className="flex-1 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
>
{saving ? (
<span className="flex items-center justify-center gap-2">
<i className="ri-loader-4-line animate-spin"></i>
Salvando...
</span>
) : (
'Salvar Alterações'
)}
</button>
<Link
href="/admin/usuarios"
className="px-8 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-center"
>
Cancelar
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useToast } from '@/contexts/ToastContext';
export default function NewUserPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const { success, error: showError } = useToast();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
setAvatarFile(null);
setAvatarPreview(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validações
if (!formData.name || !formData.email || !formData.password) {
setError('Preencha todos os campos obrigatórios');
return;
}
if (formData.password !== formData.confirmPassword) {
setError('As senhas não coincidem');
return;
}
if (formData.password.length < 6) {
setError('A senha deve ter no mínimo 6 caracteres');
return;
}
setLoading(true);
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Erro ao criar usuário');
setLoading(false);
return;
}
// Upload avatar if exists
if (avatarFile && data.id) {
try {
const avatarFormData = new FormData();
avatarFormData.append('avatar', avatarFile);
avatarFormData.append('userId', data.id);
await fetch('/api/users/avatar', {
method: 'POST',
body: avatarFormData,
});
} catch (err) {
console.error('Erro ao fazer upload do avatar:', err);
}
}
router.push('/admin/usuarios');
} catch (err) {
setError('Erro ao conectar com o servidor');
setLoading(false);
}
};
return (
<div>
<div className="mb-8">
<Link
href="/admin/usuarios"
className="text-primary hover:text-orange-600 transition-colors inline-flex items-center gap-2 mb-4"
>
<i className="ri-arrow-left-line"></i>
Voltar para Usuários
</Link>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Novo Usuário</h1>
<p className="text-gray-600 dark:text-gray-400">Adicione um novo usuário administrador ao sistema</p>
</div>
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
<i className="ri-error-warning-line"></i>
{error}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Foto de Perfil
</label>
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 flex items-center justify-center">
{avatarPreview ? (
<img src={avatarPreview} alt="Preview" className="w-full h-full object-cover" />
) : (
<i className="ri-user-3-line text-4xl text-gray-400"></i>
)}
</div>
<div className="flex-1">
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 border border-gray-300 dark:border-white/10 rounded-lg transition-colors">
<i className="ri-upload-2-line"></i>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Escolher Foto</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
{avatarPreview && (
<button
type="button"
onClick={handleRemoveAvatar}
className="ml-2 text-sm text-red-500 hover:text-red-600"
>
Remover
</button>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">JPEG, PNG ou WEBP (máx. 5MB)</p>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="João da Silva"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
E-mail *
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="usuario@occto.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Senha *
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="••••••••"
minLength={6}
required
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Mínimo de 6 caracteres</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirmar Senha *
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
placeholder="••••••••"
required
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={loading}
className="flex-1 py-3 bg-primary text-white rounded-xl font-bold hover:bg-orange-600 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<i className="ri-loader-4-line animate-spin"></i>
Criando...
</span>
) : (
'Criar Usuário'
)}
</button>
<Link
href="/admin/usuarios"
className="px-8 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-center"
>
Cancelar
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
interface User {
id: string;
email: string;
name: string | null;
avatar: string | null;
createdAt: string;
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const { success, error } = useToast();
const { confirm } = useConfirm();
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
} catch (error) {
console.error('Erro ao carregar usuários:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
const confirmed = await confirm({
title: 'Excluir Usuário',
message: 'Tem certeza que deseja excluir este usuário? Esta ação não pode ser desfeita.',
confirmText: 'Excluir',
cancelText: 'Cancelar',
type: 'danger',
});
if (!confirmed) return;
try {
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
const data = await response.json();
if (!response.ok) {
error(data.error || 'Erro ao excluir usuário');
return;
}
success('Usuário excluído com sucesso!');
fetchUsers();
} catch (err) {
console.error('Erro ao excluir usuário:', err);
error('Erro ao conectar com o servidor');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<i className="ri-loader-4-line animate-spin text-4xl text-primary"></i>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Usuários</h1>
<p className="text-gray-600 dark:text-gray-400">Gerencie os usuários administradores do sistema</p>
</div>
<Link
href="/admin/usuarios/novo"
className="px-6 py-3 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors flex items-center gap-2"
>
<i className="ri-user-add-line"></i>
Novo Usuário
</Link>
</div>
<div className="bg-white dark:bg-secondary rounded-xl shadow-sm border border-gray-100 dark:border-white/10 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Usuário</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Email</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Criado em</th>
<th className="px-6 py-4 text-right text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-white/10">
{users.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<i className="ri-user-line text-4xl mb-2 block"></i>
Nenhum usuário encontrado
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-100 dark:bg-white/5 shrink-0">
{user.avatar ? (
<img src={user.avatar} alt={user.name || 'Avatar'} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<i className="ri-user-3-line text-xl"></i>
</div>
)}
</div>
<div className="font-medium text-secondary dark:text-white">{user.name || 'Sem nome'}</div>
</div>
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">{user.email}</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{new Date(user.createdAt).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/usuarios/${user.id}`}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
title="Editar"
>
<i className="ri-edit-line text-lg"></i>
</Link>
<button
onClick={() => handleDelete(user.id)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Excluir"
>
<i className="ri-delete-bin-line text-lg"></i>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import prisma from '@/lib/prisma';
import { minioClient, ensureBucketExists } from '@/lib/minio';
import { v4 as uuidv4 } from 'uuid';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
export async function POST(request: NextRequest) {
try {
// Ensure bucket exists
await ensureBucketExists();
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
// Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
// Get form data
const formData = await request.formData();
const file = formData.get('avatar') as File;
if (!file) {
return NextResponse.json(
{ error: 'Nenhum arquivo enviado' },
{ status: 400 }
);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Tipo de arquivo inválido. Use JPEG, PNG ou WEBP' },
{ status: 400 }
);
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'Arquivo muito grande. Tamanho máximo: 5MB' },
{ status: 400 }
);
}
// Generate unique filename
const fileExtension = file.name.split('.').pop();
const fileName = `avatars/${decoded.userId}/${uuidv4()}.${fileExtension}`;
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to MinIO
await minioClient.putObject(
BUCKET_NAME,
fileName,
buffer,
buffer.length,
{
'Content-Type': file.type,
}
);
// Generate public URL
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_PORT || '9000';
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
// Delete old avatar if exists
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { avatar: true },
});
if (user?.avatar) {
try {
// Extract filename from URL
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
if (oldFileName) {
await minioClient.removeObject(BUCKET_NAME, oldFileName);
}
} catch (error) {
console.error('Error deleting old avatar:', error);
}
}
// Update user avatar in database
const updatedUser = await prisma.user.update({
where: { id: decoded.userId },
data: { avatar: avatarUrl },
select: {
id: true,
email: true,
name: true,
avatar: true,
},
});
return NextResponse.json({
message: 'Avatar atualizado com sucesso',
user: updatedUser,
});
} catch (error) {
console.error('Error uploading avatar:', error);
return NextResponse.json(
{ error: 'Erro ao fazer upload do avatar' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
// Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
// Get user's current avatar
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { avatar: true },
});
if (user?.avatar) {
try {
// Extract filename from URL
const fileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
if (fileName) {
await minioClient.removeObject(BUCKET_NAME, fileName);
}
} catch (error) {
console.error('Error deleting avatar from MinIO:', error);
}
}
// Remove avatar from database
const updatedUser = await prisma.user.update({
where: { id: decoded.userId },
data: { avatar: null },
select: {
id: true,
email: true,
name: true,
avatar: true,
},
});
return NextResponse.json({
message: 'Avatar removido com sucesso',
user: updatedUser,
});
} catch (error) {
console.error('Error deleting avatar:', error);
return NextResponse.json(
{ error: 'Erro ao remover avatar' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import * as bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-CHANGE-IN-PRODUCTION';
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutos
// Rate limiting simples (em produção, use Redis)
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
export async function POST(request: Request) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
}
// Rate limiting básico
const attempts = loginAttempts.get(email);
if (attempts) {
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;
if (timeSinceLastAttempt < LOCKOUT_TIME) {
const minutesLeft = Math.ceil((LOCKOUT_TIME - timeSinceLastAttempt) / 60000);
return NextResponse.json(
{ error: `Muitas tentativas. Tente novamente em ${minutesLeft} minutos.` },
{ status: 429 }
);
} else {
loginAttempts.delete(email);
}
}
}
// Buscar usuário no banco
const user = await prisma.user.findUnique({
where: { email },
});
// Proteção contra timing attacks - sempre fazer hash mesmo se usuário não existir
const userPassword = user?.password || '$2a$10$dummyHashToPreventTimingAttack';
const passwordMatch = await bcrypt.compare(password, userPassword);
if (!user || !passwordMatch) {
// Incrementar tentativas
const current = loginAttempts.get(email) || { count: 0, lastAttempt: 0 };
loginAttempts.set(email, {
count: current.count + 1,
lastAttempt: Date.now(),
});
return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 });
}
// Limpar tentativas de login após sucesso
loginAttempts.delete(email);
// Criar JWT
const token = jwt.sign(
{
userId: user.id,
email: user.email,
},
JWT_SECRET,
{ expiresIn: '7d' }
);
// Criar resposta com cookie de sessão
const response = NextResponse.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
// Definir cookie de autenticação com JWT
response.cookies.set('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 dias
path: '/',
});
return response;
} catch (error) {
console.error('Erro no login:', error);
return NextResponse.json({ error: 'Erro ao fazer login' }, { status: 500 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
// Remover cookie de autenticação
response.cookies.delete('auth_token');
return response;
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import prisma from '@/lib/prisma';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
// Verify JWT token
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
// Get user from database
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'Usuário não encontrado' },
{ status: 404 }
);
}
return NextResponse.json({ user });
} catch (error) {
console.error('Error fetching user data:', error);
return NextResponse.json(
{ error: 'Erro ao buscar dados do usuário' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const config = await prisma.pageContent.findUnique({
where: { slug: 'config' }
});
if (!config) {
return NextResponse.json({ primaryColor: '#FF6B35' });
}
return NextResponse.json(config.content);
} catch (error) {
console.error('Error fetching config:', error);
return NextResponse.json({ primaryColor: '#FF6B35' });
}
}
export async function PUT(request: NextRequest) {
try {
const { primaryColor } = await request.json();
const config = await prisma.pageContent.upsert({
where: { slug: 'config' },
update: {
content: { primaryColor }
},
create: {
slug: 'config',
content: { primaryColor }
}
});
return NextResponse.json({ success: true, config });
} catch (error) {
console.error('Error updating config:', error);
return NextResponse.json({ error: 'Failed to update config' }, { status: 500 });
}
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const message = await prisma.message.findUnique({
where: { id },
});
if (!message) return NextResponse.json({ error: 'Message not found' }, { status: 404 });
return NextResponse.json(message);
} catch (error) {
return NextResponse.json({ error: 'Error fetching message' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const data = await request.json();
const message = await prisma.message.update({
where: { id },
data: {
status: data.status,
},
});
return NextResponse.json(message);
} catch (error) {
return NextResponse.json({ error: 'Error updating message' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.message.delete({
where: { id },
});
return NextResponse.json({ message: 'Message deleted' });
} catch (error) {
return NextResponse.json({ error: 'Error deleting message' }, { status: 500 });
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search') || '';
const status = searchParams.get('status') || '';
const where: any = {};
// Filtro de pesquisa
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ subject: { contains: search, mode: 'insensitive' } },
{ message: { contains: search, mode: 'insensitive' } }
];
}
// Filtro de status
if (status) {
where.status = status;
}
const messages = await prisma.message.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(messages);
} catch (error) {
return NextResponse.json({ error: 'Error fetching messages' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { name, email, phone, subject, message } = await request.json();
// Validação básica
if (!name || !email || !message) {
return NextResponse.json(
{ error: 'Nome, email e mensagem são obrigatórios' },
{ status: 400 }
);
}
// Criar mensagem incluindo telefone no assunto se fornecido
const fullSubject = phone
? `${subject || 'Sem assunto'} | Tel: ${phone}`
: subject || 'Sem assunto';
const newMessage = await prisma.message.create({
data: {
name,
email,
subject: fullSubject,
message,
status: 'Nova'
}
});
return NextResponse.json(newMessage, { status: 201 });
} catch (error) {
console.error('Error creating message:', error);
return NextResponse.json({ error: 'Error creating message' }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
// Middleware de autenticação
async function authenticate() {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
return user;
} catch (error) {
return null;
}
}
// GET /api/pages/[slug] - Buscar página específica (público)
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const page = await prisma.pageContent.findUnique({
where: { slug }
});
if (!page) {
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
}
return NextResponse.json(page);
} catch (error) {
console.error('Erro ao buscar página:', error);
return NextResponse.json({ error: 'Erro ao buscar página' }, { status: 500 });
}
}
// PUT /api/pages/[slug] - Atualizar página (admin apenas)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const user = await authenticate();
if (!user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { slug } = await params;
const body = await request.json();
const { content } = body;
if (!content) {
return NextResponse.json({ error: 'Conteúdo é obrigatório' }, { status: 400 });
}
const page = await prisma.pageContent.upsert({
where: { slug },
update: { content },
create: { slug, content }
});
return NextResponse.json({ success: true, page });
} catch (error) {
console.error('Erro ao atualizar página:', error);
return NextResponse.json({ error: 'Erro ao atualizar página' }, { status: 500 });
}
}
// DELETE /api/pages/[slug] - Deletar página (admin apenas)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const user = await authenticate();
if (!user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const { slug } = await params;
await prisma.pageContent.delete({
where: { slug }
});
return NextResponse.json({ success: true, message: 'Página deletada com sucesso' });
} catch (error) {
console.error('Erro ao deletar página:', error);
return NextResponse.json({ error: 'Erro ao deletar página' }, { status: 500 });
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const page = await prisma.pageContent.findUnique({
where: { slug: 'contact' }
});
if (!page) {
return NextResponse.json({ content: null }, { status: 404 });
}
return NextResponse.json({ content: page.content });
} catch (error) {
console.error('Error fetching contact page:', error);
return NextResponse.json({ error: 'Failed to fetch page' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const { content } = await request.json();
const page = await prisma.pageContent.upsert({
where: { slug: 'contact' },
update: {
content
},
create: {
slug: 'contact',
content
}
});
return NextResponse.json({ success: true, page });
} catch (error) {
console.error('Error updating contact page:', error);
return NextResponse.json({ error: 'Failed to update page' }, { status: 500 });
}
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
const prisma = new PrismaClient();
// Middleware de autenticação
async function authenticate(request: NextRequest) {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, email: true, name: true }
});
return user;
} catch (error) {
return null;
}
}
// GET /api/pages - Listar todas as páginas (público)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get('slug');
if (slug) {
// Buscar página específica
const page = await prisma.pageContent.findUnique({
where: { slug }
});
if (!page) {
return NextResponse.json({ error: 'Página não encontrada' }, { status: 404 });
}
return NextResponse.json(page);
}
// Listar todas as páginas
const pages = await prisma.pageContent.findMany({
orderBy: { slug: 'asc' }
});
return NextResponse.json(pages);
} catch (error) {
console.error('Erro ao buscar páginas:', error);
return NextResponse.json({ error: 'Erro ao buscar páginas' }, { status: 500 });
}
}
// POST /api/pages - Criar ou atualizar página (admin apenas)
export async function POST(request: NextRequest) {
try {
const user = await authenticate(request);
if (!user) {
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 });
}
const body = await request.json();
const { slug, content } = body;
if (!slug || !content) {
return NextResponse.json({ error: 'Slug e conteúdo são obrigatórios' }, { status: 400 });
}
// Upsert: criar ou atualizar se já existir
const page = await prisma.pageContent.upsert({
where: { slug },
update: { content },
create: { slug, content }
});
return NextResponse.json({ success: true, page });
} catch (error) {
console.error('Erro ao salvar página:', error);
return NextResponse.json({ error: 'Erro ao salvar página' }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id },
});
if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
return NextResponse.json(project);
} catch (error) {
return NextResponse.json({ error: 'Error fetching project' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const data = await request.json();
const project = await prisma.project.update({
where: { id },
data: {
title: data.title,
category: data.category,
client: data.client,
status: data.status,
completionDate: data.completionDate ? new Date(data.completionDate) : null,
description: data.description,
coverImage: data.coverImage,
galleryImages: data.galleryImages,
featured: data.featured,
},
});
return NextResponse.json(project);
} catch (error) {
return NextResponse.json({ error: 'Error updating project' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.project.delete({
where: { id },
});
return NextResponse.json({ message: 'Project deleted' });
} catch (error) {
return NextResponse.json({ error: 'Error deleting project' }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const projects = await prisma.project.findMany({
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(projects);
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json({ error: 'Error fetching projects' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const data = await request.json();
const project = await prisma.project.create({
data: {
title: data.title,
category: data.category,
client: data.client,
status: data.status,
completionDate: data.completionDate ? new Date(data.completionDate) : null,
description: data.description,
coverImage: data.coverImage,
galleryImages: data.galleryImages,
featured: data.featured,
},
});
return NextResponse.json(project);
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json({ error: 'Error creating project' }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const service = await prisma.service.findUnique({
where: { id },
});
if (!service) return NextResponse.json({ error: 'Service not found' }, { status: 404 });
return NextResponse.json(service);
} catch (error) {
return NextResponse.json({ error: 'Error fetching service' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const data = await request.json();
const service = await prisma.service.update({
where: { id },
data: {
title: data.title,
icon: data.icon,
shortDescription: data.shortDescription,
fullDescription: data.fullDescription,
active: data.active,
order: data.order,
},
});
return NextResponse.json(service);
} catch (error) {
return NextResponse.json({ error: 'Error updating service' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.service.delete({
where: { id },
});
return NextResponse.json({ message: 'Service deleted' });
} catch (error) {
return NextResponse.json({ error: 'Error deleting service' }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const services = await prisma.service.findMany({
orderBy: { order: 'asc' },
});
return NextResponse.json(services);
} catch (error) {
return NextResponse.json({ error: 'Error fetching services' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const data = await request.json();
const service = await prisma.service.create({
data: {
title: data.title,
icon: data.icon,
shortDescription: data.shortDescription,
fullDescription: data.fullDescription,
active: data.active,
order: data.order,
},
});
return NextResponse.json(service);
} catch (error) {
return NextResponse.json({ error: 'Error creating service' }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { minioClient, bucketName, ensureBucketExists } from '@/lib/minio';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: Request) {
try {
await ensureBucketExists();
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/\s+/g, '-')}`; // Sanitize filename
await minioClient.putObject(bucketName, filename, buffer, file.size, {
'Content-Type': file.type,
});
// Construct public URL
// In a real production env, this should be an env var like NEXT_PUBLIC_STORAGE_URL
const url = `http://localhost:9000/${bucketName}/${filename}`;
return NextResponse.json({ url });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Error uploading file' }, { status: 500 });
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import prisma from '@/lib/prisma';
import { minioClient } from '@/lib/minio';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
// Verify JWT token
jwt.verify(token, JWT_SECRET);
// Get user's current avatar
const user = await prisma.user.findUnique({
where: { id },
select: { avatar: true },
});
if (user?.avatar) {
try {
// Extract filename from URL
const fileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
if (fileName) {
await minioClient.removeObject(BUCKET_NAME, fileName);
}
} catch (error) {
console.error('Error deleting avatar from MinIO:', error);
}
}
// Remove avatar from database
const updatedUser = await prisma.user.update({
where: { id },
data: { avatar: null },
select: {
id: true,
email: true,
name: true,
avatar: true,
},
});
return NextResponse.json({
message: 'Avatar removido com sucesso',
user: updatedUser,
});
} catch (error) {
console.error('Error deleting avatar:', error);
return NextResponse.json(
{ error: 'Erro ao remover avatar' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,96 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import * as bcrypt from 'bcryptjs';
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
console.error('Erro no GET /api/users/[id]:', error);
return NextResponse.json({ error: 'Error fetching user' }, { status: 500 });
}
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const data = await request.json();
// Verificar se email já está em uso por outro usuário
if (data.email) {
const existingUser = await prisma.user.findFirst({
where: {
email: data.email,
NOT: { id },
},
});
if (existingUser) {
return NextResponse.json({ error: 'Email já está em uso' }, { status: 400 });
}
}
const updateData: any = {
email: data.email,
name: data.name,
};
// Se enviou senha, fazer hash
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 10);
}
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
return NextResponse.json(user);
} catch (error) {
return NextResponse.json({ error: 'Error updating user' }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
// Verificar se não é o último usuário
const userCount = await prisma.user.count();
if (userCount <= 1) {
return NextResponse.json({ error: 'Não é possível excluir o último usuário do sistema' }, { status: 400 });
}
await prisma.user.delete({
where: { id },
});
return NextResponse.json({ message: 'User deleted' });
} catch (error) {
return NextResponse.json({ error: 'Error deleting user' }, { status: 500 });
}
}

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import prisma from '@/lib/prisma';
import { minioClient, ensureBucketExists } from '@/lib/minio';
import { v4 as uuidv4 } from 'uuid';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'occto-images';
export async function POST(request: NextRequest) {
try {
// Ensure bucket exists
await ensureBucketExists();
const token = request.cookies.get('auth_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Não autenticado' },
{ status: 401 }
);
}
// Verify JWT token
jwt.verify(token, JWT_SECRET);
// Get form data
const formData = await request.formData();
const file = formData.get('avatar') as File;
const userId = formData.get('userId') as string;
if (!file) {
return NextResponse.json(
{ error: 'Nenhum arquivo enviado' },
{ status: 400 }
);
}
if (!userId) {
return NextResponse.json(
{ error: 'ID do usuário não fornecido' },
{ status: 400 }
);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Tipo de arquivo inválido. Use JPEG, PNG ou WEBP' },
{ status: 400 }
);
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'Arquivo muito grande. Tamanho máximo: 5MB' },
{ status: 400 }
);
}
// Generate unique filename
const fileExtension = file.name.split('.').pop();
const fileName = `avatars/${userId}/${uuidv4()}.${fileExtension}`;
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Upload to MinIO
await minioClient.putObject(
BUCKET_NAME,
fileName,
buffer,
buffer.length,
{
'Content-Type': file.type,
}
);
// Generate public URL
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_PORT || '9000';
const avatarUrl = `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${fileName}`;
// Delete old avatar if exists
const user = await prisma.user.findUnique({
where: { id: userId },
select: { avatar: true },
});
if (user?.avatar) {
try {
// Extract filename from URL
const oldFileName = user.avatar.split(`${BUCKET_NAME}/`)[1];
if (oldFileName) {
await minioClient.removeObject(BUCKET_NAME, oldFileName);
}
} catch (error) {
console.error('Error deleting old avatar:', error);
}
}
// Update user avatar in database
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { avatar: avatarUrl },
select: {
id: true,
email: true,
name: true,
avatar: true,
},
});
return NextResponse.json({
message: 'Avatar atualizado com sucesso',
user: updatedUser,
});
} catch (error) {
console.error('Error uploading avatar:', error);
return NextResponse.json(
{ error: 'Erro ao fazer upload do avatar' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import * as bcrypt from 'bcryptjs';
export async function GET() {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error);
return NextResponse.json({ error: 'Error fetching users' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const data = await request.json();
// Validações
if (!data.email || !data.password) {
return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
}
// Verificar se email já existe
const existingUser = await prisma.user.findUnique({
where: { email: data.email },
});
if (existingUser) {
return NextResponse.json({ error: 'Email já está em uso' }, { status: 400 });
}
// Hash da senha
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
},
select: {
id: true,
email: true,
name: true,
avatar: true,
createdAt: true,
},
});
return NextResponse.json(user);
} catch (error) {
console.error('Error creating user:', error);
return NextResponse.json({ error: 'Error creating user' }, { status: 500 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
@import url('https://fonts.googleapis.com/css2?family=Stack+Sans+Headline&display=swap');

View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
@config "../../tailwind.config.ts";
@theme {
--color-primary: #FF6B35;
--color-primary-rgb: 255 107 53;
--color-secondary: #1A1A1A;
--font-headline: 'Stack Sans Headline', sans-serif;
--font-body: var(--font-body), sans-serif;
}
:root {
--background: #ffffff;
--foreground: #171717;
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-body);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-headline);
}

View File

@@ -0,0 +1,4 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#FF6B00"/>
<path d="M12 19H14V6.00003L20.3939 8.74028C20.7616 8.89786 21 9.2594 21 9.65943V19H23V21H1V19H3V5.6499C3 5.25472 3.23273 4.89659 3.59386 4.73609L11.2969 1.31251C11.5493 1.20035 11.8448 1.314 11.9569 1.56634C11.9853 1.63027 12 1.69945 12 1.76941V19Z" fill="white" transform="translate(12, 12)"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "remixicon/fonts/remixicon.css";
import "./fonts.css";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { LanguageProvider } from "@/contexts/LanguageContext";
import { ToastProvider } from "@/contexts/ToastContext";
import { ConfirmProvider } from "@/contexts/ConfirmContext";
import { ColorProvider } from "@/components/ColorProvider";
const inter = Inter({
variable: "--font-body",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Octto Engenharia | Movimentação de Carga e Segurança",
description: "Especialistas em engenharia de movimentação de carga, projetos de dispositivos de içamento, laudos técnicos e adequação de equipamentos (NR-11/NR-12).",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body
className={`${inter.variable} antialiased flex flex-col min-h-screen`}
>
<ColorProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ToastProvider>
<ConfirmProvider>
<LanguageProvider>
{children}
</LanguageProvider>
</ConfirmProvider>
</ToastProvider>
</ThemeProvider>
</ColorProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from 'react';
export function ColorProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
loadPrimaryColor();
}, []);
const loadPrimaryColor = async () => {
try {
const response = await fetch('/api/config');
if (response.ok) {
const data = await response.json();
if (data.primaryColor) {
applyPrimaryColor(data.primaryColor);
}
}
} catch (error) {
console.error('Erro ao carregar cor primária:', error);
}
};
const applyPrimaryColor = (color: string) => {
// Converte hex para RGB
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
// Define as CSS variables
document.documentElement.style.setProperty('--color-primary-rgb', `${r} ${g} ${b}`);
document.documentElement.style.setProperty('--color-primary', color);
};
if (!mounted) {
return <>{children}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,60 @@
"use client";
interface ConfirmDialogProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
type?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
title,
message,
confirmText = 'OK',
cancelText = 'Cancelar',
onConfirm,
onCancel,
type = 'danger',
}: ConfirmDialogProps) {
const buttonStyles = {
danger: 'bg-red-500 hover:bg-red-600 text-white',
warning: 'bg-yellow-500 hover:bg-yellow-600 text-white',
info: 'bg-primary hover:bg-orange-600 text-white',
};
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 animate-in fade-in duration-200"
onClick={onCancel}
>
<div
className="bg-white dark:bg-secondary rounded-2xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-bold text-secondary dark:text-white mb-3">
{title}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{message}
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-3 border border-gray-300 dark:border-white/10 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex-1 px-4 py-3 rounded-xl font-medium transition-colors ${buttonStyles[type]}`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/contexts/LanguageContext';
export default function CookieConsent() {
const [isVisible, setIsVisible] = useState(false);
const { t } = useLanguage();
useEffect(() => {
// Check if user has already made a choice
const consent = localStorage.getItem('cookie_consent');
if (consent === null) {
// Small delay to show animation
const timer = setTimeout(() => setIsVisible(true), 1000);
return () => clearTimeout(timer);
}
}, []);
const handleAccept = () => {
localStorage.setItem('cookie_consent', 'true');
setIsVisible(false);
};
const handleDecline = () => {
localStorage.setItem('cookie_consent', 'false');
setIsVisible(false);
};
if (!isVisible) return null;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6 animate-in slide-in-from-bottom-full duration-500">
<div className="container mx-auto max-w-4xl">
<div className="bg-white dark:bg-secondary border border-gray-200 dark:border-white/10 rounded-2xl shadow-2xl p-6 md:flex items-center justify-between gap-6">
<div className="flex items-start gap-4 mb-6 md:mb-0">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center shrink-0 text-primary">
<i className="ri-cookie-2-line text-2xl"></i>
</div>
<div>
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
{t('cookie.text')}{' '}
<Link href="/privacidade" className="text-primary font-bold hover:underline">
{t('cookie.policy')}
</Link>.
</p>
</div>
</div>
<div className="flex items-center justify-center gap-3 shrink-0">
<button
onClick={handleDecline}
className="px-6 py-2.5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-300 rounded-lg font-bold hover:bg-gray-50 dark:hover:bg-white/5 transition-colors text-sm cursor-pointer"
>
{t('cookie.decline')}
</button>
<button
onClick={handleAccept}
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors text-sm shadow-lg shadow-primary/20 cursor-pointer"
>
{t('cookie.accept')}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import Link from 'next/link';
import { useLanguage } from '@/contexts/LanguageContext';
export default function Footer() {
const { t } = useLanguage();
return (
<footer className="bg-secondary text-white pt-16 pb-8">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
{/* Brand */}
<div className="col-span-1 md:col-span-1">
<div className="flex items-center gap-2 mb-6">
<i className="ri-building-2-fill text-4xl text-primary"></i>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold font-headline">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-white/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</div>
</div>
<p className="text-gray-400 mb-6">
Soluções em engenharia mecânica e segurança para movimentação de carga.
</p>
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2 mb-6">
<i className="ri-verified-badge-fill text-primary"></i>
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide">Prestador Oficial <span className="text-primary">Coca-Cola</span></span>
</div>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
<i className="ri-instagram-line"></i>
</a>
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
<i className="ri-linkedin-fill"></i>
</a>
<a href="#" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-primary transition-colors">
<i className="ri-facebook-fill"></i>
</a>
</div>
</div>
{/* Links */}
<div>
<h3 className="text-lg font-bold font-headline mb-6">Links Rápidos</h3>
<ul className="space-y-4">
<li><Link href="/" className="text-gray-400 hover:text-primary transition-colors">{t('nav.home')}</Link></li>
<li><Link href="/sobre" className="text-gray-400 hover:text-primary transition-colors">{t('nav.about')}</Link></li>
<li><Link href="/servicos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.services')}</Link></li>
<li><Link href="/projetos" className="text-gray-400 hover:text-primary transition-colors">{t('nav.projects')}</Link></li>
<li><Link href="/contato" className="text-gray-400 hover:text-primary transition-colors">{t('nav.contact')}</Link></li>
</ul>
</div>
{/* Services */}
<div>
<h3 className="text-lg font-bold font-headline mb-6">{t('services.title')}</h3>
<ul className="space-y-4">
<li className="text-gray-400">Projetos de Dispositivos</li>
<li className="text-gray-400">Engenharia de Implementos</li>
<li className="text-gray-400">Inspeção de Equipamentos</li>
<li className="text-gray-400">Laudos Técnicos (NR-11/12)</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-lg font-bold font-headline mb-6">{t('nav.contact')}</h3>
<ul className="space-y-4">
<li className="flex items-start gap-3 text-gray-400">
<i className="ri-map-pin-line mt-1 text-primary"></i>
<span>Endereço da Empresa, 123<br />Cidade - ES</span>
</li>
<li className="flex items-center gap-3 text-gray-400">
<i className="ri-phone-line text-primary"></i>
<span>(27) 99999-9999</span>
</li>
<li className="flex items-center gap-3 text-gray-400">
<i className="ri-mail-line text-primary"></i>
<span>contato@octto.com.br</span>
</li>
</ul>
</div>
</div>
<div className="border-t border-white/10 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-gray-500 text-sm">
© {new Date().getFullYear()} OCCTO Engenharia. {t('footer.rights')}
</p>
<div className="flex gap-6 text-sm text-gray-500">
<Link href="/privacidade" className="hover:text-white">Política de Privacidade</Link>
<Link href="/termos" className="hover:text-white">Termos de Uso</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useTheme } from "next-themes";
import { useLanguage } from '@/contexts/LanguageContext';
export default function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Prevent scrolling when mobile menu is open
useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isMobileMenuOpen]);
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const cycleLanguage = () => {
const langs: ('PT' | 'EN' | 'ES')[] = ['PT', 'EN', 'ES'];
const currentIndex = langs.indexOf(language);
const nextIndex = (currentIndex + 1) % langs.length;
setLanguage(langs[nextIndex]);
};
return (
<header className="w-full bg-white dark:bg-secondary shadow-sm sticky top-0 z-50 transition-colors duration-300">
<div className="container mx-auto px-4 h-20 flex items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-3 shrink-0 group mr-auto z-50 relative">
<i className="ri-building-2-fill text-4xl text-primary group-hover:scale-105 transition-transform"></i>
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-secondary dark:text-white font-headline leading-none">OCCTO</span>
<span className="text-[10px] font-bold text-primary bg-primary/10 px-2 py-1 rounded-md uppercase tracking-wider">ENG.</span>
</div>
</Link>
<div className="hidden md:flex items-center gap-4">
{/* Search Bar */}
<div className={`flex items-center bg-gray-100 dark:bg-white/10 rounded-full transition-all duration-300 ${isSearchOpen ? 'w-64 px-4 py-2' : 'w-10 h-10 justify-center cursor-pointer hover:bg-gray-200 dark:hover:bg-white/20'}`} onClick={() => !isSearchOpen && setIsSearchOpen(true)}>
<i className={`ri-search-line text-gray-500 dark:text-gray-300 ${isSearchOpen ? 'mr-2' : 'text-lg'}`}></i>
{isSearchOpen && (
<input
type="text"
placeholder={t('nav.search')}
autoFocus
onBlur={() => setIsSearchOpen(false)}
className="bg-transparent border-none outline-none text-sm w-full text-gray-600 dark:text-gray-200 placeholder-gray-400"
/>
)}
</div>
<nav className="flex items-center gap-6 mr-4">
<Link href="/" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
<i className="ri-home-4-line text-lg group-hover:scale-110 transition-transform"></i>
<span className="hidden lg:inline">{t('nav.home')}</span>
</Link>
<Link href="/servicos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
<i className="ri-tools-line text-lg group-hover:scale-110 transition-transform"></i>
<span className="hidden lg:inline">{t('nav.services')}</span>
</Link>
<Link href="/projetos" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
<i className="ri-briefcase-line text-lg group-hover:scale-110 transition-transform"></i>
<span className="hidden lg:inline">{t('nav.projects')}</span>
</Link>
<Link href="/contato" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
<i className="ri-mail-send-line text-lg group-hover:scale-110 transition-transform"></i>
<span className="hidden lg:inline">{t('nav.contact')}</span>
</Link>
<Link href="/sobre" className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary font-medium transition-colors group">
<i className="ri-user-line text-lg group-hover:scale-110 transition-transform"></i>
<span className="hidden lg:inline">{t('nav.about')}</span>
</Link>
</nav>
<div className="shrink-0 ml-2">
<Link
href="/contato"
className="px-6 py-2.5 bg-primary text-white rounded-lg font-bold hover:bg-orange-600 transition-colors flex items-center gap-2"
>
<i className="ri-whatsapp-line"></i>
<span className="hidden xl:inline">{t('nav.contact_us')}</span>
</Link>
</div>
<div className="flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-white/10">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-10 h-10 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center text-gray-600 dark:text-yellow-400 hover:bg-gray-200 dark:hover:bg-white/20 transition-colors cursor-pointer"
aria-label="Alternar tema"
>
{mounted && theme === 'dark' ? (
<i className="ri-sun-line text-xl"></i>
) : (
<i className="ri-moon-line text-xl"></i>
)}
</button>
{/* Language Dropdown */}
<div className="relative group">
<button
className="h-10 px-3 rounded-full bg-gray-100 dark:bg-white/10 flex items-center justify-center gap-2 text-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-white/20 transition-colors font-bold text-sm cursor-pointer"
aria-label="Alterar idioma"
>
<span>{language === 'PT' ? '🇧🇷' : language === 'EN' ? '🇺🇸' : '🇪🇸'}</span>
<span>{language}</span>
<i className="ri-arrow-down-s-line text-xs opacity-50"></i>
</button>
<div className="absolute top-full right-0 pt-2 w-32 hidden group-hover:block animate-in fade-in slide-in-from-top-2 duration-200">
<div className="bg-white dark:bg-secondary rounded-xl shadow-xl border border-gray-100 dark:border-white/10 overflow-hidden">
<button onClick={() => setLanguage('PT')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
<span className="text-lg">🇧🇷</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Português</span>
</button>
<button onClick={() => setLanguage('EN')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
<span className="text-lg">🇺🇸</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">English</span>
</button>
<button onClick={() => setLanguage('ES')} className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-3 transition-colors cursor-pointer">
<span className="text-lg">🇪🇸</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Español</span>
</button>
</div>
</div>
</div>
</div>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden text-2xl text-secondary dark:text-white z-50 relative w-10 h-10 flex items-center justify-center cursor-pointer"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Menu"
>
{isMobileMenuOpen ? <i className="ri-close-line"></i> : <i className="ri-menu-line"></i>}
</button>
{/* Mobile Menu Overlay */}
<div className={`fixed inset-0 bg-white dark:bg-secondary z-40 transition-transform duration-300 ease-in-out md:hidden flex flex-col pt-24 px-6 overflow-y-auto ${isMobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}`}>
{/* Mobile Search */}
<div className="mb-6 relative shrink-0">
<i className="ri-search-line absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder={t('nav.search')}
className="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
/>
</div>
<nav className="flex flex-col gap-4 text-base font-medium">
<Link href="/" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
<i className="ri-home-4-line text-primary text-lg"></i>
{t('nav.home')}
</Link>
<Link href="/servicos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
<i className="ri-tools-line text-primary text-lg"></i>
{t('nav.services')}
</Link>
<Link href="/projetos" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
<i className="ri-briefcase-line text-primary text-lg"></i>
{t('nav.projects')}
</Link>
<Link href="/contato" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
<i className="ri-mail-send-line text-primary text-lg"></i>
{t('nav.contact')}
</Link>
<Link href="/sobre" onClick={() => setIsMobileMenuOpen(false)} className="flex items-center gap-3 py-2 border-b border-gray-100 dark:border-white/10 text-secondary dark:text-white">
<i className="ri-user-line text-primary text-lg"></i>
{t('nav.about')}
</Link>
</nav>
<div className="mt-6 flex flex-col gap-4 pb-8 shrink-0">
<Link
href="/contato"
onClick={() => setIsMobileMenuOpen(false)}
className="w-full py-4 bg-primary text-white rounded-xl font-bold text-center flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
>
<i className="ri-whatsapp-line text-xl"></i>
{t('nav.contact_us')}
</Link>
<div
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
onClick={toggleTheme}
>
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.theme')}</span>
<button
className="w-10 h-10 rounded-full bg-white dark:bg-white/10 flex items-center justify-center text-gray-600 dark:text-yellow-400 shadow-sm transition-colors"
>
{mounted && theme === 'dark' ? (
<i className="ri-sun-line text-xl"></i>
) : (
<i className="ri-moon-line text-xl"></i>
)}
</button>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-white/5 rounded-xl">
<span className="text-sm font-bold text-gray-500 dark:text-gray-400">{t('nav.language')}</span>
<div className="flex gap-2">
<button onClick={() => setLanguage('PT')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'PT' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇧🇷</button>
<button onClick={() => setLanguage('EN')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'EN' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇺🇸</button>
<button onClick={() => setLanguage('ES')} className={`w-10 h-10 rounded-lg flex items-center justify-center text-xl cursor-pointer transition-all ${language === 'ES' ? 'bg-white dark:bg-white/10 shadow-sm scale-110' : 'opacity-50 hover:opacity-100'}`}>🇪🇸</button>
</div>
</div>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useEffect } from 'react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning' | 'info';
onClose: () => void;
duration?: number;
}
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const styles = {
success: 'bg-green-500 text-white',
error: 'bg-red-500 text-white',
warning: 'bg-yellow-500 text-white',
info: 'bg-blue-500 text-white',
};
const icons = {
success: 'ri-checkbox-circle-line',
error: 'ri-error-warning-line',
warning: 'ri-alert-line',
info: 'ri-information-line',
};
return (
<div className="fixed top-4 right-4 z-50 animate-in slide-in-from-top-5 fade-in duration-300">
<div className={`${styles[type]} rounded-xl shadow-lg px-6 py-4 flex items-center gap-3 min-w-[300px] max-w-md`}>
<i className={`${icons[type]} text-2xl`}></i>
<p className="flex-1 font-medium">{message}</p>
<button onClick={onClose} className="hover:opacity-70 transition-opacity">
<i className="ri-close-line text-xl"></i>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import Link from 'next/link';
import { useLanguage } from '@/contexts/LanguageContext';
export default function WhatsAppButton() {
const { t } = useLanguage();
return (
<Link
href="https://wa.me/5511999999999" // Substitua pelo número real
target="_blank"
rel="noopener noreferrer"
className="fixed bottom-6 right-6 z-40 flex flex-row-reverse items-center justify-center bg-[#25D366] text-white w-14 h-14 rounded-full shadow-lg hover:bg-[#20bd5a] transition-all hover:scale-110 group animate-in slide-in-from-bottom-4 duration-700 delay-1000 hover:w-auto hover:px-6"
aria-label={t('whatsapp.label')}
>
<i className="ri-whatsapp-line text-3xl leading-none"></i>
<span className="font-bold max-w-0 overflow-hidden group-hover:max-w-xs group-hover:mr-3 transition-all duration-500 whitespace-nowrap">
{t('whatsapp.label')}
</span>
</Link>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,71 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import ConfirmDialog from '@/components/ConfirmDialog';
interface ConfirmOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | undefined>(undefined);
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<{
show: boolean;
options: ConfirmOptions;
resolve: (value: boolean) => void;
} | null>(null);
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
setDialog({ show: true, options, resolve });
});
}, []);
const handleConfirm = () => {
if (dialog) {
dialog.resolve(true);
setDialog(null);
}
};
const handleCancel = () => {
if (dialog) {
dialog.resolve(false);
setDialog(null);
}
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{dialog?.show && (
<ConfirmDialog
title={dialog.options.title}
message={dialog.options.message}
confirmText={dialog.options.confirmText}
cancelText={dialog.options.cancelText}
type={dialog.options.type}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within ConfirmProvider');
}
return context;
}

View File

@@ -0,0 +1,535 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from 'react';
type Language = 'PT' | 'EN' | 'ES';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
tDynamic: (content: { PT: string, EN?: string, ES?: string }) => string;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const translations = {
PT: {
'nav.home': 'Início',
'nav.services': 'Serviços',
'nav.projects': 'Projetos',
'nav.contact': 'Contato',
'nav.about': 'Sobre',
'nav.search': 'Buscar...',
'nav.contact_us': 'Fale Conosco',
'nav.theme': 'Tema',
'nav.language': 'Idioma',
'footer.rights': 'Todos os direitos reservados.',
// Home - Hero
'home.hero.badge': 'Prestador de Serviço Oficial',
'home.hero.title': 'Engenharia de',
'home.hero.title_highlight': 'Dispositivos de Içamento',
'home.hero.subtitle': 'Desenvolvemos projetos, laudos e soluções técnicas para equipamentos de movimentação de carga. Segurança e conformidade normativa para sua operação.',
'home.hero.cta_primary': 'Falar com Engenheiro',
'home.hero.cta_secondary': 'Ver Soluções',
// Home - Features
'home.features.pretitle': 'Diferenciais',
'home.features.title': 'Segurança e Eficiência',
'home.features.1.title': 'Normas Técnicas',
'home.features.1.desc': 'Projetos e adequações rigorosamente alinhados com as normas NR-12, NR-11 e resoluções do CONTRAN.',
'home.features.2.title': 'Engenharia Mecânica',
'home.features.2.desc': 'Desenvolvimento de dispositivos de içamento e soluções personalizadas para otimizar sua logística.',
'home.features.3.title': 'Projetos de Implementos',
'home.features.3.desc': 'Engenharia especializada para instalação e adequação de Muncks, plataformas e dispositivos em veículos de carga.',
// Home - Services
'home.services.pretitle': 'O que fazemos',
'home.services.title': 'Soluções Especializadas',
'home.services.1.title': 'Projetos Mecânicos',
'home.services.1.desc': 'Desenvolvimento de dispositivos de içamento (Spreaders, Balancins).',
'home.services.2.title': 'Laudos Técnicos',
'home.services.2.desc': 'Inspeção e certificação de equipamentos de carga conforme normas.',
'home.services.3.title': 'Adequação NR-12',
'home.services.3.desc': 'Projetos de segurança para máquinas e equipamentos.',
'home.services.4.title': 'Engenharia Veicular',
'home.services.4.desc': 'Projetos para instalação de equipamentos em caminhões.',
'home.services.link': 'Ver todos os serviços',
// Home - About
'home.about.pretitle': 'Sobre Nós',
'home.about.title': 'Engenharia que garante segurança',
'home.about.desc': 'A Octto Engenharia é parceira técnica de grandes empresas logísticas. Não operamos frotas, nós garantimos que os equipamentos que movem sua carga sejam seguros, eficientes e estejam dentro das normas.',
'home.about.list.1': 'Projetos de Dispositivos de Içamento',
'home.about.list.2': 'Laudos Técnicos para Muncks e Guindastes',
'home.about.list.3': 'Responsabilidade Técnica (ART) garantida',
'home.about.link': 'Conheça nossa expertise',
// Home - Projects
'home.projects.pretitle': 'Portfólio',
'home.projects.title': 'Projetos Recentes',
'home.projects.link': 'Ver todos os projetos',
'home.projects.1.cat': 'Engenharia Veicular',
'home.projects.1.title': 'Projeto de Adequação - Coca-Cola',
'home.projects.2.cat': 'Inspeção Técnica',
'home.projects.2.title': 'Laudo de Guindaste Articulado',
'home.projects.3.cat': 'Projeto Mecânico',
'home.projects.3.title': 'Dispositivo de Içamento Especial',
'home.projects.4.cat': 'Laudos',
'home.projects.4.title': 'Certificação NR-12 - Parque Industrial',
'home.projects.5.cat': 'Engenharia Veicular',
'home.projects.5.title': 'Homologação de Plataforma Elevatória',
'home.projects.6.cat': 'Segurança do Trabalho',
'home.projects.6.title': 'Projeto de Linha de Vida para Caminhões',
'home.projects.view_details': 'Ver detalhes',
// Home - Testimonials
'home.testimonials.pretitle': 'Depoimentos',
'home.testimonials.title': 'Parceiros que confiam',
'home.testimonials.1.text': 'A Octto realizou a adequação de toda nossa frota de caminhões com excelência técnica e rapidez.',
'home.testimonials.1.role': 'Gerente de Frota, Distribuidora Bebidas',
'home.testimonials.2.text': 'Os laudos técnicos emitidos pela Octto nos deram total segurança jurídica e operacional.',
'home.testimonials.2.role': 'Diretora Operacional, Logística Express',
'home.testimonials.3.text': 'O projeto do dispositivo de içamento resolveu um gargalo antigo da nossa produção. Recomendo.',
'home.testimonials.3.role': 'Engenheiro Chefe, Indústria Metalúrgica',
// Home - CTA
'home.cta.title': 'Pronto para iniciar seu projeto?',
'home.cta.desc': 'Entre em contato conosco hoje mesmo e descubra como podemos ajudar a transformar sua visão em realidade.',
'home.cta.button': 'Falar com um Especialista',
// Services Page
'services.hero.title': 'Nossos Serviços',
'services.hero.subtitle': 'Soluções completas em engenharia mecânica e movimentação de carga.',
'services.cta.title': 'Precisa de uma solução personalizada?',
'services.cta.button': 'Falar com um Engenheiro',
'services.scope': 'Escopo do Serviço',
'services.title': 'Serviços',
// Projects Page
'projects.hero.title': 'Nossos Projetos',
'projects.hero.subtitle': 'Explore nosso portfólio de soluções em movimentação de carga e engenharia mecânica.',
'projects.filter.all': 'Todos',
'projects.filter.implements': 'Implementos',
'projects.filter.mechanical': 'Projetos Mecânicos',
'projects.filter.reports': 'Laudos',
'projects.card.details': 'Ver detalhes',
// About Page
'about.hero.title': 'Sobre a Octto',
'about.hero.subtitle': 'Conheça nossa trajetória, valores e o compromisso com a excelência na engenharia.',
'about.history.pretitle': 'Nossa História',
'about.history.title': 'Nossa História',
'about.history.subtitle': 'Engenharia que impulsiona a logística',
'about.history.p1': 'A Octto Engenharia nasceu da necessidade do mercado por soluções técnicas especializadas em movimentação de carga e implementos rodoviários. Identificamos que grandes frotas careciam de engenharia de ponta para garantir segurança e eficiência.',
'about.history.p2': 'Hoje, somos parceiros estratégicos de grandes empresas de distribuição, como a Coca-Cola, desenvolvendo projetos de adequação, manutenção e certificação de equipamentos que são vitais para a cadeia logística nacional.',
'about.values.pretitle': 'Nossos Pilares',
'about.values.title': 'Nossos Pilares',
'about.values.subtitle': 'Valores que nos guiam',
'about.values.1.title': 'Excelência Técnica',
'about.values.1.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
'about.values.2.title': 'Transparência',
'about.values.2.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
'about.values.3.title': 'Sustentabilidade',
'about.values.3.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
'about.values.quality.title': 'Excelência Técnica',
'about.values.quality.desc': 'Busca incessante pela perfeição em cada detalhe construtivo e de projeto.',
'about.values.transparency.title': 'Transparência',
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
'about.values.sustainability.title': 'Sustentabilidade',
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
// Contact Page
'contact.hero.title': 'Contato',
'contact.hero.subtitle': 'Estamos prontos para ouvir sobre o seu projeto. Entre em contato conosco.',
'contact.info.pretitle': 'Fale Conosco',
'contact.info.title': 'Canais de Atendimento',
'contact.info.subtitle': 'Entre em contato pelos nossos canais oficiais',
'contact.info.whatsapp.desc': 'Atendimento rápido e direto.',
'contact.info.email.desc': 'Para orçamentos e dúvidas técnicas.',
'contact.info.office.title': 'Escritório',
'contact.info.phone.title': 'WhatsApp',
'contact.info.email.title': 'E-mail',
'contact.info.address.title': 'Escritório',
'contact.form.title': 'Envie uma mensagem',
'contact.form.name': 'Nome',
'contact.form.name.placeholder': 'Seu nome',
'contact.form.phone': 'Telefone',
'contact.form.email': 'E-mail',
'contact.form.email.placeholder': 'seu@email.com',
'contact.form.subject': 'Assunto',
'contact.form.message': 'Mensagem',
'contact.form.message.placeholder': 'Como podemos ajudar?',
'contact.form.submit': 'Enviar Mensagem',
'contact.form.subject.select': 'Selecione um assunto',
'contact.form.subject.quote': 'Solicitar Orçamento',
'contact.form.subject.doubt': 'Dúvida Técnica',
'contact.form.subject.partnership': 'Parceria',
'contact.form.subject.other': 'Trabalhe Conosco',
// Cookie Consent
'cookie.text': 'Utilizamos cookies para melhorar sua experiência e analisar o tráfego do site. Ao continuar navegando, você concorda com nossa',
'cookie.policy': 'Política de Privacidade',
'cookie.accept': 'Aceitar',
'cookie.decline': 'Recusar',
// WhatsApp
'whatsapp.label': 'Atendimento Rápido',
},
EN: {
'nav.home': 'Home',
'nav.services': 'Services',
'nav.projects': 'Projects',
'nav.contact': 'Contact',
'nav.about': 'About',
'nav.search': 'Search...',
'nav.contact_us': 'Contact Us',
'nav.theme': 'Theme',
'nav.language': 'Language',
'footer.rights': 'All rights reserved.',
// Home - Hero
'home.hero.badge': 'Official Service Provider',
'home.hero.title': 'Engineering of',
'home.hero.title_highlight': 'Lifting Devices',
'home.hero.subtitle': 'We develop projects, reports and technical solutions for load handling equipment. Safety and regulatory compliance for your operation.',
'home.hero.cta_primary': 'Talk to an Engineer',
'home.hero.cta_secondary': 'View Solutions',
// Home - Features
'home.features.pretitle': 'Differentials',
'home.features.title': 'Safety and Efficiency',
'home.features.1.title': 'Technical Standards',
'home.features.1.desc': 'Projects and adaptations strictly aligned with NR-12, NR-11 standards and CONTRAN resolutions.',
'home.features.2.title': 'Mechanical Engineering',
'home.features.2.desc': 'Development of lifting devices and custom solutions to optimize your logistics.',
'home.features.3.title': 'Implement Projects',
'home.features.3.desc': 'Specialized engineering for installation and adaptation of Cranes, platforms and devices on cargo vehicles.',
// Home - Services
'home.services.pretitle': 'What we do',
'home.services.title': 'Specialized Solutions',
'home.services.1.title': 'Mechanical Projects',
'home.services.1.desc': 'Development of lifting devices (Spreaders, Beams).',
'home.services.2.title': 'Technical Reports',
'home.services.2.desc': 'Inspection and certification of cargo equipment according to standards.',
'home.services.3.title': 'NR-12 Adaptation',
'home.services.3.desc': 'Safety projects for machinery and equipment.',
'home.services.4.title': 'Vehicular Engineering',
'home.services.4.desc': 'Projects for equipment installation on trucks.',
'home.services.link': 'View all services',
// Home - About
'home.about.pretitle': 'About Us',
'home.about.title': 'Engineering that ensures safety',
'home.about.desc': 'Octto Engineering is a technical partner for major logistics companies. We do not operate fleets, we ensure that the equipment moving your cargo is safe, efficient and compliant.',
'home.about.list.1': 'Lifting Device Projects',
'home.about.list.2': 'Technical Reports for Cranes',
'home.about.list.3': 'Technical Responsibility (ART) guaranteed',
'home.about.link': 'Know our expertise',
// Home - Projects
'home.projects.pretitle': 'Portfolio',
'home.projects.title': 'Recent Projects',
'home.projects.link': 'View all projects',
'home.projects.1.cat': 'Vehicular Engineering',
'home.projects.1.title': 'Adaptation Project - Coca-Cola',
'home.projects.2.cat': 'Technical Inspection',
'home.projects.2.title': 'Articulated Crane Report',
'home.projects.3.cat': 'Mechanical Project',
'home.projects.3.title': 'Special Lifting Device',
'home.projects.4.cat': 'Reports',
'home.projects.4.title': 'NR-12 Certification - Industrial Park',
'home.projects.5.cat': 'Vehicular Engineering',
'home.projects.5.title': 'Lifting Platform Homologation',
'home.projects.6.cat': 'Work Safety',
'home.projects.6.title': 'Lifeline Project for Trucks',
'home.projects.view_details': 'View details',
// Home - Testimonials
'home.testimonials.pretitle': 'Testimonials',
'home.testimonials.title': 'Partners who trust',
'home.testimonials.1.text': 'Octto performed the adaptation of our entire truck fleet with technical excellence and speed.',
'home.testimonials.1.role': 'Fleet Manager, Beverage Distributor',
'home.testimonials.2.text': 'The technical reports issued by Octto gave us total legal and operational security.',
'home.testimonials.2.role': 'Operations Director, Logistics Express',
'home.testimonials.3.text': 'The lifting device project solved an old bottleneck in our production. Highly recommend.',
'home.testimonials.3.role': 'Chief Engineer, Metallurgical Industry',
// Home - CTA
'home.cta.title': 'Ready to start your project?',
'home.cta.desc': 'Contact us today and discover how we can help transform your vision into reality.',
'home.cta.button': 'Talk to a Specialist',
// Services Page
'services.hero.title': 'Our Services',
'services.hero.subtitle': 'Complete solutions in mechanical engineering and load handling.',
'services.cta.title': 'Need a custom solution?',
'services.cta.button': 'Talk to an Engineer',
'services.scope': 'Service Scope',
'services.title': 'Services',
// Projects Page
'projects.hero.title': 'Our Projects',
'projects.hero.subtitle': 'Explore our portfolio of solutions in load handling and mechanical engineering.',
'projects.filter.all': 'All',
'projects.filter.implements': 'Implements',
'projects.filter.mechanical': 'Mechanical Projects',
'projects.filter.reports': 'Reports',
'projects.card.details': 'View details',
// About Page
'about.hero.title': 'About Octto',
'about.hero.subtitle': 'Know our trajectory, values and commitment to engineering excellence.',
'about.history.pretitle': 'Our History',
'about.history.title': 'Our History',
'about.history.subtitle': 'Engineering that drives logistics',
'about.history.p1': 'Octto Engineering was born from the market need for specialized technical solutions in load handling and road implements. We identified that large fleets lacked cutting-edge engineering to ensure safety and efficiency.',
'about.history.p2': 'Today, we are strategic partners of major distribution companies, such as Coca-Cola, developing adaptation, maintenance and equipment certification projects that are vital to the national logistics chain.',
'about.values.pretitle': 'Our Pillars',
'about.values.title': 'Our Pillars',
'about.values.subtitle': 'Values that guide us',
'about.values.1.title': 'Technical Excellence',
'about.values.1.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
'about.values.2.title': 'Transparency',
'about.values.2.desc': 'Clear and honest relationship with customers, suppliers and employees.',
'about.values.3.title': 'Sustainability',
'about.values.3.desc': 'Commitment to practices that respect the environment and society.',
'about.values.quality.title': 'Technical Excellence',
'about.values.quality.desc': 'Relentless pursuit of perfection in every constructive and design detail.',
'about.values.transparency.title': 'Transparency',
'about.values.transparency.desc': 'Clear and honest relationship with customers, suppliers and employees.',
'about.values.sustainability.title': 'Sustainability',
'about.values.sustainability.desc': 'Commitment to practices that respect the environment and society.',
// Contact Page
'contact.hero.title': 'Contact',
'contact.hero.subtitle': 'We are ready to hear about your project. Contact us.',
'contact.info.pretitle': 'Contact Us',
'contact.info.title': 'Service Channels',
'contact.info.subtitle': 'Contact us through our official channels',
'contact.info.whatsapp.desc': 'Fast and direct service.',
'contact.info.email.desc': 'For quotes and technical questions.',
'contact.info.office.title': 'Office',
'contact.info.phone.title': 'WhatsApp',
'contact.info.email.title': 'E-mail',
'contact.info.address.title': 'Office',
'contact.form.title': 'Send a message',
'contact.form.name': 'Name',
'contact.form.name.placeholder': 'Your name',
'contact.form.phone': 'Phone',
'contact.form.email': 'E-mail',
'contact.form.email.placeholder': 'your@email.com',
'contact.form.subject': 'Subject',
'contact.form.message': 'Message',
'contact.form.message.placeholder': 'How can we help?',
'contact.form.submit': 'Send Message',
'contact.form.subject.select': 'Select a subject',
'contact.form.subject.quote': 'Request Quote',
'contact.form.subject.doubt': 'Technical Question',
'contact.form.subject.partnership': 'Partnership',
'contact.form.subject.other': 'Work with Us',
// Cookie Consent
'cookie.text': 'We use cookies to improve your experience and analyze site traffic. By continuing to browse, you agree to our',
'cookie.policy': 'Privacy Policy',
'cookie.accept': 'Accept',
'cookie.decline': 'Decline',
// WhatsApp
'whatsapp.label': 'Quick Service',
},
ES: {
'nav.home': 'Inicio',
'nav.services': 'Servicios',
'nav.projects': 'Proyectos',
'nav.contact': 'Contacto',
'nav.about': 'Sobre',
'nav.search': 'Buscar...',
'nav.contact_us': 'Hable con Nosotros',
'nav.theme': 'Tema',
'nav.language': 'Idioma',
'footer.rights': 'Todos los derechos reservados.',
// Home - Hero
'home.hero.badge': 'Proveedor de Servicio Oficial',
'home.hero.title': 'Ingeniería de',
'home.hero.title_highlight': 'Dispositivos de Elevación',
'home.hero.subtitle': 'Desarrollamos proyectos, informes y soluciones técnicas para equipos de movimiento de carga. Seguridad y cumplimiento normativo para su operación.',
'home.hero.cta_primary': 'Hablar con Ingeniero',
'home.hero.cta_secondary': 'Ver Soluciones',
// Home - Features
'home.features.pretitle': 'Diferenciales',
'home.features.title': 'Seguridad y Eficiencia',
'home.features.1.title': 'Normas Técnicas',
'home.features.1.desc': 'Proyectos y adecuaciones rigurosamente alineados con las normas NR-12, NR-11 y resoluciones del CONTRAN.',
'home.features.2.title': 'Ingeniería Mecánica',
'home.features.2.desc': 'Desarrollo de dispositivos de elevación y soluciones personalizadas para optimizar su logística.',
'home.features.3.title': 'Proyectos de Implementos',
'home.features.3.desc': 'Ingeniería especializada para instalación y adecuación de Grúas, plataformas y dispositivos en vehículos de carga.',
// Home - Services
'home.services.pretitle': 'Lo que hacemos',
'home.services.title': 'Soluciones Especializadas',
'home.services.1.title': 'Proyectos Mecánicos',
'home.services.1.desc': 'Desarrollo de dispositivos de elevación (Spreaders, Balancines).',
'home.services.2.title': 'Informes Técnicos',
'home.services.2.desc': 'Inspección y certificación de equipos de carga conforme normas.',
'home.services.3.title': 'Adecuación NR-12',
'home.services.3.desc': 'Proyectos de seguridad para máquinas y equipos.',
'home.services.4.title': 'Ingeniería Vehicular',
'home.services.4.desc': 'Proyectos para instalación de equipos en camiones.',
'home.services.link': 'Ver todos los servicios',
// Home - About
'home.about.pretitle': 'Sobre Nosotros',
'home.about.title': 'Ingeniería que garantiza seguridad',
'home.about.desc': 'Octto Ingeniería es socia técnica de grandes empresas logísticas. No operamos flotas, garantizamos que los equipos que mueven su carga sean seguros, eficientes y cumplan con las normas.',
'home.about.list.1': 'Proyectos de Dispositivos de Elevación',
'home.about.list.2': 'Informes Técnicos para Grúas',
'home.about.list.3': 'Responsabilidad Técnica (ART) garantizada',
'home.about.link': 'Conozca nuestra experiencia',
// Home - Projects
'home.projects.pretitle': 'Portafolio',
'home.projects.title': 'Proyectos Recientes',
'home.projects.link': 'Ver todos los proyectos',
'home.projects.1.cat': 'Ingeniería Vehicular',
'home.projects.1.title': 'Proyecto de Adecuación - Coca-Cola',
'home.projects.2.cat': 'Inspección Técnica',
'home.projects.2.title': 'Informe de Grúa Articulada',
'home.projects.3.cat': 'Proyecto Mecánico',
'home.projects.3.title': 'Dispositivo de Elevación Especial',
'home.projects.4.cat': 'Informes',
'home.projects.4.title': 'Certificación NR-12 - Parque Industrial',
'home.projects.5.cat': 'Ingeniería Vehicular',
'home.projects.5.title': 'Homologación de Plataforma Elevadora',
'home.projects.6.cat': 'Seguridad Laboral',
'home.projects.6.title': 'Proyecto de Línea de Vida para Camiones',
'home.projects.view_details': 'Ver detalles',
// Home - Testimonials
'home.testimonials.pretitle': 'Testimonios',
'home.testimonials.title': 'Socios que confían',
'home.testimonials.1.text': 'Octto realizó la adecuación de toda nuestra flota de camiones con excelencia técnica y rapidez.',
'home.testimonials.1.role': 'Gerente de Flota, Distribuidora Bebidas',
'home.testimonials.2.text': 'Los informes técnicos emitidos por Octto nos dieron total seguridad jurídica y operativa.',
'home.testimonials.2.role': 'Directora Operativa, Logística Express',
'home.testimonials.3.text': 'El proyecto del dispositivo de elevación resolvió un cuello de botella antiguo de nuestra producción. Recomiendo.',
'home.testimonials.3.role': 'Ingeniero Jefe, Industria Metalúrgica',
// Home - CTA
'home.cta.title': '¿Listo para iniciar su proyecto?',
'home.cta.desc': 'Contáctenos hoy mismo y descubra cómo podemos ayudar a transformar su visión en realidad.',
'home.cta.button': 'Hablar con un Especialista',
// Services Page
'services.hero.title': 'Nuestros Servicios',
'services.hero.subtitle': 'Soluciones completas en ingeniería mecánica y movimiento de carga.',
'services.cta.title': '¿Necesita una solución personalizada?',
'services.cta.button': 'Hablar con un Ingeniero',
'services.scope': 'Alcance del Servicio',
'services.title': 'Servicios',
// Projects Page
'projects.hero.title': 'Nuestros Proyectos',
'projects.hero.subtitle': 'Explore nuestro portafolio de soluciones en movimiento de carga e ingeniería mecánica.',
'projects.filter.all': 'Todos',
'projects.filter.implements': 'Implementos',
'projects.filter.mechanical': 'Proyectos Mecánicos',
'projects.filter.reports': 'Informes',
'projects.card.details': 'Ver detalles',
// About Page
'about.hero.title': 'Sobre Octto',
'about.hero.subtitle': 'Conozca nuestra trayectoria, valores y el compromiso con la excelencia en la ingeniería.',
'about.history.pretitle': 'Nuestra Historia',
'about.history.title': 'Nuestra Historia',
'about.history.subtitle': 'Ingeniería que impulsa la logística',
'about.history.p1': 'Octto Ingeniería nació de la necesidad del mercado por soluciones técnicas especializadas en movimiento de carga e implementos viales. Identificamos que grandes flotas carecían de ingeniería de punta para garantizar seguridad y eficiencia.',
'about.history.p2': 'Hoy, somos socios estratégicos de grandes empresas de distribución, como Coca-Cola, desarrollando proyectos de adecuación, mantenimiento y certificación de equipos que son vitales para la cadena logística nacional.',
'about.values.pretitle': 'Nuestros Pilares',
'about.values.title': 'Nuestros Pilares',
'about.values.subtitle': 'Valores que nos guían',
'about.values.1.title': 'Excelencia Técnica',
'about.values.1.desc': 'Búsqueda incesante de la perfección en cada detalle construtivo e de diseño.',
'about.values.2.title': 'Transparencia',
'about.values.2.desc': 'Relación clara y honesta con clientes, proveedores y empleados.',
'about.values.3.title': 'Sostenibilidad',
'about.values.3.desc': 'Compromiso con prácticas que respetan el medio ambiente y la sociedad.',
'about.values.quality.title': 'Excelência Técnica',
'about.values.quality.desc': 'Búsqueda incesante de la perfección en cada detalhe construtivo e de projeto.',
'about.values.transparency.title': 'Transparência',
'about.values.transparency.desc': 'Relacionamento claro e honesto com clientes, fornecedores e colaboradores.',
'about.values.sustainability.title': 'Sustentabilidade',
'about.values.sustainability.desc': 'Compromisso com práticas que respeitam o meio ambiente e a sociedade.',
// Contact Page
'contact.hero.title': 'Contacto',
'contact.hero.subtitle': 'Estamos listos para escuchar sobre su proyecto. Contáctenos.',
'contact.info.pretitle': 'Hable con Nosotros',
'contact.info.title': 'Canales de Atención',
'contact.info.subtitle': 'Contáctenos a través de nuestros canales oficiales',
'contact.info.whatsapp.desc': 'Atención rápida y directa.',
'contact.info.email.desc': 'Para presupuestos y dudas técnicas.',
'contact.info.office.title': 'Oficina',
'contact.info.phone.title': 'WhatsApp',
'contact.info.email.title': 'E-mail',
'contact.info.address.title': 'Oficina',
'contact.form.title': 'Envíe un mensaje',
'contact.form.name': 'Nombre',
'contact.form.name.placeholder': 'Su nombre',
'contact.form.phone': 'Teléfono',
'contact.form.email': 'E-mail',
'contact.form.email.placeholder': 'su@email.com',
'contact.form.subject': 'Asunto',
'contact.form.message': 'Mensaje',
'contact.form.message.placeholder': '¿Cómo podemos ayudar?',
'contact.form.submit': 'Enviar Mensaje',
'contact.form.subject.select': 'Seleccione un asunto',
'contact.form.subject.budget': 'Solicitar Presupuesto',
'contact.form.subject.tech': 'Duda Técnica',
'contact.form.subject.partnership': 'Asociación',
'contact.form.subject.work': 'Trabaje con Nosotros',
// Cookie Consent
'cookie.text': 'Utilizamos cookies para mejorar su experiencia y analizar el tráfico del sitio. Al continuar navegando, acepta nuestra',
'cookie.policy': 'Política de Privacidad',
'cookie.accept': 'Aceptar',
'cookie.decline': 'Rechazar',
// WhatsApp
'whatsapp.label': 'Atención Rápida',
}
};
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [language, setLanguage] = useState<Language>('PT');
const t = (key: string): string => {
return translations[language][key as keyof typeof translations[typeof language]] || key;
};
const tDynamic = (content: { PT: string, EN?: string, ES?: string }): string => {
if (language === 'PT') return content.PT;
if (language === 'EN' && content.EN) return content.EN;
if (language === 'ES' && content.ES) return content.ES;
return content.PT;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t, tDynamic }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};

View File

@@ -0,0 +1,61 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import Toast from '@/components/Toast';
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
interface ToastContextValue {
success: (message: string) => void;
error: (message: string) => void;
warning: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type']) => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const success = useCallback((message: string) => showToast(message, 'success'), [showToast]);
const error = useCallback((message: string) => showToast(message, 'error'), [showToast]);
const warning = useCallback((message: string) => showToast(message, 'warning'), [showToast]);
const info = useCallback((message: string) => showToast(message, 'info'), [showToast]);
return (
<ToastContext.Provider value={{ success, error, warning, info }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}

View File

@@ -0,0 +1,44 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }
export * as $Enums from './enums'
export * from './enums';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Project
*
*/
export type Project = Prisma.ProjectModel
/**
* Model Service
*
*/
export type Service = Prisma.ServiceModel
/**
* Model Message
*
*/
export type Message = Prisma.MessageModel
/**
* Model PageContent
*
*/
export type PageContent = Prisma.PageContentModel

View File

@@ -0,0 +1,66 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from './enums'
export * from "./enums"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Project
*
*/
export type Project = Prisma.ProjectModel
/**
* Model Service
*
*/
export type Service = Prisma.ServiceModel
/**
* Model Message
*
*/
export type Message = Prisma.MessageModel
/**
* Model PageContent
*
*/
export type PageContent = Prisma.PageContentModel

View File

@@ -0,0 +1,427 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import type * as Prisma from "./internal/prismaNamespace"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
export type JsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedJsonFilter<$PrismaModel>
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}

View File

@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}

View File

@@ -0,0 +1,230 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* Please import the `PrismaClient` class from the `client.ts` file instead.
*/
import * as runtime from "@prisma/client/runtime/client"
import type * as Prisma from "./prismaNamespace"
const config: runtime.GetPrismaClientConfig = {
"previewFeatures": [],
"clientVersion": "7.0.1",
"engineVersion": "f09f2815f091dbba658cdcd2264306d88bb5bda6",
"activeProvider": "postgresql",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../src/generated/client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Modelo de Usuário (para o Painel Admin)\nmodel User {\n id String @id @default(cuid())\n email String @unique\n password String\n name String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Projeto\nmodel Project {\n id String @id @default(cuid())\n title String\n category String\n client String?\n status String @default(\"Em andamento\") // \"Em andamento\", \"Concluído\"\n completionDate DateTime?\n description String? @db.Text\n coverImage String?\n galleryImages String[]\n featured Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Serviço\nmodel Service {\n id String @id @default(cuid())\n title String\n icon String\n shortDescription String?\n fullDescription String? @db.Text\n active Boolean @default(true)\n order Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Mensagem (Contato)\nmodel Message {\n id String @id @default(cuid())\n name String\n email String\n subject String\n message String @db.Text\n status String @default(\"Nova\") // \"Nova\", \"Lida\", \"Respondida\"\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\n// Modelo de Conteúdo de Página (para textos editáveis)\nmodel PageContent {\n id String @id @default(cuid())\n slug String @unique // \"home\", \"sobre\", \"contato\"\n content Json\n updatedAt DateTime @updatedAt\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
"types": {}
}
}
config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"client\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"completionDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"coverImage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"galleryImages\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"featured\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Service\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"shortDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fullDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Message\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subject\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"message\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"PageContent\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
const { Buffer } = await import('node:buffer')
const wasmArray = Buffer.from(wasmBase64, 'base64')
return new WebAssembly.Module(wasmArray)
}
config.compilerWasm = {
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_bg.postgresql.mjs"),
getQueryCompilerWasmModule: async () => {
const { wasm } = await import("@prisma/client/runtime/query_compiler_bg.postgresql.wasm-base64.mjs")
return await decodeBase64AsWasm(wasm)
}
}
export type LogOptions<ClientOptions extends Prisma.PrismaClientOptions> =
'log' extends keyof ClientOptions ? ClientOptions['log'] extends Array<Prisma.LogLevel | Prisma.LogDefinition> ? Prisma.GetEvents<ClientOptions['log']> : never : never
export interface PrismaClientConstructor {
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
new <
Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options> = LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
>(options: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
*/
export interface PrismaClient<
in LogOpts extends Prisma.LogLevel = never,
in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined,
in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
> {
[K: symbol]: { types: Prisma.TypeMap<ExtArgs>['other'] }
$on<V extends LogOpts>(eventType: V, callback: (event: V extends 'query' ? Prisma.QueryEvent : Prisma.LogEvent) => void): PrismaClient;
/**
* Connect with the database
*/
$connect(): runtime.Types.Utils.JsPromise<void>;
/**
* Disconnect from the database
*/
$disconnect(): runtime.Types.Utils.JsPromise<void>;
/**
* Executes a prepared raw query and returns the number of affected rows.
* @example
* ```
* const result = await prisma.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};`
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$executeRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<number>;
/**
* Executes a raw query and returns the number of affected rows.
* Susceptible to SQL injections, see documentation.
* @example
* ```
* const result = await prisma.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com')
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$executeRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<number>;
/**
* Performs a prepared raw query and returns the `SELECT` data.
* @example
* ```
* const result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};`
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$queryRaw<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Prisma.PrismaPromise<T>;
/**
* Performs a raw query and returns the `SELECT` data.
* Susceptible to SQL injections, see documentation.
* @example
* ```
* const result = await prisma.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com')
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/raw-database-access).
*/
$queryRawUnsafe<T = unknown>(query: string, ...values: any[]): Prisma.PrismaPromise<T>;
/**
* Allows the running of a sequence of read/write operations that are guaranteed to either succeed or fail as a whole.
* @example
* ```
* const [george, bob, alice] = await prisma.$transaction([
* prisma.user.create({ data: { name: 'George' } }),
* prisma.user.create({ data: { name: 'Bob' } }),
* prisma.user.create({ data: { name: 'Alice' } }),
* ])
* ```
*
* Read more in our [docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions).
*/
$transaction<P extends Prisma.PrismaPromise<any>[]>(arg: [...P], options?: { isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<runtime.Types.Utils.UnwrapTuple<P>>
$transaction<R>(fn: (prisma: Omit<PrismaClient, runtime.ITXClientDenyList>) => runtime.Types.Utils.JsPromise<R>, options?: { maxWait?: number, timeout?: number, isolationLevel?: Prisma.TransactionIsolationLevel }): runtime.Types.Utils.JsPromise<R>
$extends: runtime.Types.Extensions.ExtendsHook<"extends", Prisma.TypeMapCb<OmitOpts>, ExtArgs, runtime.Types.Utils.Call<Prisma.TypeMapCb<OmitOpts>, {
extArgs: ExtArgs
}>>
/**
* `prisma.user`: Exposes CRUD operations for the **User** model.
* Example usage:
* ```ts
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*/
get user(): Prisma.UserDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.project`: Exposes CRUD operations for the **Project** model.
* Example usage:
* ```ts
* // Fetch zero or more Projects
* const projects = await prisma.project.findMany()
* ```
*/
get project(): Prisma.ProjectDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.service`: Exposes CRUD operations for the **Service** model.
* Example usage:
* ```ts
* // Fetch zero or more Services
* const services = await prisma.service.findMany()
* ```
*/
get service(): Prisma.ServiceDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.message`: Exposes CRUD operations for the **Message** model.
* Example usage:
* ```ts
* // Fetch zero or more Messages
* const messages = await prisma.message.findMany()
* ```
*/
get message(): Prisma.MessageDelegate<ExtArgs, { omit: OmitOpts }>;
/**
* `prisma.pageContent`: Exposes CRUD operations for the **PageContent** model.
* Example usage:
* ```ts
* // Fetch zero or more PageContents
* const pageContents = await prisma.pageContent.findMany()
* ```
*/
get pageContent(): Prisma.PageContentDelegate<ExtArgs, { omit: OmitOpts }>;
}
export function getPrismaClientClass(): PrismaClientConstructor {
return runtime.getPrismaClient(config) as unknown as PrismaClientConstructor
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models'
export type * from './prismaNamespace'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Project: 'Project',
Service: 'Service',
Message: 'Message',
PageContent: 'PageContent'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = {
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
email: 'email',
password: 'password',
name: 'name',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const ProjectScalarFieldEnum = {
id: 'id',
title: 'title',
category: 'category',
client: 'client',
status: 'status',
completionDate: 'completionDate',
description: 'description',
coverImage: 'coverImage',
galleryImages: 'galleryImages',
featured: 'featured',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ProjectScalarFieldEnum = (typeof ProjectScalarFieldEnum)[keyof typeof ProjectScalarFieldEnum]
export const ServiceScalarFieldEnum = {
id: 'id',
title: 'title',
icon: 'icon',
shortDescription: 'shortDescription',
fullDescription: 'fullDescription',
active: 'active',
order: 'order',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceScalarFieldEnum = (typeof ServiceScalarFieldEnum)[keyof typeof ServiceScalarFieldEnum]
export const MessageScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
subject: 'subject',
message: 'message',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type MessageScalarFieldEnum = (typeof MessageScalarFieldEnum)[keyof typeof MessageScalarFieldEnum]
export const PageContentScalarFieldEnum = {
id: 'id',
slug: 'slug',
content: 'content',
updatedAt: 'updatedAt'
} as const
export type PageContentScalarFieldEnum = (typeof PageContentScalarFieldEnum)[keyof typeof PageContentScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const JsonNullValueInput = {
JsonNull: 'JsonNull'
} as const
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const JsonNullValueFilter = {
DbNull: 'DbNull',
JsonNull: 'JsonNull',
AnyNull: 'AnyNull'
} as const
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]

View File

@@ -0,0 +1,16 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User'
export type * from './models/Project'
export type * from './models/Service'
export type * from './models/Message'
export type * from './models/PageContent'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
interface PageContentData {
id: string;
slug: string;
content: any;
updatedAt: string;
}
export function usePageContent(slug: string) {
const [content, setContent] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchContent = async () => {
try {
const response = await fetch(`/api/pages/${slug}`);
if (response.ok) {
const data: PageContentData = await response.json();
setContent(data.content);
} else if (response.status === 404) {
// Página ainda não foi configurada no admin
setContent(null);
} else {
throw new Error('Erro ao carregar conteúdo');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro desconhecido');
} finally {
setLoading(false);
}
};
fetchContent();
}, [slug]);
return { content, loading, error };
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useState, useCallback } from 'react';
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const success = useCallback((message: string) => showToast(message, 'success'), [showToast]);
const error = useCallback((message: string) => showToast(message, 'error'), [showToast]);
const warning = useCallback((message: string) => showToast(message, 'warning'), [showToast]);
const info = useCallback((message: string) => showToast(message, 'info'), [showToast]);
return {
toasts,
removeToast,
success,
error,
warning,
info,
};
}

37
frontend/src/lib/minio.ts Normal file
View File

@@ -0,0 +1,37 @@
import * as Minio from 'minio';
export const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'admin',
secretKey: process.env.MINIO_SECRET_KEY || 'adminpassword',
});
export const bucketName = process.env.MINIO_BUCKET_NAME || 'occto-images';
// Ensure bucket exists
export async function ensureBucketExists() {
try {
const exists = await minioClient.bucketExists(bucketName);
if (!exists) {
await minioClient.makeBucket(bucketName, 'us-east-1');
// Set policy to public read
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${bucketName}/*`],
},
],
};
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy));
console.log(`Bucket ${bucketName} created and policy set to public read.`);
}
} catch (error) {
console.error('Error ensuring bucket exists:', error);
}
}

View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

52
frontend/src/proxy.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-CHANGE-IN-PRODUCTION';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
// Define public and private paths
const isPublicPath = path === '/acesso';
const isPrivatePath = path.startsWith('/admin');
// Get the token from the cookies
const token = request.cookies.get('auth_token')?.value || '';
// Validate JWT token
let isValidToken = false;
if (token) {
try {
jwt.verify(token, JWT_SECRET);
isValidToken = true;
} catch (err) {
// Token inválido ou expirado
isValidToken = false;
}
}
// Redirect logic
if (isPrivatePath && !isValidToken) {
// If trying to access admin without valid token, redirect to login
const response = NextResponse.redirect(new URL('/acesso', request.url));
// Remover token inválido
response.cookies.delete('auth_token');
return response;
}
if (isPublicPath && isValidToken) {
// If trying to access login while already logged in, redirect to admin
return NextResponse.redirect(new URL('/admin', request.url));
}
return NextResponse.next();
}
// Configure which paths the middleware should run on
export const config = {
matcher: [
'/acesso',
'/admin/:path*',
],
};

View File

@@ -0,0 +1,26 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
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: "var(--color-primary)",
secondary: "var(--color-secondary)",
background: "var(--background)",
foreground: "var(--foreground)",
},
fontFamily: {
headline: "var(--font-headline)",
body: "var(--font-body)",
},
},
},
plugins: [],
};
export default config;

34
frontend/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"]
}