@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,65 @@
1
+ "use client";
2
+
3
+ // PAGE_VIEW POR ROTA (foundation 12): a parte de CLIENTE do rastreio. Quem monta os scripts é o
4
+ // `<Rastreio>` de rastreio.tsx (servidor); este hook só avisa cada provedor que a página mudou.
5
+ //
6
+ // Dispara o page_view de cada NAVEGAÇÃO do App Router para quem NÃO conta a navegação sozinho: Meta
7
+ // Pixel (fbq), TikTok Pixel (ttq) e Pinterest Tag (pintrk). O snippet de cada um só conta o
8
+ // carregamento inicial; a navegação SPA não recarrega a página, e sem este hook a segunda página não
9
+ // existe para nenhum deles.
10
+ //
11
+ // UM DONO DO PAGE_VIEW POR PROVEDOR. O GA4 fica DE FORA deste hook, de propósito: o gtag.js, com a
12
+ // medição otimizada ligada (é o padrão de todo fluxo de dados do GA4), já manda um page_view a cada
13
+ // mudança de histórico do navegador, que é exatamente o que a navegação do App Router faz (pushState).
14
+ // Mandar outro daqui contava DUAS visualizações por navegação (Astra, B3). `send_page_view: false` não
15
+ // resolveria: ele desliga só o page_view do carregamento, não a medição de mudanças de histórico.
16
+ // Referência: https://developers.google.com/analytics/devguides/collection/ga4/views (page_view em
17
+ // aplicações de página única). Meta, TikTok e Pinterest não acompanham o histórico: cada um precisa do
18
+ // aviso, e é daqui que ele sai.
19
+ //
20
+ // QUEM ESTÁ NA PÁGINA é decidido no servidor (`rastreioEmVigor`): o que o lojista publicou no painel
21
+ // vence o ambiente, e cada provedor é injetado UMA vez. Um provedor que não entrou não existe em
22
+ // `window`, e este hook não o chama. É assim que ele nunca dispara o mesmo provedor duas vezes: um
23
+ // script por provedor na página, uma chamada por provedor por navegação.
24
+ //
25
+ // CARREGAMENTO INICIAL: quem já conta sozinho (gtag 'config', fbq PageView, ttq.page, pintrk page) é
26
+ // pulado. Quando NENHUM deles está na página, o page_view de entrada vai direto no dataLayer, que é de
27
+ // onde os contêineres de GTM leem (o central da Unbox e o próprio da marca). Sem isso, uma loja só com
28
+ // GTM ficava sem o evento de entrada.
29
+ import * as React from "react";
30
+ import { usePathname } from "next/navigation";
31
+
32
+ type Rastreadores = {
33
+ gtag?: (...a: unknown[]) => void;
34
+ fbq?: (...a: unknown[]) => void;
35
+ ttq?: { page?: () => void };
36
+ pintrk?: (...a: unknown[]) => void;
37
+ dataLayer?: unknown[];
38
+ };
39
+
40
+ export function PageViewPorRota() {
41
+ const pathname = usePathname();
42
+ const first = React.useRef(true);
43
+
44
+ React.useEffect(() => {
45
+ const w = window as unknown as Rastreadores;
46
+ const primeiro = first.current;
47
+ first.current = false;
48
+
49
+ const contadoPorOutro = typeof w.gtag === "function" || typeof w.fbq === "function" || typeof w.ttq?.page === "function" || typeof w.pintrk === "function";
50
+ if (primeiro && contadoPorOutro) return;
51
+
52
+ // o GA4 (gtag) não está aqui de propósito: ele conta a navegação sozinho (ver o topo do arquivo)
53
+ try { w.fbq?.("track", "PageView"); } catch { /* ignora */ }
54
+ try { w.ttq?.page?.(); } catch { /* ignora */ }
55
+ try { w.pintrk?.("page"); } catch { /* ignora */ }
56
+ // sem gtag, o page_view vai direto no dataLayer: é dele que todos os contêineres de GTM leem
57
+ try {
58
+ if (typeof w.gtag !== "function" && Array.isArray(w.dataLayer)) {
59
+ w.dataLayer.push({ event: "page_view", page_path: pathname });
60
+ }
61
+ } catch { /* ignora */ }
62
+ }, [pathname]);
63
+
64
+ return null;
65
+ }
@@ -0,0 +1,168 @@
1
+ // RASTREIO E MARKETING DA LOJA: o que entra na página, decidido em UM lugar (foundation 12).
2
+ //
3
+ // O app/layout.tsx da loja chama só isto, numa linha:
4
+ //
5
+ // <Rastreio doc={conteudo} ambiente={process.env} unboxGtmId={UNBOX_GTM_ID} />
6
+ //
7
+ // e não conhece provedor nenhum. Daqui saem o contêiner central da Unbox (contrato: sempre ligado), o
8
+ // contêiner PRÓPRIO do lojista, o Google Analytics 4, o Meta Pixel, o TikTok Pixel, a Pinterest Tag, o
9
+ // page_view de cada navegação e o botão flutuante de WhatsApp. O próximo provedor entra por versão da
10
+ // foundation, sem tocar no layout de loja nenhuma: foi o dono que recusou reescrever os sites a cada
11
+ // capacidade nova.
12
+ //
13
+ // QUEM DECIDE O QUE ENTRA é `rastreioEmVigor` (document.ts): o que o lojista publicou na aba Apps do
14
+ // painel vence o valor do ambiente, provedor a provedor, e cada provedor entra UMA vez. Este arquivo só
15
+ // desenha o snippet de cada valor em vigor. O contêiner da Unbox não passa por essa regra: é contrato,
16
+ // entra sempre, e o contêiner próprio do lojista só entra se for OUTRO (os dois leem o mesmo dataLayer;
17
+ // o mesmo ID duas vezes seria o mesmo contêiner carregado em dobro).
18
+ //
19
+ // SERVIDOR SÓ: lê `process.env` inteiro (as variáveis são conhecidas pelo nome, `VARIAVEIS_DE_RASTREIO`),
20
+ // e o que chega ao navegador é o snippet com o ID, que já é público de qualquer jeito. A parte que
21
+ // precisa de hook de cliente (o page_view por rota) mora em rastreio-navegacao.tsx. Não importe este
22
+ // arquivo de um componente de cliente.
23
+ //
24
+ // Os IDs chegam validados pela régua de formato (`recusaDeRastreio`): só letras, números e hífen, que
25
+ // é o que permite interpolá-los dentro de um script inline sem escapar nada. O número e a mensagem do
26
+ // WhatsApp vão num `href` (o React escapa o atributo) e a mensagem passa por encodeURIComponent em
27
+ // `linkDoWhatsapp` (document.ts), que não lança nem para documento gravado com mensagem quebrada: este
28
+ // componente está no layout de toda página, e uma exceção aqui derrubava a loja inteira.
29
+ //
30
+ // PAGE_VIEW: cada provedor tem UM dono. O GA4 conta a navegação sozinho (medição otimizada de mudanças
31
+ // de histórico, ligada por padrão); Meta, TikTok e Pinterest não, e é `PageViewPorRota`
32
+ // (rastreio-navegacao.tsx) que os avisa. O porquê está no comentário de lá.
33
+ //
34
+ // OS SCRIPTS SÓ MUDAM QUANDO O LOJISTA PUBLICA: é o documento publicado que chega aqui. E a camada herda
35
+ // o comportamento de consentimento que a loja já tem: ela não põe banner, não segura pixel nem libera
36
+ // nada a mais (ver README, "Apps: rastreio e marketing").
37
+ import Script from "next/script";
38
+ import { linkDoWhatsapp, rastreioEmVigor, type Ambiente, type ContentDocument } from "./document";
39
+ import { PageViewPorRota } from "./rastreio-navegacao";
40
+
41
+ const SNIPPET_GTM = (id: string) =>
42
+ `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${id}');`;
43
+
44
+ /** um contêiner do Google Tag Manager: o noscript e o loader. `chave` distingue o da Unbox do próprio. */
45
+ function Gtm({ id, chave }: { id: string; chave: string }) {
46
+ return (
47
+ <>
48
+ <noscript>
49
+ <iframe src={`https://www.googletagmanager.com/ns.html?id=${id}`} height="0" width="0" style={{ display: "none", visibility: "hidden" }} />
50
+ </noscript>
51
+ <Script id={chave} strategy="afterInteractive">
52
+ {SNIPPET_GTM(id)}
53
+ </Script>
54
+ </>
55
+ );
56
+ }
57
+
58
+ function Ga4({ id }: { id: string }) {
59
+ return (
60
+ <>
61
+ <Script src={`https://www.googletagmanager.com/gtag/js?id=${id}`} strategy="afterInteractive" />
62
+ <Script id="ga4" strategy="afterInteractive">
63
+ {`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}');`}
64
+ </Script>
65
+ </>
66
+ );
67
+ }
68
+
69
+ function MetaPixel({ id }: { id: string }) {
70
+ return (
71
+ <Script id="meta-pixel" strategy="afterInteractive">
72
+ {`!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init','${id}');fbq('track','PageView');`}
73
+ </Script>
74
+ );
75
+ }
76
+
77
+ function TiktokPixel({ id }: { id: string }) {
78
+ return (
79
+ <Script id="tiktok-pixel" strategy="afterInteractive">
80
+ {`!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.partner;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};n=document.createElement("script");n.type="text/javascript",n.async=!0,n.src=r+"?sdkid="+e+"&lib="+t;e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(n,e)};ttq.load('${id}');ttq.page();}(window,document,'ttq');`}
81
+ </Script>
82
+ );
83
+ }
84
+
85
+ function PinterestTag({ id }: { id: string }) {
86
+ return (
87
+ <>
88
+ <Script id="pinterest-tag" strategy="afterInteractive">
89
+ {`!function(e){if(!window.pintrk){window.pintrk=function(){window.pintrk.queue.push(Array.prototype.slice.call(arguments))};var n=window.pintrk;n.queue=[],n.version="3.0";var t=document.createElement("script");t.async=!0,t.src=e;var r=document.getElementsByTagName("script")[0];r.parentNode.insertBefore(t,r)}}("https://s.pinimg.com/ct/core.js");pintrk('load','${id}');pintrk('page');`}
90
+ </Script>
91
+ <noscript>
92
+ {/* pixel 1x1 de quem está sem JavaScript: não é imagem da página, e next/image não se aplica */}
93
+ {/* eslint-disable-next-line @next/next/no-img-element */}
94
+ <img height="1" width="1" style={{ display: "none" }} alt="" src={`https://ct.pinterest.com/v3/?tid=${id}&noscript=1`} />
95
+ </noscript>
96
+ </>
97
+ );
98
+ }
99
+
100
+ // ── BOTÃO FLUTUANTE DE WHATSAPP ──────────────────────────────────────────────────────────────────
101
+ //
102
+ // Abre wa.me/<numero>?text=<mensagem> numa aba nova. Só existe com número em vigor; sem número o
103
+ // layout fica exatamente como era. Padrão do CTA flutuante de uma loja piloto (components/floating-cta.tsx):
104
+ // fixo, some no checkout junto com o resto do chrome (`.site-chrome`, regra que as lojas já têm em
105
+ // globals.css) e no celular sobe acima da barra de navegação inferior. Canto direito, para não disputar
106
+ // o centro com o CTA de compra.
107
+ //
108
+ // O CSS vai inline, num <style> próprio, de propósito: a foundation é copiada para lojas com Tailwind
109
+ // configurado de jeitos diferentes, e uma classe utilitária que a loja não gera vira botão sem estilo.
110
+ // Ícone só, sem texto visível: o rótulo está em `aria-label`/`title`. Não há copy aqui para o lojista
111
+ // editar (`data-editor-ignore`): número e mensagem são configuração, e se editam na aba Apps.
112
+ const CSS_DO_BOTAO = `.unbox-whatsapp{position:fixed;right:16px;bottom:96px;z-index:40;display:flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:9999px;background:#25D366;color:#fff;box-shadow:0 10px 28px rgba(0,0,0,.28);transition:transform .15s ease}.unbox-whatsapp:hover{transform:scale(1.05)}.unbox-whatsapp:focus-visible{outline:2px solid #25D366;outline-offset:2px}@media (min-width:768px){.unbox-whatsapp{right:24px;bottom:24px}}`;
113
+ const CAMINHO_DO_ICONE =
114
+ "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
115
+
116
+ /** o link do botão mora em document.ts (`linkDoWhatsapp`, testado no runner do editor); sai daqui também */
117
+ export { linkDoWhatsapp };
118
+
119
+ function BotaoDeWhatsapp({ numero, mensagem }: { numero: string; mensagem?: string }) {
120
+ return (
121
+ <>
122
+ <style>{CSS_DO_BOTAO}</style>
123
+ <a
124
+ href={linkDoWhatsapp(numero, mensagem)}
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ aria-label="Falar no WhatsApp"
128
+ title="Falar no WhatsApp"
129
+ data-editor-ignore
130
+ className="site-chrome unbox-whatsapp"
131
+ >
132
+ <svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor" aria-hidden="true">
133
+ <path d={CAMINHO_DO_ICONE} />
134
+ </svg>
135
+ </a>
136
+ </>
137
+ );
138
+ }
139
+
140
+ /**
141
+ * A LINHA ÚNICA DO LAYOUT. Renderize no `<body>` (o fim dele é o melhor lugar: os scripts entram depois
142
+ * da hidratação em qualquer posição, e o botão de WhatsApp fica por último na ordem de tabulação).
143
+ *
144
+ * - `doc`: o documento publicado (`getPublishedContent()`); `null` = a loja renderiza só o ambiente.
145
+ * - `ambiente`: `process.env`, inteiro. Só as variáveis de `VARIAVEIS_DE_RASTREIO` são lidas.
146
+ * - `unboxGtmId`: o contêiner CONTRATUAL da Unbox, o literal carimbado no layout pelo CLI (o prebuild
147
+ * da loja cobra a presença dele lá). Não é configuração do lojista e não passa pelo documento.
148
+ */
149
+ export function Rastreio({ doc, ambiente, unboxGtmId }: { doc: ContentDocument | null | undefined; ambiente: Ambiente; unboxGtmId: string }) {
150
+ // `unboxGtmId` vai junto: o contêiner do lojista igual ao da Unbox seria o MESMO contêiner carregado duas
151
+ // vezes, e `rastreioEmVigor` o descarta (e não o conta como Tag Manager próprio para suprimir o Pixel)
152
+ const r = rastreioEmVigor(doc, ambiente, { unboxGtmId });
153
+ return (
154
+ <>
155
+ {/* GTM central da Unbox: contrato, sempre ligado, antes de qualquer outro */}
156
+ {unboxGtmId && <Gtm id={unboxGtmId} chave="unbox-gtm" />}
157
+ {r.gtm.valor && <Gtm id={r.gtm.valor} chave="gtm-loja" />}
158
+ {r.ga4.valor && <Ga4 id={r.ga4.valor} />}
159
+ {r.metaPixel.valor && <MetaPixel id={r.metaPixel.valor} />}
160
+ {r.tiktok.valor && <TiktokPixel id={r.tiktok.valor} />}
161
+ {r.pinterest.valor && <PinterestTag id={r.pinterest.valor} />}
162
+ {/* sempre: page_view de cada navegação para quem não o conta sozinho (Meta, TikTok, Pinterest; o GA4
163
+ conta), e o de entrada no dataLayer dos contêineres de GTM quando nenhum outro provedor o conta */}
164
+ <PageViewPorRota />
165
+ {r.whatsapp.valor && <BotaoDeWhatsapp numero={r.whatsapp.valor.numero} mensagem={r.whatsapp.valor.mensagem} />}
166
+ </>
167
+ );
168
+ }
@@ -0,0 +1,189 @@
1
+ // Leitura do conteúdo PUBLICADO no servidor (layout/page, com ISR).
2
+ //
3
+ // Sem EDITOR_URL, nada é buscado e a loja renderiza o que está no código.
4
+ // Falha de rede/5xx devolve null com aviso: a loja nunca cai por causa do
5
+ // editor — no pior caso mostra o conteúdo do repositório até a próxima
6
+ // revalidação (ISR de 300 s, ou publicação que chama /api/revalidate).
7
+ //
8
+ // Quem só RENDERIZA usa `getPublishedContent()`: null = conteúdo do código, seja
9
+ // porque a loja nunca publicou, seja porque a leitura falhou. Quem DECIDE a partir
10
+ // do publicado usa `lerPublicado()`, que diz qual dos dois foi (`falhou`): é o caso
11
+ // da rota /api/capi, que não pode mandar conversão para o Pixel do cadastro só
12
+ // porque a leitura do que o lojista publicou falhou (Astra, segunda rodada, 1).
13
+ import { cache } from "react";
14
+ import { documentoUsavel, isHtmlPath, leituraDoPublicado, presencaNoAmbiente as presencaPura, recusaDeHtml, type Ambiente, type ContentDocument, type LeituraDoPublicado, type ManifestApps } from "./document";
15
+ import { EDITOR_URL, STORE_SLUG } from "./config";
16
+
17
+ /**
18
+ * APPS: RASTREIO E MARKETING (foundation 12). As funções moram em document.ts (puras, testadas no runner
19
+ * do editor) e saem também por aqui porque é daqui que o app/layout.tsx da loja lê o que é do SERVIDOR:
20
+ * `presencaNoAmbiente(process.env, { unboxGtmId })` vai no manifesto pelo `EditableProvider` (só presença,
21
+ * nunca valor, o estado da CAPI e o contêiner contratual da Unbox). `rastreioEmVigor(doc, process.env)` é o
22
+ * que o `<Rastreio>` de rastreio.tsx (a linha
23
+ * única do layout) consulta para decidir o que dispara (documento vence ambiente, um script por
24
+ * provedor). `capiDaLeitura(await lerPublicado(), process.env)` é o que a rota /api/capi da loja obedece: o
25
+ * Pixel em vigor e se o token o atende, ou "leitura-falhou" quando não se conseguiu ler o publicado (aí a
26
+ * rota não envia: não se sabe qual Pixel o lojista publicou). `process.env` é lido inteiro no servidor de
27
+ * propósito: as variáveis são conhecidas pelo nome em `VARIAVEIS_DE_RASTREIO`, e o layout não precisa
28
+ * listá-las uma a uma.
29
+ */
30
+ export { rastreioEmVigor, capiEmVigor, capiDaLeitura, type RastreioEmVigor, type ManifestApps, type EstadoDoCapi, type EstadoDaRotaCapi, type LeituraDoPublicado } from "./document";
31
+
32
+ /**
33
+ * O DOCUMENTO PUBLICADO DESTE PEDIDO, para quem precisa dele sem o receber por parâmetro. `cache` do React
34
+ * é por pedido: o layout faz `await getPublishedContent()` e, na mesma renderização, chama
35
+ * `presencaNoAmbiente(process.env, { unboxGtmId })` sem passar o documento (é a linha que o CLI carimba em
36
+ * toda loja). O
37
+ * estado da CAPI depende do Pixel EM VIGOR, que pode ser o que o lojista publicou, então a presença tem de
38
+ * ver o documento: ela o pega daqui. Fora de uma renderização (rota de API), `cache` não guarda nada e o
39
+ * slot vem vazio: aí vale só o ambiente, e quem precisa do documento o passa (a rota /api/capi passa).
40
+ */
41
+ const documentoDoPedido = cache((): { doc?: ContentDocument | null } => ({}));
42
+
43
+ /**
44
+ * A presença no ambiente e o estado da CAPI para o manifesto, com o documento publicado que este pedido
45
+ * já leu (ou o `doc` passado, quando quem chama o tem), e o contêiner contratual da Unbox (`unboxGtmId`, o
46
+ * mesmo literal que o layout passa ao `<Rastreio>`), para o painel não anunciar supressão de Pixel que a
47
+ * loja não executa. A linha do layout: `presencaNoAmbiente(process.env, { unboxGtmId: UNBOX_GTM_ID })`. A
48
+ * regra está em `presencaNoAmbiente` de document.ts.
49
+ */
50
+ export function presencaNoAmbiente(ambiente: Ambiente, opcoes?: { doc?: ContentDocument | null; unboxGtmId?: string }): ManifestApps {
51
+ return presencaPura(ambiente, { doc: opcoes?.doc === undefined ? (documentoDoPedido().doc ?? null) : opcoes.doc, unboxGtmId: opcoes?.unboxGtmId });
52
+ }
53
+
54
+ /**
55
+ * BLOCO DE HTML, CAMADA 2 DE 3 — a conferência da LOJA, na porta de entrada do documento publicado.
56
+ *
57
+ * As outras duas: o EDITOR ao gravar (limpa e RELATA ao lojista o que tirou — é a única que tem alguém
58
+ * na tela para avisar) e o PRIMITIVO no cliente (a única que existe na PRÉVIA, onde o rascunho chega
59
+ * por postMessage sem ver servidor). Esta aqui não tem a quem relatar: ela só derruba, e escreve o
60
+ * motivo no log da loja — silêncio total seria a loja mudando de conteúdo sem ninguém saber por quê.
61
+ *
62
+ * ── POR QUE ELA EXISTE, se `resolveValue` já recusa HTML no caminho `.html` ────────────────────────
63
+ * 1. REGRA NOVA VALE PARA QUEM JÁ PUBLICOU. A lista de recusa (`recusaDeHtml`) mora no código da loja.
64
+ * No dia em que uma tag entrar nela, o documento publicado há seis meses continua o mesmo — é este
65
+ * varrimento, na leitura, que aplica a regra nova sem ninguém precisar republicar. Cobre também
66
+ * "voltar para uma versão antiga do documento", que é justamente um documento escrito sob a lista
67
+ * antiga.
68
+ * 2. O QUE É RECUSADO NÃO CHEGA AO NAVEGADOR. O documento INTEIRO é entregue ao `EditableProvider`
69
+ * (app/layout.tsx) e viaja serializado dentro do HTML da página. `resolveValue` defende o RENDER —
70
+ * o bloco não é injetado —, mas o texto do `<script>` recusado ainda sairia impresso no payload da
71
+ * página, para qualquer um ler ou copiar. Derrubando na porta, ele não sai do servidor.
72
+ * MEDIDO em `next build` + `next start`: o HTML recusado não aparece nenhuma vez na página
73
+ * servida (nem escapado como `\u003cscript\u003e`). Em `next dev` ele AINDA aparece, e não é esta
74
+ * função falhando: o modo de desenvolvimento serializa a resposta crua do `fetch` e ecoa o
75
+ * `console.warn` do servidor no fluxo da página, tudo isso ANTES de o documento chegar aqui. Quem
76
+ * for conferir, confira no build de produção.
77
+ * 3. CAMINHO SEM PRIMITIVO NUNCA PASSA POR `resolveValue`. Um `.html` de seção que saiu do código, ou
78
+ * de uma seção criada cujo tipo deixou o catálogo, não é lido por primitivo nenhum — e mesmo assim
79
+ * viajaria no documento pelo motivo 2.
80
+ *
81
+ * Não depende do editor: `recusaDeHtml` é função pura do `document.ts` desta loja, sem rede e sem
82
+ * biblioteca. A loja não pede opinião a ninguém para decidir o que renderiza.
83
+ *
84
+ * O que ela NÃO faz: a LIMPEZA por allowlist (tirar uma tag fora da lista, um atributo que não serve)
85
+ * continua só no editor, e de propósito — ela roda no momento da colagem, com o relatório do que saiu
86
+ * na tela. Repeti-la aqui, muda, faria a página publicada divergir em silêncio da prévia que o lojista
87
+ * aprovou. Aqui vale a RECUSA, que é a decisão do dono: o bloco inteiro entra ou não entra.
88
+ */
89
+ function htmlRecusadoDoDocumento(doc: ContentDocument): [string, string][] {
90
+ const recusados: [string, string][] = [];
91
+ for (const [path, v] of Object.entries(doc.values)) {
92
+ if (!isHtmlPath(path)) continue;
93
+ // valor que nem sequer é texto já é lixo neste caminho: `recusaDeHtml` só sabe ler string
94
+ const motivo = typeof v === "string" ? recusaDeHtml(v) : "o valor gravado não é texto";
95
+ if (motivo) recusados.push([path, motivo]);
96
+ }
97
+ return recusados;
98
+ }
99
+
100
+ function semHtmlRecusado(doc: ContentDocument): ContentDocument {
101
+ const recusados = htmlRecusadoDoDocumento(doc);
102
+ if (recusados.length === 0) return doc; // o caso normal: nada é copiado à toa
103
+ const values = { ...doc.values };
104
+ for (const [path, motivo] of recusados) {
105
+ delete values[path];
106
+ // apagar o caminho = a loja cai no `fallback` do código (bloco de HTML nasce vazio, então some).
107
+ console.warn(`[editable] bloco de HTML recusado na leitura do publicado (${path}): ${motivo}`);
108
+ }
109
+ return { ...doc, values };
110
+ }
111
+
112
+ /**
113
+ * A LEITURA do publicado deste pedido, dizendo qual dos dois casos foi: AUSÊNCIA (`doc: null`, `falhou:
114
+ * false`: 404, a loja nunca publicou, ou loja desligada do editor) ou FALHA DE LEITURA (`falhou: true`: rede,
115
+ * 5xx, JSON inválido, documento malformado ou de outra loja). A classificação é `leituraDoPublicado` de
116
+ * document.ts (pura, testada no runner do editor); aqui entram o `fetch` com ISR, o aviso no log e a
117
+ * conferência dos blocos de HTML. É o que a rota /api/capi lê (`capiDaLeitura`).
118
+ */
119
+ export const lerPublicado = cache(async (): Promise<LeituraDoPublicado> => {
120
+ const leitura = await lerDoEditor();
121
+ documentoDoPedido().doc = leitura.doc; // o que o `presencaNoAmbiente` deste arquivo vê no mesmo pedido
122
+ return leitura;
123
+ });
124
+
125
+ /** o documento publicado, ou null: a loja nunca publicou OU a leitura falhou. Para RENDERIZAR, os dois são "vale o código". */
126
+ export const getPublishedContent = cache(async (): Promise<ContentDocument | null> => (await lerPublicado()).doc);
127
+
128
+ async function lerDoEditor(): Promise<LeituraDoPublicado> {
129
+ // loja desligada do editor: AUSÊNCIA, não falha (não há o que ler)
130
+ if (!EDITOR_URL || !STORE_SLUG) return { doc: null, falhou: false };
131
+ const leitura = await leituraDoPublicado(
132
+ () => fetch(`${EDITOR_URL}/api/content/${encodeURIComponent(STORE_SLUG)}/published`, {
133
+ next: { revalidate: 300, tags: ["unbox-editor-content"] },
134
+ headers: { accept: "application/json" },
135
+ }),
136
+ STORE_SLUG,
137
+ );
138
+ // A loja não cai por causa do editor: é a promessa do topo deste arquivo. Falhou = conteúdo do código, com
139
+ // o motivo no log (e `falhou: true` para quem decide a partir do publicado).
140
+ if (leitura.falhou) {
141
+ console.warn(`[editable] ${leitura.motivo}; usando conteúdo do código`);
142
+ return leitura;
143
+ }
144
+ // o documento só sai daqui depois da conferência dos blocos de HTML (ver `semHtmlRecusado`)
145
+ return { doc: leitura.doc && semHtmlRecusado(leitura.doc), falhou: false };
146
+ }
147
+
148
+ /**
149
+ * O RECIBO DA REVALIDAÇÃO — o que esta loja está servindo AGORA, para o editor poder (ou não) dizer
150
+ * "a loja já está no ar com a versão N".
151
+ *
152
+ * O editor publica e chama `/api/revalidate` daqui. Até hoje ele concluía "está no ar" porque a
153
+ * resposta trazia a palavra "revalidated" — e isso não prova nada: purgar o cache diz que a PRÓXIMA
154
+ * leitura vai buscar de novo, não que ela aconteceu nem que deu certo. Uma loja sem `EDITOR_URL` (que
155
+ * nunca lê o editor), uma loja que responde 401, ou um documento de outra loja no meio do caminho
156
+ * davam a MESMA frase de sucesso para o lojista.
157
+ *
158
+ * Então esta função vai ver: lê o conteúdo publicado SEM CACHE e devolve o `updatedAt` do documento
159
+ * que passou por todas as portas que a loja usa para renderizar (`documentoUsavel`, a conferência do
160
+ * slug e a dos blocos de HTML). O editor compara com o que acabou de publicar. Se bater, "no ar" é
161
+ * afirmação verificada; se não bater, ele diz o que sabe (atualização pedida).
162
+ *
163
+ * Não substitui `getPublishedContent` nem aquece o cache dele: é uma leitura à parte, curta, feita
164
+ * DEPOIS do `revalidateTag`. O que ela garante é que o conteúdo novo chegou até aqui e é utilizável,
165
+ * e que a próxima renderização vai buscar do mesmo lugar.
166
+ */
167
+ export type ConteudoAgora = { ok: true; updatedAt?: string; blocosRecusados: number } | { ok: false; motivo: string };
168
+
169
+ export async function conteudoPublicadoAgora(): Promise<ConteudoAgora> {
170
+ if (!EDITOR_URL || !STORE_SLUG) return { ok: false, motivo: "esta loja não está ligada ao editor (sem EDITOR_URL)" };
171
+ try {
172
+ const res = await fetch(`${EDITOR_URL}/api/content/${encodeURIComponent(STORE_SLUG)}/published`, {
173
+ cache: "no-store",
174
+ headers: { accept: "application/json" },
175
+ signal: AbortSignal.timeout(8_000),
176
+ redirect: "manual",
177
+ });
178
+ if (res.status === 404) return { ok: false, motivo: "o editor ainda não tem conteúdo publicado para esta loja" };
179
+ if (!res.ok) return { ok: false, motivo: `o editor respondeu ${res.status}` };
180
+ const doc = (await res.json()) as ContentDocument;
181
+ if (!documentoUsavel(doc)) return { ok: false, motivo: "o conteúdo publicado veio malformado" };
182
+ if (doc.shop !== STORE_SLUG) return { ok: false, motivo: `o conteúdo publicado é de outra loja (${String(doc.shop)})` };
183
+ // blocos recusados NÃO derrubam o recibo: o resto do documento está no ar, e é isso que o editor
184
+ // afirma. O número sobe junto para o editor poder avisar o lojista do pedaço que ficou de fora.
185
+ return { ok: true, updatedAt: typeof doc.updatedAt === "string" ? doc.updatedAt : undefined, blocosRecusados: htmlRecusadoDoDocumento(doc).length };
186
+ } catch (err) {
187
+ return { ok: false, motivo: err instanceof Error ? err.message : String(err) };
188
+ }
189
+ }
@@ -0,0 +1,22 @@
1
+ // Tokens de cor que o lojista pode mudar. É a ÚNICA lista feita à mão do
2
+ // editor — e é por loja: o construtor acrescenta os tokens próprios da marca.
3
+ // Tudo que não está aqui fica fora do alcance do editor.
4
+ import type { EditableTokenSpec } from "./provider";
5
+
6
+ export const EDITABLE_TOKENS: EditableTokenSpec[] = [
7
+ { token: "--store-primary", label: "Cor principal" },
8
+ { token: "--store-cta", label: "Cor do botão de compra" },
9
+ { token: "--store-cta-fg", label: "Texto do botão de compra" },
10
+ { token: "--store-bg", label: "Fundo da página" },
11
+ { token: "--store-ink", label: "Cor do texto" },
12
+ { token: "--store-surface", label: "Fundo de cartões" },
13
+ { token: "--store-sale", label: "Cor de oferta" },
14
+ // O lojista pediu "trocar a cor da barra de cima" e não havia como: o cabeçalho pinta com
15
+ // `--store-surface-2` e o rodapé com `--store-chrome-bg`, e nenhum dos dois estava aqui. O chat
16
+ // então mexeu na cor da marca, disse "alterada" e a tela não mudou. Lista curta demais é uma
17
+ // forma de esconder a loja do dono dela.
18
+ { token: "--store-surface-2", label: "Fundo do cabeçalho" },
19
+ { token: "--store-chrome-bg", label: "Fundo do rodapé" },
20
+ { token: "--store-chrome-text", label: "Texto sobre o rodapé" },
21
+ { token: "--store-line", label: "Cor dos filetes" },
22
+ ];
@@ -0,0 +1,24 @@
1
+ // Verificação dos tokens assinados pelo editor (ES256, chave pública via JWKS).
2
+ // Roda no edge (middleware) e no Node (rotas). A loja NÃO guarda segredo do
3
+ // editor: só busca a chave pública em EDITOR_URL/.well-known/jwks.json.
4
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
5
+ import { EDITOR_URL, STORE_SLUG } from "./config";
6
+
7
+ let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
8
+
9
+ // "catalogo": leitura do catálogo da loja pelo SELETOR de vitrine do editor. É só
10
+ // leitura, e a loja continua sendo a única que fala com a Unbox — o editor nunca
11
+ // vê credencial (ver app/api/unbox/catalogo/route.ts).
12
+ export type EditorTokenPurpose = "preview" | "revalidate" | "catalogo";
13
+
14
+ export async function verifyEditorToken(token: string, purpose: EditorTokenPurpose): Promise<JWTPayload | null> {
15
+ if (!EDITOR_URL || !token) return null;
16
+ try {
17
+ jwks ??= createRemoteJWKSet(new URL(`${EDITOR_URL}/.well-known/jwks.json`), { cooldownDuration: 30_000, cacheMaxAge: 600_000 });
18
+ const { payload } = await jwtVerify(token, jwks, { issuer: "unbox-editor", audience: `loja:${STORE_SLUG}`, algorithms: ["ES256"] });
19
+ if (payload.purpose !== purpose) return null;
20
+ return payload;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
@@ -0,0 +1,184 @@
1
+ // COMBOS: kits temáticos da loja. Substitua pelos combos da nova marca.
2
+ // Cada item usa `family` (familyCode do products.json) para resolver contra o catálogo Unbox.
3
+ // Para usar: popule lib/enrichment/products.json à mão, no formato de lib/enrichment/index.ts.
4
+ //
5
+ // ===== Combos (kits temáticos) =====
6
+ // Bundles curados a partir das famílias do products.json (familyCode). Cada item aponta para uma
7
+ // FAMÍLIA — na resolução escolhemos o produto comprável do catálogo (por nome+tamanho), então o
8
+ // combo nunca quebra se um tamanho específico sair de linha. Preço e imagem vêm do catálogo Unbox;
9
+ // o desconto do combo é distribuído por item (mesmo padrão de `add({ price })` da buy box).
10
+ import { getEnrichmentByName, parseSize } from "./index";
11
+ import { resolveProductPrice } from "../format";
12
+
13
+ /** "min" = menor tamanho da família (combo mais acessível); "max" = maior. */
14
+ export interface ComboItem {
15
+ family: string;
16
+ qty?: number;
17
+ size?: "min" | "max";
18
+ }
19
+
20
+ export interface ComboHighlight {
21
+ /** chave de ícone resolvida no componente */
22
+ icon: "box" | "fire" | "sparkle" | "star" | "shield" | "heart" | "tag" | "gift";
23
+ label: string;
24
+ }
25
+
26
+ export interface ComboDef {
27
+ id: string;
28
+ name: string;
29
+ description: string;
30
+ /** imagem temática do combo, composta a partir das fotos reais dos produtos */
31
+ imageUrl: string;
32
+ /** selo opcional, ex.: "MAIS VENDIDO" */
33
+ badge?: string;
34
+ highlights: ComboHighlight[];
35
+ /** % de desconto sobre a soma dos itens */
36
+ /** @deprecated ignorado: o preço final é sempre o do catálogo (ver resolveCombos). */
37
+ discountPct?: number;
38
+ items: ComboItem[];
39
+ }
40
+
41
+ // TODO: defina os combos/kits da sua loja aqui.
42
+ // Cada item usa `family` (familyCode do products.json) para resolver contra o catálogo Unbox.
43
+ // Para usar: popule lib/enrichment/products.json com os dados dos produtos e defina os familyCodes.
44
+ //
45
+ // Exemplo de combo:
46
+ // {
47
+ // id: "kit-essencial",
48
+ // name: "Kit Essencial",
49
+ // description: "Tudo o que você precisa para começar.",
50
+ // imageUrl: "/brand/combos/kit-essencial.webp",
51
+ // badge: "MAIS VENDIDO",
52
+ // discountPct: 15,
53
+ // highlights: [
54
+ // { icon: "sparkle", label: "3 itens" },
55
+ // { icon: "box", label: "Pronto para usar" },
56
+ // ],
57
+ // items: [
58
+ // { family: "FAMILIA_A" },
59
+ // { family: "FAMILIA_B" },
60
+ // { family: "FAMILIA_C", size: "min" },
61
+ // ],
62
+ // },
63
+ export const COMBOS: ComboDef[] = [];
64
+
65
+ // ----- Resolução contra o catálogo -----
66
+
67
+ export interface ResolvedComboItem {
68
+ family: string;
69
+ qty: number;
70
+ name: string;
71
+ weight: string;
72
+ imageUrl: string | null;
73
+ slug: string;
74
+ productId: string;
75
+ variantId: string;
76
+ /** preço unitário cheio */
77
+ price: number;
78
+ /** preço unitário com o desconto do combo (para enviar ao carrinho) */
79
+ discountedPrice: number;
80
+ }
81
+
82
+ export interface ResolvedCombo {
83
+ id: string;
84
+ name: string;
85
+ description: string;
86
+ imageUrl: string;
87
+ badge?: string;
88
+ highlights: ComboHighlight[];
89
+ discountPct: number;
90
+ items: ResolvedComboItem[];
91
+ itemCount: number;
92
+ subtotal: number;
93
+ total: number;
94
+ save: number;
95
+ }
96
+
97
+ const round2 = (n: number) => Math.round(n * 100) / 100;
98
+
99
+ /** "Nome ( Name in English )" → "Nome"; sem parênteses devolve o nome inteiro. */
100
+ function familyLabel(name: string): string {
101
+ const pt = name.split("(")[0].trim().replace(/\s+/g, " ");
102
+ return pt || name.trim();
103
+ }
104
+
105
+ function fmtWeight(w: { value: number; unit: string } | null): string {
106
+ if (!w) return "";
107
+ const v = Number.isInteger(w.value) ? String(w.value) : String(w.value).replace(".", ",");
108
+ return `${v} ${w.unit}`;
109
+ }
110
+
111
+ /** Indexa os produtos do catálogo por familyCode (ordenados por peso crescente). */
112
+ function indexByFamily(catalogProducts: any[]) {
113
+ const map = new Map<string, { product: any; weight: number }[]>();
114
+ for (const p of catalogProducts) {
115
+ if (!p?.slug) continue;
116
+ const e = getEnrichmentByName(p.title);
117
+ if (!e) continue;
118
+ const w = parseSize(p.title)?.value ?? e.weight?.value ?? 0;
119
+ const arr = map.get(e.familyCode) ?? [];
120
+ arr.push({ product: p, weight: w });
121
+ map.set(e.familyCode, arr);
122
+ }
123
+ for (const arr of map.values()) arr.sort((a, b) => a.weight - b.weight);
124
+ return map;
125
+ }
126
+
127
+ /** Resolve todos os combos contra a lista de produtos do catálogo. Descarta itens/combos sem match. */
128
+ export function resolveCombos(catalogProducts: any[]): ResolvedCombo[] {
129
+ const byFamily = indexByFamily(catalogProducts);
130
+ const out: ResolvedCombo[] = [];
131
+
132
+ for (const def of COMBOS) {
133
+ const items: ResolvedComboItem[] = [];
134
+ for (const it of def.items) {
135
+ const fam = byFamily.get(it.family);
136
+ if (!fam || fam.length === 0) continue;
137
+ const list = fam;
138
+ const picked = it.size === "max" ? list[list.length - 1] : list[0];
139
+ const cp = picked.product;
140
+ const v0 = cp.variants?.[0];
141
+ const rp = resolveProductPrice(cp);
142
+ if (!v0?._id || rp.price == null) continue;
143
+ const e = getEnrichmentByName(cp.title);
144
+ const qty = it.qty ?? 1;
145
+ // O preço final de um item é sempre o do catálogo: o servidor recalcula o carrinho a
146
+ // partir dele (lib/unbox/types.ts). Um "preço do kit" calculado aqui vira promessa que o
147
+ // carrinho desmente. Desconto de kit real se configura como regra de preço/cupom no painel.
148
+ const discountedPrice = round2(rp.price);
149
+ items.push({
150
+ family: it.family,
151
+ qty,
152
+ name: familyLabel(e?.name ?? cp.title),
153
+ weight: fmtWeight(parseSize(cp.title) ?? e?.weight ?? null),
154
+ imageUrl: cp.imageUrls?.[0] ?? null,
155
+ slug: cp.slug,
156
+ productId: cp.productId,
157
+ variantId: v0._id,
158
+ price: rp.price,
159
+ discountedPrice,
160
+ });
161
+ }
162
+
163
+ if (items.length < 2) continue; // combo precisa de pelo menos 2 itens reais
164
+
165
+ const subtotal = round2(items.reduce((s, i) => s + i.price * i.qty, 0));
166
+ const total = round2(items.reduce((s, i) => s + i.discountedPrice * i.qty, 0));
167
+ out.push({
168
+ id: def.id,
169
+ name: def.name,
170
+ description: def.description,
171
+ imageUrl: def.imageUrl,
172
+ badge: def.badge,
173
+ highlights: def.highlights,
174
+ discountPct: 0,
175
+ items,
176
+ itemCount: items.reduce((s, i) => s + i.qty, 0),
177
+ subtotal,
178
+ total,
179
+ save: round2(subtotal - total),
180
+ });
181
+ }
182
+
183
+ return out;
184
+ }