@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,66 @@
1
+ // webhooks.ts — verificação de webhooks da Unbox (HMAC-SHA256 do corpo cru com o secret).
2
+ // Spec oficial: header X-Unbox-Signature = HMAC-SHA256(rawBody, secret) em hex.
3
+
4
+ import crypto from "node:crypto";
5
+
6
+ export type UnboxWebhookEventType = "PING" | "ORDER_CREATED" | "ORDER_STATUS_UPDATE";
7
+
8
+ export interface UnboxWebhookEnvelope<T = any> {
9
+ _id: string; // id único DESTA chamada (use para idempotência)
10
+ type: UnboxWebhookEventType;
11
+ data: T;
12
+ createdAt: string; // ISO 8601
13
+ }
14
+
15
+ export interface UnboxOrderWebhookData {
16
+ orderId: string;
17
+ shopSlug: string;
18
+ shopDomain?: string;
19
+ orderStatus: string; // OrderStatusEnum
20
+ paymentStatus: string; // PaymentStatusEnum
21
+ paymentType: "pix" | "credit" | "boleto";
22
+ paymentTransactionId: string;
23
+ paymentSummary: {
24
+ itemsSubtotal: number; discountTotal: number;
25
+ providerShippingCost: number; finalShippingCost: number; orderTotal: number;
26
+ };
27
+ discounts?: Array<{ code?: string; label?: string; description?: string; method: string; type: string }>;
28
+ orderCreatedAt: string;
29
+ orderUpdatedAt: string;
30
+ utmParameters?: { campaignName: string; source: string; marketingMedium: string; targetContent?: string; searchTerms?: string };
31
+ }
32
+
33
+ /**
34
+ * Verifica a assinatura `X-Unbox-Signature` de um webhook.
35
+ * @param rawBody corpo CRU do request (string exata — NÃO re-serialize o JSON antes).
36
+ * @param signature valor do header `x-unbox-signature`.
37
+ * @param secret secret (ou LISTA de secrets) retornado(s) no subscribeToWebhook. Cada assinatura
38
+ * de webhook tem o seu próprio secret (ex.: ORDER_CREATED e ORDER_STATUS_UPDATE
39
+ * geram secrets diferentes) — passe todos para validar qualquer evento.
40
+ * @returns true se a assinatura confere com ALGUM dos secrets (comparação em tempo constante).
41
+ */
42
+ export function verifyUnboxWebhook(rawBody: string, signature: string, secret: string | string[]): boolean {
43
+ if (!signature) return false;
44
+ const secrets = (Array.isArray(secret) ? secret : [secret]).filter(Boolean);
45
+ if (!secrets.length) return false;
46
+ const sig = Buffer.from(signature);
47
+ return secrets.some((s) => {
48
+ const expected = crypto.createHmac("sha256", s).update(rawBody, "utf-8").digest("hex");
49
+ if (signature.length !== expected.length) return false;
50
+ return crypto.timingSafeEqual(sig, Buffer.from(expected));
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Helper completo p/ Route Handler: valida a assinatura e devolve o evento tipado.
56
+ * Lança se a assinatura for inválida. Trate `PING` (handshake) e deduplique por `evt._id`.
57
+ *
58
+ * const evt = await parseUnboxWebhook(req, process.env.UNBOX_WEBHOOK_SECRET!);
59
+ * if (evt.type === "PING") return new Response("ok");
60
+ */
61
+ export async function parseUnboxWebhook(req: Request, secret: string | string[]): Promise<UnboxWebhookEnvelope> {
62
+ const raw = await req.text();
63
+ const sig = req.headers.get("x-unbox-signature") ?? "";
64
+ if (!verifyUnboxWebhook(raw, sig, secret)) throw new Error("UNBOX_WEBHOOK_INVALID_SIGNATURE");
65
+ return JSON.parse(raw) as UnboxWebhookEnvelope;
66
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,242 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // VITRINE — de ESCOLHA do lojista para PRODUTOS de verdade.
3
+ //
4
+ // O editor guarda só a ESCOLHA (`VitrineValue`: uma categoria da Unbox, uma
5
+ // lista de produtos ou uma busca). Quem transforma isso em produto com preço e
6
+ // estoque é a LOJA, aqui, no servidor, com o mesmo cliente da Unbox que a home
7
+ // e a PDP já usam. O editor nunca vê credencial nem catálogo — ele só pergunta
8
+ // o que existe pelo endpoint de leitura (app/api/unbox/catalogo/route.ts).
9
+ //
10
+ // ⚠️ A LOJA NÃO PODE CAIR POR CAUSA DISSO. Toda falha (Unbox fora, id que não
11
+ // existe mais, categoria vazia) devolve lista vazia; quem chama volta para o
12
+ // que o CÓDIGO mostra — a mesma regra de `getPublishedContent`.
13
+ //
14
+ // (A honestidade do 502 vale para a ROTA do seletor, não para cá: lá o editor
15
+ // precisa distinguir "categoria vazia" de "não consegui ler"; aqui, na página
16
+ // do cliente, a única saída aceitável é continuar mostrando os produtos do
17
+ // código.)
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ import "server-only";
20
+ import { unstable_cache } from "next/cache";
21
+ import { getCatalog, getProductBySlug } from "./queries";
22
+ import { withStoreClient } from "./unbox/store";
23
+ import { resolveProductPrice } from "./format";
24
+ import { CAMPO_VITRINE, VITRINE_MAX, vitrineValida, type ContentDocument, type VitrineValue } from "./editable/document";
25
+
26
+ /**
27
+ * O que a loja mostra de um produto numa vitrine — e, campo por campo, o que o
28
+ * endpoint de leitura devolve ao seletor do editor.
29
+ *
30
+ * É de propósito o MÍNIMO: nada de cadastro interno, nada de credencial. Preço
31
+ * pode vir `null` (produto sem variante com preço no painel); quem renderiza
32
+ * mostra "—" em vez de inventar zero.
33
+ */
34
+ export interface VitrineProduto {
35
+ /** `productId` da Unbox — é o que o addToCart e `getProductById` usam. */
36
+ id: string;
37
+ titulo: string;
38
+ slug: string;
39
+ imagem: string | null;
40
+ preco: number | null;
41
+ esgotado: boolean;
42
+ }
43
+
44
+ /** Quantos produtos uma vitrine traz quando o lojista não disse. */
45
+ export const VITRINE_LIMITE_PADRAO = 12;
46
+
47
+ /**
48
+ * O ÚLTIMO segmento do caminho de toda vitrine desta loja.
49
+ *
50
+ * O caminho completo é `<container>.<id da seção>.<este campo>` — e ele NÃO é
51
+ * escrito à mão em lugar nenhum: no cliente, `Editable.Vitrine path={CAMPO_VITRINE}`
52
+ * o monta a partir do escopo da seção (`joinPath(ctx.scope, CAMPO_VITRINE)`); no
53
+ * servidor, `resolverVitrinesDoDocumento` o DESCOBRE varrendo o documento. É a
54
+ * mesma string derivada dos dois lados, e é por isso que renomear o id de uma
55
+ * seção — ou o lojista adicionar uma vitrine nova, com id sorteado em tempo de
56
+ * execução (`novo-<tipo>-<n>`) — não descola loja e painel.
57
+ *
58
+ * A CONSTANTE mora na FOUNDATION (lib/editable/document.ts) e é só reexportada aqui. Este arquivo é
59
+ * `server-only`: um componente de cliente não consegue importá-lo, e enquanto o literal vivia aqui
60
+ * cada seção repetia `const CAMPO = "vitrine"` com um comentário pedindo para trocar os dois juntos.
61
+ * Uma string, um lugar.
62
+ */
63
+ export { CAMPO_VITRINE };
64
+
65
+ /**
66
+ * Teto de vitrines resolvidas por página. Cada uma pode custar uma consulta ao
67
+ * catálogo; um documento adulterado com centenas de caminhos `.vitrine` viraria
68
+ * uma enxurrada de chamadas à Unbox no render da home.
69
+ */
70
+ const VITRINES_POR_PAGINA_MAX = 12;
71
+
72
+ /** Limite pedido, preso entre 1 e `VITRINE_MAX` (o teto da foundation). */
73
+ export function limiteDaVitrine(limite?: number | null): number {
74
+ const n = Number.isInteger(limite) ? (limite as number) : VITRINE_LIMITE_PADRAO;
75
+ return Math.max(1, Math.min(VITRINE_MAX, n));
76
+ }
77
+
78
+ /** Node do catálogo (ou item de PDP) → produto de vitrine. `null` quando não dá para identificar. */
79
+ export function produtoDaVitrine(node: any): VitrineProduto | null {
80
+ const p = node?.product ?? node;
81
+ if (!p?.slug) return null;
82
+ return {
83
+ id: String(p.productId ?? p._id ?? p.slug),
84
+ titulo: p.title ?? "",
85
+ slug: p.slug,
86
+ imagem: p.imageUrls?.[0] ?? null,
87
+ preco: resolveProductPrice(p).price,
88
+ esgotado: !!p.isSoldOut,
89
+ };
90
+ }
91
+
92
+ function mapear(nodes: any[] | undefined, limite: number): VitrineProduto[] {
93
+ const out: VitrineProduto[] = [];
94
+ for (const n of nodes ?? []) {
95
+ const p = produtoDaVitrine(n);
96
+ if (p) out.push(p);
97
+ if (out.length >= limite) break;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Um produto pelo `productId`. Cacheado como o resto do catálogo (público e
104
+ * estável), e só é chamado para os ids que a página de catálogo já lida não
105
+ * cobriu — no caso normal, nenhum.
106
+ */
107
+ const porId = (id: string) =>
108
+ unstable_cache(
109
+ () => withStoreClient((c) => c.getProductById(id)),
110
+ ["unbox-vitrine-produto", id],
111
+ { revalidate: 300, tags: ["catalog"] },
112
+ )();
113
+
114
+ /**
115
+ * Resolve a escolha do lojista em produtos.
116
+ *
117
+ * `produtos` preserva a ORDEM em que ele escolheu — é a única informação que a
118
+ * lista carrega e que o catálogo não sabe repor.
119
+ *
120
+ * `estrito` é para quem PRECISA saber que a Unbox falhou: a rota da prévia
121
+ * (app/api/unbox/vitrine/route.ts), que responde 502 em vez de 200 com lista
122
+ * vazia. Sem ele — o padrão, usado pela página do cliente — uma falha vira
123
+ * lista vazia e a seção volta aos produtos do código.
124
+ *
125
+ * ⚠️ A diferença é UMA linha: a página do catálogo no modo "produtos", a única
126
+ * consulta que este arquivo engolia. Nos modos "categoria" e "busca" o
127
+ * `getCatalog` já sobe o erro, e quem chama é que decide o que fazer com ele.
128
+ * A busca de UM produto continua tolerante nos dois modos: id que não volta
129
+ * costuma ser produto apagado do painel, não Unbox fora do ar, e derrubar a
130
+ * vitrine inteira por causa de um item some com os outros quatro.
131
+ */
132
+ export async function resolverVitrine(escolha: VitrineValue, opts?: { estrito?: boolean }): Promise<VitrineProduto[]> {
133
+ const limite = limiteDaVitrine(escolha.limite);
134
+
135
+ if (escolha.modo === "categoria") {
136
+ const c = await getCatalog({ first: limite, tagIds: [escolha.tagId] });
137
+ return mapear(c?.nodes, limite);
138
+ }
139
+
140
+ if (escolha.modo === "busca") {
141
+ const c = await getCatalog({ first: limite, searchText: escolha.texto });
142
+ return mapear(c?.nodes, limite);
143
+ }
144
+
145
+ // ── modo "produtos" ──────────────────────────────────────────────────────
146
+ // Primeiro a página de catálogo que a home JÁ busca (mesmos `opts`, mesma
147
+ // chave de cache → nenhuma chamada nova). Só o que sobrar vira consulta
148
+ // individual, no máximo VITRINE_MAX delas.
149
+ // No modo PRODUTOS o lojista escolheu a lista a dedo: ela é a resposta, e um `limite` gravado antes
150
+ // não pode encolhê-la (ele nem tem onde mexer nesse número). O teto da foundation continua valendo.
151
+ // Repetido entra uma vez só: a mesma vitrine mostrando o mesmo produto duas vezes é defeito, não escolha.
152
+ const semRepetir = [...new Set(escolha.produtos)];
153
+ const pedidos = semRepetir.slice(0, VITRINE_MAX);
154
+ const pagina = opts?.estrito ? await getCatalog({ first: 100 }) : await getCatalog({ first: 100 }).catch(() => null);
155
+ const achados = new Map<string, VitrineProduto>();
156
+ const indexar = (node: any) => {
157
+ const p = node?.product ?? node;
158
+ const item = produtoDaVitrine(node);
159
+ if (!item) return;
160
+ // o lojista pode ter guardado productId, _id ou slug — os três apontam para cá
161
+ for (const chave of [p.productId, p._id, p.slug]) if (chave) achados.set(String(chave), item);
162
+ };
163
+ for (const n of pagina?.nodes ?? []) indexar(n);
164
+
165
+ const faltando = pedidos.filter((id) => !achados.has(id));
166
+ if (faltando.length) {
167
+ const extras = await Promise.all(
168
+ faltando.map(async (id) => {
169
+ // id pode ser productId ou slug; tenta os dois caminhos que a loja já tem
170
+ const porProduto = await porId(id).catch(() => null);
171
+ return porProduto ?? (await getProductBySlug(id).catch(() => null));
172
+ }),
173
+ );
174
+ for (const e of extras) if (e) indexar(e);
175
+ }
176
+
177
+ // Item pedido que não resolveu SOME da vitrine. Isso é o certo na tela (produto que saiu do
178
+ // catálogo não deve aparecer), mas some sem sinal nenhum, e a causa mais comum é vínculo por
179
+ // slug depois de o slug mudar no painel. Registrar dá o "por quê" a quem for olhar o log.
180
+ const perdidos = pedidos.filter((id) => !achados.has(id));
181
+ if (perdidos.length) {
182
+ console.warn(`[unbox] vitrine: ${perdidos.length} vínculo(s) não resolveram e sumiram da seção: ${perdidos.join(", ")}`);
183
+ }
184
+ return pedidos.map((id) => achados.get(id)).filter((p): p is VitrineProduto => !!p);
185
+ }
186
+
187
+ /** Produtos já resolvidos, indexados pelo CAMINHO da vitrine — é assim que a seção acha os seus. */
188
+ export type VitrinesResolvidas = Record<string, VitrineProduto[]>;
189
+
190
+ /**
191
+ * Os caminhos de vitrine que ESTE documento tem dentro de um container.
192
+ *
193
+ * ⚠️ Este é o ponto que uma loja piloto errou e o scaffold não repete: lá o
194
+ * caminho era uma CONSTANTE escrita à mão (`"home.vitrine-principal.vitrine"`)
195
+ * enquanto o primitivo o deriva do id da seção — renomear o id descola os dois
196
+ * EM SILÊNCIO (a loja lê uma caixa, o painel escreve em outra, e ninguém vê erro
197
+ * nenhum). Aqui não existe constante de caminho: quem manda é o documento. E
198
+ * tem de ser assim, porque a vitrine é um tipo ADICIONÁVEL — o id nasce em
199
+ * tempo de execução (`novo-vitrine-de-produtos-2`) quando o lojista clica no
200
+ * "+", e nenhuma constante do repositório poderia conhecê-lo de antemão.
201
+ *
202
+ * Cada seção tem o SEU caminho, então duas vitrines na mesma página têm duas
203
+ * escolhas independentes.
204
+ */
205
+ export function caminhosDeVitrine(doc: ContentDocument | null | undefined, container = "home"): string[] {
206
+ const prefixo = `${container}.`;
207
+ const sufixo = `.${CAMPO_VITRINE}`;
208
+ return Object.entries(doc?.values ?? {})
209
+ .filter(([caminho, valor]) => caminho.startsWith(prefixo) && caminho.endsWith(sufixo) && vitrineValida(valor))
210
+ .map(([caminho]) => caminho)
211
+ .sort()
212
+ .slice(0, VITRINES_POR_PAGINA_MAX);
213
+ }
214
+
215
+ /**
216
+ * Resolve TODAS as vitrines que o lojista configurou num container, de uma vez,
217
+ * para a página entregar às seções já prontas.
218
+ *
219
+ * Sem documento, sem escolha, ou com a Unbox fora, devolve `{}` — e cada seção
220
+ * cai no que o CÓDIGO mostra. Vitrine vazia lê como loja quebrada; os produtos
221
+ * do código são a rede de segurança.
222
+ */
223
+ export async function resolverVitrinesDoDocumento(
224
+ doc: ContentDocument | null | undefined,
225
+ container = "home",
226
+ ): Promise<VitrinesResolvidas> {
227
+ const caminhos = caminhosDeVitrine(doc, container);
228
+ if (!caminhos.length) return {};
229
+ const pares = await Promise.all(
230
+ caminhos.map(async (caminho) => {
231
+ const escolha = doc!.values[caminho] as VitrineValue;
232
+ const produtos = await resolverVitrine(escolha).catch((err) => {
233
+ console.warn("[vitrine] não consegui resolver", caminho, err instanceof Error ? err.message : err);
234
+ return [] as VitrineProduto[];
235
+ });
236
+ return [caminho, produtos] as const;
237
+ }),
238
+ );
239
+ // caminho que não resolveu em NADA fica de fora: a seção some do mapa e volta
240
+ // para os produtos do código, em vez de renderizar uma grade vazia
241
+ return Object.fromEntries(pares.filter(([, produtos]) => produtos.length > 0));
242
+ }
@@ -0,0 +1,22 @@
1
+ // Idempotência de webhook em memória (doc 07): deduplica por event._id e mantém uma fila
2
+ // simples de processamento. Responder 200 rápido (<30s) e processar fora do hot path.
3
+ //
4
+ // PROD: trocar o Set/array por uma tabela no DB (dedup durável) + fila real (QStash, SQS…).
5
+ import "server-only";
6
+
7
+ const seen = new Set<string>();
8
+ // limita o tamanho do Set para não vazar memória em runtime longo
9
+ const MAX = 5000;
10
+
11
+ /** Retorna true se o evento é novo (deve ser processado); false se já foi visto. */
12
+ export function markIfNew(eventId: string): boolean {
13
+ if (!eventId) return true;
14
+ if (seen.has(eventId)) return false;
15
+ seen.add(eventId);
16
+ if (seen.size > MAX) {
17
+ // descarta os mais antigos (FIFO aproximado)
18
+ const it = seen.values();
19
+ for (let i = 0; i < 1000; i++) seen.delete(it.next().value as string);
20
+ }
21
+ return true;
22
+ }
@@ -0,0 +1,178 @@
1
+ // PORTA DE PREVIEW — trava a loja atrás da tela /acesso enquanto ela não é pública.
2
+ //
3
+ // ⚠️ ISTO NÃO É SEGURANÇA. É um aviso de "ainda não está pronto": quem preenche o
4
+ // formulário entra, e o cookie vale 30 dias.
5
+ //
6
+ // A porta se ESCOPA PELO DOMÍNIO sozinha: só existe nos hosts de preview
7
+ // (*.vercel.app, *.myunbox.com.br e o que PREVIEW_HOSTS acrescentar). Em domínio
8
+ // PRÓPRIO da marca — o lançamento de verdade — e no localhost do dev, ela some sem
9
+ // precisar mexer em env. Overrides: PREVIEW_DISABLED=1 desliga em qualquer lugar;
10
+ // PREVIEW_FORCE=1 liga em qualquer lugar (ex.: testar a porta no localhost).
11
+ //
12
+ // Time interno não preenche formulário: qualquer URL da loja com ?chave=<senha>
13
+ // (a PREVIEW_PASSWORD, padrão "unbox") grava o cookie e segue limpo. Ex.:
14
+ // https://loja.vercel.app/?chave=unbox
15
+ //
16
+ // O cookie guarda o SHA-256 da chave, nunca a chave em si.
17
+ import { NextResponse, type NextRequest } from "next/server";
18
+ import { verifyEditorToken } from "@/lib/editable/verify";
19
+
20
+ const LOJA = "minhaloja"; // o CLI troca pelo slug da loja no scaffold
21
+
22
+ export const COOKIE = `${LOJA}_preview`;
23
+
24
+ /** Chave que assina o cookie. Sobrescreva com PREVIEW_PASSWORD na Vercel.
25
+ * Trocar a chave invalida os acessos já concedidos. */
26
+ export function senhaDoPreview() {
27
+ return process.env.PREVIEW_PASSWORD || "unbox";
28
+ }
29
+
30
+ /** SHA-256 em hex via Web Crypto (o middleware roda no edge; sem node:crypto). */
31
+ export async function tokenDaSenha(valor: string) {
32
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(valor));
33
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
34
+ }
35
+
36
+ /** Host de PREVIEW = porta ligada. Domínio próprio da marca/localhost = loja aberta. */
37
+ function hostDePreview(hostHeader: string): boolean {
38
+ const h = hostHeader.split(":")[0].toLowerCase();
39
+ const sufixos = [".vercel.app", ".myunbox.com.br"];
40
+ for (const extra of (process.env.PREVIEW_HOSTS ?? "").split(",")) {
41
+ const s = extra.trim().toLowerCase();
42
+ if (s) sufixos.push(s.startsWith(".") ? s : `.${s}`);
43
+ }
44
+ return sufixos.some((s) => h.endsWith(s));
45
+ }
46
+
47
+ // ═══ CONTRATO DA URL DO CHECKOUT — não remova, o build bloqueia sem isto ═══
48
+ // A URL do checkout SEMPRE carrega ?id=&token= do carrinho. É o que permite o CRM montar o
49
+ // e-mail de carrinho abandonado a partir da URL navegada (pixel/GTM capturam a URL, não o
50
+ // cookie) e o link colar o carrinho em outro aparelho. Sem os params, a recuperação de
51
+ // carrinho simplesmente não existe pra quem só enxerga a navegação.
52
+ //
53
+ // POR QUE AQUI, e não só no app/(loja)/checkout/page.tsx (que também tem a guarda):
54
+ // o middleware roda em TODA requisição — navegação do servidor, navegação client-side do
55
+ // App Router, link direto, refresh, retorno de gateway — e independe de o componente da
56
+ // página existir ou de alguém ter lembrado de chamar o helper certo. A regra já se perdeu
57
+ // uma vez porque vivia só na aplicação: bastava um caminho novo pro checkout, ou uma
58
+ // refatoração da página, pra ela sumir sem nenhum sinal.
59
+ //
60
+ // Roda ANTES de qualquer coisa da porta de preview, porque vale em todo host — inclusive
61
+ // no domínio próprio da marca, onde o resto deste arquivo não faz nada.
62
+ const COOKIE_CARRINHO = "unbox_cart";
63
+ const COOKIE_FREQ = "unbox_recur_freq";
64
+
65
+ function urlDoCheckoutComPonteiro(req: NextRequest): NextResponse | null {
66
+ if (req.nextUrl.pathname !== "/checkout") return null;
67
+ const q = req.nextUrl.searchParams;
68
+ if (q.get("id") && q.get("token")) return null; // já está completa
69
+
70
+ const bruto = req.cookies.get(COOKIE_CARRINHO)?.value;
71
+ if (!bruto) return null; // sem carrinho não há o que recuperar
72
+ let ref: { cartId?: string; cartToken?: string };
73
+ try { ref = JSON.parse(bruto); } catch { return null; }
74
+ if (!ref.cartId || !ref.cartToken) return null;
75
+
76
+ const url = req.nextUrl.clone();
77
+ url.searchParams.set("id", ref.cartId);
78
+ url.searchParams.set("token", ref.cartToken);
79
+ const freq = req.cookies.get(COOKIE_FREQ)?.value;
80
+ if (freq && !url.searchParams.get("freq")) url.searchParams.set("freq", freq);
81
+ if (!url.searchParams.get("step")) url.searchParams.set("step", "1");
82
+ return NextResponse.redirect(url);
83
+ }
84
+
85
+ export async function middleware(req: NextRequest) {
86
+ const comPonteiro = urlDoCheckoutComPonteiro(req);
87
+ if (comPonteiro) return comPonteiro;
88
+
89
+ if (process.env.PREVIEW_DISABLED === "1") return NextResponse.next();
90
+ if (process.env.PREVIEW_FORCE !== "1" && !hostDePreview(req.headers.get("host") ?? "")) {
91
+ return NextResponse.next();
92
+ }
93
+
94
+ const { pathname } = req.nextUrl;
95
+
96
+ // Enquanto a porta estiver de pé, NADA pode ir pra cache compartilhado: a resposta
97
+ // depende do cookie. Sem isso a CDN serve o redirect pra quem já entrou (ou a loja
98
+ // pra quem não entrou) — é o sintoma de "preenchi e não foi".
99
+ const semCache = (res: NextResponse) => {
100
+ res.headers.set("Cache-Control", "private, no-store, must-revalidate");
101
+ res.headers.set("X-Robots-Tag", "noindex, nofollow");
102
+ return res;
103
+ };
104
+
105
+ // A própria porta e o endpoint do lead passam (senão vira loop de redirect),
106
+ // mas COM noindex: tela de acesso não aparece em buscador.
107
+ if (pathname === "/acesso" || pathname === "/api/acesso") return semCache(NextResponse.next());
108
+
109
+ // Rotas chamadas por SERVIDOR ficam de fora — máquina não preenche formulário.
110
+ // (Cada uma já tem sua própria proteção: HMAC no webhook, secret ou JWT do editor no revalidate.)
111
+ // /api/revalidate: quem chama é também o EDITOR da Unbox (servidor), com JWT próprio; atrás da
112
+ // porta ele levava 307 para /acesso e a revalidação nunca acontecia.
113
+ // /api/unbox/catalogo: é o SELETOR de vitrine do editor pedindo categorias e produtos, servidor a
114
+ // servidor, com o mesmo JWT (purpose "catalogo"). Atrás da porta o lojista veria um seletor vazio.
115
+ // A rota valida o token por conta própria e é só leitura.
116
+ // /api/unbox/paginas: é a loja dizendo quais páginas ela tem, informação que o sitemap já publica.
117
+ // Quem pergunta é o editor (servidor, para o seletor de página e a revalidação) e o gate de
118
+ // cobertura, nenhum dos dois com cookie da porta. A rota é pública por contrato e só lê.
119
+ // (/api/unbox/vitrine NÃO entra aqui: quem a chama é a própria página da loja, dentro do iframe do
120
+ // editor, e ela já carrega o cookie que o token de prévia grava logo abaixo.)
121
+ if (
122
+ pathname === "/api/webhooks/unbox" ||
123
+ pathname === "/api/revalidate" ||
124
+ pathname === "/api/unbox/catalogo" ||
125
+ pathname === "/api/unbox/paginas"
126
+ ) return NextResponse.next();
127
+
128
+ // PRÉVIA DO EDITOR DA UNBOX: a loja abre dentro do iframe do editor com um token assinado por ele
129
+ // (ES256; a loja só busca a chave pública no JWKS do editor, não guarda segredo nenhum). Token
130
+ // válido passa pela porta e recebe o cookie dela, para a navegação interna da prévia também passar.
131
+ // `sameSite: "none"` + `secure` de propósito: dentro de um iframe de OUTRA origem, cookie `lax`
132
+ // não viaja, e cada clique na prévia cairia de novo na porta. Sem EDITOR_URL, nunca valida.
133
+ const tokenDoEditor = req.nextUrl.searchParams.get("unbox_editor_token");
134
+ if (tokenDoEditor && (await verifyEditorToken(tokenDoEditor, "preview"))) {
135
+ const res = semCache(NextResponse.next());
136
+ res.cookies.set(COOKIE, await tokenDaSenha(senhaDoPreview()), { path: "/", httpOnly: true, sameSite: "none", secure: true, maxAge: 60 * 60 * 8 });
137
+ return res;
138
+ }
139
+
140
+ // CHAVE DO TIME: ?chave=<senha> em qualquer URL grava o cookie e redireciona pra
141
+ // mesma página sem o parâmetro (a chave não fica no histórico/URL compartilhada).
142
+ // É o atalho interno; lead de verdade continua entrando pelo formulário.
143
+ const chave = req.nextUrl.searchParams.get("chave");
144
+ if (chave && chave === senhaDoPreview()) {
145
+ const url = req.nextUrl.clone();
146
+ url.searchParams.delete("chave");
147
+ const res = NextResponse.redirect(url);
148
+ res.cookies.set(COOKIE, await tokenDaSenha(senhaDoPreview()), {
149
+ httpOnly: true,
150
+ sameSite: "lax",
151
+ secure: process.env.NODE_ENV === "production",
152
+ path: "/",
153
+ maxAge: 60 * 60 * 24 * 30,
154
+ });
155
+ return semCache(res);
156
+ }
157
+
158
+ const esperado = await tokenDaSenha(senhaDoPreview());
159
+ if (req.cookies.get(COOKIE)?.value === esperado) {
160
+ return semCache(NextResponse.next());
161
+ }
162
+
163
+ const url = req.nextUrl.clone();
164
+ url.pathname = "/acesso";
165
+ // Limpa a query antes de montar a da porta: o destino inteiro (com sua própria query) vai
166
+ // no "de", e herdar os params da página de origem só duplicaria dado na URL da porta — no
167
+ // caso do checkout, duplicaria o token do carrinho, que não tem o que fazer aqui.
168
+ url.search = "";
169
+ // Guarda pra onde a pessoa queria ir, pra devolver depois de entrar.
170
+ url.searchParams.set("de", pathname + req.nextUrl.search);
171
+ return semCache(NextResponse.redirect(url));
172
+ }
173
+
174
+ export const config = {
175
+ // Deixa passar o que a PRÓPRIA tela de acesso precisa pra renderizar: assets do
176
+ // Next, /brand (logo), /unbox (selo) e os ícones da aba.
177
+ matcher: ["/((?!_next/static|_next/image|brand/|unbox/|favicon.ico|icon.svg|apple-icon.svg|manifest.webmanifest).*)"],
178
+ };
@@ -0,0 +1,63 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // A loja declara as próprias páginas varrendo `app/(loja)/` (lib/rotas-editaveis.ts). Em produção
5
+ // a função só recebe os arquivos que o Next rastreia, e um `page.tsx` que ninguém importa não é
6
+ // rastreado: sem esta linha a varredura acha zero e /api/unbox/paginas responde a falha em vez da
7
+ // lista. `UNBOX_ROTAS_EDITAVEIS` continua sendo a saída se algum dia isto deixar de valer.
8
+ outputFileTracingIncludes: {
9
+ "/api/unbox/paginas": ["./app/**/page.tsx", "./app/**/page.ts", "./app/**/page.jsx", "./app/**/page.js"],
10
+ },
11
+ images: {
12
+ remotePatterns: [
13
+ { protocol: "https", hostname: "unbox-customer-images-production.s3.amazonaws.com" },
14
+ { protocol: "https", hostname: "**.s3.amazonaws.com" },
15
+ { protocol: "https", hostname: "**.unbox.com.br" },
16
+ // imagens que o lojista sobe pelo editor da Unbox (Vercel Blob) e o próprio editor
17
+ { protocol: "https", hostname: "**.public.blob.vercel-storage.com" },
18
+ { protocol: "https", hostname: "**.myunbox.com.br" },
19
+ ],
20
+ },
21
+ // URLs antigas (/p/, /c/) → novas (/produto/, /categoria/), preservando SEO/links.
22
+ //
23
+ // statusCode: 301, não permanent: true. `permanent: true` emite 308, e 308 preserva o
24
+ // método HTTP — tecnicamente mais correto e passa PageRank igual. Mas os critérios de
25
+ // aceitação dos briefings pedem "301 slug a slug" ao pé da letra, e auditoria que lê o
26
+ // status literal já reprovou duas lojas por isso. Um redirect de GET não ganha nada com
27
+ // 308: não vale discutir com o auditor.
28
+ async redirects() {
29
+ return [
30
+ { source: "/p/:slug", destination: "/produto/:slug", statusCode: 301 },
31
+ { source: "/c/:slug", destination: "/categoria/:slug", statusCode: 301 },
32
+ ];
33
+ },
34
+ async headers() {
35
+ const securityHeaders = [
36
+ { key: "X-Content-Type-Options", value: "nosniff" },
37
+ // Quem pode enquadrar a loja: ela mesma e o EDITOR da Unbox (o CMS visual abre a loja num
38
+ // iframe). X-Frame-Options não aceita origem externa; CSP frame-ancestors aceita e tem
39
+ // precedência. Sem NEXT_PUBLIC_EDITOR_ORIGIN vale só 'self', que é o mesmo que SAMEORIGIN.
40
+ { key: "Content-Security-Policy", value: `frame-ancestors 'self' ${process.env.NEXT_PUBLIC_EDITOR_ORIGIN ?? ""}`.trim() },
41
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
42
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
43
+ // HSTS: força HTTPS por 1 ano; Vercel já faz isso mas deixar explícito é boa prática.
44
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
45
+ ];
46
+ return [
47
+ { source: "/:path*", headers: securityHeaders },
48
+ // doc 09/10: não vazar cartToken/PII via header Referer em carrinho/checkout
49
+ { source: "/carrinho/:path*", headers: [{ key: "Referrer-Policy", value: "no-referrer" }] },
50
+ { source: "/checkout/:path*", headers: [{ key: "Referrer-Policy", value: "no-referrer" }] },
51
+ { source: "/conta/:path*", headers: [{ key: "Referrer-Policy", value: "no-referrer" }] },
52
+ // Assets da marca: sem isto o Next serve `max-age=0, must-revalidate` e o navegador
53
+ // revalida a pasta inteira a cada visita (uma loja media 25 MB em /brand).
54
+ // 30 dias e SEM `immutable`, de propósito: estes arquivos são trocados à mão, mantendo o
55
+ // mesmo nome (diferente de /_next/static, cujo nome muda a cada build). Com `immutable`,
56
+ // trocar uma foto deixaria a antiga presa no navegador de quem já visitou.
57
+ { source: "/brand/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=2592000" }] },
58
+ { source: "/unbox/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=2592000" }] },
59
+ ];
60
+ },
61
+ };
62
+
63
+ export default nextConfig;
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "storefront",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "prebuild": "node scripts/check-unbox-brand.mjs && node scripts/medir-sistema.mjs",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "eslint",
11
+ "typecheck": "tsc --noEmit",
12
+ "unbox:test": "tsx scripts/test-live.ts",
13
+ "unbox:dump": "tsx scripts/dump-catalog.ts",
14
+ "unbox:honestidade": "node scripts/check-honestidade.mjs",
15
+ "unbox:receita": "node scripts/check-recipe.mjs",
16
+ "unbox:webhook:subscribe": "tsx scripts/subscribe-webhook.ts",
17
+ "unbox:order:pix": "tsx scripts/place-order-pix.ts",
18
+ "unbox:abandoned": "tsx scripts/abandoned-cart.ts",
19
+ "unbox:qa": "node scripts/qa-screenshots.mjs",
20
+ "unbox:sistema": "python3 scripts/sistema.py .",
21
+ "unbox:vocabulario": "python3 scripts/vocabulario.py .",
22
+ "unbox:medir": "node scripts/medir-sistema.mjs",
23
+ "unbox:placeholder": "node scripts/check-placeholder.mjs",
24
+ "unbox:editavel": "node scripts/check-editable.mjs --url ${UNBOX_URL:-http://localhost:3000}"
25
+ },
26
+ "dependencies": {
27
+ "@base-ui/react": "^1.6.0",
28
+ "@phosphor-icons/react": "^2.1.10",
29
+ "class-variance-authority": "^0.7.1",
30
+ "clsx": "^2.1.1",
31
+ "cmdk": "^1.1.1",
32
+ "embla-carousel-react": "^8.6.0",
33
+ "input-otp": "^1.4.2",
34
+ "jose": "^6.2.12",
35
+ "next": "15.5.19",
36
+ "qrcode": "^1.5.4",
37
+ "react": "19.1.0",
38
+ "react-dom": "19.1.0",
39
+ "sanitize-html": "^2.17.5",
40
+ "shadcn": "^4.11.0",
41
+ "sonner": "^2.0.7",
42
+ "tailwind-merge": "^3.6.0",
43
+ "tw-animate-css": "^1.4.0",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/eslintrc": "^3",
48
+ "@next/env": "15.5.19",
49
+ "@tailwindcss/postcss": "^4",
50
+ "@types/node": "^20",
51
+ "@types/qrcode": "^1.5.6",
52
+ "@types/react": "^19",
53
+ "@types/react-dom": "^19",
54
+ "@types/sanitize-html": "^2.16.1",
55
+ "eslint": "^9",
56
+ "eslint-config-next": "15.5.19",
57
+ "playwright-core": "^1.63.0",
58
+ "tailwindcss": "^4",
59
+ "tsx": "^4.22.4",
60
+ "typescript": "^5"
61
+ }
62
+ }
@@ -0,0 +1,5 @@
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;