@unbox-plus/cli 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +46 -0
- package/README.md +175 -0
- package/bin/cli.js +439 -0
- package/package.json +43 -0
- package/src/colors.js +100 -0
- package/src/presets.js +241 -0
- package/src/theme.js +253 -0
- package/template/.claude/agents/assets-marca.md +40 -0
- package/template/.claude/agents/avaliador-visual.md +48 -0
- package/template/.claude/agents/branding-briefing.md +401 -0
- package/template/.claude/agents/conteudo-secao.md +46 -0
- package/template/.claude/agents/paginas-legais.md +44 -0
- package/template/.claude/settings.json +3 -0
- package/template/.claude/skills/frontend-design/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +55 -0
- package/template/.claude/skills/web-design-guidelines/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/web-design-guidelines/SKILL.md +50 -0
- package/template/.claude/skills/web-design-guidelines/guidelines-snapshot.md +180 -0
- package/template/.env.example +158 -0
- package/template/.mcp.json.example +14 -0
- package/template/.nvmrc +1 -0
- package/template/CLAUDE.md +333 -0
- package/template/COVERAGE.md +80 -0
- package/template/DEPLOY.md +179 -0
- package/template/QA.md +108 -0
- package/template/README.md +152 -0
- package/template/agents/CONSTRUCAO.md +207 -0
- package/template/agents/MANAGER.md +192 -0
- package/template/agents/PADROES.md +255 -0
- package/template/agents/definitions/00-scaffold.md +56 -0
- package/template/agents/definitions/01-layout.md +47 -0
- package/template/agents/definitions/02-homepage.md +70 -0
- package/template/agents/definitions/03-catalog.md +86 -0
- package/template/agents/definitions/04-pdp.md +97 -0
- package/template/agents/definitions/05-cart.md +116 -0
- package/template/agents/definitions/06-checkout.md +146 -0
- package/template/agents/definitions/07-auth.md +130 -0
- package/template/agents/definitions/08-customer.md +55 -0
- package/template/agents/definitions/09-promotions.md +62 -0
- package/template/agents/definitions/10-feedback.md +128 -0
- package/template/agents/definitions/11-seo-infra.md +159 -0
- package/template/agents/definitions/12-deploy.md +173 -0
- package/template/agents/definitions/13-cro.md +275 -0
- package/template/agents/definitions/14-seo.md +388 -0
- package/template/agents/definitions/15-branding.md +26 -0
- package/template/agents/definitions/16-qa-visual.md +138 -0
- package/template/agents/definitions/17-aeo.md +257 -0
- package/template/app/(loja)/busca/page.tsx +72 -0
- package/template/app/(loja)/carrinho/oferta/page.tsx +57 -0
- package/template/app/(loja)/carrinho/page.tsx +160 -0
- package/template/app/(loja)/categoria/[tagSlug]/layout.tsx +31 -0
- package/template/app/(loja)/categoria/[tagSlug]/loading.tsx +17 -0
- package/template/app/(loja)/categoria/[tagSlug]/page.tsx +49 -0
- package/template/app/(loja)/checkout/page.tsx +80 -0
- package/template/app/(loja)/checkout/pix/[ref]/page.tsx +133 -0
- package/template/app/(loja)/conta/assinaturas/[referenceId]/page.tsx +112 -0
- package/template/app/(loja)/conta/assinaturas/page.tsx +51 -0
- package/template/app/(loja)/conta/enderecos/page.tsx +20 -0
- package/template/app/(loja)/conta/entrar/page.tsx +119 -0
- package/template/app/(loja)/conta/page.tsx +45 -0
- package/template/app/(loja)/conta/pedidos/[referenceId]/page.tsx +49 -0
- package/template/app/(loja)/conta/pedidos/page.tsx +104 -0
- package/template/app/(loja)/conta/preferencias/page.tsx +24 -0
- package/template/app/(loja)/devolucoes/page.tsx +36 -0
- package/template/app/(loja)/layout.tsx +22 -0
- package/template/app/(loja)/oferta/page.tsx +57 -0
- package/template/app/(loja)/page.tsx +86 -0
- package/template/app/(loja)/pedido/[referenceId]/page.tsx +78 -0
- package/template/app/(loja)/privacidade/page.tsx +113 -0
- package/template/app/(loja)/produto/[productSlug]/layout.tsx +23 -0
- package/template/app/(loja)/produto/[productSlug]/loading.tsx +20 -0
- package/template/app/(loja)/produto/[productSlug]/page.tsx +372 -0
- package/template/app/(loja)/produtos/loading.tsx +16 -0
- package/template/app/(loja)/produtos/page.tsx +33 -0
- package/template/app/(loja)/termos/page.tsx +104 -0
- package/template/app/acesso/page.tsx +129 -0
- package/template/app/api/account/addresses/route.ts +39 -0
- package/template/app/api/account/exists/route.ts +21 -0
- package/template/app/api/account/me/route.ts +25 -0
- package/template/app/api/account/otp/route.ts +26 -0
- package/template/app/api/account/preferences/route.ts +19 -0
- package/template/app/api/account/signin/route.ts +27 -0
- package/template/app/api/account/signout/route.ts +9 -0
- package/template/app/api/acesso/route.ts +171 -0
- package/template/app/api/capi/route.ts +34 -0
- package/template/app/api/cart/coupon/route.ts +49 -0
- package/template/app/api/cart/items/route.ts +45 -0
- package/template/app/api/cart/link/route.ts +69 -0
- package/template/app/api/cart/route.ts +113 -0
- package/template/app/api/cart/share/route.ts +21 -0
- package/template/app/api/cep/[code]/route.ts +18 -0
- package/template/app/api/checkout/address/route.ts +24 -0
- package/template/app/api/checkout/email/route.ts +41 -0
- package/template/app/api/checkout/installments/route.ts +16 -0
- package/template/app/api/checkout/route.ts +110 -0
- package/template/app/api/checkout/shipping/route.ts +65 -0
- package/template/app/api/checkout-destination/route.ts +31 -0
- package/template/app/api/order/[ref]/route.ts +17 -0
- package/template/app/api/payment-link/route.ts +28 -0
- package/template/app/api/revalidate/route.ts +52 -0
- package/template/app/api/shipping/quote/route.ts +29 -0
- package/template/app/api/subscriptions/[id]/route.ts +52 -0
- package/template/app/api/track/route.ts +28 -0
- package/template/app/api/unbox/catalogo/route.ts +136 -0
- package/template/app/api/unbox/paginas/route.ts +167 -0
- package/template/app/api/unbox/vitrine/route.ts +84 -0
- package/template/app/api/webhooks/unbox/route.ts +97 -0
- package/template/app/apple-icon.svg +5 -0
- package/template/app/error.tsx +15 -0
- package/template/app/globals.css +457 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +90 -0
- package/template/app/llms.txt/route.ts +43 -0
- package/template/app/manifest.ts +21 -0
- package/template/app/not-found.tsx +18 -0
- package/template/app/opengraph-image.tsx +35 -0
- package/template/app/robots.ts +31 -0
- package/template/app/sitemap.ts +42 -0
- package/template/bootstrap.sh +87 -0
- package/template/components/account/account-shell.tsx +47 -0
- package/template/components/account/address-book.tsx +147 -0
- package/template/components/account/preferences-form.tsx +77 -0
- package/template/components/account/reorder-button.tsx +47 -0
- package/template/components/account/signout-button.tsx +20 -0
- package/template/components/account/subscription-actions.tsx +204 -0
- package/template/components/account-nav.tsx +41 -0
- package/template/components/address-fields.tsx +102 -0
- package/template/components/analytics/data-layer-ready.tsx +16 -0
- package/template/components/analytics/purchase-tracker.tsx +32 -0
- package/template/components/brand-search.tsx +34 -0
- package/template/components/cart/cart-provider.tsx +340 -0
- package/template/components/cart/mini-cart.tsx +228 -0
- package/template/components/cart-button.tsx +35 -0
- package/template/components/catalog/catalog-client.tsx +523 -0
- package/template/components/catalog/grid-skeleton.tsx +19 -0
- package/template/components/catalog/pager.tsx +66 -0
- package/template/components/catalog/product-grid-card.tsx +52 -0
- package/template/components/catalog/product-grid.tsx +27 -0
- package/template/components/checkout/checkout-client.tsx +1333 -0
- package/template/components/chrome/announce-bar.tsx +57 -0
- package/template/components/chrome/chrome-recipe.ts +13 -0
- package/template/components/chrome/footers/captura-botao.tsx +28 -0
- package/template/components/chrome/footers/colunas.tsx +110 -0
- package/template/components/chrome/footers/conversao.tsx +90 -0
- package/template/components/chrome/footers/editorial.tsx +98 -0
- package/template/components/chrome/footers/minimal.tsx +55 -0
- package/template/components/chrome/header-bar-mobile.tsx +64 -0
- package/template/components/chrome/headers/centralizado.tsx +71 -0
- package/template/components/chrome/headers/classico.tsx +64 -0
- package/template/components/chrome/headers/compacto.tsx +66 -0
- package/template/components/chrome/headers/equilibrado.tsx +73 -0
- package/template/components/chrome/headers/imersivo.tsx +62 -0
- package/template/components/chrome/registry.ts +70 -0
- package/template/components/chrome/solid-on-scroll.tsx +39 -0
- package/template/components/empty-state.tsx +22 -0
- package/template/components/home/combo-card-compact.tsx +67 -0
- package/template/components/home/combos-home.tsx +64 -0
- package/template/components/home/combos-section.tsx +177 -0
- package/template/components/home/home-recipe.ts +41 -0
- package/template/components/home/sections/attributes-marquee.tsx +178 -0
- package/template/components/home/sections/benefits.tsx +80 -0
- package/template/components/home/sections/bloco-html.tsx +47 -0
- package/template/components/home/sections/catalogo.ts +165 -0
- package/template/components/home/sections/category-pills.tsx +32 -0
- package/template/components/home/sections/combos-carousel.tsx +141 -0
- package/template/components/home/sections/comparison.tsx +73 -0
- package/template/components/home/sections/founder-story.tsx +40 -0
- package/template/components/home/sections/hero.tsx +187 -0
- package/template/components/home/sections/kits.tsx +11 -0
- package/template/components/home/sections/media-cards.tsx +70 -0
- package/template/components/home/sections/newsletter.tsx +62 -0
- package/template/components/home/sections/product-showcase.tsx +175 -0
- package/template/components/home/sections/purchase-hero.tsx +187 -0
- package/template/components/home/sections/quote-banner.tsx +50 -0
- package/template/components/home/sections/registry.ts +186 -0
- package/template/components/home/sections/reviews-carousel.tsx +66 -0
- package/template/components/home/sections/reviews.tsx +68 -0
- package/template/components/home/sections/ritual.tsx +96 -0
- package/template/components/home/sections/savings.tsx +47 -0
- package/template/components/home/sections/social-row.tsx +65 -0
- package/template/components/home/sections/spec-table.tsx +117 -0
- package/template/components/home/sections/stars.tsx +12 -0
- package/template/components/home/sections/stats-grid.tsx +54 -0
- package/template/components/home/sections/trust-bar.tsx +56 -0
- package/template/components/home/sections/trust-strip.tsx +40 -0
- package/template/components/home/sections/video-wall.tsx +59 -0
- package/template/components/landing/landing-recipe.ts +20 -0
- package/template/components/landing/oferta-sections.tsx +40 -0
- package/template/components/landing/product-picker.tsx +257 -0
- package/template/components/mobile-nav.tsx +71 -0
- package/template/components/order-status.tsx +163 -0
- package/template/components/powered-by-unbox.tsx +27 -0
- package/template/components/product/pdp/buy-box.tsx +604 -0
- package/template/components/product/pdp/catalog-grid.tsx +113 -0
- package/template/components/product/pdp/faq-modelo.ts +86 -0
- package/template/components/product/pdp/gallery.tsx +87 -0
- package/template/components/product/pdp/interactive.tsx +200 -0
- package/template/components/product/pdp/newsletter.tsx +64 -0
- package/template/components/product/pdp/payment-chips.tsx +22 -0
- package/template/components/product/pdp/pdp-view.tsx +227 -0
- package/template/components/product/pdp/recommendations.tsx +144 -0
- package/template/components/product/pdp/sections.tsx +173 -0
- package/template/components/quantity-stepper.tsx +36 -0
- package/template/components/search-box.tsx +37 -0
- package/template/components/site-footer.tsx +59 -0
- package/template/components/site-header.tsx +64 -0
- package/template/components/ui/accordion.tsx +72 -0
- package/template/components/ui/alert-dialog.tsx +187 -0
- package/template/components/ui/alert.tsx +76 -0
- package/template/components/ui/aspect-ratio.tsx +22 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +52 -0
- package/template/components/ui/breadcrumb.tsx +125 -0
- package/template/components/ui/button.tsx +58 -0
- package/template/components/ui/card.tsx +103 -0
- package/template/components/ui/carousel.tsx +242 -0
- package/template/components/ui/command.tsx +196 -0
- package/template/components/ui/dialog.tsx +160 -0
- package/template/components/ui/dropdown-menu.tsx +268 -0
- package/template/components/ui/foto.tsx +54 -0
- package/template/components/ui/input-group.tsx +158 -0
- package/template/components/ui/input-otp.tsx +87 -0
- package/template/components/ui/input.tsx +20 -0
- package/template/components/ui/label.tsx +20 -0
- package/template/components/ui/navigation-menu.tsx +168 -0
- package/template/components/ui/pagination.tsx +132 -0
- package/template/components/ui/popover.tsx +90 -0
- package/template/components/ui/progress.tsx +83 -0
- package/template/components/ui/radio-group.tsx +38 -0
- package/template/components/ui/scroll-area.tsx +55 -0
- package/template/components/ui/select.tsx +201 -0
- package/template/components/ui/separator.tsx +25 -0
- package/template/components/ui/sheet.tsx +138 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/sonner.tsx +44 -0
- package/template/components/ui/switch.tsx +32 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +82 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +89 -0
- package/template/components/ui/toggle.tsx +45 -0
- package/template/components/ui/tooltip.tsx +66 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +36 -0
- package/template/gitignore +21 -0
- package/template/lib/analytics.ts +369 -0
- package/template/lib/api.ts +60 -0
- package/template/lib/capi.ts +87 -0
- package/template/lib/cart-link.ts +51 -0
- package/template/lib/cart-normalize.ts +142 -0
- package/template/lib/cart-recovery.ts +23 -0
- package/template/lib/cart-response.ts +48 -0
- package/template/lib/catalog-map.ts +54 -0
- package/template/lib/catalog.ts +3 -0
- package/template/lib/checkout-lock.ts +21 -0
- package/template/lib/checkout-nav.ts +22 -0
- package/template/lib/config.ts +51 -0
- package/template/lib/crm.ts +40 -0
- package/template/lib/customer-session.ts +21 -0
- package/template/lib/dataloader.ts +23 -0
- package/template/lib/editable/config.ts +11 -0
- package/template/lib/editable/document.ts +1762 -0
- package/template/lib/editable/index.ts +3 -0
- package/template/lib/editable/primitives.tsx +821 -0
- package/template/lib/editable/provider.tsx +861 -0
- package/template/lib/editable/rastreio-navegacao.tsx +65 -0
- package/template/lib/editable/rastreio.tsx +168 -0
- package/template/lib/editable/server.ts +189 -0
- package/template/lib/editable/tokens.ts +22 -0
- package/template/lib/editable/verify.ts +24 -0
- package/template/lib/enrichment/combos.ts +184 -0
- package/template/lib/enrichment/index.ts +188 -0
- package/template/lib/enrichment/products.json +1 -0
- package/template/lib/env-check.ts +47 -0
- package/template/lib/format.ts +130 -0
- package/template/lib/icons.ts +67 -0
- package/template/lib/json-ld.ts +9 -0
- package/template/lib/llms-txt.ts +75 -0
- package/template/lib/mockup.ts +24 -0
- package/template/lib/newsletter.ts +12 -0
- package/template/lib/orders.ts +160 -0
- package/template/lib/queries.ts +66 -0
- package/template/lib/ratelimit.ts +47 -0
- package/template/lib/rotas-editaveis.ts +192 -0
- package/template/lib/sanitize.ts +36 -0
- package/template/lib/schemas.ts +78 -0
- package/template/lib/session.ts +98 -0
- package/template/lib/store-config.ts +67 -0
- package/template/lib/unbox/client.ts +829 -0
- package/template/lib/unbox/customer.ts +224 -0
- package/template/lib/unbox/errors.ts +97 -0
- package/template/lib/unbox/index.ts +10 -0
- package/template/lib/unbox/store.ts +113 -0
- package/template/lib/unbox/types.ts +195 -0
- package/template/lib/unbox/webhooks.ts +66 -0
- package/template/lib/utils.ts +6 -0
- package/template/lib/vitrine.ts +242 -0
- package/template/lib/webhook-store.ts +22 -0
- package/template/middleware.ts +178 -0
- package/template/next.config.ts +63 -0
- package/template/package.json +62 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/brand/coll/default.png +0 -0
- package/template/public/brand/hero-desktop.svg +12 -0
- package/template/public/brand/hero-mobile.svg +12 -0
- package/template/public/brand/heros/boutique-desktop.svg +11 -0
- package/template/public/brand/heros/boutique-mobile.svg +11 -0
- package/template/public/brand/heros/editorial-desktop.svg +11 -0
- package/template/public/brand/heros/editorial-mobile.svg +11 -0
- package/template/public/brand/heros/essencial-desktop.svg +12 -0
- package/template/public/brand/heros/essencial-mobile.svg +12 -0
- package/template/public/brand/heros/promocional-desktop.svg +12 -0
- package/template/public/brand/heros/promocional-mobile.svg +12 -0
- package/template/public/brand/logo-chrome.svg +6 -0
- package/template/public/brand/logo-white.svg +6 -0
- package/template/public/brand/logo.svg +7 -0
- package/template/public/brand/pay-amex.webp +0 -0
- package/template/public/brand/pay-elo.webp +0 -0
- package/template/public/brand/pay-mastercard.webp +0 -0
- package/template/public/brand/pay-pix.webp +0 -0
- package/template/public/brand/pay-visa.webp +0 -0
- package/template/public/brand/ph/avatar-a.svg +5 -0
- package/template/public/brand/ph/avatar-b.svg +5 -0
- package/template/public/brand/ph/avatar-c.svg +5 -0
- package/template/public/brand/ph/avatar-d.svg +5 -0
- package/template/public/brand/ph/photo-a.svg +7 -0
- package/template/public/brand/ph/photo-b.svg +6 -0
- package/template/public/brand/ph/photo-c.svg +6 -0
- package/template/public/brand/ph/poster-a.svg +6 -0
- package/template/public/brand/ph/poster-b.svg +6 -0
- package/template/public/brand/ph/poster-c.svg +6 -0
- package/template/public/unbox/powered-by-fundo-preto.png +0 -0
- package/template/public/unbox/powered-by-transparente.png +0 -0
- package/template/public/unbox/powered-by.png +0 -0
- package/template/scripts/abandoned-cart.ts +82 -0
- package/template/scripts/check-editable.mjs +467 -0
- package/template/scripts/check-honestidade.mjs +111 -0
- package/template/scripts/check-placeholder.mjs +200 -0
- package/template/scripts/check-recipe.mjs +76 -0
- package/template/scripts/check-unbox-brand.mjs +298 -0
- package/template/scripts/contraste.js +177 -0
- package/template/scripts/dump-catalog.ts +95 -0
- package/template/scripts/load-env.ts +13 -0
- package/template/scripts/medir-sistema.mjs +56 -0
- package/template/scripts/place-order-pix.ts +61 -0
- package/template/scripts/qa-screenshots.mjs +150 -0
- package/template/scripts/sistema.py +231 -0
- package/template/scripts/subscribe-webhook.ts +36 -0
- package/template/scripts/test-live.ts +186 -0
- package/template/scripts/vocabulario.py +264 -0
- package/template/tsconfig.json +27 -0
- package/tools/LEIA-ME.md +39 -0
- package/tools/check-template-neutro.mjs +111 -0
- package/tools/notion-doc.mjs +191 -0
- package/tools/tokenize-neutrals.mjs +0 -0
- package/tools/tokenize-radius.mjs +52 -0
- package/tools/workflow-storefront.legado.js +1207 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// analytics.ts — a camada ÚNICA de tracking da loja. Não existe outra; se você achar um
|
|
5
|
+
// segundo lugar empurrando no dataLayer, é bug.
|
|
6
|
+
//
|
|
7
|
+
// Cada evento sai por até quatro canais, no mesmo disparo:
|
|
8
|
+
// 1. window.dataLayer — SEMPRE, em formato de OBJETO. É o que o GTM lê. Serve ao container
|
|
9
|
+
// central da Unbox (GTM-PZLT336) e a qualquer container da loja (inclusive server-side
|
|
10
|
+
// via Stape). O objeto carrega o formato GA4 padrão (`ecommerce.items` com `item_id`) E,
|
|
11
|
+
// na compra, as chaves que o container central lê (`ecommerce.purchase.*` e as clássicas
|
|
12
|
+
// `transactionId`/`transactionTotal`), porque ele foi construído em três gerações e lê
|
|
13
|
+
// as três. Medido no JS do container, não suposição.
|
|
14
|
+
// 2. gtag('event') — só se existir (loja com NEXT_PUBLIC_GA_ID, GA4 direto sem GTM). Antes
|
|
15
|
+
// este canal tinha PRIORIDADE e o dataLayer só era usado na ausência dele: bastava ligar
|
|
16
|
+
// o GA4 próprio para o GTM central ficar cego a todo o e-commerce.
|
|
17
|
+
// 3. fbq('track') — Meta Pixel, com eventID para dedupe com a CAPI.
|
|
18
|
+
// 4. POST /api/capi — Meta Conversions API, server-side, mesmo eventID.
|
|
19
|
+
//
|
|
20
|
+
// Regras que vieram de um relatório de cliente (She Talks, ago/2026) sobre a loja nativa da
|
|
21
|
+
// Unbox, e que esta foundation cumpre:
|
|
22
|
+
// • `value` é sempre NÚMERO (nunca "R$23,34"). Um único campo de valor.
|
|
23
|
+
// • `discount` por item vem do carrinho; a soma dos itens fecha com o valor.
|
|
24
|
+
// • UMA estrutura para todos os eventos: `ecommerce.items[{item_id, ...}]`.
|
|
25
|
+
// • purchase dispara UMA vez (dedupe em localStorage) e SÓ quando o pedido está PAGO — quem
|
|
26
|
+
// decide é o chamador (order.paid). Pix pendente nunca é venda.
|
|
27
|
+
// • Dados de cliente para correspondência (em, ph, fn, ln, zp, external_id) saem daqui JÁ
|
|
28
|
+
// HASHEADOS (SHA-256) — no dataLayer, no Pixel e na CAPI. Texto aberto não entra no
|
|
29
|
+
// dataLayer, porque qualquer tag de qualquer container o lê.
|
|
30
|
+
// • dataLayerReady { pageType, products[] } no carregamento de produto/carrinho/checkout/
|
|
31
|
+
// confirmação: é o que alimenta o remarketing do Google Ads no container central.
|
|
32
|
+
//
|
|
33
|
+
// Não há page_view manual no dataLayer: os containers têm listener de History Change e
|
|
34
|
+
// contariam em dobro. O page_view de SPA vai só pro gtag direto, que não tem esse listener.
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
export interface TrackItem {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
variant?: string;
|
|
41
|
+
price?: number;
|
|
42
|
+
/** desconto unitário já aplicado (cupom/promoção). Preço de tabela = price + discount. */
|
|
43
|
+
discount?: number;
|
|
44
|
+
quantity?: number;
|
|
45
|
+
category?: string;
|
|
46
|
+
listName?: string;
|
|
47
|
+
index?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Dados do cliente. Entram em TEXTO ABERTO aqui e saem hasheados de todos os canais. */
|
|
51
|
+
export interface TrackUser {
|
|
52
|
+
email?: string;
|
|
53
|
+
phone?: string;
|
|
54
|
+
firstName?: string;
|
|
55
|
+
lastName?: string;
|
|
56
|
+
zip?: string;
|
|
57
|
+
/** id estável do cliente na Unbox; sem ele, o e-mail hasheado faz o papel */
|
|
58
|
+
customerId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PageType = "home" | "category" | "product" | "cart" | "checkout" | "purchase" | "search" | "other";
|
|
62
|
+
|
|
63
|
+
type Win = Window & { gtag?: (...a: any[]) => void; fbq?: (...a: any[]) => void; dataLayer?: any[] };
|
|
64
|
+
|
|
65
|
+
const CURRENCY = "BRL";
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------------ utilitários
|
|
68
|
+
function genEventId(): string {
|
|
69
|
+
try { if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); } catch { /* ignora */ }
|
|
70
|
+
return `e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sha256(value?: string): Promise<string | undefined> {
|
|
74
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
75
|
+
if (!v || typeof crypto === "undefined" || !crypto.subtle) return undefined;
|
|
76
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v));
|
|
77
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Telefone p/ Meta: só dígitos, com DDI 55 quando ausente. */
|
|
81
|
+
function normPhone(phone?: string): string | undefined {
|
|
82
|
+
const d = (phone ?? "").replace(/\D/g, "");
|
|
83
|
+
if (!d) return undefined;
|
|
84
|
+
return d.length <= 11 ? `55${d}` : d;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** CEP p/ Meta: só dígitos (o Meta normaliza os 5 primeiros; mandamos os 8). */
|
|
88
|
+
function normZip(zip?: string): string | undefined {
|
|
89
|
+
const d = (zip ?? "").replace(/\D/g, "");
|
|
90
|
+
return d || undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Nome sem acento, minúsculo — é assim que o Meta espera antes do hash. */
|
|
94
|
+
function normName(name?: string): string | undefined {
|
|
95
|
+
const n = (name ?? "").trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
96
|
+
return n || undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface HashedUser { em?: string; ph?: string; fn?: string; ln?: string; zp?: string; external_id?: string }
|
|
100
|
+
|
|
101
|
+
/** Hasheia os campos de correspondência. Retorna só o que existir. */
|
|
102
|
+
export async function hashUser(u?: TrackUser): Promise<HashedUser | undefined> {
|
|
103
|
+
if (!u) return undefined;
|
|
104
|
+
const [em, ph, fn, ln, zp, ext] = await Promise.all([
|
|
105
|
+
sha256(u.email),
|
|
106
|
+
sha256(normPhone(u.phone)),
|
|
107
|
+
sha256(normName(u.firstName)),
|
|
108
|
+
sha256(normName(u.lastName)),
|
|
109
|
+
sha256(normZip(u.zip)),
|
|
110
|
+
// external_id: id da Unbox quando logado; senão o e-mail hasheado faz o papel de id estável
|
|
111
|
+
sha256(u.customerId || u.email),
|
|
112
|
+
]);
|
|
113
|
+
const out: HashedUser = {};
|
|
114
|
+
if (em) out.em = em; if (ph) out.ph = ph; if (fn) out.fn = fn; if (ln) out.ln = ln; if (zp) out.zp = zp;
|
|
115
|
+
if (ext) out.external_id = ext;
|
|
116
|
+
return Object.keys(out).length ? out : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let brandCache: string | undefined;
|
|
120
|
+
function brand(): string | undefined {
|
|
121
|
+
if (brandCache !== undefined) return brandCache || undefined;
|
|
122
|
+
try {
|
|
123
|
+
brandCache = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement | null)?.content ?? "";
|
|
124
|
+
} catch { brandCache = ""; }
|
|
125
|
+
return brandCache || undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function round2(n: number) { return Math.round(n * 100) / 100; }
|
|
129
|
+
|
|
130
|
+
/** Item no formato GA4. Só chaves com valor — o GTM não gosta de `undefined` em variável. */
|
|
131
|
+
function gaItem(it: TrackItem) {
|
|
132
|
+
const o: Record<string, any> = { item_id: it.id, item_name: it.name, quantity: it.quantity ?? 1 };
|
|
133
|
+
const b = brand(); if (b) o.item_brand = b;
|
|
134
|
+
if (it.variant) o.item_variant = it.variant;
|
|
135
|
+
if (it.category) o.item_category = it.category;
|
|
136
|
+
if (it.listName) o.item_list_name = it.listName;
|
|
137
|
+
if (it.index != null) o.index = it.index;
|
|
138
|
+
if (it.price != null) o.price = round2(it.price);
|
|
139
|
+
if (it.discount) o.discount = round2(it.discount);
|
|
140
|
+
return o;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Item no formato próprio da Unbox (`products[]` do dataLayerReady, lido pelo container central). */
|
|
144
|
+
function unboxProduct(it: TrackItem) {
|
|
145
|
+
const price = it.price ?? 0;
|
|
146
|
+
return {
|
|
147
|
+
id: it.id,
|
|
148
|
+
name: it.name,
|
|
149
|
+
price: round2(price),
|
|
150
|
+
originalPrice: round2(price + (it.discount ?? 0)),
|
|
151
|
+
available: true,
|
|
152
|
+
quantity: it.quantity ?? 1,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function metaContents(items: TrackItem[]) {
|
|
157
|
+
return {
|
|
158
|
+
content_type: "product",
|
|
159
|
+
content_ids: items.map((it) => it.id),
|
|
160
|
+
contents: items.map((it) => ({ id: it.id, quantity: it.quantity ?? 1, item_price: it.price })),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sumValue(items: TrackItem[]) {
|
|
165
|
+
return round2(items.reduce((s, it) => s + (it.price ?? 0) * (it.quantity ?? 1), 0));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function dlPush(obj: Record<string, any>) {
|
|
169
|
+
const w = window as Win;
|
|
170
|
+
w.dataLayer = w.dataLayer || [];
|
|
171
|
+
w.dataLayer.push({ ecommerce: null }); // limpa o objeto anterior — senão o GTM mistura eventos
|
|
172
|
+
w.dataLayer.push(obj);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ECOMM_PAGETYPE: Record<PageType, string> = {
|
|
176
|
+
home: "home", category: "category", product: "product", cart: "cart",
|
|
177
|
+
checkout: "cart", purchase: "purchase", search: "searchresults", other: "other",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------------ o disparo
|
|
181
|
+
interface FireOpts {
|
|
182
|
+
gaEvent: string;
|
|
183
|
+
/** sem metaEvent = evento só de GA/GTM (listagem, seleção, remoção...) */
|
|
184
|
+
metaEvent?: string;
|
|
185
|
+
items: TrackItem[];
|
|
186
|
+
value?: number;
|
|
187
|
+
coupon?: string;
|
|
188
|
+
transactionId?: string;
|
|
189
|
+
shipping?: number;
|
|
190
|
+
tax?: number;
|
|
191
|
+
/** chaves extras dentro de `ecommerce` (ex.: shipping_tier, payment_type, item_list_name) */
|
|
192
|
+
gaExtra?: Record<string, any>;
|
|
193
|
+
user?: TrackUser;
|
|
194
|
+
/** força o id do evento (purchase usa o id do pedido → dedupe com o servidor) */
|
|
195
|
+
eventId?: string;
|
|
196
|
+
pageType?: PageType;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function fire(o: FireOpts) {
|
|
200
|
+
if (typeof window === "undefined") return;
|
|
201
|
+
const w = window as Win;
|
|
202
|
+
const eventId = o.eventId ?? genEventId();
|
|
203
|
+
const value = o.value != null ? round2(o.value) : sumValue(o.items);
|
|
204
|
+
const hashed = await hashUser(o.user);
|
|
205
|
+
|
|
206
|
+
// 1) dataLayer — sempre, formato objeto, GA4 + chaves do container central
|
|
207
|
+
try {
|
|
208
|
+
const ecommerce: Record<string, any> = { currency: CURRENCY, value, items: o.items.map(gaItem), ...o.gaExtra };
|
|
209
|
+
if (o.coupon) ecommerce.coupon = o.coupon;
|
|
210
|
+
if (o.transactionId) ecommerce.transaction_id = o.transactionId;
|
|
211
|
+
if (o.shipping != null) ecommerce.shipping = round2(o.shipping);
|
|
212
|
+
if (o.tax != null) ecommerce.tax = round2(o.tax);
|
|
213
|
+
|
|
214
|
+
const payload: Record<string, any> = { event: o.gaEvent, ecommerce };
|
|
215
|
+
if (o.pageType) { payload.pageType = o.pageType; payload.ecomm_pagetype = ECOMM_PAGETYPE[o.pageType]; }
|
|
216
|
+
if (hashed) payload.user_data = hashed;
|
|
217
|
+
|
|
218
|
+
if (o.gaEvent === "purchase" && o.transactionId) {
|
|
219
|
+
// O container central da Unbox lê a compra em `ecommerce.purchase.*` (formato Universal
|
|
220
|
+
// Analytics) e nas chaves clássicas `transaction*`. Sem isto, a compra chega vazia lá.
|
|
221
|
+
ecommerce.purchase = {
|
|
222
|
+
transaction_id: o.transactionId,
|
|
223
|
+
value,
|
|
224
|
+
shipping: o.shipping != null ? round2(o.shipping) : undefined,
|
|
225
|
+
items: ecommerce.items,
|
|
226
|
+
};
|
|
227
|
+
payload.transactionId = o.transactionId;
|
|
228
|
+
payload.transactionTotal = value;
|
|
229
|
+
if (o.shipping != null) payload.transactionShipping = round2(o.shipping);
|
|
230
|
+
payload.transactionProducts = o.items.map(unboxProduct);
|
|
231
|
+
}
|
|
232
|
+
dlPush(payload);
|
|
233
|
+
} catch { /* nunca quebra a UI */ }
|
|
234
|
+
|
|
235
|
+
// 2) gtag direto — só GA4 sem GTM
|
|
236
|
+
try {
|
|
237
|
+
if (typeof w.gtag === "function") {
|
|
238
|
+
const p: Record<string, any> = { currency: CURRENCY, value, items: o.items.map(gaItem), ...o.gaExtra };
|
|
239
|
+
if (o.coupon) p.coupon = o.coupon;
|
|
240
|
+
if (o.transactionId) p.transaction_id = o.transactionId;
|
|
241
|
+
if (o.shipping != null) p.shipping = round2(o.shipping);
|
|
242
|
+
if (o.tax != null) p.tax = round2(o.tax);
|
|
243
|
+
w.gtag("event", o.gaEvent, p);
|
|
244
|
+
}
|
|
245
|
+
} catch { /* ignora */ }
|
|
246
|
+
|
|
247
|
+
if (!o.metaEvent) return;
|
|
248
|
+
|
|
249
|
+
// 3) Meta Pixel — com eventID p/ dedupe com a CAPI (e com o servidor, na compra)
|
|
250
|
+
try {
|
|
251
|
+
const mp: Record<string, any> = { currency: CURRENCY, value, ...metaContents(o.items) };
|
|
252
|
+
if (o.transactionId) mp.order_id = o.transactionId;
|
|
253
|
+
if (typeof w.fbq === "function") w.fbq("track", o.metaEvent, mp, { eventID: eventId });
|
|
254
|
+
} catch { /* ignora */ }
|
|
255
|
+
|
|
256
|
+
// 4) CAPI — mesmo eventID; só hash sai daqui
|
|
257
|
+
try {
|
|
258
|
+
void fetch("/api/capi", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/json" },
|
|
261
|
+
keepalive: true,
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
eventId,
|
|
264
|
+
eventName: o.metaEvent,
|
|
265
|
+
value,
|
|
266
|
+
currency: CURRENCY,
|
|
267
|
+
orderId: o.transactionId,
|
|
268
|
+
contents: o.items.map((it) => ({ id: it.id, quantity: it.quantity ?? 1, item_price: it.price })),
|
|
269
|
+
sourceUrl: window.location.href,
|
|
270
|
+
...(hashed ?? {}),
|
|
271
|
+
}),
|
|
272
|
+
}).catch(() => {});
|
|
273
|
+
} catch { /* ignora */ }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ------------------------------------------------------------------ eventos públicos
|
|
277
|
+
|
|
278
|
+
/** Tipo da página + produtos em vista. Dispare UMA vez por carregamento (ver <DataLayerReady/>). */
|
|
279
|
+
export function trackPageType(pageType: PageType, products: TrackItem[] = []) {
|
|
280
|
+
if (typeof window === "undefined") return;
|
|
281
|
+
try {
|
|
282
|
+
const payload: Record<string, any> = { event: "dataLayerReady", pageType, ecomm_pagetype: ECOMM_PAGETYPE[pageType] };
|
|
283
|
+
if (products.length) {
|
|
284
|
+
payload.products = products.map(unboxProduct);
|
|
285
|
+
payload.ecomm_prodid = products.map((p) => p.id);
|
|
286
|
+
payload.ecomm_totalvalue = sumValue(products);
|
|
287
|
+
}
|
|
288
|
+
dlPush(payload);
|
|
289
|
+
} catch { /* ignora */ }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function trackViewItemList(items: TrackItem[], listName: string) {
|
|
293
|
+
const withList = items.map((it, i) => ({ ...it, listName, index: it.index ?? i }));
|
|
294
|
+
void fire({ gaEvent: "view_item_list", items: withList, gaExtra: { item_list_name: listName } });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function trackSelectItem(item: TrackItem, listName: string) {
|
|
298
|
+
void fire({ gaEvent: "select_item", items: [{ ...item, listName }], gaExtra: { item_list_name: listName } });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function trackViewItem(item: TrackItem) {
|
|
302
|
+
void fire({ gaEvent: "view_item", metaEvent: "ViewContent", items: [item] });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function trackAddToCart(item: TrackItem) {
|
|
306
|
+
void fire({ gaEvent: "add_to_cart", metaEvent: "AddToCart", items: [item], pageType: "cart" });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function trackRemoveFromCart(item: TrackItem) {
|
|
310
|
+
void fire({ gaEvent: "remove_from_cart", items: [item] });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function trackViewCart(items: TrackItem[], value?: number) {
|
|
314
|
+
void fire({ gaEvent: "view_cart", items, value });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function trackBeginCheckout(items: TrackItem[], value?: number, coupon?: string, user?: TrackUser) {
|
|
318
|
+
void fire({ gaEvent: "begin_checkout", metaEvent: "InitiateCheckout", items, value, coupon, user });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function trackAddShippingInfo(items: TrackItem[], value: number | undefined, shippingTier: string, coupon?: string) {
|
|
322
|
+
void fire({ gaEvent: "add_shipping_info", items, value, coupon, gaExtra: { shipping_tier: shippingTier } });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function trackAddPaymentInfo(items: TrackItem[], value: number | undefined, paymentType: string, coupon?: string, user?: TrackUser) {
|
|
326
|
+
void fire({ gaEvent: "add_payment_info", metaEvent: "AddPaymentInfo", items, value, coupon, gaExtra: { payment_type: paymentType }, user });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const PURCHASE_KEY = "store_purchase_fired";
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Compra concluída. Dispara UMA vez por transação (dedupe em localStorage), e o chamador só
|
|
333
|
+
* chama quando o pedido está PAGO. O eventID é derivado do id do pedido: o servidor (webhook
|
|
334
|
+
* de pagamento → CAPI) usa o mesmo, e o Meta conta uma vez mesmo que os dois disparem.
|
|
335
|
+
*/
|
|
336
|
+
export function trackPurchase(p: {
|
|
337
|
+
transactionId: string;
|
|
338
|
+
items: TrackItem[];
|
|
339
|
+
value?: number;
|
|
340
|
+
coupon?: string;
|
|
341
|
+
shipping?: number;
|
|
342
|
+
tax?: number;
|
|
343
|
+
user?: TrackUser;
|
|
344
|
+
}) {
|
|
345
|
+
if (!p.transactionId) return;
|
|
346
|
+
try {
|
|
347
|
+
const fired: string[] = JSON.parse(localStorage.getItem(PURCHASE_KEY) || "[]");
|
|
348
|
+
if (fired.includes(p.transactionId)) return;
|
|
349
|
+
localStorage.setItem(PURCHASE_KEY, JSON.stringify([...fired.slice(-49), p.transactionId]));
|
|
350
|
+
} catch { /* sem localStorage: segue e dispara */ }
|
|
351
|
+
void fire({
|
|
352
|
+
gaEvent: "purchase",
|
|
353
|
+
metaEvent: "Purchase",
|
|
354
|
+
items: p.items,
|
|
355
|
+
value: p.value,
|
|
356
|
+
coupon: p.coupon,
|
|
357
|
+
shipping: p.shipping,
|
|
358
|
+
tax: p.tax,
|
|
359
|
+
transactionId: p.transactionId,
|
|
360
|
+
eventId: purchaseEventId(p.transactionId),
|
|
361
|
+
user: p.user,
|
|
362
|
+
pageType: "purchase",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Mesmo id no browser e no servidor → dedupe na Meta. Exportado para o webhook usar. */
|
|
367
|
+
export function purchaseEventId(transactionId: string) {
|
|
368
|
+
return `purchase-${transactionId}`;
|
|
369
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Helpers de resposta para Route Handlers (BFF). Centraliza o tratamento de erro:
|
|
2
|
+
// nunca vaza segredo, traduz erro da Unbox em mensagem amigável.
|
|
3
|
+
import "server-only";
|
|
4
|
+
import { NextResponse } from "next/server";
|
|
5
|
+
import { friendlyError } from "./unbox/errors";
|
|
6
|
+
import { UnboxError } from "./unbox/client";
|
|
7
|
+
|
|
8
|
+
export function ok<T>(data: T, init?: ResponseInit) {
|
|
9
|
+
return NextResponse.json(data as any, init);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function fail(message: string, status = 400, extra?: Record<string, any>) {
|
|
13
|
+
return NextResponse.json({ error: message, ...extra }, { status });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Nome técnico do campo de endereço, do jeito que a pessoa vê no formulário. */
|
|
17
|
+
const CAMPO_ENDERECO: Record<string, string> = {
|
|
18
|
+
fullName: "nome completo", address1: "endereço", number: "número", neighborhood: "bairro",
|
|
19
|
+
city: "cidade", region: "estado (UF)", postal: "CEP", phone: "telefone",
|
|
20
|
+
taxPayerId: "CPF/CNPJ", country: "país", address2: "complemento",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A Unbox diz QUAL campo do endereço recusou, em `invalidAddressFields`. Sem ler isso, a
|
|
24
|
+
* pessoa lê "endereço inválido", não sabe o que corrigir e desiste (caso real em produção). */
|
|
25
|
+
function erroDeEndereco(e: unknown): string | null {
|
|
26
|
+
if (!(e instanceof UnboxError)) return null;
|
|
27
|
+
for (const err of e.errors ?? []) {
|
|
28
|
+
const campos = (err as any)?.extensions?.invalidAddressFields ?? (err as any)?.invalidAddressFields;
|
|
29
|
+
const nomes = Object.keys(campos ?? {}).map((k) => CAMPO_ENDERECO[k] ?? k);
|
|
30
|
+
if (!nomes.length) continue;
|
|
31
|
+
const lista = nomes.length === 1 ? nomes[0] : `${nomes.slice(0, -1).join(", ")} e ${nomes[nomes.length - 1]}`;
|
|
32
|
+
return `Confira ${nomes.length === 1 ? "o campo" : "os campos"} ${lista} e tente de novo.`;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Converte qualquer erro numa resposta JSON amigável (sem vazar stack/segredos).
|
|
38
|
+
*
|
|
39
|
+
* SEMPRE loga no servidor, em QUALQUER status: o `code` da resposta morre no navegador de quem
|
|
40
|
+
* tentou, e sem este log o painel mostra só "POST /api/checkout 400". Em uma semana de loja no ar,
|
|
41
|
+
* três erros diferentes chegaram ao cliente como "Algo deu errado" sem deixar rastro (o DDD do
|
|
42
|
+
* telefone num pagamento, o código do login e a consulta do pedido) e os três eram 4xx, que o log
|
|
43
|
+
* antigo não cobria. Registra só o código e os erros do GraphQL: nunca o corpo da requisição,
|
|
44
|
+
* que carrega dado de cliente. Passe `context` com o que identifica a operação (cartId,
|
|
45
|
+
* referenceId...), nunca com segredo. */
|
|
46
|
+
export function failFrom(e: unknown, status = 400, context?: Record<string, unknown>) {
|
|
47
|
+
const raw = e instanceof UnboxError ? e.errors?.[0]?.message ?? e.message : e instanceof Error ? e.message : String(e);
|
|
48
|
+
console.error(JSON.stringify({
|
|
49
|
+
tag: "[api-erro]",
|
|
50
|
+
status,
|
|
51
|
+
erro: String(raw).slice(0, 500),
|
|
52
|
+
detalhes: e instanceof UnboxError ? e.errors ?? null : null, // erros do GraphQL: a causa real costuma estar aqui
|
|
53
|
+
...context,
|
|
54
|
+
quando: new Date().toISOString(),
|
|
55
|
+
}));
|
|
56
|
+
if (raw === "UNAUTHENTICATED") return fail("Sessão expirada. Entre novamente.", 401);
|
|
57
|
+
const endereco = erroDeEndereco(e);
|
|
58
|
+
if (endereco) return fail(endereco, status, { code: raw });
|
|
59
|
+
return fail(friendlyError(e), status, { code: raw });
|
|
60
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Meta Conversions API — remetente compartilhado. Usado pela rota /api/capi (eventos vindos do
|
|
2
|
+
// browser, já hasheados) e pelo webhook de pagamento (compra confirmada fora do navegador).
|
|
3
|
+
// NO-OP sem META_PIXEL_ID / META_CAPI_TOKEN. Nunca recebe PII crua: quem chama já hasheou.
|
|
4
|
+
import "server-only";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const GRAPH = "https://graph.facebook.com/v19.0";
|
|
8
|
+
|
|
9
|
+
export interface CapiUserData {
|
|
10
|
+
em?: string; ph?: string; fn?: string; ln?: string; zp?: string; external_id?: string;
|
|
11
|
+
fbp?: string; fbc?: string; client_ip_address?: string; client_user_agent?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CapiEvent {
|
|
15
|
+
eventId: string;
|
|
16
|
+
eventName: string;
|
|
17
|
+
value?: number;
|
|
18
|
+
currency?: string;
|
|
19
|
+
orderId?: string;
|
|
20
|
+
contents?: { id: string; quantity: number; item_price?: number }[];
|
|
21
|
+
sourceUrl?: string;
|
|
22
|
+
/** "website" para evento do browser; "system_generated" para o webhook de pagamento */
|
|
23
|
+
actionSource?: "website" | "system_generated";
|
|
24
|
+
userData: CapiUserData;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** SHA-256 hex de um valor normalizado (minúsculo, sem espaço nas pontas). */
|
|
28
|
+
export function sha256(value?: string): string | undefined {
|
|
29
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
30
|
+
return v ? crypto.createHash("sha256").update(v).digest("hex") : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Configurada de verdade: pixel NUMÉRICO e token sem cara de comentário. Presença truthy não
|
|
34
|
+
* basta — uma frase copiada do .env passava no guard antigo e virava 502 em todo evento. */
|
|
35
|
+
export function capiConfigured() {
|
|
36
|
+
const pixel = process.env.META_PIXEL_ID ?? "";
|
|
37
|
+
const token = process.env.META_CAPI_TOKEN ?? "";
|
|
38
|
+
return /^\d{5,}$/.test(pixel) && token.length > 20 && !/[\s#]/.test(token);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function sendCapi(ev: CapiEvent): Promise<{ ok: boolean; capi: boolean }> {
|
|
42
|
+
if (!capiConfigured()) return { ok: true, capi: false };
|
|
43
|
+
const pixelId = process.env.META_PIXEL_ID!;
|
|
44
|
+
const token = process.env.META_CAPI_TOKEN!;
|
|
45
|
+
|
|
46
|
+
// O Meta exige ao menos um identificador de cliente; sem nenhum, rejeita o evento.
|
|
47
|
+
const ud = Object.fromEntries(Object.entries(ev.userData).filter(([, v]) => v));
|
|
48
|
+
if (!Object.keys(ud).length) return { ok: false, capi: false };
|
|
49
|
+
// em/ph/fn/ln/zp/external_id vão como ARRAY de hashes, por spec
|
|
50
|
+
for (const k of ["em", "ph", "fn", "ln", "zp", "external_id"]) if (ud[k]) ud[k] = [ud[k]];
|
|
51
|
+
|
|
52
|
+
const customData: Record<string, any> = { currency: ev.currency || "BRL", value: ev.value };
|
|
53
|
+
if (ev.orderId) customData.order_id = ev.orderId;
|
|
54
|
+
if (ev.contents?.length) {
|
|
55
|
+
customData.contents = ev.contents;
|
|
56
|
+
customData.content_type = "product";
|
|
57
|
+
customData.content_ids = ev.contents.map((c) => c.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = {
|
|
61
|
+
data: [{
|
|
62
|
+
event_name: ev.eventName,
|
|
63
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
64
|
+
event_id: ev.eventId,
|
|
65
|
+
action_source: ev.actionSource ?? "website",
|
|
66
|
+
...(ev.sourceUrl ? { event_source_url: ev.sourceUrl } : {}),
|
|
67
|
+
user_data: ud,
|
|
68
|
+
custom_data: customData,
|
|
69
|
+
}],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${GRAPH}/${pixelId}/events?access_token=${encodeURIComponent(token)}`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify(payload),
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
console.error("[capi] meta rejeitou", res.status, (await res.text().catch(() => "")).slice(0, 300));
|
|
80
|
+
return { ok: false, capi: true };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, capi: true };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error("[capi] falha de rede", e);
|
|
85
|
+
return { ok: false, capi: true };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Monta carrinho a partir de um link: ?produtos=SKU:qtd,SKU:qtd (SKUs do products.json).
|
|
2
|
+
// A Unbox usa productId/variantId, não SKU — então resolvemos o SKU → família (products.json) →
|
|
3
|
+
// produto comprável do catálogo (mesma família + peso mais próximo), como fazemos em combos/tamanhos.
|
|
4
|
+
import { getEnrichmentBySku, getEnrichmentByName, parseSize } from "./enrichment";
|
|
5
|
+
import { resolveProductPrice } from "./format";
|
|
6
|
+
|
|
7
|
+
export interface LinkCartItem { productId: string; productVariantId: string; price: number; quantity: number }
|
|
8
|
+
|
|
9
|
+
/** "320:2,60745:1" → [{sku:"320",qty:2},{sku:"60745",qty:1}] */
|
|
10
|
+
export function parseProdutosParam(param: string): { sku: string; qty: number }[] {
|
|
11
|
+
return param
|
|
12
|
+
.split(",")
|
|
13
|
+
.map((p) => {
|
|
14
|
+
const [sku, q] = p.split(":");
|
|
15
|
+
return { sku: (sku ?? "").trim(), qty: Math.max(1, parseInt(q ?? "1", 10) || 1) };
|
|
16
|
+
})
|
|
17
|
+
.filter((x) => x.sku);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve SKUs (products.json) para itens compráveis do catálogo. Ignora os que não casam. */
|
|
21
|
+
export function resolveSkusToItems(skuQtys: { sku: string; qty: number }[], catalogProducts: any[]): LinkCartItem[] {
|
|
22
|
+
const byFamily = new Map<string, any[]>();
|
|
23
|
+
for (const cp of catalogProducts) {
|
|
24
|
+
if (!cp?.title) continue;
|
|
25
|
+
const e = getEnrichmentByName(cp.title);
|
|
26
|
+
if (!e) continue;
|
|
27
|
+
const arr = byFamily.get(e.familyCode) ?? [];
|
|
28
|
+
arr.push(cp);
|
|
29
|
+
byFamily.set(e.familyCode, arr);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const out: LinkCartItem[] = [];
|
|
33
|
+
for (const { sku, qty } of skuQtys) {
|
|
34
|
+
const e = getEnrichmentBySku(sku);
|
|
35
|
+
if (!e) continue;
|
|
36
|
+
const fam = byFamily.get(e.familyCode);
|
|
37
|
+
if (!fam || !fam.length) continue;
|
|
38
|
+
const target = e.weight?.value ?? 0;
|
|
39
|
+
// escolhe o produto da família com peso mais próximo do SKU pedido
|
|
40
|
+
const pick = fam.reduce((best, cp) => {
|
|
41
|
+
const w = parseSize(cp.title)?.value ?? 0;
|
|
42
|
+
const bw = parseSize(best.title)?.value ?? 0;
|
|
43
|
+
return Math.abs(w - target) < Math.abs(bw - target) ? cp : best;
|
|
44
|
+
});
|
|
45
|
+
const v0 = pick.variants?.[0];
|
|
46
|
+
const rp = resolveProductPrice(pick);
|
|
47
|
+
if (!v0?._id || rp.price == null) continue;
|
|
48
|
+
out.push({ productId: pick.productId, productVariantId: v0._id, price: rp.price, quantity: qty });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|