/* ============================================================
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