@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,187 @@
1
+ "use client";
2
+
3
+ // HERO da home — 5 variantes (escolhidas na receita):
4
+ // "imagem-full" banner de imagem full-width clicável (default)
5
+ // "carousel" carrossel de banners com setas + dots (portado de loja real em produção)
6
+ // "split-editorial" texto + CTA à esquerda, imagem à direita
7
+ // "minimal-texto" hero tipográfico sobre o chrome, sem imagem (nunca dá 404)
8
+ // "imagem-imersiva" foto sangrando até a borda, PASSANDO POR BAIXO do header — é a única
9
+ // variante que emite `.hero-imersivo`, a classe que deixa o header
10
+ // "imersivo" ficar transparente (regra em app/globals.css). Exige foto
11
+ // real e boa: sem ela, prefira imagem-full ou minimal-texto.
12
+ // props da receita: imageDesktop/imageMobile (paths), banners (carousel),
13
+ // title/subtitle/ctaLabel/ctaHref (texto).
14
+ //
15
+ // EDITOR: a copy é editável no PONTO DE USO (a receita segue sendo só o literal de partida), com os
16
+ // MESMOS caminhos em toda variante (titulo · subtitulo · cta · cta-icone · arte-desktop · arte-mobile):
17
+ // trocar a variante na receita não descola o que o lojista já editou. No carrossel cada banner tem o
18
+ // próprio escopo (`banner-N.arte-desktop`, `banner-N.arte-mobile`); só o banner na vez está no DOM.
19
+ // A arte do celular mora no srcSet do <source>: é valor de imagem que NÃO vira <img>, então vai de
20
+ // Slot (Editable.Img ali trocaria o <source> por uma segunda foto na tela). O <img> é puro, e não
21
+ // next/image, de propósito: o next/image traria wrapper + srcset e a paridade com o HTML de antes cairia.
22
+ // Para o lojista, isto se chama BANNER (id/rótulo da receita), nunca "hero": vocabulário de código.
23
+ import * as React from "react";
24
+ import Link from "next/link";
25
+ import { ArrowRight, CaretLeft, CaretRight } from "@phosphor-icons/react/dist/ssr";
26
+ import { Editable, EditableScope } from "@/lib/editable";
27
+ import type { SectionComponentProps } from "./registry";
28
+
29
+ function scrollToDestaques() {
30
+ document.getElementById("destaques")?.scrollIntoView({ behavior: "smooth", block: "start" });
31
+ }
32
+
33
+ type Banner = { imageDesktop: string; imageMobile?: string; href?: string; alt?: string };
34
+
35
+ export function HeroSection({ variant = "imagem-full", sectionProps = {} }: SectionComponentProps) {
36
+ const {
37
+ // TODO: o agente de branding troca pelas imagens reais da marca (.webp) via receita
38
+ imageDesktop = "/brand/hero-desktop.svg",
39
+ imageMobile = "/brand/hero-mobile.svg",
40
+ title = "Bem-vindo à nossa loja",
41
+ subtitle = "Produtos escolhidos com cuidado, do nosso catálogo pra sua casa.",
42
+ ctaLabel = "Ver destaques",
43
+ ctaHref = "",
44
+ } = sectionProps as Record<string, string>;
45
+ const banners = (sectionProps.banners as Banner[] | undefined) ?? [
46
+ { imageDesktop, imageMobile },
47
+ { imageDesktop, imageMobile },
48
+ { imageDesktop, imageMobile },
49
+ ];
50
+ const [idx, setIdx] = React.useState(0);
51
+
52
+ if (variant === "carousel") {
53
+ const step = (d: number) => setIdx((i) => (i + d + banners.length) % banners.length);
54
+ const b = banners[idx];
55
+ const Frame = ({ children }: { children: React.ReactNode }) =>
56
+ b.href ? <Link href={b.href} className="block">{children}</Link> : <button type="button" onClick={scrollToDestaques} aria-label="Ver destaques" className="block w-full cursor-pointer">{children}</button>;
57
+ return (
58
+ // full-bleed: banner em card arredondado é o tell nº 1 de "site de template".
59
+ <div className="w-full">
60
+ <div className="relative overflow-hidden bg-[var(--store-chrome-bg,#18181B)]">
61
+ <Frame>
62
+ {/* cada banner tem o próprio escopo: trocar a foto do 2º não mexe no 1º */}
63
+ <EditableScope path={`banner-${idx + 1}`}>
64
+ <picture>
65
+ {b.imageMobile && (
66
+ <Editable.Slot path="arte-mobile" type="image" fallback={{ src: b.imageMobile }} label={`Imagem do banner ${idx + 1} (celular)`}>
67
+ {(v, attrs, ref) => <source ref={ref} media="(max-width: 767px)" srcSet={v.src} width="1122" height="1402" sizes="100vw" {...attrs} />}
68
+ </Editable.Slot>
69
+ )}
70
+ <Editable.Img path="arte-desktop" fallback={{ src: b.imageDesktop, alt: b.alt ?? "" }} label={`Imagem do banner ${idx + 1} (computador)`} width="1915" height="821" fetchPriority="high" className="block h-auto w-full" />
71
+ </picture>
72
+ </EditableScope>
73
+ </Frame>
74
+ {banners.length > 1 && (
75
+ <>
76
+ <button type="button" onClick={() => step(-1)} aria-label="Banner anterior" className="absolute left-3 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-[var(--store-ink)] transition-colors hover:bg-white"><CaretLeft weight="bold" /></button>
77
+ <button type="button" onClick={() => step(1)} aria-label="Próximo banner" className="absolute right-3 top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-[var(--store-ink)] transition-colors hover:bg-white"><CaretRight weight="bold" /></button>
78
+ <div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5">
79
+ {banners.map((_, i) => (
80
+ <button key={i} type="button" onClick={() => setIdx(i)} aria-label={`Banner ${i + 1}`} className={`h-2 w-2 rounded-full ${i === idx ? "bg-white" : "bg-white/45"}`} />
81
+ ))}
82
+ </div>
83
+ </>
84
+ )}
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (variant === "imagem-imersiva") {
91
+ return (
92
+ // .hero-imersivo: marcador que o CSS usa pra deixar o header transparente. Sem
93
+ // max-w/px/pt/rounded — a foto tem que encostar nas 4 bordas e subir por baixo do header.
94
+ <div className="hero-imersivo relative w-full overflow-hidden">
95
+ <picture>
96
+ <Editable.Slot path="arte-mobile" type="image" fallback={{ src: imageMobile }} label="Imagem do banner (celular)">
97
+ {(v, attrs, ref) => <source ref={ref} media="(max-width: 767px)" srcSet={v.src} width="1122" height="1402" sizes="100vw" {...attrs} />}
98
+ </Editable.Slot>
99
+ {/* parallax: a foto desliza DENTRO do frame. Não é reveal — nada da primeira dobra
100
+ entra animado (isso atrasaria a percepção de carregamento e mexeria no LCP).
101
+ alt="" continua vazio de propósito: a foto é decorativa, quem fala é a headline. */}
102
+ <Editable.Img path="arte-desktop" fallback={{ src: imageDesktop, alt: "" }} label="Imagem do banner (computador)" fetchPriority="high" className="parallax-slow block h-[78svh] max-h-[760px] min-h-[420px] w-full object-cover" />
103
+ </picture>
104
+ {/* véu inferior: garante contraste do texto sobre qualquer foto */}
105
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/55 to-transparent" />
106
+ <div className="absolute inset-x-0 bottom-0 mx-auto max-w-[var(--container-max,1240px)] px-6 pb-12 sm:pb-16">
107
+ {/* `as="h1"` mantém o MESMO elemento de antes; `multiline` deixa o lojista escolher onde a
108
+ headline quebra sem mexer no CSS */}
109
+ <Editable.Text as="h1" path="titulo" fallback={title} label="Título" multiline className="font-display max-w-[680px] text-[34px] font-extrabold leading-[1.08] text-white sm:text-[52px]" />
110
+ <Editable.Text as="p" path="subtitulo" fallback={subtitle} label="Frase de apoio" className="mt-3 max-w-[520px] text-[15px] leading-[1.5] text-white/85 sm:text-[16px]" />
111
+ <HeroCta label={ctaLabel} href={ctaHref} className="mt-7" />
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ if (variant === "minimal-texto") {
118
+ return (
119
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pt-[22px] sm:px-6">
120
+ <div className="rounded-2xl bg-[var(--store-chrome-bg,#18181B)] px-7 py-14 text-center sm:py-20">
121
+ <Editable.Text as="h1" path="titulo" fallback={title} label="Título" multiline className="font-display mx-auto max-w-[720px] text-[32px] font-extrabold leading-[1.12] text-[var(--store-chrome-text,#ffffff)] sm:text-[44px]" />
122
+ <Editable.Text as="p" path="subtitulo" fallback={subtitle} label="Frase de apoio" className="mx-auto mt-4 max-w-[520px] text-[15px] leading-[1.5] text-[var(--store-chrome-muted)]" />
123
+ <HeroCta label={ctaLabel} href={ctaHref} className="mt-8" />
124
+ </div>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ if (variant === "split-editorial") {
130
+ return (
131
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pt-[22px] sm:px-6">
132
+ <div className="grid overflow-hidden store-card rounded-2xl md:grid-cols-[0.9fr_1.1fr]">
133
+ <div className="flex flex-col items-start justify-center gap-4 px-7 py-10 sm:px-10">
134
+ <Editable.Text as="h1" path="titulo" fallback={title} label="Título" multiline className="font-display text-[28px] font-extrabold leading-[1.15] sm:text-[36px]" />
135
+ <Editable.Text as="p" path="subtitulo" fallback={subtitle} label="Frase de apoio" className="max-w-[420px] text-[15px] leading-[1.55] text-[var(--store-muted)]" />
136
+ <HeroCta label={ctaLabel} href={ctaHref} className="mt-2" />
137
+ </div>
138
+ <Editable.Img path="arte-desktop" fallback={{ src: imageDesktop, alt: "" }} label="Imagem do banner" fetchPriority="high" className="block h-full min-h-[260px] w-full object-cover" />
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ // imagem-full (default)
145
+ return (
146
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pt-[22px] sm:px-6">
147
+ <button
148
+ type="button"
149
+ onClick={scrollToDestaques}
150
+ aria-label="Ver destaques da loja"
151
+ className="block w-full cursor-pointer overflow-hidden rounded-2xl bg-[var(--store-chrome-bg,#18181B)] shadow-sm outline-none transition-transform hover:scale-[1.003] focus-visible:ring-4 focus-visible:ring-[var(--store-cta,#D97706)] focus-visible:ring-offset-2"
152
+ >
153
+ <picture>
154
+ <Editable.Slot path="arte-mobile" type="image" fallback={{ src: imageMobile }} label="Imagem do banner (celular)">
155
+ {(v, attrs, ref) => <source ref={ref} media="(max-width: 767px)" srcSet={v.src} width="1122" height="1402" sizes="100vw" {...attrs} />}
156
+ </Editable.Slot>
157
+ <Editable.Img path="arte-desktop" fallback={{ src: imageDesktop, alt: "" }} label="Imagem do banner (computador)" width="1915" height="821" fetchPriority="high" className="block h-auto w-full" />
158
+ </picture>
159
+ </button>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ // O rótulo vem da receita por prop, mas o ponto EDITÁVEL é aqui, no uso. Vai de Slot (e não de
165
+ // Editable.Text) porque o botão é `inline-flex gap-2` com dois filhos, rótulo e seta: um <span> a
166
+ // mais viraria um terceiro item de flex. Assim o ponto editável é o próprio <a>/<button>, e o HTML
167
+ // de produção continua idêntico. O 4º argumento do render-prop é a cor que o lojista deu SÓ a este
168
+ // botão; sem ele, cor por elemento não funcionaria aqui.
169
+ function HeroCta({ label, href, className = "" }: { label: string; href: string; className?: string }) {
170
+ const cls = "font-display inline-flex items-center gap-2 rounded-full bg-[var(--store-cta,#D97706)] px-7 py-3 text-[15px] font-extrabold text-[var(--store-cta-fg,#1C1207)] no-underline transition-colors hover:bg-[var(--store-cta-dark,#B45309)]";
171
+ const seta = (
172
+ <Editable.Icon path="cta-icone" label="Ícone do botão" size={16}>
173
+ <ArrowRight weight="bold" />
174
+ </Editable.Icon>
175
+ );
176
+ return (
177
+ <Editable.Slot path="cta" type="text" fallback={label} label="Botão principal">
178
+ {(v, attrs, ref, estilo) =>
179
+ href ? (
180
+ <Link ref={ref} href={href} className={`${cls} ${className}`} style={estilo} {...attrs}>{v}{seta}</Link>
181
+ ) : (
182
+ <button ref={ref} type="button" onClick={scrollToDestaques} className={`${cls} ${className}`} style={estilo} {...attrs}>{v}{seta}</button>
183
+ )
184
+ }
185
+ </Editable.Slot>
186
+ );
187
+ }
@@ -0,0 +1,11 @@
1
+ "use client";
2
+
3
+ // KITS temáticos — combos curados de lib/enrichment/combos.ts (some se COMBOS vazio).
4
+ // EDITOR: o cabeçalho da seção (chapéu, título, subtítulo) é copy e vira caminho da seção, dentro
5
+ // de CombosSection; os kits em si (nome, itens, preço) são DADO da loja e ficam fora (README §8).
6
+ import { CombosSection } from "@/components/home/combos-section";
7
+ import type { SectionComponentProps } from "./registry";
8
+
9
+ export function KitsSection({ data }: SectionComponentProps) {
10
+ return <CombosSection combos={data.bundles} />;
11
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ // Carrossel horizontal de cards de imagem com legenda sobreposta (na loja de referência: categorias
4
+ // de ingredientes). Serve pra ingredientes, materiais, coleções, bastidores...
5
+ // Conteúdo via receita; defaults neutros com placeholders recoloridos pela marca.
6
+ //
7
+ // EDITOR: título e subtítulo são caminhos da seção; os cards são uma LISTA editável
8
+ // (`cards.card-N`, com foto, título e texto). O invólucro do item é display:contents, então quem
9
+ // continua sendo o filho do carrossel (`flex-none`) é o próprio card. As setas são interface.
10
+ // A foto continua passando pelo `Foto` (otimizador do Next): o `Editable.Slot` só lhe entrega o
11
+ // src/alt do lojista e os atributos de seleção.
12
+ import * as React from "react";
13
+ import { CaretLeft, CaretRight } from "@phosphor-icons/react/dist/ssr";
14
+ import { Editable, EditableScope } from "@/lib/editable";
15
+ import type { SectionComponentProps } from "./registry";
16
+ import { Foto } from "@/components/ui/foto";
17
+
18
+ type MediaCard = { image: string; title: string; text?: string };
19
+
20
+ const DEFAULT_CARDS: MediaCard[] = [
21
+ { image: "/brand/ph/photo-a.svg", title: "Feito pra durar", text: "Escolha de materiais e fornecedores com critério." },
22
+ { image: "/brand/ph/photo-b.svg", title: "Detalhe importa", text: "Acabamento e apresentação que fazem diferença." },
23
+ { image: "/brand/ph/photo-c.svg", title: "Do nosso jeito", text: "Processo próprio, testado no dia a dia." },
24
+ { image: "/brand/ph/photo-a.svg", title: "Pra sua rotina", text: "Pensado pra caber na vida real." },
25
+ ];
26
+
27
+ export function MediaCardsSection({ sectionProps = {} }: SectionComponentProps) {
28
+ const {
29
+ title = "Por dentro do que você leva",
30
+ subtitle = "O que entra em cada produto, sem segredo.",
31
+ } = sectionProps as Record<string, string>;
32
+ const cards = (sectionProps.cards as MediaCard[] | undefined) ?? DEFAULT_CARDS;
33
+ const rowRef = React.useRef<HTMLDivElement | null>(null);
34
+ const scrollRow = (dir: number) => rowRef.current?.scrollBy({ left: dir * 300, behavior: "smooth" });
35
+
36
+ return (
37
+ <div className="reveal mx-auto max-w-[var(--container-max,1240px)] px-4 pt-[var(--section-gap,52px)] sm:px-6">
38
+ <div className="mb-[18px] flex items-end justify-between gap-3">
39
+ <div>
40
+ <Editable.Text as="h2" path="titulo" fallback={title} label="Título" className="font-display text-[28px] font-extrabold leading-[1.15]" />
41
+ <Editable.Text as="p" path="subtitulo" fallback={subtitle} label="Subtítulo" className="mt-1 text-sm text-[var(--store-muted)]" />
42
+ </div>
43
+ <div className="hidden gap-2.5 sm:flex">
44
+ <button type="button" onClick={() => scrollRow(-1)} aria-label="Anterior" className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-[1.5px] border-[var(--store-line-2)] bg-white hover:border-[var(--store-primary,#18181B)]"><CaretLeft weight="bold" className="text-[var(--store-ink-2)]" /></button>
45
+ <button type="button" onClick={() => scrollRow(1)} aria-label="Próximo" className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-[1.5px] border-[var(--store-line-2)] bg-white hover:border-[var(--store-primary,#18181B)]"><CaretRight weight="bold" className="text-[var(--store-ink-2)]" /></button>
46
+ </div>
47
+ </div>
48
+ <div ref={rowRef} className="flex gap-[18px] overflow-x-auto pb-3.5 [scrollbar-width:thin]">
49
+ <EditableScope path="cards">
50
+ <Editable.Sections nested>
51
+ {cards.map((c, i) => (
52
+ <Editable.Section key={i} item id={`card-${i + 1}`} label={`Card ${i + 1}`}>
53
+ <div className="relative w-[264px] flex-none overflow-hidden rounded-2xl">
54
+ {/* TODO: trocar por fotos reais (aspect 4:5) */}
55
+ <Editable.Slot path="imagem" type="image" fallback={{ src: c.image, alt: "" }} label="Foto do card">
56
+ {(v, attrs, ref) => <Foto ref={ref} attrs={attrs} src={v.src} alt={v.alt ?? ""} width={528} height={660} sizes="264px" className="aspect-[4/5] w-full object-cover" />}
57
+ </Editable.Slot>
58
+ <div className="absolute inset-x-3 bottom-3 rounded-xl bg-[var(--store-chrome-bg,#18181B)]/92 px-4 py-3.5">
59
+ <Editable.Text as="div" path="titulo" fallback={c.title} label="Título do card" className="font-display text-[15px] font-bold text-[var(--store-chrome-text,#ffffff)]" />
60
+ {c.text && <Editable.Text as="p" path="texto" fallback={c.text} label="Texto do card" multiline className="mt-1 text-[12.5px] leading-[1.45] text-[var(--store-chrome-muted)]" />}
61
+ </div>
62
+ </div>
63
+ </Editable.Section>
64
+ ))}
65
+ </Editable.Sections>
66
+ </EditableScope>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ // NEWSLETTER — variantes: "bloco" (bloco CTA chamativo, default) e "inline" (linha fina
4
+ // discreta). Renderiza SÓ com `title` e `action` na receita: o título é a promessa do lojista
5
+ // ("receba ofertas exclusivas" é dele, não da foundation) e `action` é a URL que recebe o
6
+ // e-mail (endpoint do CRM/ESP). Sem `action`, um formulário que descarta o e-mail é mentira.
7
+ // O form é POST de verdade (campo `email`), sem preventDefault.
8
+ //
9
+ // EDITOR: o título e o texto do botão são copy (caminhos `titulo` e `botao`); campo e placeholder do
10
+ // formulário são INTERFACE, e a `action` é configuração: ficam fora do editor (README §8). O botão é
11
+ // um `Editable.Slot`: ele é filho direto de um flex e precisa manter type="submit"; o Slot devolve o
12
+ // MESMO elemento, sem invólucro.
13
+ import { Editable } from "@/lib/editable";
14
+ import type { SectionComponentProps } from "./registry";
15
+ import { NEWSLETTER_ACTION } from "@/lib/newsletter";
16
+
17
+ export function NewsletterSection({ variant = "bloco", sectionProps = {} }: SectionComponentProps) {
18
+ const { title = "", action = "", buttonLabel = "Quero receber", placeholder = "Seu melhor e-mail" } = sectionProps as Record<string, string>;
19
+ // `action` da receita vence (uma seção pode ter destino próprio); sem ela vale o destino da
20
+ // loja, o mesmo que o rodapé e o catálogo usam. Sem nenhum dos dois, a seção não renderiza.
21
+ const destino = action || NEWSLETTER_ACTION;
22
+ if (!title || !destino) return null;
23
+
24
+ if (variant === "inline") {
25
+ return (
26
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pb-12 pt-8 sm:px-6">
27
+ <div className="flex flex-wrap items-center gap-5 store-card rounded-xl px-6 py-5">
28
+ <Editable.Text as="div" path="titulo" fallback={title} label="Título" className="font-display flex-1 text-[16px] font-bold leading-[1.3] text-[var(--store-ink)]" />
29
+ <form method="post" action={destino} className="flex w-full max-w-[420px] items-center gap-1.5 rounded-full border border-[var(--store-line-2)] bg-[var(--store-surface)] p-1">
30
+ <input type="email" name="email" required placeholder={placeholder} className="h-10 min-w-0 flex-1 rounded-full bg-transparent px-4 text-sm outline-none" />
31
+ <Editable.Slot path="botao" type="text" fallback={buttonLabel} label="Texto do botão">
32
+ {(v, attrs, ref, estilo) => (
33
+ <button ref={ref} {...attrs} type="submit" className="font-display h-10 shrink-0 rounded-full bg-[var(--store-primary,#18181B)] px-5 text-[13px] font-extrabold text-white transition-colors hover:bg-[var(--store-primary-hover,#3F3F46)]" style={estilo}>
34
+ {v}
35
+ </button>
36
+ )}
37
+ </Editable.Slot>
38
+ </form>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ // bloco (default)
45
+ return (
46
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pb-12 pt-8 sm:px-6">
47
+ <div className="flex flex-wrap items-center gap-7 rounded-xl bg-[var(--store-cta,#D97706)] px-8 py-7">
48
+ <Editable.Text as="div" path="titulo" fallback={title} label="Título" multiline className="font-display flex-1 text-[20px] font-extrabold leading-[1.25] text-[var(--store-cta-fg,#1C1207)] sm:text-[23px]" />
49
+ <form method="post" action={destino} className="flex w-full max-w-[480px] items-center gap-1.5 rounded-full bg-white p-1.5">
50
+ <input type="email" name="email" required placeholder={placeholder} className="h-11 min-w-0 flex-1 rounded-full bg-transparent px-4 text-sm outline-none" />
51
+ <Editable.Slot path="botao" type="text" fallback={buttonLabel} label="Texto do botão">
52
+ {(v, attrs, ref, estilo) => (
53
+ <button ref={ref} {...attrs} type="submit" className="font-display h-11 shrink-0 rounded-full bg-[var(--store-chrome-bg,#18181B)] px-6 text-[14px] font-extrabold tracking-[0.5px] text-[var(--store-chrome-text,#ffffff)] transition-opacity hover:opacity-90" style={estilo}>
54
+ {v}
55
+ </button>
56
+ )}
57
+ </Editable.Slot>
58
+ </form>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ // Vitrine de produtos com fundo colorido por card (portado da vitrine de uma loja real em produção).
4
+ // Diferença importante: aqui os produtos e PREÇOS vêm do CATÁLOGO REAL (data.combos),
5
+ // não de constante hardcoded. A receita pode customizar título, cores de fundo e CTA.
6
+ //
7
+ // ── EDITOR: esta é a vitrine que o lojista ADICIONA pelo "+" ────────────────
8
+ // `Editable.Vitrine` deixa o LOJISTA escolher o que aparece aqui (uma categoria da Unbox, uma
9
+ // lista de produtos, ou uma busca). O documento guarda só a ESCOLHA; quem a transforma em produto
10
+ // com preço e estoque é a LOJA, no servidor (lib/vitrine.ts), e os produtos chegam prontos em
11
+ // `data.vitrines`.
12
+ //
13
+ // O CAMINHO NÃO É CONSTANTE. `path={CAMPO_VITRINE}` é RELATIVO à seção: o primitivo o resolve para
14
+ // `<container>.<id da seção>.vitrine` pelo escopo, e aqui embaixo `joinPath(ctx.scope, CAMPO_VITRINE)`
15
+ // deriva EXATAMENTE a mesma string para achar os produtos já resolvidos. Escrever o caminho
16
+ // completo à mão descolaria os dois lados no primeiro id renomeado, e aqui nem teria como
17
+ // funcionar: esta seção é um TIPO ADICIONÁVEL (sections/catalogo.ts), então o id só existe em
18
+ // tempo de execução (`novo-vitrine-de-produtos-2`, sorteado quando o lojista clica no "+").
19
+ //
20
+ // Duas vitrines na mesma página = dois ids = dois caminhos = DUAS ESCOLHAS INDEPENDENTES.
21
+ //
22
+ // FALLBACK = o que o código mostra hoje (`data.combos`). Enquanto o lojista não escolher, e
23
+ // também quando a escolha não resolve em nada (Unbox fora, categoria esvaziada, produto apagado),
24
+ // esta seção renderiza o mesmo HTML de antes. Vitrine vazia lê como loja quebrada.
25
+ //
26
+ // ── NA PRÉVIA, os produtos vêm pelo 4º argumento ──────────────────────────────────────────────
27
+ // O rascunho do editor não passa pelo servidor da loja: ele chega ao iframe por postMessage. Então,
28
+ // em modo edição, é o PRIMITIVO que pergunta à loja (`POST /api/unbox/vitrine`) o que a escolha do
29
+ // rascunho vira, e entrega em `previa`. `previa.produtos` GANHA de `data.vitrines` quando existe.
30
+ // Em produção `previa` é `undefined` e nada aqui muda.
31
+ import Link from "next/link";
32
+ import type * as React from "react";
33
+ import { CAMPO_VITRINE, Editable, useEditableContext, joinPath, type VitrineProdutoResolvido, type VitrineValue } from "@/lib/editable";
34
+ import { formatBRL } from "@/lib/format";
35
+ import type { SectionComponentProps } from "./registry";
36
+ import { Foto } from "@/components/ui/foto";
37
+
38
+ const DEFAULT_BGS = [
39
+ "var(--store-primary-soft, #F1F1F3)",
40
+ "var(--store-cta-soft, #FDF0DC)",
41
+ "var(--store-surface-2)",
42
+ "var(--store-sale-soft, #FBE7E7)",
43
+ ];
44
+
45
+ /**
46
+ * A TARJA DO "ESTA SEÇÃO NÃO VAI PARA O SITE": só na prévia.
47
+ *
48
+ * `style` inline, como as tarjas do primitivo: em produção ela não existe, e uma classe do CSS da
49
+ * loja faria o aviso depender de estilo que outra loja pode não ter.
50
+ */
51
+ const AVISO_SEM_PRODUTO: React.CSSProperties = {
52
+ margin: "0 0 12px",
53
+ padding: "8px 12px",
54
+ borderRadius: 8,
55
+ background: "#FEF3C7",
56
+ color: "#7C2D12",
57
+ font: "500 13px/1.4 system-ui, sans-serif",
58
+ };
59
+
60
+ /** O mínimo que um card precisa, venha ele do catálogo do código ou da escolha do lojista. */
61
+ interface CardDeVitrine {
62
+ chave: string;
63
+ slug: string;
64
+ titulo: string;
65
+ imagem: string | null;
66
+ preco: string;
67
+ esgotado: boolean;
68
+ }
69
+
70
+ export function ProductShowcaseSection({ data, sectionProps = {} }: SectionComponentProps) {
71
+ const {
72
+ title = "Escolha o seu",
73
+ subtitle = "Os favoritos da loja, direto do catálogo.",
74
+ ctaHref = "",
75
+ } = sectionProps as Record<string, string>;
76
+ const bgs = (sectionProps.backgrounds as string[] | undefined) ?? DEFAULT_BGS;
77
+ const max = (sectionProps.max as number | undefined) ?? 4;
78
+
79
+ // O MESMO caminho que o primitivo registra logo abaixo: derivado do escopo da seção,
80
+ // nunca escrito à mão (ver o cabeçalho).
81
+ const ctx = useEditableContext();
82
+ const caminho = joinPath(ctx.scope, CAMPO_VITRINE);
83
+ const doServidor = data.vitrines?.[caminho] ?? null;
84
+
85
+ const doCodigo = data.combos.slice(0, max);
86
+ // A escolha que o CÓDIGO representa: os produtos que esta seção lista hoje, na ordem em que
87
+ // aparecem. É o que o painel mostra como estado atual antes do primeiro clique do lojista.
88
+ // Vínculo pelo productId, não pelo slug: slug é editável no painel da Unbox (renomear produto,
89
+ // ajuste de SEO) e, quando muda, o vínculo por slug para de resolver e o produto SOME da seção
90
+ // sem erro nenhum. O id é estável. `resolverVitrine` aceita os dois, então o slug fica de reserva
91
+ // para catálogo que não devolva id.
92
+ const fallbackVitrine: VitrineValue = { modo: "produtos", produtos: doCodigo.map((p) => p.productId || p.slug) };
93
+
94
+ // Produtos resolvidos (pelo servidor em produção, pela prévia no editor) → cards.
95
+ const cardsDe = (lista: VitrineProdutoResolvido[], teto: number): CardDeVitrine[] =>
96
+ lista.slice(0, teto).map((p) => ({
97
+ chave: p.id,
98
+ slug: p.slug,
99
+ titulo: p.titulo,
100
+ imagem: p.imagem,
101
+ // `preco: null` = produto sem variante com preço no painel; `formatBRL` devolve "—".
102
+ // Ausência honesta, nunca "R$ 0,00".
103
+ preco: formatBRL(p.preco),
104
+ esgotado: p.esgotado,
105
+ }));
106
+
107
+ const cardsDoCodigo: CardDeVitrine[] = doCodigo.map((p) => ({
108
+ chave: p.slug,
109
+ slug: p.slug,
110
+ titulo: p.title,
111
+ imagem: p.imageUrl,
112
+ preco: p.displayPrice,
113
+ esgotado: p.soldOut,
114
+ }));
115
+
116
+ // Sem catálogo nenhum a seção não tem o que mostrar. No modo edição ela CONTINUA na tela (com a
117
+ // moldura vazia): sumindo dali, o lojista não conseguiria selecionar a vitrine para trocar a
118
+ // escolha, que é justamente o que ele precisa fazer quando ela está vazia.
119
+ if ((doServidor?.length ? cardsDe(doServidor, max) : cardsDoCodigo).length === 0 && !ctx.editing) return null;
120
+
121
+ return (
122
+ <div className="mx-auto max-w-[var(--container-max,1240px)] px-4 pt-[var(--section-gap,52px)] sm:px-6">
123
+ <div className="mx-auto mb-7 max-w-[560px] text-center">
124
+ <Editable.Text as="h2" path="titulo" fallback={title} label="Título da vitrine" className="font-display text-[28px] font-extrabold leading-[1.15]" />
125
+ <Editable.Text as="p" path="subtitulo" fallback={subtitle} label="Subtítulo da vitrine" className="mt-1.5 text-[15px] text-[var(--store-muted)]" />
126
+ </div>
127
+ {/* O primitivo só REGISTRA o caminho e devolve os atributos de seleção; em produção ele não
128
+ põe nada no DOM: sem escolha do lojista, o HTML aqui é o mesmo de antes. */}
129
+ <Editable.Vitrine path={CAMPO_VITRINE} label="Produtos desta vitrine" fallback={fallbackVitrine}>
130
+ {(escolha, attrs, ref, previa) => {
131
+ // No modo PRODUTOS a lista É a resposta: mostra tantos quantos o lojista escolheu, e o
132
+ // `limite` gravado é ignorado. Nos outros modos o limite decide o tamanho, com o `max`
133
+ // da receita como padrão.
134
+ const teto = escolha.modo === "produtos" ? escolha.produtos.length : (escolha.limite ?? max);
135
+ // a prévia (rascunho de agora) ganha do que o servidor resolveu (documento publicado)
136
+ const resolvidos = previa?.produtos ?? doServidor;
137
+ // LISTA VAZIA cai nos produtos do CÓDIGO, não numa grade em branco. É o que a página
138
+ // publicada faz (`resolverVitrinesDoDocumento` deixa de fora o caminho que não resolveu em
139
+ // nada), e a prévia tem de mostrar o que publicar vai dar. Quem avisa o lojista de que a
140
+ // escolha dele não trouxe produto é a tarja âmbar do primitivo, que só existe na prévia.
141
+ const cards = resolvidos && resolvidos.length ? cardsDe(resolvidos, teto) : cardsDoCodigo;
142
+ // A PRÉVIA DESENHA A SEÇÃO; A PÁGINA PUBLICADA NÃO. Sem nenhum produto (nem da escolha do
143
+ // lojista, nem do código), a seção devolve `null` no site (a linha lá em cima). No editor
144
+ // ela CONTINUA na tela, para ele conseguir clicar e trocar a escolha, e a frase avisa que o
145
+ // site não vai mostrá-la assim. Só sai quando dá para AFIRMAR: com a prévia carregando ou
146
+ // em erro, não se sabe.
147
+ const foraDoSite = ctx.editing && cards.length === 0 && !previa?.carregando && !previa?.erro;
148
+ return (
149
+ <>
150
+ {foraDoSite ? <p style={AVISO_SEM_PRODUTO}>Sem nenhum produto para mostrar, esta seção não aparece no site. Escolha os produtos dela para ela ir ao ar.</p> : null}
151
+ <div ref={ref as React.Ref<HTMLDivElement>} {...attrs} className="grid grid-cols-2 gap-4 lg:grid-cols-4">
152
+ {cards.map((p, i) => (
153
+ <Link key={p.chave} href={ctaHref || `/produto/${encodeURIComponent(p.slug)}`} className="group flex flex-col overflow-hidden rounded-2xl no-underline transition-transform hover:scale-[1.015]" style={{ background: bgs[i % bgs.length] }}>
154
+ <div className="flex aspect-square items-center justify-center p-6">
155
+ {p.imagem
156
+ ? <Foto src={p.imagem} alt={p.titulo} width={560} height={560} sizes="(min-width: 1024px) 280px, 45vw" className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105" />
157
+ : <span className="font-display text-[42px] font-extrabold text-[var(--store-primary,#18181B)]/30">{p.titulo.charAt(0)}</span>}
158
+ </div>
159
+ <div className="px-5 pb-5 text-center">
160
+ <div className="font-display text-[16px] font-extrabold leading-[1.25] text-[var(--store-ink)]">{p.titulo}</div>
161
+ <div className="mt-1.5 text-[14.5px] font-bold text-[var(--store-primary,#18181B)]">{p.preco}</div>
162
+ <span className="font-display mt-3 inline-block rounded-full bg-[var(--store-primary,#18181B)] px-5 py-2 text-[13px] font-extrabold text-white transition-colors group-hover:bg-[var(--store-primary-dark,#09090B)]">
163
+ {p.esgotado ? "esgotado" : "comprar"}
164
+ </span>
165
+ </div>
166
+ </Link>
167
+ ))}
168
+ </div>
169
+ </>
170
+ );
171
+ }}
172
+ </Editable.Vitrine>
173
+ </div>
174
+ );
175
+ }