/* AAU CRM — App shell: TopBar + 2-level Nav + hash router */
const NAV = [
{ label: 'Tổng quan', items: [
{ label: 'Executive Dashboard', icon: 'dashboard', path: '/exec' },
{ label: 'Flow Map', icon: 'flow', path: '/flow' },
{ label: 'Lập kế hoạch', icon: 'target', path: '/planning' },
{ label: 'Theo dõi KPI', icon: 'chart', path: '/planning/tracking' },
]},
{ label: 'Marketing', items: [
{ label: 'Marketing Overview', icon: 'dashboard', path: '/marketing/overview' },
{ label: 'Nguồn dữ liệu', icon: 'layers', path: '/marketing/connections' },
{ label: 'Ad Tracking', icon: 'megaphone', path: '/marketing/ads' },
{ label: 'Content Analytics', icon: 'fileText', path: '/marketing/content' },
{ label: 'Lịch nội dung', icon: 'calendar', path: '/marketing/calendar' },
{ label: 'Source Performance', icon: 'chart', path: '/marketing/sources' },
{ label: 'Attribution', icon: 'flow', path: '/marketing/attribution' },
{ label: 'Cấu hình Marketing', icon: 'settings', path: '/marketing/settings' },
]},
{ label: 'Inbox', items: [
{ label: 'Hội thoại', icon: 'inbox', path: '/inbox', dynamicBadge: 'inbox' },
{ label: 'Báo cáo Inbox', icon: 'chart', path: '/inbox/report' },
{ label: 'Nhật ký cuộc gọi', icon: 'phone', path: '/calls' },
]},
{ label: 'Sales CRM', items: [
{ label: 'Hôm nay', icon: 'bolt', path: '/sales/today' },
{ label: 'Pipeline Kanban', icon: 'kanban', path: '/leads/kanban' },
{ label: 'Bảng Pipeline', icon: 'table', path: '/leads' },
{ label: 'Hồ sơ 360°', icon: 'user', path: '/leads/L1' },
{ label: 'Sales Activity', icon: 'chart', path: '/sales/activity' },
{ label: 'Báo cáo Sales', icon: 'chart', path: '/sales/report' },
{ label: 'Cấu hình & Playbook', icon: 'settings', path: '/leads/pipeline-config' },
]},
{ label: 'Vận hành', items: [
{ label: 'Khóa học', icon: 'graduation', path: '/courses' },
{ label: 'Lịch khai giảng', icon: 'calendar', path: '/classes' },
{ label: 'Ghi danh', icon: 'users', path: '/enrollments' },
{ label: 'Giảng viên', icon: 'user', path: '/instructors' },
]},
{ label: 'Tài chính', items: [
{ label: 'Tổng quan hợp nhất', icon: 'dollar', path: '/finance' },
{ label: 'P&L theo khóa', icon: 'graduation', path: '/finance/cohorts' },
{ label: 'Báo cáo doanh thu', icon: 'chart', path: '/finance/revenue' },
{ label: 'So sánh kỳ', icon: 'pieChart', path: '/finance/compare' },
{ label: 'Nguồn & Nhập liệu', icon: 'table', path: '/finance/sources' },
]},
{ label: 'Enablement & AI', items: [
{ label: 'Conversation Intel', icon: 'target', path: '/enablement/intelligence' },
{ label: 'Asset Library', icon: 'layers', path: '/enablement/assets' },
{ label: 'Script Flow', icon: 'flow', path: '/enablement/scripts' },
{ label: 'Knowledge Base', icon: 'book', path: '/enablement/kb' },
{ label: 'AI Chat Config', icon: 'robot', path: '/settings/ai-chat' },
]},
{ label: 'QC Engine', items: [
{ label: 'QC Review', icon: 'sparkles', path: '/qc/review', badge: 6 },
{ label: 'Rulesets', icon: 'checkCircle', path: '/qc/rulesets' },
{ label: 'QC Dashboard', icon: 'chart', path: '/qc/dashboard' },
{ label: 'QC Daily Report', icon: 'fileText', path: '/qc/reports' },
]},
{ label: 'Hệ thống', items: [
{ label: 'Quản lý người dùng', icon: 'users', path: '/admin/users' },
{ label: 'RBAC', icon: 'shield', path: '/admin/permissions' },
{ label: 'Nguồn kết nối', icon: 'message', path: '/system/channels' },
{ label: 'MCP Connections', icon: 'externalLink', path: '/system/mcp' },
{ label: 'Agents Center', icon: 'robot', path: '/system/agents' },
{ label: 'Cấu hình AI', icon: 'sparkles', path: '/system/ai-settings' },
{ label: 'Nhật ký & Chi phí', icon: 'fileText', children: [
{ label: 'Nhật ký hoạt động', path: '/system/activity' },
{ label: 'Nhật ký thông báo', path: '/system/notifications' },
{ label: 'AI Cost', path: '/system/ai-cost' },
]},
{ label: 'Nhật ký cập nhật', icon: 'fileText', path: '/changelog' },
{ label: 'System Settings', icon: 'settings', path: '/settings' },
]},
];
/* ── Universal search: not just features. Indexes screens + live data
(khách hàng, hội thoại, khóa học, lớp, giảng viên) so the box answers
the user's actual intent — a matching feature surfaces first, otherwise
it behaves like a normal flexible search over everything. ── */
function stripDia(s) {
return (s == null ? '' : String(s)).toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd');
}
// Result categories — order defines how groups stack in the panel.
const SEARCH_KINDS = {
feature: { label: 'Màn hình & tính năng', order: 0, cap: 6, hint: 'mở' },
lead: { label: 'Khách hàng & Lead', order: 1, cap: 5, hint: 'hồ sơ' },
conv: { label: 'Hội thoại', order: 2, cap: 4, hint: 'mở inbox' },
course: { label: 'Khóa học', order: 3, cap: 4, hint: 'xem' },
class: { label: 'Lớp khai giảng', order: 4, cap: 3, hint: 'xem' },
instructor: { label: 'Giảng viên', order: 5, cap: 3, hint: 'xem' },
};
function buildSearchIndex() {
const items = [];
// 1) Screens / features from the nav tree
for (const g of NAV) {
for (const it of g.items) {
if (it.path) items.push({ kind: 'feature', label: it.label, sub: g.label, path: it.path, icon: it.icon, badge: it.badge });
if (it.children) for (const c of it.children) {
items.push({ kind: 'feature', label: c.label, sub: g.label + ' · ' + it.label, path: c.path, icon: it.icon, badge: c.badge });
}
}
}
const A = window.AAU;
if (A) {
// 2) Khách hàng / Lead → Hồ sơ 360°
(A.leads || []).forEach(l => {
if (l.name === 'Spam Bot') return;
const st = A.stageById ? A.stageById(l.stage) : null;
items.push({
kind: 'lead', label: l.name, path: '/leads/' + l.id, icon: 'user',
sub: [l.company && l.company !== '—' ? l.company : null, l.region, st && st.name].filter(Boolean).join(' · '),
meta: l.hot ? '🔥' : (l.grade ? l.grade : ''),
text: [l.name, l.company, l.phone, l.email, l.region, l.industry, l.bizModel, (l.painpoints || []).join(' ')].join(' '),
});
});
// 3) Hội thoại → Inbox (deep-link to the conversation)
Object.values(A.conversations || {}).forEach(cv => {
const l = A.leadById(cv.leadId);
const last = cv.messages && cv.messages[cv.messages.length - 1];
const lastTxt = last ? (last.text || (last.attachment ? '📎 ' + last.attachment : '')) : '';
items.push({
kind: 'conv', label: l ? l.name : cv.leadId, path: '/inbox', icon: 'inbox',
intent: { inboxConv: cv.id },
sub: cv.channel.toUpperCase() + (lastTxt ? ' · “' + lastTxt.slice(0, 46) + (lastTxt.length > 46 ? '…' : '') + '”' : ''),
badge: cv.unread || undefined,
text: [l ? l.name : '', l ? l.company : '', cv.channel, (cv.messages || []).map(m => m.text).join(' ')].join(' '),
});
});
// 4) Khóa học
(A.courses || []).forEach(c => items.push({
kind: 'course', label: c.name, path: '/courses', icon: 'graduation',
sub: [c.code, A.courseGroupLabels ? A.courseGroupLabels[c.group] : c.group, c.sessions ? c.sessions + ' buổi' : null].filter(Boolean).join(' · '),
text: [c.name, c.code, c.en, c.desc].join(' '),
}));
// 5) Lớp khai giảng
(A.classes || []).forEach(cl => {
const c = A.courseById ? A.courseById(cl.course) : null;
items.push({
kind: 'class', label: (c ? c.name : cl.course) + ' — ' + cl.batch, path: '/classes', icon: 'calendar',
sub: [cl.start, cl.patLabel, cl.cat].filter(Boolean).join(' · '),
text: [cl.batch, c ? c.name : '', cl.start, cl.cat, cl.patLabel].join(' '),
});
});
// 6) Giảng viên
(A.instructors || []).forEach(i => items.push({
kind: 'instructor', label: i.name, path: '/instructors', icon: 'user',
sub: [i.title, i.expertise].filter(Boolean).join(' · '),
text: [i.name, i.title, i.expertise].join(' '),
}));
}
// precompute normalized search strings
items.forEach(it => { it._l = stripDia(it.label); it._s = stripDia(it.label + ' ' + (it.sub || '') + ' ' + (it.text || '')); });
return items;
}
let _searchIndex = null;
function getSearchIndex() { if (!_searchIndex) _searchIndex = buildSearchIndex(); return _searchIndex; }
function scoreItem(it, qs) {
const l = it._l, s = it._s;
if (l.startsWith(qs)) return 100;
if ((' ' + l).includes(' ' + qs)) return 86; // matches a word start in the label
if (l.includes(qs)) return 72;
if (s.includes(qs)) return 46;
// subsequence match over the label (typo / abbrev tolerant)
let i = 0; for (const ch of l) { if (ch === qs[i]) i++; if (i === qs.length) break; }
if (i === qs.length) return 18;
return -1;
}
function CommandSearch() {
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const inputRef = useRef(null);
const listRef = useRef(null);
const results = useMemo(() => {
const idx = getSearchIndex();
const query = q.trim();
if (!query) {
// Quick access — just the top screens, but the box searches far more.
return idx.filter(i => i.kind === 'feature').slice(0, 7);
}
const qs = stripDia(query);
const scored = idx
.map(it => ({ it, sc: scoreItem(it, qs) }))
.filter(x => x.sc >= 0)
.sort((a, b) => b.sc - a.sc);
// Bucket by kind, cap each, then stack groups in defined order.
const byKind = {};
scored.forEach(({ it, sc }) => { (byKind[it.kind] = byKind[it.kind] || []).push({ ...it, score: sc }); });
const out = [];
Object.keys(SEARCH_KINDS)
.sort((a, b) => SEARCH_KINDS[a].order - SEARCH_KINDS[b].order)
.forEach(k => { if (byKind[k]) out.push(...byKind[k].slice(0, SEARCH_KINDS[k].cap)); });
return out.slice(0, 18);
}, [q]);
useEffect(() => { setActive(0); }, [q]);
// ⌘K / Ctrl-K to focus
useEffect(() => {
const on = e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); inputRef.current && inputRef.current.focus(); setOpen(true);
}
};
window.addEventListener('keydown', on);
return () => window.removeEventListener('keydown', on);
}, []);
const go = (item) => {
if (!item) return;
if (item.intent) window.NavIntent = item.intent; // deep-link payload (e.g. which conversation)
navigate(item.path);
setOpen(false); setQ('');
inputRef.current && inputRef.current.blur();
};
const onKey = e => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(a + 1, results.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); go(results[active]); }
else if (e.key === 'Escape') { setOpen(false); inputRef.current && inputRef.current.blur(); }
};
// keep active item scrolled into view inside the list (skip group headers)
useEffect(() => {
if (!open || !listRef.current) return;
const el = listRef.current.querySelectorAll('.cmd-item')[active];
if (el) { const lr = listRef.current, t = el.offsetTop, b = t + el.offsetHeight;
if (t < lr.scrollTop) lr.scrollTop = t;
else if (b > lr.scrollTop + lr.clientHeight) lr.scrollTop = b - lr.clientHeight; }
}, [active, open]);
return (
inputRef.current && inputRef.current.focus()}>
{ setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={onKey}
/>
⌘K
{open && (
{q.trim() ? (results.length + ' kết quả') : 'Truy cập nhanh'}
{results.length === 0 ? (
Không có kết quả cho “{q.trim()}”.
Thử tên khách, số điện thoại, kênh chat, tên khóa học hoặc giảng viên.
) : (
)}
↑↓ di chuyển
↵ mở
esc đóng
)}
);
}
function useHashRoute() {
const [path, setPath] = useState(() => (location.hash || '#/exec').slice(1));
useEffect(() => {
const on = () => setPath((location.hash || '#/exec').slice(1));
window.addEventListener('hashchange', on);
return () => window.removeEventListener('hashchange', on);
}, []);
return path;
}
function navigate(p) { location.hash = '#' + p; const m = document.querySelector('.main'); if (m) m.scrollTop = 0; }
window.navigate = navigate;
function matchRoute(routes, path) {
for (const r of routes) {
if (r.path === path) return { comp: r.comp, params: {}, route: r };
if (r.path.includes(':')) {
const rp = r.path.split('/'), pp = path.split('/');
if (rp.length !== pp.length) continue;
const params = {}; let ok = true;
for (let i = 0; i < rp.length; i++) { if (rp[i].startsWith(':')) params[rp[i].slice(1)] = decodeURIComponent(pp[i]); else if (rp[i] !== pp[i]) { ok = false; break; } }
if (ok) return { comp: r.comp, params, route: r };
}
}
return null;
}
// Tổng tin chưa đọc thật từ AAU.conversations (cho badge sidebar Hội thoại).
function inboxUnread() {
try { return Object.values(AAU.conversations || {}).reduce((s, c) => s + (Number(c.unread) || 0), 0); }
catch (e) { return 0; }
}
function NavItem({ item, path }) {
const hasKids = item.children && item.children.length;
const childActive = hasKids && item.children.some(c => c.path === path);
const [open, setOpen] = useState(childActive);
useEffect(() => { if (childActive) setOpen(true); }, [childActive]);
const active = item.path === path;
const badge = item.dynamicBadge === 'inbox' ? inboxUnread() : item.badge;
if (!hasKids) {
return (
navigate(item.path)}>
{item.label}
{badge ? {badge} : null}
);
}
return (
setOpen(!open)}>
{item.label}
{open &&
{item.children.map(c => (
navigate(c.path)}>
{c.label}{c.badge && {c.badge}}
))}
}
);
}
function NavGroup({ group, path }) {
const groupHasActive = group.items.some(it =>
it.path === path || (it.children && it.children.some(c => c.path === path))
);
const key = 'navgroup:' + group.label;
const [open, setOpen] = useState(() => {
const saved = localStorage.getItem(key);
if (saved !== null) return saved === '1';
return true;
});
// Always reveal a group that contains the active route
useEffect(() => { if (groupHasActive && !open) setOpen(true); }, [groupHasActive]);
const toggle = () => { const n = !open; setOpen(n); localStorage.setItem(key, n ? '1' : '0'); };
return (
{open &&
{group.items.map(it => )}
}
);
}
function AppShell({ routes }) {
const path = useHashRoute();
const matched = matchRoute(routes, path) || { comp: () => , params: {} };
const Comp = matched.comp;
const [tenant, setTenant] = useState(AAU.tenants[0]);
const viewer = useViewer();
// Re-render sidebar khi dữ liệu hội thoại đổi (hydrate / wipe / đọc tin) → badge real-time.
const [, bump] = React.useReducer(x => x + 1, 0);
useEffect(() => {
const on = () => bump();
window.addEventListener('aau:conversations-changed', on);
return () => window.removeEventListener('aau:conversations-changed', on);
}, []);
return (
navigate('/exec')} style={{ cursor: 'pointer' }}>
A
AAU CRM Center
Học viện F&B
Xem với
{tenant.short}
);
}
function NotFound({ path }) {
return navigate('/exec')}>Về Dashboard} />
;
}
// ============================================================
// Authentication gate — a real login screen in front of the app.
// Enforced only when served from a real host (the VPS); local dev
// (localhost / 127.0.0.1 / file://) opens straight through so the
// build workflow stays frictionless. ?api / token still work as before.
// ============================================================
const LOGIN_INP = { width: '100%', boxSizing: 'border-box', padding: '9px 11px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: 14, outline: 'none' };
function applyAuthUser(u) {
if (!u || !u.id) return;
try { AAU.currentUser = u; } catch (e) { }
try { localStorage.setItem('aau_user', JSON.stringify(u)); } catch (e) { }
try { if (AAU.users.find(x => x.id === u.id)) AccessStore.set(u.id); } catch (e) { }
}
function authLogout() {
try { window.API && window.API.logout && window.API.logout(); } catch (e) { }
try { localStorage.removeItem('aau_token'); localStorage.removeItem('aau_user'); } catch (e) { }
location.reload();
}
function LoginScreen({ onSuccess }) {
const [email, setEmail] = useState('');
const [pw, setPw] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e) {
if (e) e.preventDefault();
if (!email.trim() || !pw) { setErr('Nhập email và mật khẩu.'); return; }
setBusy(true); setErr('');
try {
const res = await window.API.login(email.trim().toLowerCase(), pw);
applyAuthUser(res.user);
onSuccess(res.user);
} catch (ex) {
setErr('Email hoặc mật khẩu không đúng.');
setBusy(false);
}
}
return (
);
}
// ---- shared auth-card chrome (login / activate / forgot / reset) ----
const AUTH_CARD = { width: 380, background: '#fff', borderRadius: 14, padding: '30px 28px', boxShadow: '0 8px 30px rgba(0,0,0,.10)', border: '1px solid #e3e6ea' };
const AUTH_WRAP = { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f4f6f8' };
function authHead(sub) {
return (
);
}
const AUTH_BTN = (off) => ({ width: '100%', marginTop: 18, padding: '10px', borderRadius: 8, border: 'none', background: '#0a7d4f', color: '#fff', fontWeight: 700, fontSize: 14, opacity: off ? .6 : 1, cursor: off ? 'default' : 'pointer' });
// ---- forgot password (request a reset link by email) ----
function ForgotScreen() {
const [email, setEmail] = useState('');
const [msg, setMsg] = useState(''); const [ok, setOk] = useState(true);
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [sent, setSent] = useState(false);
async function submit(e) {
if (e) e.preventDefault();
if (!email.trim()) { setErr('Nhập email của bạn.'); return; }
setBusy(true); setErr('');
try {
await window.AAU_BOOT;
const r = await window.API.forgotPassword(email.trim().toLowerCase());
setOk(r && r.ok !== false);
setMsg((r && r.message) || 'Nếu email tồn tại, chúng tôi đã gửi liên kết đặt lại mật khẩu.');
setSent(true);
} catch (ex) { setErr(ex.message || 'Có lỗi xảy ra, vui lòng thử lại.'); }
setBusy(false);
}
return (
);
}
// ---- reset password (set a new password from the emailed link) ----
function ResetScreen() {
const token = hashQuery('token');
const [info, setInfo] = useState(null);
const [pw, setPw] = useState(''); const [pw2, setPw2] = useState('');
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
(async () => {
try { await window.AAU_BOOT; } catch (e) { }
if (!token) { if (alive) setErr('Link không hợp lệ (thiếu mã đặt lại).'); return; }
try { const r = await window.API.resetInfo(token); if (alive) setInfo(r); }
catch (ex) { if (alive) setErr(ex.message || 'Link không hợp lệ hoặc đã hết hạn.'); }
})();
return () => { alive = false; };
}, []);
async function submit(e) {
if (e) e.preventDefault();
if (pw.length < 6) { setErr('Mật khẩu tối thiểu 6 ký tự.'); return; }
if (pw !== pw2) { setErr('Mật khẩu nhập lại không khớp.'); return; }
setBusy(true); setErr('');
try {
await window.API.resetPassword(token, pw);
setDone(true);
setTimeout(() => { location.hash = '#/exec'; location.reload(); }, 1200);
} catch (ex) { setErr(ex.message || 'Đặt lại mật khẩu thất bại.'); setBusy(false); }
}
if (done) return {authHead('Đặt lại mật khẩu')}
✓ Đổi mật khẩu thành công! Đang đưa bạn vào hệ thống…
;
if (err && !info) return {authHead('Đặt lại mật khẩu')}
{err}
;
return (
);
}
// ---- account activation (invited user sets their own password) ----
function hashRoute() { return (location.hash || '').replace(/^#/, '').split('?')[0]; }
function hashQuery(name) {
const h = location.hash || ''; const i = h.indexOf('?');
if (i < 0) return '';
try { return new URLSearchParams(h.slice(i + 1)).get(name) || ''; } catch (e) { return ''; }
}
function ActivationScreen() {
const token = hashQuery('token');
const [info, setInfo] = useState(null);
const [pw, setPw] = useState(''); const [pw2, setPw2] = useState('');
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
(async () => {
try { await window.AAU_BOOT; } catch (e) { }
if (!token) { if (alive) setErr('Link không hợp lệ (thiếu mã kích hoạt).'); return; }
try { const r = await window.API.activateInfo(token); if (alive) setInfo(r); }
catch (ex) { if (alive) setErr(ex.message || 'Link không hợp lệ hoặc đã hết hạn.'); }
})();
return () => { alive = false; };
}, []);
async function submit(e) {
if (e) e.preventDefault();
if (pw.length < 6) { setErr('Mật khẩu tối thiểu 6 ký tự.'); return; }
if (pw !== pw2) { setErr('Mật khẩu nhập lại không khớp.'); return; }
setBusy(true); setErr('');
try {
await window.API.activate(token, pw);
setDone(true);
setTimeout(() => { location.hash = '#/exec'; location.reload(); }, 1200);
} catch (ex) { setErr(ex.message || 'Kích hoạt thất bại.'); setBusy(false); }
}
const card = { width: 380, background: '#fff', borderRadius: 14, padding: '30px 28px', boxShadow: '0 8px 30px rgba(0,0,0,.10)', border: '1px solid #e3e6ea' };
const wrap = { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f4f6f8' };
const head = (
A
AAU CRM Center
Kích hoạt tài khoản
);
if (done) return {head}
✓ Kích hoạt thành công! Đang đưa bạn vào hệ thống…
;
if (err && !info) return {head}
{err}
Hãy đề nghị quản trị viên gửi lại link mời mới.
;
return (
);
}
function AuthGate({ children }) {
const isLocalDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1' || location.protocol === 'file:';
const [route, setRoute] = useState(hashRoute());
useEffect(() => {
const on = () => setRoute(hashRoute());
window.addEventListener('hashchange', on);
return () => window.removeEventListener('hashchange', on);
}, []);
const [state, setState] = useState(isLocalDev ? 'authed' : 'checking');
const PUBLIC_ROUTES = ['/activate', '/reset', '/forgot'];
useEffect(() => {
if (isLocalDev) return;
if (PUBLIC_ROUTES.includes(hashRoute())) return; // public, no auth check needed
let alive = true;
(async () => {
try { await window.AAU_BOOT; } catch (e) { }
if (!localStorage.getItem('aau_token')) { if (alive) setState('login'); return; }
try {
const me = await window.API.get('/auth/me');
applyAuthUser(me);
if (alive) setState('authed');
} catch (e) {
try { localStorage.removeItem('aau_token'); localStorage.removeItem('aau_user'); } catch (_) { }
if (alive) setState('login');
}
})();
return () => { alive = false; };
}, []);
if (route === '/activate') return ; // public, even on the VPS
if (route === '/reset') return ; // public — opened from reset email
if (route === '/forgot') return ; // public — request a reset link
if (state === 'checking') return Đang tải…
;
if (state === 'login') return setState('authed')} />;
return children;
}
Object.assign(window, { AppShell, NAV, AuthGate, authLogout });