// mb-api.jsx — camada de comunicação com a API Morar Bem // Carregado antes de qualquer outro arquivo JSX. // Altura da tab bar incluindo a safe-area do iPhone real. // Usar como string em inline-styles: paddingBottom: TAB_H const TAB_H = "calc(72px + env(safe-area-inset-bottom, 0px))"; const TAB_H2 = "calc(80px + env(safe-area-inset-bottom, 0px))"; // um pouco mais folgado // Padding-top considerando status bar / notch do dispositivo real const TOP_H = "max(52px, calc(20px + env(safe-area-inset-top, 0px)))"; const TOP_H2 = "max(58px, calc(20px + env(safe-area-inset-top, 0px)))"; const API_BASE = (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") ? "http://localhost:8080" : window.location.origin; // ── Token storage ───────────────────────────────────────────── const Auth = { get: () => localStorage.getItem("mb_token"), set: (t) => localStorage.setItem("mb_token", t), setRefresh: (t) => localStorage.setItem("mb_refresh", t), getRefresh: () => localStorage.getItem("mb_refresh"), clear: () => { localStorage.removeItem("mb_token"); localStorage.removeItem("mb_refresh"); }, isLogged: () => !!localStorage.getItem("mb_token"), }; // ── Fetch base ──────────────────────────────────────────────── async function apiFetch(path, { method = "GET", body, auth = true, idempotencyKey } = {}) { const headers = { "Content-Type": "application/json" }; if (auth) { const token = Auth.get(); if (token) headers["Authorization"] = "Bearer " + token; } if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const res = await fetch(API_BASE + path, { method, headers, body: body != null ? JSON.stringify(body) : undefined, }); // Auto-refresh se 401 if (res.status === 401 && auth) { const refresh = Auth.getRefresh(); if (refresh) { const r = await fetch(API_BASE + "/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refresh }), }); if (r.ok) { const { access_token, refresh_token } = await r.json(); Auth.set(access_token); Auth.setRefresh(refresh_token); headers["Authorization"] = "Bearer " + access_token; const retry = await fetch(API_BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); return retry.ok ? retry.json() : Promise.reject(await retry.json()); } } Auth.clear(); return Promise.reject({ status: 401, title: "Sessão expirada" }); } if (!res.ok) return Promise.reject(await res.json().catch(() => ({ status: res.status }))); if (res.status === 204) return null; return res.json(); } // ── API endpoints ───────────────────────────────────────────── const API = { // Auth auth: { register: (b) => apiFetch("/auth/customer/register", { method: "POST", body: b, auth: false }), login: (b) => apiFetch("/auth/customer/login", { method: "POST", body: b, auth: false }), forgot: (b) => apiFetch("/auth/customer/forgot", { method: "POST", body: b, auth: false }), verifyOtp: (b) => apiFetch("/auth/customer/verify-otp", { method: "POST", body: b, auth: false }), reset: (b) => apiFetch("/auth/customer/reset", { method: "POST", body: b, auth: false }), refresh: () => apiFetch("/auth/refresh", { method: "POST", body: { refresh_token: Auth.getRefresh() }, auth: false }), }, // Perfil me: { get: () => apiFetch("/me"), update: (b) => apiFetch("/me", { method: "PATCH", body: b }), addresses: () => apiFetch("/me/addresses"), addAddress: (b) => apiFetch("/me/addresses", { method: "POST", body: b }), deleteAddress: (id) => apiFetch(`/me/addresses/${id}`, { method: "DELETE" }), paymentMethods: () => apiFetch("/me/payment-methods"), favorites: () => apiFetch("/me/favorites"), addFavorite: (id) => apiFetch(`/me/favorites/${id}`, { method: "POST" }), removeFavorite:(id) => apiFetch(`/me/favorites/${id}`, { method: "DELETE" }), validateCoupon:(c) => apiFetch(`/me/coupons/validate?code=${encodeURIComponent(c)}`), notifications: () => apiFetch("/me/notifications"), markNotifsRead: () => apiFetch("/me/notifications/read", { method: "POST" }), }, // Carteira wallet: { get: () => apiFetch("/wallet"), transactions: () => apiFetch("/wallet/transactions"), }, // Conteúdo público home: () => apiFetch("/home", { auth: false }), regions: () => apiFetch("/regions", { auth: false }), segments: () => apiFetch("/segments", { auth: false }), guideEditions: () => apiFetch("/guide-editions", { auth: false }), journeyStages: () => apiFetch("/journey-stages", { auth: false }), homeServiceCats: () => apiFetch("/home-service-categories", { auth: false }), gastroCuisines: () => apiFetch("/gastro-categories", { auth: false }), gastroPartners: (id, params = {}) => { const q = new URLSearchParams(); if (params.regionId) q.set("region_id", params.regionId); return apiFetch(`/gastro-categories/${encodeURIComponent(id)}/companies?${q}`, { auth: false }); }, designAssets: () => apiFetch("/design-assets", { auth: false }), // Descobrir discover: (params = {}) => { const q = new URLSearchParams(); if (params.regionId) q.set("region_id", params.regionId); if (params.segmentId) q.set("segment_id", params.segmentId); if (params.categoryId) q.set("category_id", params.categoryId); if (params.lat != null) q.set("lat", params.lat); if (params.lng != null) q.set("lng", params.lng); if (params.radius) q.set("radius", params.radius); if (params.mode) q.set("mode", params.mode); if (params.page) q.set("page", params.page); return apiFetch("/discover/companies?" + q.toString(), { auth: false }); }, // Parceiro / catálogo company: (id) => apiFetch(`/companies/${id}`, { auth: false }), products: (id) => apiFetch(`/companies/${id}/products`, { auth: false }), services: (id) => apiFetch(`/companies/${id}/services`, { auth: false }), listings: (id) => apiFetch(`/companies/${id}/listings`, { auth: false }), companyReviews: (id) => apiFetch(`/companies/${id}/reviews`), myCompanyReview: (id) => apiFetch(`/companies/${id}/my-review`), createCompanyReview: (id, b) => apiFetch(`/companies/${id}/reviews`, { method: "POST", body: b }), // Pedidos orders: { create: (b, key) => apiFetch("/orders", { method: "POST", body: { ...b, idempotency_key: key || crypto.randomUUID() } }), list: () => apiFetch("/orders"), get: (id) => apiFetch(`/orders/${id}`), review: (id, b) => apiFetch(`/orders/${id}/review`, { method: "POST", body: b }), }, // Agendamentos appointments: { create: (b) => apiFetch("/appointments", { method: "POST", body: b }), list: () => apiFetch("/appointments"), get: (id) => apiFetch(`/appointments/${id}`), cancel: (id) => apiFetch(`/appointments/${id}/cancel`, { method: "PATCH" }), reschedule:(id,b)=> apiFetch(`/appointments/${id}/reschedule`, { method: "PATCH", body: b }), }, // Leads leads: { create: (b) => apiFetch("/leads", { method: "POST", body: b }), list: () => apiFetch("/leads"), }, // Clube club: { plans: () => apiFetch("/club/plans", { auth: false }), subscribe: (b) => apiFetch("/club/subscribe", { method: "POST", body: b }), subscription: () => apiFetch("/club/subscription"), cancel: () => apiFetch("/club/cancel", { method: "POST" }), }, // Blog blog: { categories: () => apiFetch("/blog/categories", { auth: false }), posts: (params = {}) => { const q = new URLSearchParams(); if (params.category_id) q.set("category_id", params.category_id); if (params.page) q.set("page", params.page); return apiFetch("/blog/posts?" + q, { auth: false }); }, post: (id) => apiFetch(`/blog/posts/${id}`, { auth: false }), }, }; // ── Helpers ─────────────────────────────────────────────────── // Cálculo de distância Haversine (km) entre dois pontos function haversineKm(lat1, lng1, lat2, lng2) { if (!lat1 || !lng1 || !lat2 || !lng2) return null; const R = 6371, toRad = x => x * Math.PI / 180; const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1); const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2; return +(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))).toFixed(1); } // Recalcula distâncias de uma lista de parceiros dado a posição do usuário function recalcDistances(partners, userLat, userLng) { if (!userLat || !userLng) return partners; return partners.map(p => { const lat = p._raw?.latitude, lng = p._raw?.longitude; const dist = haversineKm(userLat, userLng, lat, lng); return dist != null ? { ...p, dist } : p; }); } // Ordena por plano: Diamante > Ouro > Prata > Bronze > Grátis (aplicar em todo listing) const PLAN_RANK_MAP = { Diamante: 5, Ouro: 4, Prata: 3, Bronze: 2, "Grátis": 1 }; function planRank(p) { return PLAN_RANK_MAP[p._raw?.plan_name || p.plan_name] || 0; } // Converte resposta da API para o formato do mock (retrocompatibilidade) function apiCompanyToPartner(c) { return { id: c.id, name: c.name, seg: c.segment_id || null, // UUID — usado para filtro no ShopScreen cat: null, // nome de categoria não vem no discover (só display) categoryIds: c.category_ids || [], // UUIDs das categorias via company_categories region: c.region_name || "", area: (() => { const addr = (c.endereco || "").trim(); if (!addr) return c.region_name || ""; // Format "Rua X, 123 — Bairro, Cidade/SE" → "Bairro, Cidade/SE" if (addr.includes("—")) return addr.split("—").pop().trim() || c.region_name || ""; // Short format "Bairro, Cidade/SE" → use as-is if (/\/[A-Z]{2}$/.test(addr)) return addr; // Full CNPJ-style address "RUA, NUM, BAIRRO, CIDADE, SE" → use region as fallback return c.region_name || ""; })(), rating: parseFloat(c.rating) || 0, reviews: c.total_avaliacoes || 0, cashback: parseFloat(c.bemcash_padrao_pct) || 0, turbo: parseFloat(c.bemcash_turbo_pct) || 0, dist: c.dist_km != null ? +parseFloat(c.dist_km).toFixed(1) : 0, fee: parseFloat(c.taxa_entrega) || 0, free: parseFloat(c.frete_gratis_acima) || 0, time: c.tempo_estimado || "", delivery: c.aceita_entrega, pickup: c.aceita_retirada, open: c.status === "active", tags: c.bemcash_turbo_pct > 0 ? ["Turbo"] : [], color: c.color || "#1E63C8", blurb: c.sobre || "", instagram:c.instagram, video: c.video_url, phone: c.telefone, whatsapp: c.whatsapp, horario: c.horario, address: c.endereco, type: c.type === "divulga" ? "vitrine" : c.type, hub: c.hub_only, lojaOn: c.loja_habilitada, cashbackOn: c.bemcash_habilitado, logoUrl: c.logo_url || null, coverUrl: c.cover_url || null, photoUrls: c.photo_urls || [], plan_name: c.plan_name || null, _raw: c, // objeto original completo }; } function apiWalletToMock(w, txs = []) { return { balance: parseFloat(w.balance_available) || 0, pending: parseFloat(w.balance_pending) || 0, movements: (txs || []).map(t => ({ id: t.id, kind: t.kind, turbo: t.is_turbo, label: t.label, date: new Date(t.created_at).toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }), value: t.kind === "out" ? -parseFloat(t.amount) : parseFloat(t.amount), })), }; } function apiOrderToMock(o) { return { id: o.id, partner: o.company_id, status: statusLabel(o.status), rawStatus: o.status, date: new Date(o.created_at).toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }), total: parseFloat(o.total) || 0, cashback: parseFloat(o.bemcash_creditado) || 0, turbo: parseFloat(o.bemcash_turbo_creditado) || 0, items: (o.items || []).map(i => i.name), mode: o.mode === "entrega" ? "Entrega" : "Retirada", rated: false, checkoutUrl: o.mp_checkout_url || null, _raw: o, }; } function apiApptToMock(a) { return { id: a.id, partner: a.company_id, service: a.service_name, date: a.scheduled_date?.slice(0, 10).split("-").reverse().join(" ").replace(" ", " ").replace("-", " ") || "", time: a.scheduled_time?.slice(0, 5) || "", local: a.local, status: apptStatusLabel(a.status), upcoming: a.status === "solicitado" || a.status === "confirmado", rated: false, _raw: a, }; } function statusLabel(s) { return { aguardando_pagamento: "Aguardando pagamento", pendente: "Aguardando", em_preparo: "Em preparo", saiu_para_entrega: "A caminho", entregue: "Entregue", concluido: "Avaliar", recusado: "Recusado", cancelado: "Cancelado", }[s] || s; } function apptStatusLabel(s) { return { solicitado: "Aguardando", confirmado: "Confirmado", em_atendimento: "Em andamento", concluido: "Concluído", cancelado: "Cancelado" }[s] || s; } // Expõe globalmente Object.assign(window, { API, Auth, apiFetch, apiCompanyToPartner, haversineKm, recalcDistances, planRank, apiWalletToMock, apiOrderToMock, apiApptToMock, TAB_H, TAB_H2, TOP_H, TOP_H2 });