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:
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user