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"]
|
"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": {
|
"firestore": {
|
||||||
@@ -67,6 +77,11 @@
|
|||||||
"testimonials/{testimonialId}": {
|
"testimonials/{testimonialId}": {
|
||||||
"schema": "Testimonial",
|
"schema": "Testimonial",
|
||||||
"description": "Public user testimonials."
|
"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.name is string && data.name.size() <= 100
|
||||||
&& (data.isPremium is bool || data.isPremium == null)
|
&& (data.isPremium is bool || data.isPremium == null)
|
||||||
&& (data.isAdmin is bool || data.isAdmin == 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.analysisLimit is int || data.analysisLimit == null)
|
||||||
&& (data.isApproved is bool || data.isApproved == null);
|
&& (data.isApproved is bool || data.isApproved == null);
|
||||||
}
|
}
|
||||||
@@ -120,5 +120,11 @@ service cloud.firestore {
|
|||||||
allow create: if isSignedIn() && isValidTestimonial(incoming());
|
allow create: if isSignedIn() && isValidTestimonial(incoming());
|
||||||
allow update, delete: if isDbAdmin();
|
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 { LandingPage } from './components/LandingPage';
|
||||||
import { ProfileView } from './components/ProfileView';
|
import { ProfileView } from './components/ProfileView';
|
||||||
import { NotificationManager } from './components/NotificationManager';
|
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 { TrendingUp, ShieldAlert, Ghost, Mail, Lock, UserPlus, LogIn, Loader2, ArrowLeft, User as UserIcon, Eye, EyeOff } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile, signOut, sendEmailVerification, sendPasswordResetEmail, User, setPersistence, browserLocalPersistence, browserSessionPersistence } from 'firebase/auth';
|
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';
|
import { auth, db, handleFirestoreError, OperationType } from './lib/firebase';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -24,6 +25,8 @@ export default function App() {
|
|||||||
const [userData, setUserData] = useState<any>(null);
|
const [userData, setUserData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState('scan');
|
const [activeTab, setActiveTab] = useState('scan');
|
||||||
|
const [maintenanceMode, setMaintenanceMode] = useState(false);
|
||||||
|
const [maintenanceMessage, setMaintenanceMessage] = useState('');
|
||||||
|
|
||||||
// Auth & View State
|
// Auth & View State
|
||||||
const [isSecretAdminRoute, setIsSecretAdminRoute] = useState(() => window.location.pathname === '/fxbros-admin' || window.location.hash === '#/fxbros-admin');
|
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');
|
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(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||||
if (currentUser && currentUser.emailVerified) {
|
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 (!user) {
|
||||||
if (!showAuth) {
|
if (!showAuth) {
|
||||||
if (unauthView === 'hero') {
|
if (unauthView === 'hero') {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { 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 { db, handleFirestoreError, OperationType, auth } from '../lib/firebase';
|
||||||
import { ImageUploader } from './ImageUploader';
|
import { ImageUploader } from './ImageUploader';
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
const [testText, setTestText] = useState('');
|
const [testText, setTestText] = useState('');
|
||||||
const [testImageUrls, setTestImageUrls] = useState<string[]>([]);
|
const [testImageUrls, setTestImageUrls] = useState<string[]>([]);
|
||||||
const [submittingTestimonial, setSubmittingTestimonial] = useState(false);
|
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) => {
|
const handleCreateTestimonial = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -70,12 +73,41 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
console.error("Testimonials fetch error:", error);
|
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 () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
unsubTestimonials();
|
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) => {
|
const handleToggleApproval = async (userId: string, currentStatus: boolean) => {
|
||||||
try {
|
try {
|
||||||
await updateDoc(doc(db, 'users', userId), {
|
await updateDoc(doc(db, 'users', userId), {
|
||||||
@@ -156,6 +188,61 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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">
|
<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 React, { useEffect, useState } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
import { Signal, SignalResult, SignalType } from '../types';
|
import { Signal, SignalResult, SignalType } from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { Clock, TrendingUp, TrendingDown, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Clock, TrendingUp, TrendingDown, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
@@ -169,14 +170,20 @@ export const SignalHistory: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-[10px] text-zinc-500 font-black uppercase">Resultado</span>
|
<span className="text-[10px] text-zinc-500 font-black uppercase">Resultado</span>
|
||||||
<div className={cn(
|
<motion.div
|
||||||
"flex items-center justify-end gap-1 font-black italic uppercase text-sm",
|
key={signal.result}
|
||||||
signal.result === SignalResult.GAIN ? "text-green-500" :
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
signal.result === SignalResult.LOSS ? "text-brand-red" :
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
"text-zinc-500"
|
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}
|
{signal.result}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user