@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,136 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // LEITURA DO CATÁLOGO PARA O SELETOR DE VITRINE DO EDITOR.
3
+ //
4
+ // Por que existe: quando o lojista escolhe "quais produtos esta vitrine mostra",
5
+ // o editor precisa listar as categorias e os produtos da loja. Ele NÃO fala com
6
+ // a Unbox e NÃO guarda credencial — quem tem o cliente da Unbox é a loja. Então
7
+ // o editor pergunta aqui, com o mesmo token assinado que ele já usa em
8
+ // /api/revalidate (ES256, chave pública no JWKS dele), agora com purpose
9
+ // "catalogo".
10
+ //
11
+ // SOMENTE LEITURA, e só os campos que o seletor mostra. Nada de credencial,
12
+ // nada de cadastro interno, nada de escrita.
13
+ //
14
+ // Se a Unbox falhar, isto responde 502 — NUNCA 200 com lista vazia. Lista vazia
15
+ // é uma resposta legítima ("essa categoria não tem produto") e o editor
16
+ // precisa saber diferenciar as duas coisas para não oferecer uma vitrine que na
17
+ // verdade ninguém conseguiu ler.
18
+ //
19
+ // ── CONTRATO (o outro lado é lib/catalogo.ts do editor) ────────────────────
20
+ // GET ?tipo=categorias → { categorias: [{ id, nome, slug }] }
21
+ // GET ?tipo=produtos → { produtos: [{ id, titulo, slug, imagem, preco, esgotado }] }
22
+ // GET ?busca=<texto> | ?tagId=<id> → idem, filtrado
23
+ // GET ?ids=<id,id,id> → idem, só ESSES, na ordem pedida (quem já está na vitrine)
24
+ // `limite` (1..60, padrão 30) é o tamanho da LISTA do seletor, não o teto da vitrine.
25
+ // `imagem: string|null`, `preco: number|null` (a loja formata; o editor também) —
26
+ // null é ausência honesta, nunca zero nem foto genérica. `id` é o que o editor grava
27
+ // na escolha, e o que `resolverVitrine` usa para achar o produto de volta.
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ import { ok, fail } from "@/lib/api";
30
+ import { verifyEditorToken } from "@/lib/editable/verify";
31
+ import { getCatalog, getTopTags } from "@/lib/queries";
32
+ import { VITRINE_MAX } from "@/lib/editable/document";
33
+ import { produtoDaVitrine, resolverVitrine, type VitrineProduto } from "@/lib/vitrine";
34
+
35
+ // A resposta depende do token do request; nada de cache compartilhado.
36
+ export const dynamic = "force-dynamic";
37
+
38
+ const SEM_CACHE = { headers: { "Cache-Control": "private, no-store" } };
39
+
40
+ /** Texto de busca / id de categoria: curtos, como no resto da foundation. */
41
+ const MAX_TEXTO = 120;
42
+
43
+ // Quantos itens o SELETOR lista de uma vez. NÃO é `VITRINE_MAX` (o teto de quantos
44
+ // produtos uma vitrine mostra): são duas coisas diferentes, e usar o teto da vitrine
45
+ // aqui cortava a lista de escolha em 24 mesmo quando o editor pedia 30. Os mesmos
46
+ // números vivem em lib/catalogo.ts do editor (LISTA_MAX / LISTA_PADRAO).
47
+ const LISTA_MAX = 60;
48
+ const LISTA_PADRAO = 30;
49
+
50
+ function limiteDaLista(bruto: number): number {
51
+ return Number.isFinite(bruto) && bruto >= 1 ? Math.min(LISTA_MAX, Math.floor(bruto)) : LISTA_PADRAO;
52
+ }
53
+
54
+ export async function GET(req: Request) {
55
+ const token = req.headers.get("x-editor-token");
56
+ const autorizado = token ? await verifyEditorToken(token, "catalogo") : null;
57
+ if (!autorizado) return fail("Não autorizado.", 401);
58
+
59
+ const params = new URL(req.url).searchParams;
60
+ const tipo = params.get("tipo")?.trim() ?? "";
61
+ const busca = params.get("busca")?.trim() ?? "";
62
+ const tagId = params.get("tagId")?.trim() ?? "";
63
+ const limite = limiteDaLista(Number(params.get("limite")));
64
+ // Ids de uma escolha de vitrine. O charset é o de `vitrineValida` — mais largo que o do
65
+ // tagId porque o id da Unbox pode ser OPACO (base64, com `/` e `=`) —, e o teto é
66
+ // `VITRINE_MAX` porque é o que cabe numa escolha. Id fora do formato é descartado em
67
+ // silêncio: ele não poderia estar gravado numa vitrine de todo jeito.
68
+ const ids = (params.get("ids") ?? "")
69
+ .split(",")
70
+ .map((x) => x.trim())
71
+ .filter((x) => /^[\w:.@+/=-]{1,200}$/.test(x))
72
+ .slice(0, VITRINE_MAX);
73
+
74
+ if (busca.length > MAX_TEXTO || tagId.length > MAX_TEXTO) {
75
+ return fail("Busca ou categoria longa demais.", 400);
76
+ }
77
+
78
+ try {
79
+ // ── Categorias ────────────────────────────────────────────────────────
80
+ // `getTopTags` é o nome que a foundation dá ao fetcher de tags (ele chama
81
+ // `client.getTags()` por dentro, cacheado por 10 min). `quantidade` fica de
82
+ // fora de propósito: a query de tags da Unbox não devolve contagem de
83
+ // produtos, e número de produto não se estima.
84
+ if (tipo === "categorias") {
85
+ const tags = await getTopTags();
86
+ const categorias = (tags ?? [])
87
+ .filter((t: any) => t?.isVisible !== false)
88
+ .sort((a: any, b: any) => (a?.position ?? 0) - (b?.position ?? 0))
89
+ .map((t: any) => ({ id: String(t._id ?? ""), nome: t.displayTitle || t.name || "", slug: t.slug ?? "" }))
90
+ .filter((c: { id: string; nome: string }) => c.id && c.nome)
91
+ .slice(0, limite);
92
+ return ok({ categorias }, SEM_CACHE);
93
+ }
94
+
95
+ // ── Produtos por ID: quem são os que a vitrine JÁ escolheu ───────────
96
+ // A escolha guarda só o id (e o CÓDIGO da loja guarda o SLUG no fallback da vitrine).
97
+ // Sem este caminho, a lista de escolhidos no painel mostrava o id CRU para todo produto
98
+ // que não estivesse na página de resultados aberta na hora — o lojista lia o slug ao
99
+ // lado do nome do produto, na mesma lista, sem nunca ter escrito slug nenhum.
100
+ //
101
+ // Quem responde é `resolverVitrine`, a MESMA função da vitrine publicada: mesma ordem e a
102
+ // mesma tolerância a id que é productId, `_id` ou slug. `limite: ids.length` porque sem
103
+ // limite ela assume o padrão da vitrine (12) e cortaria a lista do painel. Em modo
104
+ // ESTRITO: aqui uma resposta que não veio (rede, prazo, 5xx) não pode virar "esses produtos
105
+ // sumiram da loja" — o painel marca o ausente em vermelho, e o lojista tiraria da vitrine
106
+ // produto que existe.
107
+ if (ids.length) {
108
+ const produtos = await resolverVitrine({ modo: "produtos", produtos: ids, limite: ids.length }, { estrito: true });
109
+ return ok({ produtos }, SEM_CACHE);
110
+ }
111
+
112
+ // ── Produtos: os primeiros da loja, uma busca ou uma categoria ────────
113
+ // SEM `busca` e SEM `tagId` o seletor está ABRINDO a aba de produtos: ele espera
114
+ // os primeiros do catálogo, que é como o lojista acha o que quer sem saber o nome
115
+ // exato. Recusar isso com 400 deixava a aba de produtos vazia na abertura, com uma
116
+ // mensagem de falha, e só funcionando depois de digitar.
117
+ if (tipo === "produtos" || busca || tagId) {
118
+ const catalogo = await getCatalog(
119
+ busca ? { first: limite, searchText: busca } : tagId ? { first: limite, tagIds: [tagId] } : { first: limite },
120
+ );
121
+ const produtos: VitrineProduto[] = [];
122
+ for (const node of catalogo?.nodes ?? []) {
123
+ const p = produtoDaVitrine(node);
124
+ if (p) produtos.push(p);
125
+ if (produtos.length >= limite) break;
126
+ }
127
+ return ok({ produtos }, SEM_CACHE);
128
+ }
129
+
130
+ return fail("Diga o que buscar: ?tipo=categorias, ?tipo=produtos, ?busca=<texto> ou ?tagId=<id>.", 400);
131
+ } catch (err) {
132
+ // A mensagem crua pode carregar detalhe do gateway; fica no log do servidor.
133
+ console.warn("[vitrine] catálogo indisponível", err instanceof Error ? err.message : err);
134
+ return fail("Não consegui ler o catálogo da Unbox agora.", 502);
135
+ }
136
+ }
@@ -0,0 +1,167 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // A LOJA DIZ QUAIS PÁGINAS ELA TEM.
3
+ //
4
+ // Por que existe: a lista de páginas que o editor abre estava no `shops.json` DELE — digitada à mão,
5
+ // com slug de produto cravado, e ignorada inteira quando `SHOPS_JSON` existia no ambiente. Quem sabe
6
+ // quais páginas existem é a loja, e ela sabe sem ninguém digitar nada: a regra é a de
7
+ // lib/rotas-editaveis.ts (página de `app/(loja)/` que o `DISALLOW` do robots não bloqueia).
8
+ //
9
+ // ── CONTRATO (o outro lado é lib/paginas-da-loja.ts do editor) ─────────────
10
+ // GET /api/unbox/paginas (PÚBLICO, sem token)
11
+ // 200 → { paginas: [ { rota, caminho, label, dinamica, soChrome?, containers? } , … ] }
12
+ // rota caminho da ROTA como o Next a escreve ("/produto/[productSlug]"). É a forma que
13
+ // `revalidatePath(rota, "page")` entende, e é UMA por rota — nunca uma por produto.
14
+ // caminho caminho CONCRETO para a prévia abrir ("/produto/<slug de um produto real>").
15
+ // Igual a `rota` nas páginas estáticas. `null` = a rota existe e precisa ser
16
+ // re-renderizada, mas hoje não há exemplo para abrir (catálogo vazio ou fora do ar).
17
+ // label nome que o lojista lê no seletor de página.
18
+ // dinamica a rota tem segmento `[…]`: um `caminho` é um exemplo, não a página inteira.
19
+ // soChrome OPCIONAL, e só aparece quando `true`: a página é, por regra (README do editor, §8), só
20
+ // cabeçalho e rodapé editáveis, porque o corpo dela é texto legal, rótulo de formulário/UI
21
+ // ou mecânica de compra, e isso não vira primitivo. A lista é `SO_CHROME` em
22
+ // lib/rotas-editaveis.ts, com o motivo de cada rota. O gate não aplica a régua de 80% nela
23
+ // (o resto do gate vale), e o editor sabe que a lista de seções dela é só o chrome.
24
+ // containers OPCIONAL: os containers que esta ROTA renderiza, fora o `chrome` (que é do site inteiro).
25
+ // É a tabela `CONTAINERS_POR_ROTA` de lib/rotas-editaveis.ts, com o motivo de cada rota. `[]` é
26
+ // declaração ("só o chrome"); o campo AUSENTE é rota fora da tabela (a loja não declarou; o gate
27
+ // reprova). O editor usa para avisar "isto também aparece em «Categoria»" antes de alguém abrir
28
+ // /categoria, e para a publicação alcançar a página certa já na primeira sessão. O gate confere:
29
+ // o que o manifesto da página mostrou (fora o chrome) tem de ser exatamente isto.
30
+ // 500 → { error: "<motivo verdadeiro>" } — a loja não conseguiu descobrir as próprias páginas. Inclui a
31
+ // descoberta que falhou NO MEIO (uma subpasta de app/(loja) ilegível): a lista é recusada inteira, com o
32
+ // caminho que não abriu, em vez de sair parcial com 200 e sumir com a página do seletor e do gate.
33
+ //
34
+ // PÚBLICO de propósito: é a mesma informação que `sitemap.xml` já publica, e o gate de cobertura
35
+ // (`scripts/check-editable.mjs`) precisa dela sem ter token de editor nenhum em mãos.
36
+ //
37
+ // ⚠️ LISTA VAZIA NUNCA VIRA 200. Uma loja sem páginas não existe; lista vazia é falha da descoberta,
38
+ // e responder 200 com `[]` faria o editor (e o gate) concluírem "não há nada para editar/medir".
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ import { ok, fail } from "@/lib/api";
41
+ import { containersDaRota, rotasEditaveis, soChrome } from "@/lib/rotas-editaveis";
42
+ import { getCatalog, getTopTags } from "@/lib/queries";
43
+
44
+ // DINÂMICA de propósito. Com `revalidate` (rota estática com ISR) o Next só devolve a resposta depois
45
+ // que todo trabalho de cache pendente termina, e a busca do exemplo que estourou o prazo continuava
46
+ // pendente por trás do `Promise.race`: a rota levava 56 s para responder uma lista que estava pronta
47
+ // em 3 s (medido em dev, sem credenciais). A lista de rotas custa um `readdirSync`; o exemplo tem
48
+ // guarda própria em memória (abaixo). Nada aqui precisa do cache de rota.
49
+ export const dynamic = "force-dynamic";
50
+
51
+ /**
52
+ * O nome que o lojista lê. Derivar do segmento dá "Devolucoes" sem cedilha — então o que é COPY
53
+ * fica declarado aqui, que é a loja, e o resto cai na derivação. Rota nova com acento ou nome
54
+ * composto entra aqui; sem entrada, o rótulo é o último segmento com a inicial maiúscula.
55
+ */
56
+ const ROTULOS: Record<string, string> = {
57
+ "/": "Página inicial",
58
+ "/produtos": "Produtos",
59
+ "/sobre": "Sobre",
60
+ "/busca": "Busca",
61
+ "/oferta": "Oferta",
62
+ "/devolucoes": "Trocas e devoluções",
63
+ "/privacidade": "Privacidade",
64
+ "/termos": "Termos de uso",
65
+ "/produto/[productSlug]": "Produto",
66
+ "/categoria/[tagSlug]": "Categoria",
67
+ };
68
+
69
+ function rotulo(rota: string): string {
70
+ const declarado = ROTULOS[rota];
71
+ if (declarado) return declarado;
72
+ // último segmento que não é dinâmico ("/produto/[slug]" → "produto"); nada disso → a rota crua
73
+ const segmentos = rota.split("/").filter(Boolean);
74
+ const util = [...segmentos].reverse().find((s) => !s.startsWith("["));
75
+ if (!util) return rota;
76
+ const palavra = util.replace(/-/g, " ");
77
+ return palavra.charAt(0).toUpperCase() + palavra.slice(1);
78
+ }
79
+
80
+ /**
81
+ * O EXEMPLO CONCRETO de cada rota dinâmica: um slug de verdade, para a prévia abrir e o gate medir.
82
+ * Vive aqui porque é conhecimento da loja (quem tem o cliente da Unbox é ela, nunca o editor).
83
+ * Falha de catálogo devolve `null` — a rota continua na lista, sem exemplo, e isso é dito.
84
+ *
85
+ * COM PRAZO, e a LISTA NUNCA ESPERA POR ELE. O primeiro desenho varria o catálogo inteiro
86
+ * (`loadAllCatalogItems`, 100 por página) para achar UM slug, e sem credenciais da Unbox esperava os
87
+ * 15 s de timeout do cliente, duas vezes: a rota levava 30 s, o editor (que espera 5 s) caía no
88
+ * `shops.json` sempre, e o gate esperava meio minuto para descobrir que não havia exemplo. Agora é
89
+ * uma consulta curta (`first: 8`, cacheada como o resto do catálogo), no máximo `PRAZO_EXEMPLO_MS`
90
+ * por rota; o que não respondeu a tempo sai como `caminho: null` NESTA resposta, e a busca que ficou
91
+ * pendente corrige a guarda quando terminar. Guarda em memória porque em dev o `revalidate` da rota
92
+ * não cacheia nada, e ninguém precisa pagar o catálogo a cada login no editor.
93
+ */
94
+ const PRAZO_EXEMPLO_MS = 3_000;
95
+ const TTL_EXEMPLO_MS = 5 * 60_000;
96
+ const TTL_SEM_EXEMPLO_MS = 60_000;
97
+ const EXEMPLOS: Record<string, () => Promise<string | null>> = {
98
+ "/produto/[productSlug]": async () => {
99
+ const pagina = await getCatalog({ first: 8, offset: 0 });
100
+ const p = ((pagina as any)?.nodes ?? []).map((n: any) => n?.product ?? n).find((p: any) => p && p.isVisible !== false && p.slug);
101
+ return p ? `/produto/${encodeURIComponent(p.slug)}` : null;
102
+ },
103
+ "/categoria/[tagSlug]": async () => {
104
+ const tags = (await getTopTags()) as any[];
105
+ const t = tags.find((t) => t && t.isVisible !== false && t.slug);
106
+ return t ? `/categoria/${encodeURIComponent(t.slug)}` : null;
107
+ },
108
+ };
109
+ const exemplosGuardados = new Map<string, { ate: number; caminho: string | null }>();
110
+
111
+ async function exemploDe(rota: string): Promise<string | null> {
112
+ const guardado = exemplosGuardados.get(rota);
113
+ if (guardado && guardado.ate > Date.now()) return guardado.caminho;
114
+ const buscar = EXEMPLOS[rota];
115
+ if (!buscar) return null;
116
+ const pendente = buscar().then(
117
+ (caminho) => {
118
+ exemplosGuardados.set(rota, { ate: Date.now() + TTL_EXEMPLO_MS, caminho });
119
+ return caminho;
120
+ },
121
+ (err: unknown) => {
122
+ console.warn("[paginas] não consegui um exemplo para", rota, err instanceof Error ? err.message : err);
123
+ exemplosGuardados.set(rota, { ate: Date.now() + TTL_SEM_EXEMPLO_MS, caminho: null });
124
+ return null;
125
+ },
126
+ );
127
+ const prazo = new Promise<"prazo">((r) => setTimeout(() => r("prazo"), PRAZO_EXEMPLO_MS));
128
+ const resultado = await Promise.race([pendente, prazo]);
129
+ if (resultado !== "prazo") return resultado;
130
+ console.warn(`[paginas] o catálogo não respondeu em ${PRAZO_EXEMPLO_MS} ms para ${rota}; esta resposta sai sem exemplo`);
131
+ // "sem exemplo" fica guardado por pouco tempo, para os próximos pedidos não esperarem de novo; a
132
+ // busca pendente sobrescreve a guarda assim que terminar
133
+ exemplosGuardados.set(rota, { ate: Date.now() + TTL_SEM_EXEMPLO_MS, caminho: null });
134
+ return null;
135
+ }
136
+
137
+ export async function GET() {
138
+ let rotas: string[];
139
+ try {
140
+ rotas = rotasEditaveis();
141
+ } catch (e) {
142
+ // A descoberta falhou (raiz ausente sem lista no ambiente, subpasta ilegível, nenhum page.tsx): a resposta é
143
+ // recusar a lista INTEIRA, com o motivo. Uma lista parcial com 200 faria a página sumir do seletor e o gate
144
+ // deixar de medi-la sem dizer NÃO RODOU.
145
+ const motivo = e instanceof Error ? e.message : String(e);
146
+ console.error(`[paginas] descoberta recusada: ${motivo}`);
147
+ return fail(`A loja não conseguiu descobrir as próprias páginas: ${motivo}.`, 500);
148
+ }
149
+ const paginas = await Promise.all(
150
+ rotas.map(async (rota) => {
151
+ const dinamica = rota.includes("[");
152
+ const caminho = dinamica ? await exemploDe(rota) : rota;
153
+ const containers = containersDaRota(rota);
154
+ // `soChrome` só quando é verdade, e `containers` só quando a tabela declara: quem lê a lista não precisa
155
+ // saber dos campos para continuar certo, e o campo ausente é informação ("a loja não declarou")
156
+ return { rota, caminho, label: rotulo(rota), dinamica, ...(soChrome(rota) ? { soChrome: true } : {}), ...(containers ? { containers: [...containers] } : {}) };
157
+ }),
158
+ );
159
+ // a home primeiro (é onde a prévia abre), depois as estáticas, depois as dinâmicas
160
+ paginas.sort((a, b) => {
161
+ if (a.rota === "/") return -1;
162
+ if (b.rota === "/") return 1;
163
+ if (a.dinamica !== b.dinamica) return a.dinamica ? 1 : -1;
164
+ return a.rota.localeCompare(b.rota);
165
+ });
166
+ return ok({ paginas });
167
+ }
@@ -0,0 +1,84 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // A ESCOLHA DA VITRINE VIRA PRODUTOS **NA PRÉVIA**, ANTES DE PUBLICAR.
3
+ //
4
+ // Por que existe: em produção quem resolve a vitrine é a página, no servidor,
5
+ // a partir do documento PUBLICADO (`resolverVitrinesDoDocumento`). Na prévia
6
+ // não tem como ser assim — o RASCUNHO nunca passou por este servidor: ele vive
7
+ // no editor e chega ao iframe por postMessage, no navegador. Sem esta rota, o
8
+ // lojista escolhia cinco produtos, punha limite 3, clicava em aplicar, e a tela
9
+ // não mudava; numa loja que ainda não publicou, não mudava nunca. Publicar às
10
+ // cegas é o oposto do que a prévia serve para resolver.
11
+ //
12
+ // Então o primitivo `Editable.Vitrine`, em modo edição, manda a ESCOLHA para cá
13
+ // e recebe os produtos de volta. A credencial da Unbox continua onde sempre
14
+ // esteve: nesta loja. O editor não fala com a Unbox — nem por esta rota, que ele
15
+ // não chama: quem chama é a própria página da loja, dentro do iframe dele.
16
+ //
17
+ // Irmã de app/api/unbox/catalogo/route.ts (o SELETOR: "o que existe na loja").
18
+ // Esta responde outra pergunta: "esta escolha, hoje, mostra o quê?".
19
+ //
20
+ // ── CONTRATO (o outro lado é `Editable.Vitrine`, em lib/editable/primitives.tsx) ──
21
+ // POST x-editor-token: <JWT ES256 do editor, purpose "preview">
22
+ // body { escolha: VitrineValue }
23
+ // 200 → { produtos: [{ id, titulo, slug, imagem, preco, esgotado }] } (≤ escolha.limite)
24
+ // 400 → escolha malformada 401 → sem token válido 502 → Unbox não respondeu
25
+ //
26
+ // SOMENTE LEITURA. Nada aqui grava nada, e o documento não é tocado: quem guarda
27
+ // a escolha é o editor, e ela só chega à loja de verdade quando o lojista publica.
28
+ //
29
+ // ⚠️ 502 NUNCA vira 200 com lista vazia. Lista vazia é resposta legítima ("essa
30
+ // categoria não tem produto hoje") e o lojista precisa poder distinguir as duas
31
+ // coisas — senão ele publica uma seção em branco achando que escolheu errado.
32
+ // É por isso que `resolverVitrine` é chamada em modo `estrito` aqui, e não na
33
+ // página do cliente, onde a única saída aceitável é cair nos produtos do código.
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+ import { ok, fail } from "@/lib/api";
36
+ import { verifyEditorToken } from "@/lib/editable/verify";
37
+ import { vitrineValida } from "@/lib/editable/document";
38
+ import { resolverVitrine } from "@/lib/vitrine";
39
+
40
+ // A resposta depende do token do request e do rascunho de quem pediu: nada de cache compartilhado.
41
+ export const dynamic = "force-dynamic";
42
+
43
+ const SEM_CACHE = { headers: { "Cache-Control": "private, no-store" } };
44
+
45
+ /**
46
+ * Teto do corpo, lido ANTES do JSON.parse. Uma escolha de vitrine é minúscula
47
+ * (24 ids no pior caso); qualquer coisa maior que isto não é escolha de vitrine.
48
+ */
49
+ const CORPO_MAX = 8 * 1024;
50
+
51
+ export async function POST(req: Request) {
52
+ // Mesmo portão do seletor de catálogo — só o purpose muda. "preview" é o token com que o editor
53
+ // ABRIU este iframe: ele já está no navegador do lojista, e é o único que a página tem em mãos.
54
+ // Ele vale 15 minutos; quando vence, esta rota responde 401 e o primitivo pede outro ao editor.
55
+ const token = req.headers.get("x-editor-token");
56
+ const autorizado = token ? await verifyEditorToken(token, "preview") : null;
57
+ if (!autorizado) return fail("Não autorizado.", 401);
58
+
59
+ const bruto = await req.text().catch(() => "");
60
+ if (bruto.length > CORPO_MAX) return fail("Escolha de vitrine grande demais.", 413);
61
+ let corpo: unknown;
62
+ try {
63
+ corpo = JSON.parse(bruto);
64
+ } catch {
65
+ return fail("Não entendi a escolha da vitrine.", 400);
66
+ }
67
+
68
+ // A MESMA validação que o documento usa para aceitar o valor. Se não passa aqui, não passaria lá:
69
+ // resolver uma escolha que o editor não conseguiria gravar mostraria ao lojista uma prévia de uma
70
+ // vitrine que ele nunca vai conseguir publicar.
71
+ const escolha = (corpo as { escolha?: unknown } | null)?.escolha;
72
+ if (!vitrineValida(escolha)) return fail("Escolha de vitrine inválida (modo, categoria/produtos/busca, limite).", 400);
73
+
74
+ try {
75
+ // `limite` é aplicado DENTRO de `resolverVitrine` (`limiteDaVitrine`), o mesmo caminho da
76
+ // página publicada — é o que faz "coloquei 3" limitar na prévia igual limitaria no ar.
77
+ const produtos = await resolverVitrine(escolha, { estrito: true });
78
+ return ok({ produtos }, SEM_CACHE);
79
+ } catch (err) {
80
+ // A mensagem crua pode carregar detalhe do gateway; fica no log do servidor.
81
+ console.warn("[vitrine] não consegui resolver a escolha da prévia", err instanceof Error ? err.message : err);
82
+ return fail("Não consegui buscar os produtos na Unbox agora.", 502);
83
+ }
84
+ }
@@ -0,0 +1,97 @@
1
+ // Receptor de webhooks da Unbox (doc 07/11):
2
+ // - lê o corpo CRU (req.text()) — NUNCA req.json() antes da verificação
3
+ // - valida HMAC-SHA256 (X-Unbox-Signature) com timingSafeEqual → 401 se inválido
4
+ // - PING → 200 imediato (handshake)
5
+ // - idempotência por event._id
6
+ // - responde 200 rápido; processamento pesado deve ir para fila (aqui: inline + revalidação)
7
+ import { parseUnboxWebhook } from "@/lib/unbox/webhooks";
8
+ import { serverEnv } from "@/lib/config";
9
+ import { markIfNew } from "@/lib/webhook-store";
10
+ import { dispatchCrm } from "@/lib/crm";
11
+ import { revalidatePath } from "next/cache";
12
+ import { withStoreClient } from "@/lib/unbox/store";
13
+ import { sendCapi, sha256, capiConfigured } from "@/lib/capi";
14
+
15
+ export const dynamic = "force-dynamic";
16
+
17
+ export async function POST(req: Request) {
18
+ // Cada assinatura de webhook tem o seu secret (ORDER_CREATED e ORDER_STATUS_UPDATE diferem).
19
+ // UNBOX_WEBHOOK_SECRET aceita uma LISTA separada por vírgula.
20
+ const secrets = serverEnv.webhookSecret.split(",").map((s) => s.trim()).filter(Boolean);
21
+ if (!secrets.length) {
22
+ // sem secret configurado não há como validar — recusa (configure via npm run unbox:webhook:subscribe)
23
+ return new Response("webhook secret not configured", { status: 503 });
24
+ }
25
+
26
+ let evt;
27
+ try {
28
+ evt = await parseUnboxWebhook(req, secrets);
29
+ } catch {
30
+ return new Response("invalid signature", { status: 401 });
31
+ }
32
+
33
+ if (evt.type === "PING") return new Response("ok");
34
+
35
+ // idempotência
36
+ if (!markIfNew(evt._id)) return new Response("duplicate ignored");
37
+
38
+ try {
39
+ const data = evt.data as any;
40
+ if (evt.type === "ORDER_CREATED") {
41
+ await dispatchCrm({ type: "order_created", orderId: data.orderId, referenceId: data.orderId, payload: data });
42
+ }
43
+ if (evt.type === "ORDER_STATUS_UPDATE") {
44
+ // Pix pago: paymentStatus PAID + orderStatus PROCESSING (não existe orderStatus PAID)
45
+ if (data.paymentStatus === "PAID") {
46
+ await dispatchCrm({ type: "order_paid", orderId: data.orderId, referenceId: data.orderId, payload: data });
47
+ // Compra confirmada FORA do navegador (Pix/boleto pagos depois de fechar a página):
48
+ // manda o Purchase pra Meta por CAPI, server-to-server, com o MESMO event_id que o
49
+ // browser usaria ("purchase-<ref>"). Se a pessoa ainda estava na página e o browser
50
+ // também disparou, o Meta deduplica. Pix gerado e abandonado NUNCA chega aqui —
51
+ // é o webhook de pagamento, não de criação do pedido.
52
+ await purchaseParaMeta(data).catch((e) => console.error("[webhook] capi purchase", e));
53
+ }
54
+ // revalida páginas de catálogo (estoque pode ter mudado)
55
+ revalidatePath("/");
56
+ revalidatePath("/produtos");
57
+ }
58
+ } catch (e) {
59
+ // não derruba o 200 — webhook deve ser idempotente e tolerante; loga para investigação
60
+ console.error("[webhook] erro ao processar", e);
61
+ }
62
+
63
+ return new Response("ok");
64
+ }
65
+
66
+ /**
67
+ * Purchase server-side na Meta. Busca o pedido com credenciais da LOJA (o webhook não traz itens
68
+ * nem e-mail) e envia hasheado. Sem META_PIXEL_ID/META_CAPI_TOKEN é no-op.
69
+ */
70
+ async function purchaseParaMeta(data: any) {
71
+ if (!capiConfigured() || !data?.orderId) return;
72
+ const order = await withStoreClient((c) => c.getOrder(data.orderId)).catch(() => null);
73
+ if (!order) return;
74
+
75
+ const nodes: any[] = (order.fulfillmentGroups ?? []).flatMap((g: any) => g?.items?.nodes ?? []);
76
+ const addr = (order.fulfillmentGroups ?? []).map((g: any) => g?.data?.shippingAddress).find(Boolean);
77
+ const partes = String(addr?.fullName ?? "").trim().split(/\s+/).filter(Boolean);
78
+ const semAcento = (s?: string) => (s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "");
79
+ const value = Number(data.paymentSummary?.orderTotal ?? order.summary?.total?.amount ?? 0);
80
+
81
+ await sendCapi({
82
+ eventId: `purchase-${order.referenceId}`,
83
+ eventName: "Purchase",
84
+ value,
85
+ currency: "BRL",
86
+ orderId: order.referenceId,
87
+ actionSource: "system_generated",
88
+ contents: nodes.map((n) => ({ id: n.productConfiguration?.productId ?? n._id, quantity: n.quantity ?? 1, item_price: n.price?.amount })),
89
+ userData: {
90
+ em: sha256(order.email),
91
+ fn: sha256(semAcento(partes[0])),
92
+ ln: sha256(semAcento(partes.slice(1).join(" "))),
93
+ zp: sha256(String(addr?.postal ?? "").replace(/\D/g, "")),
94
+ external_id: sha256(order.email),
95
+ },
96
+ });
97
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" width="180" height="180">
2
+ <!-- unbox-placeholder: troque este ícone pelo da marca (fundo OPACO, iOS não aplica transparência) -->
3
+ <rect width="180" height="180" fill="#FFFFFF"/>
4
+ <rect x="30" y="30" width="120" height="120" rx="26" fill="#D4D4D8"/>
5
+ </svg>
@@ -0,0 +1,15 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
6
+ return (
7
+ <div className="flex flex-col items-center justify-center py-24 text-center">
8
+ <h1 className="text-xl font-semibold">Algo deu errado</h1>
9
+ <p className="mt-1 max-w-sm text-sm text-muted-foreground">
10
+ Tivemos um problema ao carregar esta página. Tente novamente em instantes.
11
+ </p>
12
+ <Button className="mt-6" onClick={() => reset()}>Tentar novamente</Button>
13
+ </div>
14
+ );
15
+ }