@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.
Files changed (358) hide show
  1. package/LICENSE.md +46 -0
  2. package/README.md +175 -0
  3. package/bin/cli.js +439 -0
  4. package/package.json +43 -0
  5. package/src/colors.js +100 -0
  6. package/src/presets.js +241 -0
  7. package/src/theme.js +253 -0
  8. package/template/.claude/agents/assets-marca.md +40 -0
  9. package/template/.claude/agents/avaliador-visual.md +48 -0
  10. package/template/.claude/agents/branding-briefing.md +401 -0
  11. package/template/.claude/agents/conteudo-secao.md +46 -0
  12. package/template/.claude/agents/paginas-legais.md +44 -0
  13. package/template/.claude/settings.json +3 -0
  14. package/template/.claude/skills/frontend-design/ATTRIBUTION.md +3 -0
  15. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  16. package/template/.claude/skills/frontend-design/SKILL.md +55 -0
  17. package/template/.claude/skills/web-design-guidelines/ATTRIBUTION.md +3 -0
  18. package/template/.claude/skills/web-design-guidelines/SKILL.md +50 -0
  19. package/template/.claude/skills/web-design-guidelines/guidelines-snapshot.md +180 -0
  20. package/template/.env.example +158 -0
  21. package/template/.mcp.json.example +14 -0
  22. package/template/.nvmrc +1 -0
  23. package/template/CLAUDE.md +333 -0
  24. package/template/COVERAGE.md +80 -0
  25. package/template/DEPLOY.md +179 -0
  26. package/template/QA.md +108 -0
  27. package/template/README.md +152 -0
  28. package/template/agents/CONSTRUCAO.md +207 -0
  29. package/template/agents/MANAGER.md +192 -0
  30. package/template/agents/PADROES.md +255 -0
  31. package/template/agents/definitions/00-scaffold.md +56 -0
  32. package/template/agents/definitions/01-layout.md +47 -0
  33. package/template/agents/definitions/02-homepage.md +70 -0
  34. package/template/agents/definitions/03-catalog.md +86 -0
  35. package/template/agents/definitions/04-pdp.md +97 -0
  36. package/template/agents/definitions/05-cart.md +116 -0
  37. package/template/agents/definitions/06-checkout.md +146 -0
  38. package/template/agents/definitions/07-auth.md +130 -0
  39. package/template/agents/definitions/08-customer.md +55 -0
  40. package/template/agents/definitions/09-promotions.md +62 -0
  41. package/template/agents/definitions/10-feedback.md +128 -0
  42. package/template/agents/definitions/11-seo-infra.md +159 -0
  43. package/template/agents/definitions/12-deploy.md +173 -0
  44. package/template/agents/definitions/13-cro.md +275 -0
  45. package/template/agents/definitions/14-seo.md +388 -0
  46. package/template/agents/definitions/15-branding.md +26 -0
  47. package/template/agents/definitions/16-qa-visual.md +138 -0
  48. package/template/agents/definitions/17-aeo.md +257 -0
  49. package/template/app/(loja)/busca/page.tsx +72 -0
  50. package/template/app/(loja)/carrinho/oferta/page.tsx +57 -0
  51. package/template/app/(loja)/carrinho/page.tsx +160 -0
  52. package/template/app/(loja)/categoria/[tagSlug]/layout.tsx +31 -0
  53. package/template/app/(loja)/categoria/[tagSlug]/loading.tsx +17 -0
  54. package/template/app/(loja)/categoria/[tagSlug]/page.tsx +49 -0
  55. package/template/app/(loja)/checkout/page.tsx +80 -0
  56. package/template/app/(loja)/checkout/pix/[ref]/page.tsx +133 -0
  57. package/template/app/(loja)/conta/assinaturas/[referenceId]/page.tsx +112 -0
  58. package/template/app/(loja)/conta/assinaturas/page.tsx +51 -0
  59. package/template/app/(loja)/conta/enderecos/page.tsx +20 -0
  60. package/template/app/(loja)/conta/entrar/page.tsx +119 -0
  61. package/template/app/(loja)/conta/page.tsx +45 -0
  62. package/template/app/(loja)/conta/pedidos/[referenceId]/page.tsx +49 -0
  63. package/template/app/(loja)/conta/pedidos/page.tsx +104 -0
  64. package/template/app/(loja)/conta/preferencias/page.tsx +24 -0
  65. package/template/app/(loja)/devolucoes/page.tsx +36 -0
  66. package/template/app/(loja)/layout.tsx +22 -0
  67. package/template/app/(loja)/oferta/page.tsx +57 -0
  68. package/template/app/(loja)/page.tsx +86 -0
  69. package/template/app/(loja)/pedido/[referenceId]/page.tsx +78 -0
  70. package/template/app/(loja)/privacidade/page.tsx +113 -0
  71. package/template/app/(loja)/produto/[productSlug]/layout.tsx +23 -0
  72. package/template/app/(loja)/produto/[productSlug]/loading.tsx +20 -0
  73. package/template/app/(loja)/produto/[productSlug]/page.tsx +372 -0
  74. package/template/app/(loja)/produtos/loading.tsx +16 -0
  75. package/template/app/(loja)/produtos/page.tsx +33 -0
  76. package/template/app/(loja)/termos/page.tsx +104 -0
  77. package/template/app/acesso/page.tsx +129 -0
  78. package/template/app/api/account/addresses/route.ts +39 -0
  79. package/template/app/api/account/exists/route.ts +21 -0
  80. package/template/app/api/account/me/route.ts +25 -0
  81. package/template/app/api/account/otp/route.ts +26 -0
  82. package/template/app/api/account/preferences/route.ts +19 -0
  83. package/template/app/api/account/signin/route.ts +27 -0
  84. package/template/app/api/account/signout/route.ts +9 -0
  85. package/template/app/api/acesso/route.ts +171 -0
  86. package/template/app/api/capi/route.ts +34 -0
  87. package/template/app/api/cart/coupon/route.ts +49 -0
  88. package/template/app/api/cart/items/route.ts +45 -0
  89. package/template/app/api/cart/link/route.ts +69 -0
  90. package/template/app/api/cart/route.ts +113 -0
  91. package/template/app/api/cart/share/route.ts +21 -0
  92. package/template/app/api/cep/[code]/route.ts +18 -0
  93. package/template/app/api/checkout/address/route.ts +24 -0
  94. package/template/app/api/checkout/email/route.ts +41 -0
  95. package/template/app/api/checkout/installments/route.ts +16 -0
  96. package/template/app/api/checkout/route.ts +110 -0
  97. package/template/app/api/checkout/shipping/route.ts +65 -0
  98. package/template/app/api/checkout-destination/route.ts +31 -0
  99. package/template/app/api/order/[ref]/route.ts +17 -0
  100. package/template/app/api/payment-link/route.ts +28 -0
  101. package/template/app/api/revalidate/route.ts +52 -0
  102. package/template/app/api/shipping/quote/route.ts +29 -0
  103. package/template/app/api/subscriptions/[id]/route.ts +52 -0
  104. package/template/app/api/track/route.ts +28 -0
  105. package/template/app/api/unbox/catalogo/route.ts +136 -0
  106. package/template/app/api/unbox/paginas/route.ts +167 -0
  107. package/template/app/api/unbox/vitrine/route.ts +84 -0
  108. package/template/app/api/webhooks/unbox/route.ts +97 -0
  109. package/template/app/apple-icon.svg +5 -0
  110. package/template/app/error.tsx +15 -0
  111. package/template/app/globals.css +457 -0
  112. package/template/app/icon.svg +4 -0
  113. package/template/app/layout.tsx +90 -0
  114. package/template/app/llms.txt/route.ts +43 -0
  115. package/template/app/manifest.ts +21 -0
  116. package/template/app/not-found.tsx +18 -0
  117. package/template/app/opengraph-image.tsx +35 -0
  118. package/template/app/robots.ts +31 -0
  119. package/template/app/sitemap.ts +42 -0
  120. package/template/bootstrap.sh +87 -0
  121. package/template/components/account/account-shell.tsx +47 -0
  122. package/template/components/account/address-book.tsx +147 -0
  123. package/template/components/account/preferences-form.tsx +77 -0
  124. package/template/components/account/reorder-button.tsx +47 -0
  125. package/template/components/account/signout-button.tsx +20 -0
  126. package/template/components/account/subscription-actions.tsx +204 -0
  127. package/template/components/account-nav.tsx +41 -0
  128. package/template/components/address-fields.tsx +102 -0
  129. package/template/components/analytics/data-layer-ready.tsx +16 -0
  130. package/template/components/analytics/purchase-tracker.tsx +32 -0
  131. package/template/components/brand-search.tsx +34 -0
  132. package/template/components/cart/cart-provider.tsx +340 -0
  133. package/template/components/cart/mini-cart.tsx +228 -0
  134. package/template/components/cart-button.tsx +35 -0
  135. package/template/components/catalog/catalog-client.tsx +523 -0
  136. package/template/components/catalog/grid-skeleton.tsx +19 -0
  137. package/template/components/catalog/pager.tsx +66 -0
  138. package/template/components/catalog/product-grid-card.tsx +52 -0
  139. package/template/components/catalog/product-grid.tsx +27 -0
  140. package/template/components/checkout/checkout-client.tsx +1333 -0
  141. package/template/components/chrome/announce-bar.tsx +57 -0
  142. package/template/components/chrome/chrome-recipe.ts +13 -0
  143. package/template/components/chrome/footers/captura-botao.tsx +28 -0
  144. package/template/components/chrome/footers/colunas.tsx +110 -0
  145. package/template/components/chrome/footers/conversao.tsx +90 -0
  146. package/template/components/chrome/footers/editorial.tsx +98 -0
  147. package/template/components/chrome/footers/minimal.tsx +55 -0
  148. package/template/components/chrome/header-bar-mobile.tsx +64 -0
  149. package/template/components/chrome/headers/centralizado.tsx +71 -0
  150. package/template/components/chrome/headers/classico.tsx +64 -0
  151. package/template/components/chrome/headers/compacto.tsx +66 -0
  152. package/template/components/chrome/headers/equilibrado.tsx +73 -0
  153. package/template/components/chrome/headers/imersivo.tsx +62 -0
  154. package/template/components/chrome/registry.ts +70 -0
  155. package/template/components/chrome/solid-on-scroll.tsx +39 -0
  156. package/template/components/empty-state.tsx +22 -0
  157. package/template/components/home/combo-card-compact.tsx +67 -0
  158. package/template/components/home/combos-home.tsx +64 -0
  159. package/template/components/home/combos-section.tsx +177 -0
  160. package/template/components/home/home-recipe.ts +41 -0
  161. package/template/components/home/sections/attributes-marquee.tsx +178 -0
  162. package/template/components/home/sections/benefits.tsx +80 -0
  163. package/template/components/home/sections/bloco-html.tsx +47 -0
  164. package/template/components/home/sections/catalogo.ts +165 -0
  165. package/template/components/home/sections/category-pills.tsx +32 -0
  166. package/template/components/home/sections/combos-carousel.tsx +141 -0
  167. package/template/components/home/sections/comparison.tsx +73 -0
  168. package/template/components/home/sections/founder-story.tsx +40 -0
  169. package/template/components/home/sections/hero.tsx +187 -0
  170. package/template/components/home/sections/kits.tsx +11 -0
  171. package/template/components/home/sections/media-cards.tsx +70 -0
  172. package/template/components/home/sections/newsletter.tsx +62 -0
  173. package/template/components/home/sections/product-showcase.tsx +175 -0
  174. package/template/components/home/sections/purchase-hero.tsx +187 -0
  175. package/template/components/home/sections/quote-banner.tsx +50 -0
  176. package/template/components/home/sections/registry.ts +186 -0
  177. package/template/components/home/sections/reviews-carousel.tsx +66 -0
  178. package/template/components/home/sections/reviews.tsx +68 -0
  179. package/template/components/home/sections/ritual.tsx +96 -0
  180. package/template/components/home/sections/savings.tsx +47 -0
  181. package/template/components/home/sections/social-row.tsx +65 -0
  182. package/template/components/home/sections/spec-table.tsx +117 -0
  183. package/template/components/home/sections/stars.tsx +12 -0
  184. package/template/components/home/sections/stats-grid.tsx +54 -0
  185. package/template/components/home/sections/trust-bar.tsx +56 -0
  186. package/template/components/home/sections/trust-strip.tsx +40 -0
  187. package/template/components/home/sections/video-wall.tsx +59 -0
  188. package/template/components/landing/landing-recipe.ts +20 -0
  189. package/template/components/landing/oferta-sections.tsx +40 -0
  190. package/template/components/landing/product-picker.tsx +257 -0
  191. package/template/components/mobile-nav.tsx +71 -0
  192. package/template/components/order-status.tsx +163 -0
  193. package/template/components/powered-by-unbox.tsx +27 -0
  194. package/template/components/product/pdp/buy-box.tsx +604 -0
  195. package/template/components/product/pdp/catalog-grid.tsx +113 -0
  196. package/template/components/product/pdp/faq-modelo.ts +86 -0
  197. package/template/components/product/pdp/gallery.tsx +87 -0
  198. package/template/components/product/pdp/interactive.tsx +200 -0
  199. package/template/components/product/pdp/newsletter.tsx +64 -0
  200. package/template/components/product/pdp/payment-chips.tsx +22 -0
  201. package/template/components/product/pdp/pdp-view.tsx +227 -0
  202. package/template/components/product/pdp/recommendations.tsx +144 -0
  203. package/template/components/product/pdp/sections.tsx +173 -0
  204. package/template/components/quantity-stepper.tsx +36 -0
  205. package/template/components/search-box.tsx +37 -0
  206. package/template/components/site-footer.tsx +59 -0
  207. package/template/components/site-header.tsx +64 -0
  208. package/template/components/ui/accordion.tsx +72 -0
  209. package/template/components/ui/alert-dialog.tsx +187 -0
  210. package/template/components/ui/alert.tsx +76 -0
  211. package/template/components/ui/aspect-ratio.tsx +22 -0
  212. package/template/components/ui/avatar.tsx +109 -0
  213. package/template/components/ui/badge.tsx +52 -0
  214. package/template/components/ui/breadcrumb.tsx +125 -0
  215. package/template/components/ui/button.tsx +58 -0
  216. package/template/components/ui/card.tsx +103 -0
  217. package/template/components/ui/carousel.tsx +242 -0
  218. package/template/components/ui/command.tsx +196 -0
  219. package/template/components/ui/dialog.tsx +160 -0
  220. package/template/components/ui/dropdown-menu.tsx +268 -0
  221. package/template/components/ui/foto.tsx +54 -0
  222. package/template/components/ui/input-group.tsx +158 -0
  223. package/template/components/ui/input-otp.tsx +87 -0
  224. package/template/components/ui/input.tsx +20 -0
  225. package/template/components/ui/label.tsx +20 -0
  226. package/template/components/ui/navigation-menu.tsx +168 -0
  227. package/template/components/ui/pagination.tsx +132 -0
  228. package/template/components/ui/popover.tsx +90 -0
  229. package/template/components/ui/progress.tsx +83 -0
  230. package/template/components/ui/radio-group.tsx +38 -0
  231. package/template/components/ui/scroll-area.tsx +55 -0
  232. package/template/components/ui/select.tsx +201 -0
  233. package/template/components/ui/separator.tsx +25 -0
  234. package/template/components/ui/sheet.tsx +138 -0
  235. package/template/components/ui/skeleton.tsx +13 -0
  236. package/template/components/ui/sonner.tsx +44 -0
  237. package/template/components/ui/switch.tsx +32 -0
  238. package/template/components/ui/table.tsx +116 -0
  239. package/template/components/ui/tabs.tsx +82 -0
  240. package/template/components/ui/textarea.tsx +18 -0
  241. package/template/components/ui/toggle-group.tsx +89 -0
  242. package/template/components/ui/toggle.tsx +45 -0
  243. package/template/components/ui/tooltip.tsx +66 -0
  244. package/template/components.json +25 -0
  245. package/template/eslint.config.mjs +36 -0
  246. package/template/gitignore +21 -0
  247. package/template/lib/analytics.ts +369 -0
  248. package/template/lib/api.ts +60 -0
  249. package/template/lib/capi.ts +87 -0
  250. package/template/lib/cart-link.ts +51 -0
  251. package/template/lib/cart-normalize.ts +142 -0
  252. package/template/lib/cart-recovery.ts +23 -0
  253. package/template/lib/cart-response.ts +48 -0
  254. package/template/lib/catalog-map.ts +54 -0
  255. package/template/lib/catalog.ts +3 -0
  256. package/template/lib/checkout-lock.ts +21 -0
  257. package/template/lib/checkout-nav.ts +22 -0
  258. package/template/lib/config.ts +51 -0
  259. package/template/lib/crm.ts +40 -0
  260. package/template/lib/customer-session.ts +21 -0
  261. package/template/lib/dataloader.ts +23 -0
  262. package/template/lib/editable/config.ts +11 -0
  263. package/template/lib/editable/document.ts +1762 -0
  264. package/template/lib/editable/index.ts +3 -0
  265. package/template/lib/editable/primitives.tsx +821 -0
  266. package/template/lib/editable/provider.tsx +861 -0
  267. package/template/lib/editable/rastreio-navegacao.tsx +65 -0
  268. package/template/lib/editable/rastreio.tsx +168 -0
  269. package/template/lib/editable/server.ts +189 -0
  270. package/template/lib/editable/tokens.ts +22 -0
  271. package/template/lib/editable/verify.ts +24 -0
  272. package/template/lib/enrichment/combos.ts +184 -0
  273. package/template/lib/enrichment/index.ts +188 -0
  274. package/template/lib/enrichment/products.json +1 -0
  275. package/template/lib/env-check.ts +47 -0
  276. package/template/lib/format.ts +130 -0
  277. package/template/lib/icons.ts +67 -0
  278. package/template/lib/json-ld.ts +9 -0
  279. package/template/lib/llms-txt.ts +75 -0
  280. package/template/lib/mockup.ts +24 -0
  281. package/template/lib/newsletter.ts +12 -0
  282. package/template/lib/orders.ts +160 -0
  283. package/template/lib/queries.ts +66 -0
  284. package/template/lib/ratelimit.ts +47 -0
  285. package/template/lib/rotas-editaveis.ts +192 -0
  286. package/template/lib/sanitize.ts +36 -0
  287. package/template/lib/schemas.ts +78 -0
  288. package/template/lib/session.ts +98 -0
  289. package/template/lib/store-config.ts +67 -0
  290. package/template/lib/unbox/client.ts +829 -0
  291. package/template/lib/unbox/customer.ts +224 -0
  292. package/template/lib/unbox/errors.ts +97 -0
  293. package/template/lib/unbox/index.ts +10 -0
  294. package/template/lib/unbox/store.ts +113 -0
  295. package/template/lib/unbox/types.ts +195 -0
  296. package/template/lib/unbox/webhooks.ts +66 -0
  297. package/template/lib/utils.ts +6 -0
  298. package/template/lib/vitrine.ts +242 -0
  299. package/template/lib/webhook-store.ts +22 -0
  300. package/template/middleware.ts +178 -0
  301. package/template/next.config.ts +63 -0
  302. package/template/package.json +62 -0
  303. package/template/postcss.config.mjs +5 -0
  304. package/template/public/brand/coll/default.png +0 -0
  305. package/template/public/brand/hero-desktop.svg +12 -0
  306. package/template/public/brand/hero-mobile.svg +12 -0
  307. package/template/public/brand/heros/boutique-desktop.svg +11 -0
  308. package/template/public/brand/heros/boutique-mobile.svg +11 -0
  309. package/template/public/brand/heros/editorial-desktop.svg +11 -0
  310. package/template/public/brand/heros/editorial-mobile.svg +11 -0
  311. package/template/public/brand/heros/essencial-desktop.svg +12 -0
  312. package/template/public/brand/heros/essencial-mobile.svg +12 -0
  313. package/template/public/brand/heros/promocional-desktop.svg +12 -0
  314. package/template/public/brand/heros/promocional-mobile.svg +12 -0
  315. package/template/public/brand/logo-chrome.svg +6 -0
  316. package/template/public/brand/logo-white.svg +6 -0
  317. package/template/public/brand/logo.svg +7 -0
  318. package/template/public/brand/pay-amex.webp +0 -0
  319. package/template/public/brand/pay-elo.webp +0 -0
  320. package/template/public/brand/pay-mastercard.webp +0 -0
  321. package/template/public/brand/pay-pix.webp +0 -0
  322. package/template/public/brand/pay-visa.webp +0 -0
  323. package/template/public/brand/ph/avatar-a.svg +5 -0
  324. package/template/public/brand/ph/avatar-b.svg +5 -0
  325. package/template/public/brand/ph/avatar-c.svg +5 -0
  326. package/template/public/brand/ph/avatar-d.svg +5 -0
  327. package/template/public/brand/ph/photo-a.svg +7 -0
  328. package/template/public/brand/ph/photo-b.svg +6 -0
  329. package/template/public/brand/ph/photo-c.svg +6 -0
  330. package/template/public/brand/ph/poster-a.svg +6 -0
  331. package/template/public/brand/ph/poster-b.svg +6 -0
  332. package/template/public/brand/ph/poster-c.svg +6 -0
  333. package/template/public/unbox/powered-by-fundo-preto.png +0 -0
  334. package/template/public/unbox/powered-by-transparente.png +0 -0
  335. package/template/public/unbox/powered-by.png +0 -0
  336. package/template/scripts/abandoned-cart.ts +82 -0
  337. package/template/scripts/check-editable.mjs +467 -0
  338. package/template/scripts/check-honestidade.mjs +111 -0
  339. package/template/scripts/check-placeholder.mjs +200 -0
  340. package/template/scripts/check-recipe.mjs +76 -0
  341. package/template/scripts/check-unbox-brand.mjs +298 -0
  342. package/template/scripts/contraste.js +177 -0
  343. package/template/scripts/dump-catalog.ts +95 -0
  344. package/template/scripts/load-env.ts +13 -0
  345. package/template/scripts/medir-sistema.mjs +56 -0
  346. package/template/scripts/place-order-pix.ts +61 -0
  347. package/template/scripts/qa-screenshots.mjs +150 -0
  348. package/template/scripts/sistema.py +231 -0
  349. package/template/scripts/subscribe-webhook.ts +36 -0
  350. package/template/scripts/test-live.ts +186 -0
  351. package/template/scripts/vocabulario.py +264 -0
  352. package/template/tsconfig.json +27 -0
  353. package/tools/LEIA-ME.md +39 -0
  354. package/tools/check-template-neutro.mjs +111 -0
  355. package/tools/notion-doc.mjs +191 -0
  356. package/tools/tokenize-neutrals.mjs +0 -0
  357. package/tools/tokenize-radius.mjs +52 -0
  358. 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
+ }