// Main app — routing + state + iPhone shell
const { useState, useEffect, useRef, useCallback } = React;
// ─── Persistência local (localStorage) ────────────────────────────
const STORAGE_KEY = 'cdv:state:v1';
const BOOTSTRAP_KEY = 'cdv:bootstrapped:v1';
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (e) {
return null;
}
}
function loadBootstrap() {
try {
return localStorage.getItem(BOOTSTRAP_KEY) === '1';
} catch (e) {
return false;
}
}
const USER_KEY = 'cdv:user:v1';
function loadUser() {
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
function persistUser(user) {
try {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
else localStorage.removeItem(USER_KEY);
} catch (e) {}
}
function persistState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
// storage cheio ou bloqueado — ignora silenciosamente
}
}
function resetAllData() {
try {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(BOOTSTRAP_KEY);
localStorage.removeItem(USER_KEY);
} catch (e) {}
}
// ─── Preferência de tema ──────────────────────────────────────────
const THEME_PREF_KEY = 'cdv:themePref:v1';
function loadThemePref() {
try {
const v = localStorage.getItem(THEME_PREF_KEY);
return v === 'light' || v === 'dark' || v === 'auto' ? v : 'auto';
} catch (e) { return 'auto'; }
}
function saveThemePref(pref) {
try { localStorage.setItem(THEME_PREF_KEY, pref); } catch (e) {}
}
// Aplica tema sincronamente antes da hidratação do React para evitar flash
(function applyInitialTheme() {
try {
const pref = loadThemePref();
const mode = window.resolveTheme ? window.resolveTheme(pref) : 'dark';
if (window.applyTheme) window.applyTheme(mode);
} catch (e) {}
})();
// Expor reset global para o cliente poder limpar dados via console se precisar
window.CDV_RESET = resetAllData;
const PhoneShell = ({ children, dark = true }) => {
// Detecta se está num celular real (largura < 900px) — aí ocupa tela cheia
// sem o frame iPhone. No desktop, mantém o frame pra preview.
const [isMobile, setIsMobile] = React.useState(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 900;
});
React.useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 900);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
if (isMobile) {
// Celular real: altura FIXA do viewport via flex column. cdv-screen interno
// usa flex: 1 (não height: 100%) pra não depender de cascade de altura.
return (
{children}
);
}
// Desktop: frame iPhone bonito pra preview
return (
);
};
const App = () => {
const [bootstrapped, setBootstrapped] = useState(loadBootstrap);
const [user, setUser] = useState(loadUser);
const [profile, setProfile] = useState(null);
const [route, setRoute] = useState(() => {
try {
const r = new URLSearchParams(location.search).get('route');
const valid = ['dashboard','tasks','finance','habits','goals','calendar','ai','more','profile','achievements','plans','notifications','admin','vault'];
return r && valid.includes(r) ? r : 'dashboard';
} catch (e) { return 'dashboard'; }
});
const [toast, setToast] = useState({ text: '', visible: false, icon: 'check' });
const toastTimer = useRef(null);
const [syncStatus, setSyncStatus] = useState({ status: 'idle', lastSyncedAt: null });
// Quando aplicamos um update vindo do cloud, evitamos disparar save em loop
const skipNextCloudSaveRef = useRef(false);
const debouncedSaveRef = useRef(null);
// Tema
const [themePref, setThemePref] = useState(loadThemePref);
const [themeTick, setThemeTick] = useState(0); // força re-render quando tema muda
// Push notifications: registra SW + re-agenda timers conforme tarefas mudam
useEffect(() => {
if (window.Push && window.Push.registerServiceWorker) {
window.Push.registerServiceWorker();
}
// Listener pra mensagens vindas do SW (clique em notificação)
if (navigator && navigator.serviceWorker) {
const onMsg = (event) => {
const data = event.data || {};
if (data.type === 'NAVIGATE' && data.route) {
setRoute(data.route);
}
};
navigator.serviceWorker.addEventListener('message', onMsg);
return () => navigator.serviceWorker.removeEventListener('message', onMsg);
}
}, []);
const applyAndPersistTheme = useCallback((pref) => {
setThemePref(pref);
saveThemePref(pref);
const mode = window.resolveTheme ? window.resolveTheme(pref) : 'dark';
window.applyTheme && window.applyTheme(mode);
setThemeTick(t => t + 1);
}, []);
// Re-aplicar tema quando preferência muda e, em 'auto', reavaliar a cada 5 min
useEffect(() => {
const mode = window.resolveTheme ? window.resolveTheme(themePref) : 'dark';
window.applyTheme && window.applyTheme(mode);
setThemeTick(t => t + 1);
if (themePref !== 'auto') return;
const interval = setInterval(() => {
const next = window.resolveTheme ? window.resolveTheme('auto') : 'dark';
if (next !== (window.CDV && window.CDV._mode)) {
window.applyTheme && window.applyTheme(next);
setThemeTick(t => t + 1);
}
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [themePref]);
// Sincronizar sessão Supabase ao montar e quando muda em outra aba/redirect OAuth
useEffect(() => {
if (!window.Auth) return;
let mounted = true;
window.Auth.getSession().then((u) => {
if (mounted && u) {
setUser(u);
persistUser(u);
// Se já tinha bootstrap salvo, mantém — senão considera logado e já bootstrapped
if (!loadBootstrap()) setBootstrapped(true);
}
});
const sub = window.Auth.onAuthStateChange((u) => {
setUser(u);
persistUser(u);
if (u && !loadBootstrap()) setBootstrapped(true);
});
return () => { mounted = false; sub && sub.unsubscribe && sub.unsubscribe(); };
}, []);
const handleLogout = useCallback(async () => {
try { window.Auth && await window.Auth.signOut(); } catch (e) {}
setUser(null);
setProfile(null);
persistUser(null);
setBootstrapped(false);
setRoute('dashboard');
}, []);
// Carregar profile do user logado (papel admin, plano, etc.)
useEffect(() => {
if (!user || !user.id || user.isMock) { setProfile(null); return; }
if (!window.AdminAPI) return;
let cancelled = false;
(async () => {
const p = await window.AdminAPI.ensureProfile(user);
if (!cancelled) setProfile(p);
})();
return () => { cancelled = true; };
}, [user && user.id]);
const [state, setState] = useState(() => {
const saved = loadState();
if (saved) return saved;
// Sem state salvo: se Supabase está configurado (usuário real), começa LIMPO.
// Caso contrário (modo demo), carrega os dados de exemplo.
// Atenção: o finance precisa ter a mesma estrutura do initialFinance
// (saldo, categoriaGastos, cofrinhos etc) — várias telas leem direto.
const cfg = window.Auth && window.Auth.isConfigured();
if (cfg) {
return {
tasks: [], habits: [], goals: [],
finance: {
saldo: 0, entradas: 0, saidas: 0, budget: 0,
cartao: 0, cartaoLimite: 0, vencimento: '',
transactions: [], cofrinhos: [], categoriaGastos: [], cards: [],
},
events: [], vault: [], notifications: null,
xp: 0, level: 1, streak: 0,
};
}
return {
tasks: initialTasks,
habits: initialHabits,
goals: initialGoals,
finance: initialFinance,
events: eventsToday,
notifications: null,
xp: 1830,
level: 12,
streak: 47,
};
});
// Persistir state e flag de bootstrap a cada mudança
useEffect(() => { persistState(state); }, [state]);
// Re-agendar notificações sempre que tarefas mudarem
useEffect(() => {
if (window.Push && window.Push.rescheduleTasks) {
window.Push.rescheduleTasks(state.tasks);
}
}, [state.tasks]);
useEffect(() => {
try { localStorage.setItem(BOOTSTRAP_KEY, bootstrapped ? '1' : '0'); } catch (e) {}
}, [bootstrapped]);
// ─── Cloud sync ─────────────────────────────────────────────
// Quando o user fica disponível e o CloudSync está configurado:
// 1. Inicializa debounced saver
// 2. Carrega state da nuvem (resolve conflito com local via updated_at)
// 3. Inscreve em UPDATEs realtime (outros dispositivos)
useEffect(() => {
if (!user || !user.id || user.isMock) return;
if (!window.CloudSync || !window.CloudSync.isConfigured()) return;
let cancelled = false;
// IMPORTANTE: NÃO inicializa debouncedSaveRef aqui. Só depois que o load
// do cloud terminar — senão race condition sobrescreve dados da nuvem.
// Defaults completos pra mesclar com state da nuvem (compat com versões antigas)
const stateDefaults = {
tasks: [], habits: [], goals: [], events: [], vault: [], notifications: null,
xp: 0, level: 1, streak: 0,
finance: {
saldo: 0, entradas: 0, saidas: 0, budget: 0,
cartao: 0, cartaoLimite: 0, vencimento: '',
transactions: [], cofrinhos: [], categoriaGastos: [], cards: [],
},
};
const mergeState = (data) => {
const m = {
...stateDefaults,
...(data || {}),
finance: { ...stateDefaults.finance, ...((data && data.finance) || {}) },
};
if (!Array.isArray(m.tasks)) m.tasks = [];
if (!Array.isArray(m.habits)) m.habits = [];
if (!Array.isArray(m.goals)) m.goals = [];
if (!Array.isArray(m.events)) m.events = [];
if (!Array.isArray(m.vault)) m.vault = [];
if (!Array.isArray(m.finance.transactions)) m.finance.transactions = [];
if (!Array.isArray(m.finance.cofrinhos)) m.finance.cofrinhos = [];
if (!Array.isArray(m.finance.categoriaGastos)) m.finance.categoriaGastos = [];
if (!Array.isArray(m.finance.cards)) m.finance.cards = [];
return m;
};
(async () => {
setSyncStatus({ status: 'loading', lastSyncedAt: null });
const cloud = await window.CloudSync.loadState(user.id);
if (cancelled) return;
if (cloud && cloud.data && Object.keys(cloud.data).length > 0) {
// Tem dados na nuvem — aplica localmente sem disparar save de volta
skipNextCloudSaveRef.current = true;
setState(mergeState(cloud.data));
setSyncStatus({ status: 'saved', lastSyncedAt: cloud.updatedAt });
} else {
// Primeira sincronização: empurra o state atual pro cloud
const res = await window.CloudSync.saveState(user.id, state);
if (cancelled) return;
setSyncStatus({ status: res.ok ? 'saved' : 'error', lastSyncedAt: new Date().toISOString() });
}
// SÓ AGORA habilita o save automático (depois que o load completou)
if (!cancelled) {
debouncedSaveRef.current = window.CloudSync.makeDebouncedSaver(1500);
}
})();
const unsubscribe = window.CloudSync.subscribe(user.id, (remoteData, updatedAt) => {
// Atualiza local sem reenviar pro cloud
skipNextCloudSaveRef.current = true;
setState(mergeState(remoteData));
setSyncStatus({ status: 'saved', lastSyncedAt: updatedAt });
});
return () => {
cancelled = true;
unsubscribe && unsubscribe();
};
}, [user && user.id]);
// Salva no cloud quando state muda (debounced)
useEffect(() => {
if (!user || !user.id || user.isMock) return;
if (!window.CloudSync || !window.CloudSync.isConfigured()) return;
if (!debouncedSaveRef.current) return;
if (skipNextCloudSaveRef.current) {
skipNextCloudSaveRef.current = false;
return;
}
debouncedSaveRef.current(user.id, state, (info) => {
if (info.status === 'pending') {
setSyncStatus(s => ({ ...s, status: 'pending' }));
} else if (info.status === 'saving') {
setSyncStatus(s => ({ ...s, status: 'saving' }));
} else if (info.status === 'saved') {
setSyncStatus({ status: 'saved', lastSyncedAt: new Date().toISOString() });
} else if (info.status === 'error') {
setSyncStatus(s => ({ ...s, status: 'error' }));
}
});
}, [state, user && user.id]);
const showToast = useCallback((text, icon = 'check') => {
clearTimeout(toastTimer.current);
setToast({ text, icon, visible: true });
toastTimer.current = setTimeout(() => setToast(t => ({ ...t, visible: false })), 1800);
}, []);
const navigate = (r) => setRoute(r);
const openAI = () => setRoute('ai');
// Map tab IDs to routes; 'more' covers a few inner pages
const bottomTab = ['dashboard', 'tasks', 'ai', 'finance', 'more', 'profile', 'achievements', 'plans', 'notifications', 'goals', 'calendar', 'habits', 'admin', 'vault'].includes(route)
? (['profile', 'achievements', 'plans', 'notifications', 'goals', 'habits', 'calendar', 'admin', 'vault'].includes(route) ? 'more' : route)
: 'dashboard';
// Calcula status do trial/paywall ANTES dos early returns
// pra manter ordem de hooks consistente entre renders.
const trialStatus = window.Checkout && profile
? window.Checkout.checkTrialStatus(profile)
: { expired: false, isPaying: false };
const isAdminUser = profile && profile.role === 'admin';
const showPaywallMobile = trialStatus.expired && !trialStatus.isPaying && !isAdminUser && window.MobilePaywall;
// Polling automático: a cada 8s rebusca o profile pra detectar quando
// o webhook do Nexano libera o plano após pagamento.
// IMPORTANTE: este useEffect TEM que vir antes dos early returns abaixo.
useEffect(() => {
if (!showPaywallMobile || !user || !user.id || !window.AdminAPI) return;
const tick = async () => {
const p = await window.AdminAPI.ensureProfile(user);
if (p) setProfile(p);
};
const id = setInterval(tick, 8000);
return () => clearInterval(id);
}, [showPaywallMobile, user && user.id]);
if (!bootstrapped) {
return (
{
if (payload && payload.user) { setUser(payload.user); persistUser(payload.user); }
if (payload && payload.profile) {
setState(s => ({ ...s, profile: payload.profile }));
}
if (payload && payload.paymentMethod && payload.user && payload.user.id && window.AdminAPI) {
// Marca trial como ativo no Supabase
const trialEnds = new Date();
trialEnds.setDate(trialEnds.getDate() + 3);
window.AdminAPI.updateMyProfile(payload.user.id, {
plan: 'trial',
trial_ends_at: trialEnds.toISOString(),
}).catch(() => {});
// Salva info do cartão localmente no state (último 4 + bandeira)
setState(s => ({ ...s, paymentMethod: payload.paymentMethod }));
}
setBootstrapped(true);
}} />
);
}
if (showPaywallMobile) {
const refreshProfile = async () => {
if (!window.AdminAPI || !user || !user.id) return;
const p = await window.AdminAPI.ensureProfile(user);
setProfile(p);
showToast('Perfil atualizado');
};
return (
);
}
return (
{route === 'ai' && (
<>
Sofia ouvindo
>
)}
{route === 'dashboard' && (
<>
47 dias
· Lv 12
>
)}
{route === 'dashboard' &&
}
{route === 'tasks' &&
}
{route === 'finance' &&
}
{route === 'habits' &&
}
{route === 'goals' &&
}
{route === 'ai' &&
}
{route === 'calendar' &&
}
{route === 'more' &&
}
{route === 'admin' &&
}
{route === 'vault' &&
}
{route === 'profile' &&
}
{route === 'achievements' &&
}
{route === 'plans' &&
}
{route === 'notifications' &&
}
);
};
ReactDOM.createRoot(document.getElementById('root')).render();