feat: Implement maintenance mode functionality

Introduces a new maintenance mode feature to allow administrators to temporarily disable the application for updates or maintenance.

This includes:
- A dedicated `MaintenancePage` component.
- Global state management for maintenance mode and message in `App.tsx`.
- Firestore integration for storing and retrieving maintenance settings.
- Updates to `AdminDashboard.tsx` to manage these settings.
- Firebase security rules adjustments for the new settings collection.
- Animate transitions for signal results in `SignalHistory.tsx`.
This commit is contained in:
desartstudio95
2026-05-08 11:42:38 +02:00
parent 15b618dda3
commit 6af03ef95e
6 changed files with 216 additions and 11 deletions
+15
View File
@@ -53,6 +53,16 @@
}
},
"required": ["userId", "userName", "text", "timestamp"]
},
"AppSettings": {
"title": "App Settings",
"description": "Global application settings.",
"type": "object",
"properties": {
"maintenanceMode": { "type": "boolean" },
"maintenanceMessage": { "type": "string" }
},
"required": ["maintenanceMode"]
}
},
"firestore": {
@@ -67,6 +77,11 @@
"testimonials/{testimonialId}": {
"schema": "Testimonial",
"description": "Public user testimonials."
},
"settings/{settingId}": {
"schema": "AppSettings",
"description": "Global application settings."
}
}
}
+7 -1
View File
@@ -58,7 +58,7 @@ service cloud.firestore {
&& data.name is string && data.name.size() <= 100
&& (data.isPremium is bool || data.isPremium == null)
&& (data.isAdmin is bool || data.isAdmin == null)
&& data.plan in ['basic', 'pro', 'elite']
&& data.plan in ['basic', 'pro', 'elite', 'lifetime']
&& (data.analysisLimit is int || data.analysisLimit == null)
&& (data.isApproved is bool || data.isApproved == null);
}
@@ -120,5 +120,11 @@ service cloud.firestore {
allow create: if isSignedIn() && isValidTestimonial(incoming());
allow update, delete: if isDbAdmin();
}
// --- Settings Collection ---
match /settings/{settingId} {
allow read: if true;
allow update, create, delete: if isDbAdmin();
}
}
}
+24 -1
View File
@@ -13,10 +13,11 @@ import { AdminDashboard } from './components/AdminDashboard';
import { LandingPage } from './components/LandingPage';
import { ProfileView } from './components/ProfileView';
import { NotificationManager } from './components/NotificationManager';
import { MaintenancePage } from './components/MaintenancePage';
import { TrendingUp, ShieldAlert, Ghost, Mail, Lock, UserPlus, LogIn, Loader2, ArrowLeft, User as UserIcon, Eye, EyeOff } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile, signOut, sendEmailVerification, sendPasswordResetEmail, User, setPersistence, browserLocalPersistence, browserSessionPersistence } from 'firebase/auth';
import { doc, setDoc, getDoc, updateDoc } from 'firebase/firestore';
import { doc, setDoc, getDoc, updateDoc, onSnapshot } from 'firebase/firestore';
import { auth, db, handleFirestoreError, OperationType } from './lib/firebase';
export default function App() {
@@ -24,6 +25,8 @@ export default function App() {
const [userData, setUserData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('scan');
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [maintenanceMessage, setMaintenanceMessage] = useState('');
// Auth & View State
const [isSecretAdminRoute, setIsSecretAdminRoute] = useState(() => window.location.pathname === '/fxbros-admin' || window.location.hash === '#/fxbros-admin');
@@ -45,6 +48,21 @@ export default function App() {
const [unauthView, setUnauthView] = useState<'hero' | 'plans'>('hero');
useEffect(() => {
// Listen for settings changes
const settingsRef = doc(db, 'settings', 'app');
const unsubSettings = onSnapshot(settingsRef, (docSnap) => {
if (docSnap.exists()) {
setMaintenanceMode(docSnap.data().maintenanceMode || false);
setMaintenanceMessage(docSnap.data().maintenanceMessage || '');
}
}, (error) => {
console.warn("Failed to listen to settings:", error);
});
return () => unsubSettings();
}, []);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
if (currentUser && currentUser.emailVerified) {
@@ -235,6 +253,11 @@ export default function App() {
);
}
// Se estiver em manutenção e o usuário não for admin, mostra a página de manutenção
if (maintenanceMode && !isAdmin) {
return <MaintenancePage message={maintenanceMessage} />;
}
if (!user) {
if (!showAuth) {
if (unauthView === 'hero') {
+89 -2
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Users, ShieldCheck, Search, Loader2 } from 'lucide-react';
import { Users, ShieldCheck, Search, Loader2, Settings, ShieldAlert } from 'lucide-react';
import { cn } from '../lib/utils';
import { collection, onSnapshot, doc, updateDoc, addDoc, deleteDoc, query, orderBy } from 'firebase/firestore';
import { collection, onSnapshot, doc, updateDoc, addDoc, deleteDoc, query, orderBy, getDoc, setDoc } from 'firebase/firestore';
import { db, handleFirestoreError, OperationType, auth } from '../lib/firebase';
import { ImageUploader } from './ImageUploader';
@@ -25,6 +25,9 @@ export const AdminDashboard: React.FC = () => {
const [testText, setTestText] = useState('');
const [testImageUrls, setTestImageUrls] = useState<string[]>([]);
const [submittingTestimonial, setSubmittingTestimonial] = useState(false);
const [maintenanceMode, setMaintenanceMode] = useState(false);
const [maintenanceMessage, setMaintenanceMessage] = useState('');
const [savingSettings, setSavingSettings] = useState(false);
const handleCreateTestimonial = async (e: React.FormEvent) => {
e.preventDefault();
@@ -70,12 +73,41 @@ export const AdminDashboard: React.FC = () => {
console.error("Testimonials fetch error:", error);
});
const settingsRef = doc(db, 'settings', 'app');
const unsubSettings = onSnapshot(settingsRef, (docSnap) => {
if (docSnap.exists()) {
setMaintenanceMode(docSnap.data().maintenanceMode || false);
setMaintenanceMessage(docSnap.data().maintenanceMessage || '');
}
}, (error) => {
console.warn("Failed to listen to settings:", error);
});
return () => {
unsub();
unsubTestimonials();
unsubSettings();
};
}, []);
const handleSaveSettings = async () => {
setSavingSettings(true);
try {
const settingsRef = doc(db, 'settings', 'app');
// Using setDoc with merge to create it if it doesn't exist
await setDoc(settingsRef, {
maintenanceMode,
maintenanceMessage
}, { merge: true });
alert('Configurações salvas com sucesso!');
} catch (err) {
console.error(err);
alert('Erro ao salvar as configurações.');
} finally {
setSavingSettings(false);
}
};
const handleToggleApproval = async (userId: string, currentStatus: boolean) => {
try {
await updateDoc(doc(db, 'users', userId), {
@@ -156,6 +188,61 @@ export const AdminDashboard: React.FC = () => {
</div>
</div>
<div className="glass-card p-6 space-y-4 border-l-4 border-l-brand-red mb-8">
<div className="flex items-center gap-3 mb-4">
<Settings className="text-zinc-400" size={20} />
<h3 className="font-black uppercase tracking-widest text-zinc-300 text-sm">Configurações do Sistema</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4 bg-black/20 p-4 rounded-xl border border-white/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className={maintenanceMode ? "text-brand-red" : "text-zinc-500"} size={18} />
<span className="text-sm font-bold text-white uppercase tracking-wider">Modo de Manutenção</span>
</div>
<button
onClick={() => setMaintenanceMode(!maintenanceMode)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
maintenanceMode ? "bg-brand-red" : "bg-zinc-700"
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
maintenanceMode ? "translate-x-6" : "translate-x-1"
)} />
</button>
</div>
<p className="text-xs text-zinc-500 leading-relaxed">
O modo de manutenção bloqueia o acesso ao aplicativo para todos os usuários comuns, exibindo uma tela de aviso. Somente administradores poderão acessar a plataforma.
</p>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">
Mensagem de Manutenção (Opcional)
</label>
<textarea
placeholder="Ex: Estamos realizando melhorias..."
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-white focus:outline-none focus:ring-1 focus:ring-brand-red text-sm min-h-[80px]"
value={maintenanceMessage}
onChange={(e) => setMaintenanceMessage(e.target.value)}
disabled={!maintenanceMode}
/>
</div>
</div>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className="mt-6 w-full md:w-auto px-6 py-2.5 bg-white/10 hover:bg-white/20 text-white text-xs font-black uppercase tracking-widest rounded-lg transition-colors flex items-center justify-center gap-2"
>
{savingSettings ? <Loader2 size={16} className="animate-spin" /> : <ShieldCheck size={16} />}
Salvar Configurações
</button>
</div>
<form onSubmit={handleCreateTestimonial} className="glass-card p-6 space-y-4">
<h3 className="font-black uppercase tracking-widest text-brand-red text-sm">Postar Resultado como Admin</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { motion } from 'motion/react';
import { ShieldAlert, RefreshCw, Activity } from 'lucide-react';
interface MaintenancePageProps {
message?: string;
}
export const MaintenancePage: React.FC<MaintenancePageProps> = ({ message }) => {
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6 relative overflow-hidden">
{/* Background Ambience */}
<div className="absolute inset-0 z-0">
<div className="absolute top-1/-4 left-1/4 w-96 h-96 bg-brand-red/10 rounded-full blur-[120px]" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-brand-red/5 rounded-full blur-[120px]" />
</div>
{/* Grid Pattern */}
<div
className="absolute inset-0 z-0 opacity-20 pointer-events-none"
style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)', backgroundSize: '40px 40px' }}
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 max-w-xl w-full flex flex-col items-center text-center"
>
<div className="relative mb-8">
<div className="absolute inset-0 bg-brand-red/20 blur-xl rounded-full" />
<div className="w-24 h-24 bg-black border border-brand-red/30 rounded-2xl flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-tr from-brand-red/20 to-transparent" />
<Activity className="text-brand-red w-10 h-10 animate-pulse" />
</div>
</div>
<h1 className="text-4xl md:text-5xl font-black italic tracking-tighter mb-4 text-white uppercase drop-shadow-sm">
SISTEMA EM <span className="text-brand-red">MANUTENÇÃO</span>
</h1>
<p className="text-zinc-400 text-lg md:text-xl font-medium mb-8 max-w-md mx-auto">
{message || 'Estamos realizando atualizações e melhorias no QuantScan IA. Voltaremos em breve com o sistema operando em capacidade máxima.'}
</p>
<div className="glass-card w-full p-6 bg-black/40 border border-brand-red/10 flex flex-col items-center">
<div className="flex items-center gap-3 text-brand-red mb-3">
<ShieldAlert size={20} />
<span className="font-black text-xs tracking-widest uppercase">Status do Servidor Offline</span>
</div>
<div className="w-full bg-white/5 rounded-full h-1.5 mb-2 overflow-hidden">
<div className="bg-brand-red h-full w-full rounded-full animate-pulse" style={{ animationDuration: '2s' }} />
</div>
<p className="text-[10px] uppercase tracking-widest text-zinc-500 font-bold">Aguardando reconexão institucional</p>
</div>
<button
onClick={() => window.location.reload()}
className="mt-10 flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-xs font-black uppercase tracking-widest"
>
<RefreshCw size={14} />
Tentar Novamente
</button>
</motion.div>
</div>
);
};
+14 -7
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { Signal, SignalResult, SignalType } from '../types';
import { cn } from '../lib/utils';
import { Clock, TrendingUp, TrendingDown, ChevronDown, ChevronUp } from 'lucide-react';
@@ -169,14 +170,20 @@ export const SignalHistory: React.FC = () => {
</div>
<div className="text-right">
<span className="text-[10px] text-zinc-500 font-black uppercase">Resultado</span>
<div className={cn(
"flex items-center justify-end gap-1 font-black italic uppercase text-sm",
signal.result === SignalResult.GAIN ? "text-green-500" :
signal.result === SignalResult.LOSS ? "text-brand-red" :
"text-zinc-500"
)}>
<motion.div
key={signal.result}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, type: 'spring', bounce: 0.4 }}
className={cn(
"flex items-center justify-end gap-1 font-black italic uppercase text-sm",
signal.result === SignalResult.GAIN ? "text-green-500" :
signal.result === SignalResult.LOSS ? "text-brand-red" :
"text-zinc-500"
)}
>
{signal.result}
</div>
</motion.div>
</div>
</div>