/* ============================================================ core.jsx — shared state, router, mock data, UI primitives ============================================================ */ // ---------- helpers ---------- const fmtBRL = (n) => (Number(n) || 0).toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); const fmtDate = (d) => { const dt = typeof d === "string" ? new Date(d) : d; return dt.toLocaleDateString("pt-BR", { day: "2-digit", month: "short", year: "numeric" }); }; const fmtDateTime = (d) => { const dt = typeof d === "string" ? new Date(d) : d; return dt.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" }); }; const cls = (...xs) => xs.filter(Boolean).join(" "); const initials = (name = "") => name .trim() .split(/\s+/) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase(); const ago = (d) => { const dt = typeof d === "string" ? new Date(d) : d; const s = Math.floor((Date.now() - dt.getTime()) / 1000); if (s < 60) return "agora"; const m = Math.floor(s / 60); if (m < 60) return `${m} min`; const h = Math.floor(m / 60); if (h < 24) return `${h} h`; const days = Math.floor(h / 24); return `${days} d`; }; const uid = () => Math.random().toString(36).slice(2, 9); // ---------- icons ---------- const Icon = ({ name, size = 18, ...rest }) => { const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", ...rest }; switch (name) { case "dash": return ; case "plus": return ; case "minus": return ; case "link": return ; case "copy": return ; case "check": return ; case "wa": return ; case "qr": return ; case "cart": return ; case "tag": return ; case "user": return ; case "users": return ; case "money": return ; case "card": return ; case "pix": return ; case "set": return ; case "log-out": return ; case "search": return ; case "bell": return ; case "more": return ; case "edit": return ; case "trash": return ; case "dup": return ; case "x": return ; case "lock": return ; case "arrow": return ; case "eye": return ; case "eye-off": return ; case "calendar": return ; case "filter": return ; case "send": return ; case "shield": return ; case "bolt": return ; case "globe": return ; default: return null; } }; // ============================================================ // Mock data store (in-memory; can be lifted to localStorage later) // ============================================================ const seedProducts = [ { id: "p1", name: "Identidade Visual — Pacote Essencial", desc: "Logotipo + paleta + tipografia", price: 2490, active: true, sales: 18, image: null }, { id: "p2", name: "Identidade Visual — Pacote Prime", desc: "Essencial + papelaria + brand book", price: 5990, active: true, sales: 12, image: null }, { id: "p3", name: "Identidade Visual — Exclusive", desc: "Pacote completo, naming e estratégia", price: 12990, active: true, sales: 4, image: null }, { id: "p4", name: "Refresh de Logotipo", desc: "Modernização do logotipo existente", price: 990, active: false, sales: 26, image: null }, ]; const seedBumps = [ { id: "b1", name: "Social Kit Pro", desc: "10 templates editáveis no Figma para Instagram", price: 197, active: true, image: null }, { id: "b2", name: "Manual da Marca Premium", desc: "Brand book em PDF com 24 páginas", price: 297, active: true, image: null }, { id: "b3", name: "Sessão de Estratégia", desc: "1h de consultoria de posicionamento ao vivo", price: 397, active: true, image: null }, { id: "b4", name: "Assinatura de e-mail", desc: "Template HTML responsivo para sua equipe", price: 97, active: false, image: null }, ]; const seedCustomers = [ { id: "c1", name: "Mariana Coelho", email: "mariana@nove.estudio", phone: "(21) 99812-3344", spend: 7280, orders: 2 }, { id: "c2", name: "Rafael Bittencourt", email: "rafa@bittencourt.adv", phone: "(11) 99644-1010", spend: 12990, orders: 1 }, { id: "c3", name: "Luiza Andrade", email: "luiza@andrade.consult", phone: "(31) 99125-7878", spend: 2787, orders: 3 }, { id: "c4", name: "Felipe Tavares", email: "ft@grupotavares.com", phone: "(81) 99088-1212", spend: 5990, orders: 1 }, { id: "c5", name: "Beatriz Nascimento", email: "bia@nascimento.studio", phone: "(41) 99311-2244", spend: 2490, orders: 1 }, { id: "c6", name: "Henrique Lopes", email: "henrique@lopesgroup.com", phone: "(11) 98741-9090", spend: 990, orders: 1 }, ]; const seedSales = [ { id: "ORD-7841", date: "2026-05-13T14:22", customer: "Mariana Coelho", product: "Pacote Prime", method: "pix", installments: 1, amount: 5990, status: "pago" }, { id: "ORD-7840", date: "2026-05-13T11:08", customer: "Rafael Bittencourt", product: "Exclusive", method: "card", installments: 6, amount: 12990, status: "pago" }, { id: "ORD-7839", date: "2026-05-13T09:41", customer: "Luiza Andrade", product: "Refresh Logo", method: "card", installments: 3, amount: 990, status: "pendente" }, { id: "ORD-7838", date: "2026-05-12T18:55", customer: "Felipe Tavares", product: "Pacote Prime", method: "card", installments: 12, amount: 5990, status: "pago" }, { id: "ORD-7837", date: "2026-05-12T17:02", customer: "Beatriz Nascimento", product: "Essencial", method: "pix", installments: 1, amount: 2490, status: "pago" }, { id: "ORD-7836", date: "2026-05-12T15:18", customer: "Henrique Lopes", product: "Refresh Logo", method: "card", installments: 2, amount: 990, status: "recusado" }, { id: "ORD-7835", date: "2026-05-11T22:30", customer: "Mariana Coelho", product: "Social Kit Pro", method: "pix", installments: 1, amount: 1290, status: "pago" }, { id: "ORD-7834", date: "2026-05-11T20:12", customer: "Luiza Andrade", product: "Brand book PDF", method: "card", installments: 1, amount: 897, status: "expirado" }, { id: "ORD-7833", date: "2026-05-11T16:44", customer: "Rafael Bittencourt", product: "Sessão Estratégia",method: "pix", installments: 1, amount: 397, status: "pago" }, ]; // Sales series for chart (29 days) const buildSeries = () => { const base = []; const days = 29; for (let i = 0; i < days; i++) { const v = 800 + Math.round(Math.sin(i / 3) * 200) + Math.round(Math.random() * 800) + (i > 22 ? 600 : 0); base.push(v); } return base; }; const seriesSeed = buildSeries(); // ============================================================ // Global app context — exposes router, toasts, modal, store // ============================================================ const AppContext = React.createContext(null); const useApp = () => React.useContext(AppContext); function AppProvider({ children }){ const [route, _setRoute] = React.useState(() => { const h = (window.location.hash || "#/login").slice(1); return h || "/login"; }); const setRoute = (r) => { window.location.hash = r; _setRoute(r); }; React.useEffect(() => { const onHash = () => _setRoute((window.location.hash || "#/login").slice(1)); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); // auth — restaura sessão do localStorage no boot (sobrevive a F5), // e escuta evento de 401 pra deslogar limpo. const [user, setUser] = React.useState(() => { try { if (window.api && window.api.isAuthed && window.api.isAuthed()) { return window.api.getUser(); } } catch {} return null; }); React.useEffect(() => { const onExpired = () => { setUser(null); setRoute("/login"); }; window.addEventListener("blackx:auth-expired", onExpired); return () => window.removeEventListener("blackx:auth-expired", onExpired); }, []); // ============================================================ // Store — sincronizado com a API real do backend. // Mantém a mesma assinatura `setProducts(fn)` / `setBumps(fn)` que // o resto do código já usa, mas detecta automaticamente se a operação // é create / update / delete e chama o endpoint correspondente. // ============================================================ const [products, setProductsState] = React.useState([]); const [bumps, setBumpsState] = React.useState([]); const [customers, setCustomersState] = React.useState([]); const [sales, setSalesState] = React.useState([]); const [series] = React.useState(seriesSeed); // Normaliza um produto vindo do backend ({id, name, description, price, active, sales_count}) // para o formato que o frontend espera ({id, name, desc, price, active, sales, image}). const normalizeProduct = (p) => ({ id: String(p.id), name: p.name || "", desc: p.description || "", price: Number(p.price) || 0, active: !!Number(p.active), sales: Number(p.sales_count) || 0, image: null, }); const normalizeBump = (b) => ({ id: String(b.id), name: b.name || "", desc: b.description || "", price: Number(b.price) || 0, active: !!Number(b.active), image: null, }); const normalizeCustomer = (c) => ({ id: String(c.id), name: c.name || "", email: c.email || "", phone: c.phone || "", spend: Number(c.total_spend) || 0, orders: Number(c.orders_count) || 0, }); const normalizeSale = (s) => ({ id: s.order_id || String(s.id), date: s.created_at || s.paid_at || "", customer: s.customer_name || s.customer_email || "—", product: "—", method: s.method || "card", installments: Number(s.installments) || 1, amount: Number(s.amount) || 0, status: s.status || "pendente", }); // Carrega coleções da API quando o usuário loga. // Só dispara depois do login pq antes não tem token. const fetchAll = React.useCallback(async () => { if (!window.api || !window.api.isAuthed()) return; try { const [pr, bu, cu, sa] = await Promise.all([ window.api.products.list().catch(() => ({ items: [] })), window.api.bumps.list().catch(() => ({ items: [] })), window.api.customers.list({ per_page: 100 }).catch(() => ({ items: [] })), window.api.sales.list({ per_page: 100 }).catch(() => ({ items: [] })), ]); setProductsState((pr.items || []).map(normalizeProduct)); setBumpsState((bu.items || []).map(normalizeBump)); setCustomersState((cu.items || []).map(normalizeCustomer)); setSalesState((sa.items || []).map(normalizeSale)); } catch (e) { console.warn("Falha ao carregar dados do backend:", e); } }, []); // ------------------------------------------------------------ // setProducts híbrido: aceita os mesmos updaters do React mas roda // contra a API. Detecta se virou create (item novo), update (item // alterado com o mesmo id) ou delete (item sumiu). Otimista: atualiza // UI antes de a API responder, faz rollback se falhar. // ------------------------------------------------------------ const makeSyncSetter = (setLocal, apiKey, normalize, label) => { return (updater) => { const apiObj = (window.api && window.api[apiKey]) || null; // Usa o setLocal funcional pra ter acesso ao state mais recente, // independentemente de quando esta closure foi criada. setLocal((prev) => { const next = typeof updater === "function" ? updater(prev) : updater; if (!apiObj) { console.warn(`[${label}] api.${apiKey} indisponível — operando offline.`); return next; } const prevById = new Map(prev.map((x) => [String(x.id), x])); const nextById = new Map(next.map((x) => [String(x.id), x])); // CREATEs — items que estão em next mas não em prev for (const item of next) { const id = String(item.id); if (prevById.has(id)) continue; apiObj.create({ name: item.name, description: item.desc || "", price: Number(item.price) || 0, active: !!item.active, }).then((created) => { setLocal((arr) => arr.map((x) => String(x.id) === id ? normalize(created) : x)); }).catch((err) => { console.error(`[${label}] create falhou`, err); setLocal(prev); // rollback if (window.__pagblackx_toast) window.__pagblackx_toast(`Erro ao criar ${label}.`, "bad"); }); } // UPDATEs — items presentes em ambos mas com conteúdo diferente for (const item of next) { const id = String(item.id); const before = prevById.get(id); if (!before) continue; if ( before.name !== item.name || before.desc !== item.desc || Number(before.price) !== Number(item.price) || !!before.active !== !!item.active ) { if (!/^\d+$/.test(id)) continue; apiObj.update(Number(id), { name: item.name, description: item.desc || "", price: Number(item.price) || 0, active: !!item.active, }).catch((err) => { console.error(`[${label}] update falhou`, err); setLocal(prev); if (window.__pagblackx_toast) window.__pagblackx_toast(`Erro ao atualizar ${label}.`, "bad"); }); } } // DELETEs — items que estavam em prev mas sumiram em next for (const item of prev) { const id = String(item.id); if (nextById.has(id)) continue; if (!/^\d+$/.test(id)) continue; apiObj.remove(Number(id)).catch((err) => { console.error(`[${label}] delete falhou`, err); setLocal(prev); if (window.__pagblackx_toast) window.__pagblackx_toast(`Erro ao remover ${label}.`, "bad"); }); } return next; }); }; }; const setProducts = React.useMemo(() => makeSyncSetter(setProductsState, "products", normalizeProduct, "produto"), []); const setBumps = React.useMemo(() => makeSyncSetter(setBumpsState, "bumps", normalizeBump, "bump"), []); // Sales não tem CRUD admin (são criadas pelo cliente final via /api/pay), // mas o painel ainda chama setSales em filtros etc — manter como state local. const setSales = setSalesState; // Integrations — Stripe must be explicitly enabled by the admin before // it shows up on the customer-facing checkout. const [stripeEnabled, setStripeEnabled] = React.useState(false); // Facebook Pixel + Webhooks personalizados (Make / Zapier / n8n / endpoint próprio) const [pixel, setPixel] = React.useState({ fbPixelId: "", // ex: 1234567890123456 enabled: false, // só dispara quando ativo + ID preenchido trackInitiate: true, trackPurchase: true, }); // Carrega o Pixel sempre que o admin salva um ID novo. React.useEffect(() => { if (pixel.enabled && pixel.fbPixelId && window.__pagblackx) { window.__pagblackx.initPixel(pixel.fbPixelId); } }, [pixel.enabled, pixel.fbPixelId]); const [webhooks, setWebhooks] = React.useState([ { id: "wh1", name: "Webhook Mercado Pago", url: "https://pagblackx.holdingcriativa.com.br/api/webhook/mp", event: "system", active: true, system: true }, { id: "wh2", name: "Webhook Stripe", url: "https://pagblackx.holdingcriativa.com.br/api/webhook/stripe", event: "system", active: true, system: true }, ]); // Toast queue const [toasts, setToasts] = React.useState([]); const pushToast = (msg, kind = "ok") => { const id = uid(); setToasts((t) => [...t, { id, msg, kind }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200); }; // Expõe pushToast no window pro setter híbrido conseguir avisar erros React.useEffect(() => { window.__pagblackx_toast = pushToast; }, []); // Modal manager const [modal, setModal] = React.useState(null); // Sempre que houver login válido (user vira non-null), carrega dados reais. React.useEffect(() => { if (user) fetchAll(); }, [user, fetchAll]); const value = { route, setRoute, user, setUser, products, setProducts, bumps, setBumps, customers, sales, setSales, series, stripeEnabled, setStripeEnabled, pixel, setPixel, webhooks, setWebhooks, pushToast, modal, setModal, refresh: fetchAll, }; return {children} setModal(null)} />; } function Toasts({ toasts }){ return (
{toasts.map((t) => (
{t.kind === "ok" && } {t.kind === "bad" && } {t.kind === "warn" && } {t.msg}
))}
); } function ModalHost({ modal, onClose }){ if (!modal) return null; return (
e.stopPropagation()}> {modal.title && (

{modal.title}

{modal.sub &&
{modal.sub}
}
)}
{modal.body}
{modal.foot &&
{modal.foot}
}
); } // ============================================================ // Reusable UI primitives // ============================================================ function StatusBadge({ status }){ const map = { pago: { cls: "badge-ok", label: "Pago" }, pendente: { cls: "badge-warn", label: "Pendente" }, recusado: { cls: "badge-bad", label: "Recusado" }, expirado: { cls: "badge-mute", label: "Expirado" }, rascunho: { cls: "badge-mute", label: "Rascunho" }, ativo: { cls: "badge-ok", label: "Ativo" }, inativo: { cls: "badge-mute", label: "Inativo" }, }; const it = map[status] || { cls: "badge-mute", label: status }; return ( {it.label} ); } function MethodPill({ method }){ if (method === "pix") return Pix; if (method === "card") return Cartão; return {method}; } // SVG line chart function LineChart({ data, height = 220 }){ const w = 800; const h = height; const pad = 16; const max = Math.max(...data); const min = Math.min(...data); const pts = data.map((v, i) => { const x = pad + (i / (data.length - 1)) * (w - pad * 2); const y = h - pad - ((v - min) / (max - min || 1)) * (h - pad * 2); return [x, y]; }); const path = pts.map((p, i) => (i === 0 ? `M ${p[0]} ${p[1]}` : `L ${p[0]} ${p[1]}`)).join(" "); const area = `${path} L ${pts[pts.length - 1][0]} ${h - pad} L ${pts[0][0]} ${h - pad} Z`; return ( {[0.25, 0.5, 0.75].map((p, i) => ( ))} {pts.map((p, i) => (i === pts.length - 1 ? ( ) : null))} ); } // Mini sparkline for KPIs function Sparkline({ data, width = 90, height = 30, color = "rgba(10,10,10,.45)" }){ const max = Math.max(...data), min = Math.min(...data); const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * width; const y = height - ((v - min) / (max - min || 1)) * height; return `${x},${y}`; }); return ( ); } // Fake QR (deterministic from seed string) function FakeQR({ seed = "blkk" }){ const cells = 21; let s = 0; for (const c of seed) s = (s * 31 + c.charCodeAt(0)) >>> 0; const grid = []; for (let y = 0; y < cells; y++){ for (let x = 0; x < cells; x++){ s = (s * 1103515245 + 12345) >>> 0; const isFinder = (x < 7 && y < 7) || (x > cells - 8 && y < 7) || (x < 7 && y > cells - 8); const inFinder = (x < 7 && y < 7 && (x === 0 || x === 6 || y === 0 || y === 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4))) || (x > cells - 8 && y < 7 && (x === cells - 7 || x === cells - 1 || y === 0 || y === 6 || (x >= cells - 5 && x <= cells - 3 && y >= 2 && y <= 4))) || (x < 7 && y > cells - 8 && (x === 0 || x === 6 || y === cells - 7 || y === cells - 1 || (x >= 2 && x <= 4 && y >= cells - 5 && y <= cells - 3))); const on = isFinder ? inFinder : (s & 1) === 1; grid.push(on); } } return (
{grid.map((v, i) => (
))}
); } // Brand wordmark — auto-picks the right logo for the surface theme. // → use on light backgrounds (sidebar, dark hero) // → use on dark backgrounds function BrandLogo({ theme = "dark", height = 22, withPay = false }){ const src = theme === "light" ? "assets/blackx-logo-light.png" : "assets/blackx-logo-dark.png"; return ( BlackX {withPay && ( pay )} ); } // Keep BrandMark as a compact square mark (favicon-ish), unused in most places now function BrandMark({ size = 30 }){ return (
B
); } // ============================================================ // ImageDropper — drag/drop or click to upload a product/bump image // ============================================================ function ImageDropper({ value, onChange, label = "Imagem", hint = "PNG ou JPG · proporção 4:3 ideal" }){ const [drag, setDrag] = React.useState(false); const inputRef = React.useRef(null); const handleFile = (file) => { if (!file || !file.type.startsWith("image/")) return; const reader = new FileReader(); reader.onload = () => onChange(reader.result); reader.readAsDataURL(file); }; return (
inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={(e) => { e.preventDefault(); setDrag(false); handleFile(e.dataTransfer.files?.[0]); }} > {value ? ( <>
) : (
Adicionar imagem Arraste aqui ou clique para selecionar {hint}
)} handleFile(e.target.files?.[0])}/>
); } // Expose globals (each Babel