feat: Adicionar sistema de código de acesso, upload de foto de perfil e melhorias de UI
- Adicionar modal de verificação de código de acesso na página inicial para cadastro - Adicionar modal de seleção profissional/cliente após verificação do código - Criar página AccessCodeManagement para CEO/SUPERADMIN gerar e gerenciar códigos de acesso - Adicionar upload de foto de perfil no formulário de cadastro de profissional - Adicionar upload de foto de perfil no modal de edição de perfil - Remover botão 'Entrar/Cadastrar' do header nas rotas de login e cadastro - Separar campos Conta e PIX no cadastro de profissional (apenas PIX obrigatório) - Adicionar campo de descrição de equipamentos no cadastro de profissional - Remover edição manual de 'Gastos Captação' (campo calculado) - Converter página de códigos de acesso para formato de tabela estilo Excel - Restaurar fallback de login do usuário demo quando o backend estiver indisponível - Padronizar espaçamentos e fontes do header nas páginas administrativas - Adicionar novo item de menu 'Códigos de Acesso' para CEO/SUPERADMIN
This commit is contained in:
parent
8bea8d1162
commit
888ae9eb62
8 changed files with 633 additions and 45 deletions
|
|
@ -20,6 +20,7 @@ import { SettingsPage } from "./pages/Settings";
|
|||
import { CourseManagement } from "./pages/CourseManagement";
|
||||
import { InspirationPage } from "./pages/Inspiration";
|
||||
import { UserApproval } from "./pages/UserApproval";
|
||||
import { AccessCodeManagement } from "./pages/AccessCodeManagement";
|
||||
import { PrivacyPolicy } from "./pages/PrivacyPolicy";
|
||||
import { TermsOfUse } from "./pages/TermsOfUse";
|
||||
import { LGPD } from "./pages/LGPD";
|
||||
|
|
@ -520,6 +521,18 @@ const AppContent: React.FC = () => {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/codigos-acesso"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
allowedRoles={[UserRole.SUPERADMIN, UserRole.BUSINESS_OWNER]}
|
||||
>
|
||||
<PageWrapper>
|
||||
<AccessCodeManagement onNavigate={(page) => {}} />
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Rota padrão - redireciona para home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -287,25 +287,6 @@ export const FotForm: React.FC<FotFormProps> = ({ onCancel, onSubmit, token, exi
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Gastos Captação */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gastos Captação
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.gastos_captacao}
|
||||
onChange={(e) => setFormData({ ...formData, gastos_captacao: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-brand-gold"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre Venda Checkbox */}
|
||||
<div className="flex items-center h-full pt-6">
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
phone: "",
|
||||
avatar: user?.avatar || "",
|
||||
});
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
|
@ -64,6 +66,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
{ name: "Equipe", path: "equipe" },
|
||||
{ name: "Cadastro de FOT", path: "cursos" },
|
||||
{ name: "Aprovação de Cadastros", path: "aprovacao-cadastros" },
|
||||
{ name: "Códigos de Acesso", path: "codigos-acesso" },
|
||||
{ name: "Financeiro", path: "financeiro" },
|
||||
];
|
||||
case UserRole.EVENT_OWNER:
|
||||
|
|
@ -99,6 +102,32 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
)}&background=random&color=fff&size=128`;
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("A imagem deve ter no máximo 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert("Por favor, selecione uma imagem válida");
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="fixed w-full z-50 bg-white shadow-sm py-2 sm:py-3">
|
||||
|
|
@ -261,6 +290,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!['entrar', 'cadastro', 'cadastro-profissional'].includes(currentPage) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -322,6 +352,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -454,6 +485,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
</button>
|
||||
</>
|
||||
) : (
|
||||
!['entrar', 'cadastro', 'cadastro-profissional'].includes(currentPage) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
|
|
@ -507,6 +539,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -649,13 +682,23 @@ export const Navbar: React.FC<NavbarProps> = ({ onNavigate, currentPage }) => {
|
|||
</button>
|
||||
<div className="w-24 h-24 mx-auto mb-4 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border-4 border-white/30 overflow-hidden relative group">
|
||||
<img
|
||||
src={getAvatarSrc(profileData)}
|
||||
src={avatarPreview || getAvatarSrc(profileData)}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer">
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Camera size={28} className="text-white" />
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Editar Perfil
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface ProfessionalData {
|
|||
email: string;
|
||||
senha: string;
|
||||
confirmarSenha: string;
|
||||
avatar?: File | null;
|
||||
funcaoId: string;
|
||||
cep: string;
|
||||
rua: string;
|
||||
|
|
@ -25,11 +26,13 @@ export interface ProfessionalData {
|
|||
cpfCnpj: string;
|
||||
banco: string;
|
||||
agencia: string;
|
||||
contaPix: string;
|
||||
conta: string;
|
||||
pix: string;
|
||||
carroDisponivel: string;
|
||||
possuiEstudio: string;
|
||||
qtdEstudios: string;
|
||||
tipoCartao: string;
|
||||
equipamentos: string;
|
||||
observacao: string;
|
||||
}
|
||||
|
||||
|
|
@ -43,11 +46,13 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
const [isLoadingFunctions, setIsLoadingFunctions] = useState(false);
|
||||
const [functionsError, setFunctionsError] = useState(false);
|
||||
const [isLoadingCep, setIsLoadingCep] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<ProfessionalData>({
|
||||
nome: "",
|
||||
email: "",
|
||||
senha: "",
|
||||
confirmarSenha: "",
|
||||
avatar: null,
|
||||
funcaoId: "",
|
||||
cep: "",
|
||||
rua: "",
|
||||
|
|
@ -60,11 +65,13 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
cpfCnpj: "",
|
||||
banco: "",
|
||||
agencia: "",
|
||||
contaPix: "",
|
||||
conta: "",
|
||||
pix: "",
|
||||
carroDisponivel: "nao",
|
||||
possuiEstudio: "nao",
|
||||
qtdEstudios: "0",
|
||||
tipoCartao: "",
|
||||
equipamentos: "",
|
||||
observacao: "",
|
||||
});
|
||||
|
||||
|
|
@ -91,6 +98,19 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setFormData((prev) => ({ ...prev, avatar: file }));
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCepBlur = async () => {
|
||||
const cep = formData.cep.replace(/\D/g, "");
|
||||
if (cep.length !== 8) return;
|
||||
|
|
@ -214,6 +234,57 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
onChange={(e) => handleChange("nome", e.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Foto de Perfil
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{avatarPreview ? (
|
||||
<div className="relative w-24 h-24 rounded-full overflow-hidden border-4 border-[#B9CF33] shadow-lg">
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAvatarPreview(null);
|
||||
setFormData((prev) => ({ ...prev, avatar: null }));
|
||||
}}
|
||||
className="absolute top-0 right-0 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
|
||||
title="Remover foto"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<label className="cursor-pointer inline-flex items-center px-4 py-2 bg-[#B9CF33] text-white rounded-lg hover:opacity-90 transition-opacity">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Escolher Foto
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">PNG, JPG ou JPEG até 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="E-mail *"
|
||||
type="email"
|
||||
|
|
@ -419,30 +490,38 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Banco *"
|
||||
label="Banco"
|
||||
type="text"
|
||||
required
|
||||
value={formData.banco}
|
||||
onChange={(e) => handleChange("banco", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Agência *"
|
||||
label="Agência"
|
||||
type="text"
|
||||
required
|
||||
value={formData.agencia}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, "");
|
||||
handleChange("agencia", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Conta / Pix *"
|
||||
label="Conta"
|
||||
type="text"
|
||||
value={formData.conta}
|
||||
onChange={(e) => handleChange("conta", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="PIX *"
|
||||
type="text"
|
||||
required
|
||||
value={formData.contaPix}
|
||||
onChange={(e) => handleChange("contaPix", e.target.value)}
|
||||
placeholder="E-mail, telefone, CPF ou chave aleatória"
|
||||
value={formData.pix}
|
||||
onChange={(e) => handleChange("pix", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -503,6 +582,19 @@ export const ProfessionalForm: React.FC<ProfessionalFormProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Equipamentos
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.equipamentos}
|
||||
onChange={(e) => handleChange("equipamentos", e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent resize-none"
|
||||
placeholder="Descreva os equipamentos que você possui (câmeras, lentes, flashes, etc.)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Tipo de Cartão (Cartão de Memória) *"
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -116,15 +116,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
};
|
||||
|
||||
const login = async (email: string, password?: string) => {
|
||||
// 1. Check for Demo/Mock users first - REMOVED to force API usage
|
||||
// const mockUser = MOCK_USERS.find(u => u.email === email);
|
||||
// if (mockUser) {
|
||||
// await new Promise(resolve => setTimeout(resolve, 800)); // Simulate delay
|
||||
// setUser({ ...mockUser, ativo: true });
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// 2. Try Real API
|
||||
// 1. Try Real API first
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
|
|
@ -166,6 +158,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
return true;
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
|
||||
// 2. Fallback to Demo/Mock users if API fails
|
||||
const mockUser = MOCK_USERS.find(u => u.email === email);
|
||||
if (mockUser) {
|
||||
console.log('Using demo user:', mockUser.email);
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay
|
||||
setUser({ ...mockUser, ativo: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
293
frontend/pages/AccessCodeManagement.tsx
Normal file
293
frontend/pages/AccessCodeManagement.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "../components/Button";
|
||||
import { Copy, Trash2, Plus, Calendar, Key, Check } from "lucide-react";
|
||||
|
||||
interface AccessCodeManagementProps {
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
interface AccessCode {
|
||||
id: string;
|
||||
code: string;
|
||||
expiresIn: number; // dias
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const AccessCodeManagement: React.FC<AccessCodeManagementProps> = ({
|
||||
onNavigate,
|
||||
}) => {
|
||||
const [accessCodes, setAccessCodes] = useState<AccessCode[]>([
|
||||
{
|
||||
id: "1",
|
||||
code: "PHOTUM2025",
|
||||
expiresIn: 30,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
isActive: true,
|
||||
},
|
||||
]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newCodeDays, setNewCodeDays] = useState("7");
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
const generateCode = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 10; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
};
|
||||
|
||||
const handleCreateCode = () => {
|
||||
const days = parseInt(newCodeDays);
|
||||
if (isNaN(days) || days < 1 || days > 365) {
|
||||
alert("Por favor, insira um número válido de dias entre 1 e 365");
|
||||
return;
|
||||
}
|
||||
|
||||
const newCode: AccessCode = {
|
||||
id: Date.now().toString(),
|
||||
code: generateCode(),
|
||||
expiresIn: days,
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
setAccessCodes([newCode, ...accessCodes]);
|
||||
setShowCreateModal(false);
|
||||
setNewCodeDays("7");
|
||||
};
|
||||
|
||||
const handleDeleteCode = (id: string) => {
|
||||
if (confirm("Tem certeza que deseja excluir este código de acesso?")) {
|
||||
setAccessCodes(accessCodes.filter((code) => code.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getDaysRemaining = (expiresAt: Date) => {
|
||||
const now = new Date();
|
||||
const diffTime = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: Date) => {
|
||||
return new Date() > expiresAt;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-serif font-bold text-brand-black mb-2">
|
||||
Gerenciar Códigos de Acesso
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Crie e gerencie códigos de acesso temporários para novos cadastros
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Botão Criar Código */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Gerar Novo Código
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Códigos */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
{accessCodes.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Key size={48} className="mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-500 text-lg mb-2">
|
||||
Nenhum código de acesso criado
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Clique em "Gerar Novo Código" para criar um código de acesso
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Código
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Validade (dias)
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Data de Criação
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Data de Expiração
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{accessCodes.map((code) => {
|
||||
const expired = isExpired(code.expiresAt);
|
||||
const daysRemaining = getDaysRemaining(code.expiresAt);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={code.id}
|
||||
className={`hover:bg-gray-50 transition-colors ${
|
||||
expired ? "bg-red-50/30" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
expired
|
||||
? "bg-red-100 text-red-700"
|
||||
: daysRemaining <= 3
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-green-100 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{expired
|
||||
? "Expirado"
|
||||
: daysRemaining === 1
|
||||
? "1 dia"
|
||||
: `${daysRemaining} dias`}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-lg font-bold text-[#492E61] tracking-wider">
|
||||
{code.code}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopyCode(code.code)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded transition-colors"
|
||||
title="Copiar código"
|
||||
>
|
||||
{copiedCode === code.code ? (
|
||||
<Check size={16} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={16} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{code.expiresIn} {code.expiresIn === 1 ? "dia" : "dias"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{formatDate(code.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{formatDate(code.expiresAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteCode(code.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-lg transition-colors text-sm font-medium"
|
||||
title="Excluir código"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Excluir
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Criar Código */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 sm:p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Gerar Novo Código de Acesso
|
||||
</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Validade do Código (em dias) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={newCodeDays}
|
||||
onChange={(e) => setNewCodeDays(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#492E61] focus:border-transparent"
|
||||
placeholder="Ex: 7"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
O código será válido por {newCodeDays} dia(s) a partir da
|
||||
criação
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Dica:</strong> O código será gerado automaticamente e
|
||||
poderá ser compartilhado com os usuários que desejam se
|
||||
cadastrar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateCode} className="flex-1">
|
||||
Gerar Código
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,49 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../components/Button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface HomeProps {
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
||||
const [showAccessCodeModal, setShowAccessCodeModal] = useState(false);
|
||||
const [accessCode, setAccessCode] = useState("");
|
||||
const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false);
|
||||
const [codeError, setCodeError] = useState("");
|
||||
|
||||
// Código mockado - em produção, verificar com o backend
|
||||
const MOCK_ACCESS_CODE = "PHOTUM2025";
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
setShowAccessCodeModal(true);
|
||||
setAccessCode("");
|
||||
setCodeError("");
|
||||
};
|
||||
|
||||
const handleVerifyCode = () => {
|
||||
if (accessCode.trim() === "") {
|
||||
setCodeError("Por favor, digite o código de acesso");
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessCode.toUpperCase() === MOCK_ACCESS_CODE) {
|
||||
setShowAccessCodeModal(false);
|
||||
setShowProfessionalPrompt(true);
|
||||
} else {
|
||||
setCodeError("Código de acesso inválido ou expirado");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfessionalChoice = (isProfessional: boolean) => {
|
||||
setShowProfessionalPrompt(false);
|
||||
if (isProfessional) {
|
||||
window.location.href = "/cadastro-profissional";
|
||||
} else {
|
||||
window.location.href = "/cadastro";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center relative overflow-hidden"
|
||||
|
|
@ -69,7 +107,7 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => (window.location.href = "/cadastro")}
|
||||
onClick={handleRegisterClick}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
|
|
@ -78,6 +116,132 @@ export const Home: React.FC<HomeProps> = ({ onEnter }) => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de Código de Acesso */}
|
||||
{showAccessCodeModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowAccessCodeModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 sm:p-8 animate-fade-in-scale"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Código de Acesso
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAccessCodeModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Digite o código de acesso fornecido pela empresa para continuar
|
||||
com o cadastro.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código de Acesso *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accessCode}
|
||||
onChange={(e) => {
|
||||
setAccessCode(e.target.value.toUpperCase());
|
||||
setCodeError("");
|
||||
}}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleVerifyCode()}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent uppercase"
|
||||
placeholder="Digite o código"
|
||||
autoFocus
|
||||
/>
|
||||
{codeError && (
|
||||
<p className="text-red-500 text-sm mt-2">{codeError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Atenção:</strong> O código de acesso é fornecido pela
|
||||
empresa e tem validade temporária.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAccessCodeModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleVerifyCode} className="flex-1">
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Escolha de Tipo de Cadastro */}
|
||||
{showProfessionalPrompt && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowProfessionalPrompt(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 sm:p-8 animate-fade-in-scale"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Tipo de Cadastro
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowProfessionalPrompt(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Você é um profissional (fotógrafo/cinegrafista) ou um cliente?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => handleProfessionalChoice(true)}
|
||||
className="w-full p-4 border-2 border-[#492E61] bg-[#492E61]/5 hover:bg-[#492E61]/10 rounded-xl transition-colors text-left group"
|
||||
>
|
||||
<h3 className="font-semibold text-[#492E61] mb-1">
|
||||
Sou Profissional
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Cadastro para fotógrafos e cinegrafistas
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleProfessionalChoice(false)}
|
||||
className="w-full p-4 border-2 border-[#B9CF33] bg-[#B9CF33]/5 hover:bg-[#B9CF33]/10 rounded-xl transition-colors text-left group"
|
||||
>
|
||||
<h3 className="font-semibold text-[#492E61] mb-1">
|
||||
Sou Cliente
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Cadastro para solicitar serviços
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
|
|||
banco: professionalData.banco,
|
||||
carro_disponivel: professionalData.carroDisponivel === "sim",
|
||||
cidade: professionalData.cidade,
|
||||
conta_pix: professionalData.contaPix,
|
||||
conta_pix: professionalData.pix, // Usando o campo PIX separado
|
||||
cpf_cnpj_titular: professionalData.cpfCnpj,
|
||||
endereco: `${professionalData.cep}, ${professionalData.rua}, ${professionalData.numero} - ${professionalData.bairro}`,
|
||||
equipamentos: "", // Campo não está no form explícito, talvez observação ou outro?
|
||||
equipamentos: professionalData.equipamentos,
|
||||
extra_por_equipamento: false, // Default
|
||||
funcao_profissional_id: professionalData.funcaoId,
|
||||
observacao: professionalData.observacao,
|
||||
|
|
|
|||
Loading…
Reference in a new issue