@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,523 @@
1
+ "use client";
2
+
3
+ // Catálogo: hero + coleções + sidebar de filtros + grid, tudo client-side sobre o catálogo
4
+ // real (filtra/ordena/pagina em memória). Header/footer/carrinho são globais (layout).
5
+ import * as React from "react";
6
+ import { trackViewItemList, trackSelectItem, type TrackItem } from "@/lib/analytics";
7
+ import Link from "next/link";
8
+ import Image from "next/image";
9
+ import {
10
+ Sparkle, SlidersHorizontal, Check, Tag, Plus,
11
+ CaretRight, Spinner, X, FunnelSimple,
12
+ SquaresFour, Rows, Truck, Lightning, LockSimple, SealCheck,
13
+ } from "@phosphor-icons/react/dist/ssr";
14
+ import { useCart } from "@/components/cart/cart-provider";
15
+ import { FREE_SHIPPING_THRESHOLD } from "@/lib/store-config";
16
+ import { ProductGridCard } from "@/components/catalog/product-grid-card";
17
+ import { ComboCardCompact } from "@/components/home/combo-card-compact";
18
+ import type { ResolvedCombo } from "@/lib/enrichment/combos";
19
+ // EDITOR: a copy de marca desta página (nome no caminho, título da lista, aviso de lista vazia,
20
+ // cabeçalho dos kits, faixa de confiança, newsletter) mora no container "catalogo", que /produtos e
21
+ // /categoria/[tagSlug] declaram DE PROPÓSITO: é a mesma copy nas duas, e o painel diz "estas seções
22
+ // também aparecem em…". Só /produtos manda na ordem (`layout`); a categoria reaproveita com
23
+ // layout={false}. O que é catálogo (card, preço, filtro, ordenação, contador, paginação) não vira
24
+ // primitivo (README §8); esses blocos levam `data-editor-ignore`, que é o atributo que o gate respeita
25
+ // para não cobrar o que ficou fora por decisão.
26
+ import { Editable } from "@/lib/editable";
27
+ import { NEWSLETTER_ACTION } from "@/lib/newsletter";
28
+
29
+ export interface CatalogProductItem {
30
+ slug: string;
31
+ title: string;
32
+ weight: string;
33
+ categories: string[];
34
+ price: number;
35
+ displayPrice: string;
36
+ oldPrice: number | null;
37
+ displayOld: string | null;
38
+ offPct: number | null;
39
+ imageUrl: string | null;
40
+ productId: string;
41
+ variantId: string | null;
42
+ badge: { label: string; bg: string; fg: string } | null;
43
+ soldOut: boolean;
44
+ }
45
+
46
+ /** Item do catálogo → item de tracking (GA4). `index` é a posição na lista exibida. */
47
+ function toTrackItem(p: CatalogProductItem, index?: number): TrackItem {
48
+ const disc = p.oldPrice != null && p.oldPrice > p.price ? p.oldPrice - p.price : undefined;
49
+ return { id: p.productId, name: p.title, price: p.price, discount: disc, category: p.categories?.[0], index };
50
+ }
51
+ export interface CatalogCategory { name: string; slug: string }
52
+
53
+ const PRICE_RANGES = [
54
+ { key: "p1", label: "Até R$ 25", min: 0, max: 25 },
55
+ { key: "p2", label: "R$ 25 – R$ 40", min: 25, max: 40 },
56
+ { key: "p3", label: "R$ 40 – R$ 80", min: 40, max: 80 },
57
+ { key: "p4", label: "Acima de R$ 80", min: 80, max: Infinity },
58
+ ];
59
+
60
+
61
+ // Imagem própria para cada pill de categoria do catálogo.
62
+ // TODO: adicione os padrões de nome das categorias da sua loja aqui.
63
+ // As imagens ficam em /public/brand/coll/. Exemplo:
64
+ // if (/nome-da-categoria/.test(n)) return "/brand/coll/nome-da-categoria.webp";
65
+ export function collImageSrc(name: string): string {
66
+ const n = name.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
67
+ // Só o default existe na foundation. Adicione as suas imagens em public/brand/coll/ e
68
+ // mapeie aqui — apontar pra arquivo que não existe mostra imagem quebrada na pill.
69
+ void n;
70
+ return "/brand/coll/default.png";
71
+ }
72
+
73
+ const PAGE = 12;
74
+ const STEP = 8;
75
+
76
+ // classes de cada selo da faixa de confiança (a lista é editável, item a item)
77
+ const SELO = "flex items-center gap-3.5 px-[26px] py-6";
78
+ const SELO_ICONE = "shrink-0 text-[28px] text-[var(--store-cta,#D97706)]";
79
+ const SELO_TEXTO = "text-[13px] font-medium leading-[1.4] text-[var(--store-chrome-muted)]";
80
+
81
+ export function CatalogClient({
82
+ items,
83
+ categories,
84
+ bundles = [],
85
+ initialCategory,
86
+ // Nível do título dos resultados. Em /produtos e /categoria este é o ÚNICO título da
87
+ // página, então precisa ser h1 (as duas estavam sem nenhum). Existe como prop para que
88
+ // uma página futura que já tenha o seu próprio h1 possa pedir h2 — duas h1 numa página
89
+ // é tão ruim quanto nenhuma.
90
+ titleAs: TituloTag = "h1",
91
+ layout = true,
92
+ }: {
93
+ items: CatalogProductItem[];
94
+ categories: CatalogCategory[];
95
+ bundles?: ResolvedCombo[];
96
+ initialCategory?: string;
97
+ titleAs?: "h1" | "h2";
98
+ /**
99
+ * EDITOR: se esta página MANDA na ordem do container "catalogo" (README §3, `Editable.Sections`).
100
+ * /produtos manda; /categoria/[tagSlug] reaproveita com `layout={false}`: a copy editada vale nas
101
+ * duas, mas ordem, ocultas e cópias de seção se editam só em /produtos (o manifesto da categoria
102
+ * sai com `semLayout`).
103
+ */
104
+ layout?: boolean;
105
+ }) {
106
+ const { add } = useCart();
107
+ const [cats, setCats] = React.useState<Set<string>>(new Set(initialCategory ? [initialCategory] : []));
108
+ const [prices, setPrices] = React.useState<Set<string>>(new Set());
109
+ const [deals, setDeals] = React.useState(false);
110
+ const [sort, setSort] = React.useState("destaque");
111
+ const [visible, setVisible] = React.useState(PAGE);
112
+ const [busyId, setBusyId] = React.useState<string | null>(null);
113
+ const [showMobileFilters, setShowMobileFilters] = React.useState(false);
114
+ const [view, setView] = React.useState<"grid" | "list">("grid");
115
+
116
+ const reset = React.useCallback(() => setVisible(PAGE), []);
117
+
118
+ // produtos filtrados, ignorando categoria (para contadores dinâmicos por categoria)
119
+ const baseForCounts = React.useMemo(() => {
120
+ return items.filter((p) => {
121
+ if (prices.size && !PRICE_RANGES.some((r) => prices.has(r.key) && p.price >= r.min && p.price < r.max)) return false;
122
+ if (deals && !(p.oldPrice != null && p.oldPrice > p.price)) return false;
123
+ return true;
124
+ });
125
+ }, [items, prices, deals]);
126
+
127
+ const filtered = React.useMemo(() => {
128
+ return baseForCounts.filter((p) => {
129
+ if (cats.size && !p.categories.some((c) => cats.has(c))) return false;
130
+ return true;
131
+ });
132
+ }, [baseForCounts, cats]);
133
+
134
+ const sorted = React.useMemo(() => {
135
+ const list = [...filtered];
136
+ if (sort === "menor") list.sort((a, b) => a.price - b.price);
137
+ else if (sort === "maior") list.sort((a, b) => b.price - a.price);
138
+ // "destaque" mantém a ordem do catálogo (relevância do backend)
139
+ return list;
140
+ }, [filtered, sort]);
141
+
142
+ const shown = sorted.slice(0, visible);
143
+
144
+ const hasMore = visible < sorted.length;
145
+ const allLoaded = sorted.length > PAGE && visible >= sorted.length;
146
+
147
+ // infinite scroll
148
+ const sentinel = React.useRef<HTMLDivElement | null>(null);
149
+ React.useEffect(() => {
150
+ if (!hasMore) return;
151
+ const el = sentinel.current;
152
+ if (!el) return;
153
+ const io = new IntersectionObserver((es) => {
154
+ if (es[0]?.isIntersecting) setVisible((v) => Math.min(v + STEP, sorted.length));
155
+ }, { rootMargin: "600px" });
156
+ io.observe(el);
157
+ return () => io.disconnect();
158
+ }, [hasMore, sorted.length]);
159
+
160
+ const hasFilters = cats.size > 0 || prices.size > 0 || deals;
161
+ function clearFilters() { setCats(new Set()); setPrices(new Set()); setDeals(false); reset(); }
162
+ function toggleCat(c: string) { setCats((s) => { const n = new Set(s); if (n.has(c)) n.delete(c); else n.add(c); return n; }); reset(); }
163
+ function onlyCat(c: string) { setCats(new Set([c])); reset(); }
164
+ function togglePrice(k: string) { setPrices((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; }); reset(); }
165
+
166
+ async function quickAdd(p: CatalogProductItem) {
167
+ if (!p.productId || !p.variantId || p.soldOut) return;
168
+ setBusyId(p.variantId);
169
+ await add({ productId: p.productId, variantId: p.variantId, price: p.price, quantity: 1, thumbnail: p.imageUrl ?? undefined, title: p.title });
170
+ setBusyId(null);
171
+ }
172
+
173
+ const resultTitle = cats.size === 1 ? [...cats][0] : deals ? "Ofertas" : "Todos os produtos";
174
+ // view_item_list — a cada lista exibida (filtro/ordenação/paginação mudam a lista). Dedupe por
175
+ // assinatura para não repetir o mesmo conjunto em re-render.
176
+ const listSigRef = React.useRef("");
177
+ React.useEffect(() => {
178
+ if (!shown.length) return;
179
+ const sig = resultTitle + "|" + shown.map((p) => p.productId).join(",");
180
+ if (sig === listSigRef.current) return;
181
+ listSigRef.current = sig;
182
+ trackViewItemList(shown.map((p, i) => toTrackItem(p, i)), resultTitle);
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ }, [shown.map((p) => p.productId).join(","), resultTitle]);
185
+
186
+ const catCount = (name: string) => baseForCounts.filter((p) => p.categories.includes(name)).length;
187
+ const freeShipLabel = FREE_SHIPPING_THRESHOLD != null ? `R$${FREE_SHIPPING_THRESHOLD}` : null;
188
+
189
+ const chips = [
190
+ ...[...cats].map((c) => ({ label: c, onRemove: () => toggleCat(c) })),
191
+ ...PRICE_RANGES.filter((r) => prices.has(r.key)).map((r) => ({ label: r.label, onRemove: () => togglePrice(r.key) })),
192
+ ...(deals ? [{ label: "Em promoção", onRemove: () => { setDeals(false); reset(); } }] : []),
193
+ ];
194
+
195
+ // filtro é mecânica do catálogo, não copy da marca (README §8): fora do editor e fora da conta do gate
196
+ const Filters = (
197
+ <div data-editor-ignore className="flex flex-col gap-[18px]">
198
+ <div className="flex items-center justify-between">
199
+ <span className="flex items-center gap-2 text-[15px] font-extrabold text-[var(--store-ink)]"><SlidersHorizontal weight="bold" className="text-[var(--store-primary,#18181B)]" />Filtros</span>
200
+ {hasFilters && <button type="button" onClick={clearFilters} className="text-[12.5px] font-bold text-[var(--store-sale)]">Limpar tudo</button>}
201
+ </div>
202
+
203
+ <div className="rounded-lg border border-[var(--store-line)] bg-[var(--store-surface)] px-[18px] py-4">
204
+ <div className="mb-3 text-[13.5px] font-extrabold text-[var(--store-ink)]">Categoria</div>
205
+ <div className="flex flex-col gap-2.5">
206
+ {categories.map((c) => {
207
+ const on = cats.has(c.name);
208
+ return (
209
+ <label key={c.slug} onClick={() => toggleCat(c.name)} className="flex cursor-pointer items-center gap-2.5 text-[13.5px]">
210
+ <span className="flex h-[19px] w-[19px] items-center justify-center rounded-[6px] border-[1.5px]" style={{ borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-faint)", background: on ? "var(--store-primary,#18181B)" : "var(--store-surface)" }}>
211
+ <Check weight="bold" className="text-[11px] text-white" style={{ opacity: on ? 1 : 0 }} />
212
+ </span>
213
+ <span className="flex-1" style={{ fontWeight: on ? 700 : 500, color: on ? "var(--store-primary,#18181B)" : "var(--store-ink-2)" }}>{c.name}</span>
214
+ <span className="text-xs text-[var(--store-faint)]">{catCount(c.name)}</span>
215
+ </label>
216
+ );
217
+ })}
218
+ </div>
219
+ </div>
220
+
221
+ <div className="rounded-lg border border-[var(--store-line)] bg-[var(--store-surface)] px-[18px] py-4">
222
+ <div className="mb-3 text-[13.5px] font-extrabold text-[var(--store-ink)]">Faixa de preço</div>
223
+ <div className="flex flex-col gap-2.5">
224
+ {PRICE_RANGES.map((r) => {
225
+ const on = prices.has(r.key);
226
+ return (
227
+ <label key={r.key} onClick={() => togglePrice(r.key)} className="flex cursor-pointer items-center gap-2.5 text-[13.5px]">
228
+ <span className="flex h-[19px] w-[19px] items-center justify-center rounded-[6px] border-[1.5px]" style={{ borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-faint)", background: on ? "var(--store-primary,#18181B)" : "var(--store-surface)" }}>
229
+ <Check weight="bold" className="text-[11px] text-white" style={{ opacity: on ? 1 : 0 }} />
230
+ </span>
231
+ <span style={{ fontWeight: on ? 700 : 500, color: on ? "var(--store-primary,#18181B)" : "var(--store-ink-2)" }}>{r.label}</span>
232
+ </label>
233
+ );
234
+ })}
235
+ </div>
236
+ </div>
237
+
238
+ <div className="rounded-lg border border-[var(--store-line)] bg-[var(--store-surface)] px-[18px] py-4">
239
+ <label onClick={() => { setDeals((d) => !d); reset(); }} className="flex cursor-pointer items-center justify-between gap-2.5">
240
+ <span className="flex items-center gap-2 text-[13.5px] font-bold text-[var(--store-ink-2)]"><Tag weight="fill" className="text-[var(--store-sale)]" />Apenas em promoção</span>
241
+ <span className="relative h-[23px] w-10 rounded-full transition-colors" style={{ background: deals ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}>
242
+ <span className="absolute top-[2px] h-[19px] w-[19px] rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,.2)] transition-all" style={{ left: deals ? 19 : 2 }} />
243
+ </span>
244
+ </label>
245
+ </div>
246
+ </div>
247
+ );
248
+
249
+ return (
250
+ <div className="store-layout full-bleed bg-white text-[var(--store-ink)]">
251
+ <div className="mx-auto max-w-[1240px] px-4 sm:px-6">
252
+ <Editable.Sections container="catalogo" layout={layout}>
253
+ {/* A lista é a espinha da página: `fixed` = a copy edita, a posição e a visibilidade não. O aviso
254
+ de lista vazia vive AQUI, dentro da coluna da grade, e não numa seção própria: uma Section solta
255
+ com container= próprio entraria no "catalogo" sem `ordemNoCodigo` e travaria a reordenação das
256
+ outras seções (`recusaDeOrdem`). */}
257
+ <Editable.Section id="lista" kind="vitrine-de-produtos" label="Lista de produtos" fixed>
258
+ {/* breadcrumb: "Início" é navegação; o nome desta página é copy */}
259
+ <div className="flex items-center gap-2 pt-[18px] text-[13px] font-medium text-[var(--store-muted)]">
260
+ <Link href="/" className="no-underline hover:text-[var(--store-ink)]" data-editor-ignore="">Início</Link>
261
+ <CaretRight className="text-[11px]" />
262
+ <Editable.Text path="pagina" fallback="Catálogo" label="Nome da página no caminho" className="font-semibold text-[var(--store-ink)]" />
263
+ </div>
264
+
265
+ {/* coleções em pills (estilo do modelo: thumb 48px + label): nomes de categoria vêm do catálogo */}
266
+ {categories.length > 0 && (
267
+ <div data-editor-ignore className="mt-[34px] flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
268
+ {categories.map((c) => {
269
+ const on = cats.has(c.name) && cats.size === 1 && !deals;
270
+ return (
271
+ <button key={c.slug} type="button" onClick={() => onlyCat(c.name)}
272
+ className="flex flex-none cursor-pointer items-center gap-[11px] rounded-full border-[1.5px] bg-white py-[7px] pl-2 pr-5 transition-all hover:border-[var(--store-primary,#18181B)] hover:shadow-[var(--store-shadow-soft)]"
273
+ style={{ borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-line)" }}>
274
+ <Image src={collImageSrc(c.name)} alt="" width={48} height={48} className="h-12 w-12 flex-none object-contain" />
275
+ <span className="font-display whitespace-nowrap text-[14.5px] font-semibold text-[var(--store-ink)]">{c.name}</span>
276
+ </button>
277
+ );
278
+ })}
279
+ <button type="button" onClick={() => { setDeals((d) => !d); setCats(new Set()); reset(); }}
280
+ className="flex flex-none cursor-pointer items-center gap-[11px] rounded-full border-[1.5px] bg-white py-[7px] pl-2 pr-5 transition-all hover:border-[var(--store-primary,#18181B)] hover:shadow-[var(--store-shadow-soft)]"
281
+ style={{ borderColor: deals ? "var(--store-primary,#18181B)" : "var(--store-line)" }}>
282
+ <Image src={collImageSrc("ofertas")} alt="" width={48} height={48} className="h-12 w-12 flex-none object-contain" />
283
+ <span className="font-display whitespace-nowrap text-[14.5px] font-semibold text-[var(--store-ink)]">Ofertas</span>
284
+ </button>
285
+ </div>
286
+ )}
287
+
288
+ {/* main layout */}
289
+ <div className="mt-7 grid grid-cols-1 items-start gap-8 lg:grid-cols-[264px_1fr]">
290
+ {/* sidebar desktop */}
291
+ <aside className="sticky top-6 hidden lg:block">{Filters}</aside>
292
+
293
+ {/* coluna grid */}
294
+ <div>
295
+ {/* toolbar */}
296
+ <div className="flex flex-wrap items-end justify-between gap-3">
297
+ <div>
298
+ {/* o título só é copy quando é o da lista completa; com filtro ele é o nome da categoria (catálogo) ou o rótulo do filtro */}
299
+ {cats.size === 1 ? (
300
+ <TituloTag className="font-display text-xl font-extrabold" data-editor-ignore="">{[...cats][0]}</TituloTag>
301
+ ) : deals ? (
302
+ <TituloTag className="font-display text-xl font-extrabold" data-editor-ignore="">Ofertas</TituloTag>
303
+ ) : (
304
+ <Editable.Text path="titulo" fallback="Todos os produtos" label="Título da lista completa" as={TituloTag} className="font-display text-xl font-extrabold" />
305
+ )}
306
+ {/* contador vem de dados */}
307
+ <p data-editor-ignore className="text-[13px] text-[var(--store-muted)]">{sorted.length} produtos encontrados</p>
308
+ </div>
309
+ {/* ordenação, grade/lista e o botão de filtros são interface */}
310
+ <div data-editor-ignore className="flex items-center gap-2.5">
311
+ <button type="button" onClick={() => setShowMobileFilters(true)} className="flex h-[46px] items-center gap-2 rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-4 text-sm font-bold text-[var(--store-ink-2)] lg:hidden">
312
+ <FunnelSimple weight="bold" />Filtros{hasFilters ? ` (${chips.length})` : ""}
313
+ </button>
314
+ {/* view toggle grade/lista */}
315
+ <div className="hidden h-[46px] items-center rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white p-1 sm:flex">
316
+ <button type="button" onClick={() => setView("grid")} aria-label="Grade" className="flex h-full w-9 items-center justify-center rounded-sm" style={{ background: view === "grid" ? "var(--store-primary-soft,#F1F1F3)" : "transparent", color: view === "grid" ? "var(--store-primary,#18181B)" : "var(--store-faint)" }}><SquaresFour weight="fill" /></button>
317
+ <button type="button" onClick={() => setView("list")} aria-label="Lista" className="flex h-full w-9 items-center justify-center rounded-sm" style={{ background: view === "list" ? "var(--store-primary-soft,#F1F1F3)" : "transparent", color: view === "list" ? "var(--store-primary,#18181B)" : "var(--store-faint)" }}><Rows weight="fill" /></button>
318
+ </div>
319
+ <div className="flex h-[46px] items-center gap-2 rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-3.5">
320
+ <span className="hidden text-[13px] text-[var(--store-muted)] sm:inline">Ordenar:</span>
321
+ <select value={sort} onChange={(e) => { setSort(e.target.value); reset(); }} className="cursor-pointer bg-transparent text-sm font-bold text-[var(--store-ink)] outline-none">
322
+ <option value="destaque">Em destaque</option>
323
+ <option value="menor">Menor preço</option>
324
+ <option value="maior">Maior preço</option>
325
+ </select>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ {/* chips: estado do filtro */}
331
+ {chips.length > 0 && (
332
+ <div data-editor-ignore className="mt-3.5 flex flex-wrap gap-2">
333
+ {chips.map((c, i) => (
334
+ <button key={i} type="button" onClick={c.onRemove} className="flex items-center gap-1.5 rounded-full border border-[var(--store-primary-soft)] bg-[var(--store-primary-soft,#F1F1F3)] px-3 py-1.5 text-[12.5px] font-bold text-[var(--store-primary,#18181B)]">
335
+ {c.label}<X weight="bold" className="text-[11px]" />
336
+ </button>
337
+ ))}
338
+ </div>
339
+ )}
340
+
341
+ {/* grid: o aviso de lista vazia é copy; os cards são catálogo */}
342
+ {shown.length === 0 ? (
343
+ <div className="mt-5 rounded-xl border border-dashed border-[var(--store-line-2)] bg-[var(--store-surface)] px-5 py-[70px] text-center">
344
+ {/* o preflight faz `svg { display:block }`; o invólucro inline-flex do Icon criaria uma caixa de
345
+ linha (+6px medidos), então o ícone vai num flex centralizado, que devolve os 46px de antes */}
346
+ <div className="flex justify-center">
347
+ <Editable.Icon path="vazio.icone" label="Ícone da lista vazia" size={46} className="text-[46px] text-[var(--store-faint)]"><Sparkle /></Editable.Icon>
348
+ </div>
349
+ <Editable.Text path="vazio.titulo" fallback="Nenhum produto encontrado" label="Título da lista vazia" as="div" className="font-display mt-3 text-[17px] font-bold text-[var(--store-ink-2)]" />
350
+ <Editable.Text path="vazio.texto" fallback="Tente ajustar ou limpar os filtros aplicados." label="Texto da lista vazia" as="p" className="mt-1 text-sm text-[var(--store-muted)]" />
351
+ <button data-editor-ignore type="button" onClick={clearFilters} className="font-display mt-5 cursor-pointer rounded-md bg-[var(--store-primary,#18181B)] px-5 py-2.5 text-sm font-bold text-white">Limpar filtros</button>
352
+ </div>
353
+ ) : (
354
+ <div data-editor-ignore className={view === "list" ? "mt-4 flex flex-col gap-3" : "mt-4 grid grid-cols-2 gap-[18px] sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4"}>
355
+ {shown.map((p) => (
356
+ <Card listName={resultTitle} key={p.slug} p={p} list={view === "list"} onAdd={() => quickAdd(p)} busy={busyId === p.variantId} />
357
+ ))}
358
+ </div>
359
+ )}
360
+
361
+ {/* load more: paginação */}
362
+ {hasMore && (
363
+ <div ref={sentinel} data-editor-ignore className="flex flex-col items-center gap-3 py-9">
364
+ <span className="h-[34px] w-[34px] animate-spin rounded-full border-[3px] border-[var(--store-line-2)] border-t-[var(--store-primary,#18181B)]" />
365
+ <button type="button" onClick={() => setVisible((v) => Math.min(v + STEP, sorted.length))} className="rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-[26px] py-2.5 text-sm font-bold text-[var(--store-primary,#18181B)]">Carregar mais</button>
366
+ </div>
367
+ )}
368
+ {allLoaded && <div data-editor-ignore className="py-9 text-center text-sm text-[var(--store-faint)]">Você viu todos os {sorted.length} produtos ✦</div>}
369
+ </div>
370
+ </div>
371
+ </Editable.Section>
372
+
373
+ {/* Kits e combos com desconto: combos do enrichment, versão compacta. A seção se registra sempre
374
+ (como a KitsSection da home); o bloco só aparece quando há kit resolvido. */}
375
+ <Editable.Section id="kits" kind="vitrine-de-produtos" label="Kits e combos">
376
+ {bundles.length > 0 && (
377
+ <div className="mt-12 rounded-xl border border-[var(--store-line)] bg-[var(--store-bg)] p-6 sm:p-7">
378
+ <div className="mb-5">
379
+ <Editable.Text path="titulo" fallback="Kits & Combos com desconto" label="Título dos kits" as="h2" className="font-display text-[22px] font-extrabold" />
380
+ <Editable.Text path="subtitulo" fallback="Mais itens, mais economia: já com desconto." label="Subtítulo dos kits" as="p" className="mt-1 text-sm text-[var(--store-muted)]" />
381
+ </div>
382
+ <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-4">
383
+ {bundles.map((c) => (
384
+ <ComboCardCompact key={c.id} combo={c} />
385
+ ))}
386
+ </div>
387
+ </div>
388
+ )}
389
+ </Editable.Section>
390
+
391
+ {/* faixa de confiança (escura): lista editável de selos. O selo de frete grátis só existe com regra
392
+ real configurada, e o id de cada selo é o PAPEL dele, não a posição: o selo condicional não
393
+ desliza os outros (README §6, ids de item). A copy de fallback é só o que a foundation PODE
394
+ afirmar: SSL, o prazo do CDC e a cobertura de envio. Prazo de entrega e garantia são promessas
395
+ do lojista: ele as escreve aqui, no editor, com lastro. */}
396
+ <Editable.Section id="confianca" kind="beneficios" label="Faixa de confiança">
397
+ <div className={`mt-10 grid grid-cols-1 rounded-xl bg-[var(--store-chrome-bg)] sm:grid-cols-2 ${freeShipLabel ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
398
+ <Editable.Sections nested>
399
+ {freeShipLabel ? (
400
+ <Editable.Section item id="frete" label="Frete grátis">
401
+ <div className={SELO}>
402
+ <Editable.Icon path="icone" label="Ícone do frete grátis" size={28} className={SELO_ICONE}><Truck weight="fill" /></Editable.Icon>
403
+ {/* o valor vem da configuração da loja (FREE_SHIPPING_THRESHOLD): a frase é da marca, o número não */}
404
+ <Editable.Slot path="texto" type="text" fallback="Frete grátis acima de" label="Frase do frete grátis, sem o valor">
405
+ {(v, attrs, ref, estilo) => (
406
+ <div ref={ref} {...attrs} className={SELO_TEXTO} style={estilo}>{v} {freeShipLabel}</div>
407
+ )}
408
+ </Editable.Slot>
409
+ </div>
410
+ </Editable.Section>
411
+ ) : null}
412
+ <Editable.Section item id="entrega" label="Entrega">
413
+ <div className={SELO}>
414
+ <Editable.Icon path="icone" label="Ícone da entrega" size={28} className={SELO_ICONE}><Lightning weight="fill" /></Editable.Icon>
415
+ <Editable.Text path="texto" fallback="Entrega para todo o Brasil" label="Frase da entrega" as="div" className={SELO_TEXTO} />
416
+ </div>
417
+ </Editable.Section>
418
+ <Editable.Section item id="seguranca" label="Compra segura">
419
+ <div className={SELO}>
420
+ <Editable.Icon path="icone" label="Ícone da compra segura" size={28} className={SELO_ICONE}><LockSimple weight="fill" /></Editable.Icon>
421
+ <Editable.Text path="texto" fallback="Compra segura (SSL)" label="Frase da compra segura" as="div" className={SELO_TEXTO} />
422
+ </div>
423
+ </Editable.Section>
424
+ <Editable.Section item id="garantia" label="Garantia">
425
+ <div className={SELO}>
426
+ <Editable.Icon path="icone" label="Ícone da garantia" size={28} className={SELO_ICONE}><SealCheck weight="fill" /></Editable.Icon>
427
+ <Editable.Text path="texto" fallback="Troca em 7 dias · CDC" label="Frase da garantia" as="div" className={SELO_TEXTO} />
428
+ </div>
429
+ </Editable.Section>
430
+ </Editable.Sections>
431
+ </div>
432
+ </Editable.Section>
433
+
434
+ {/* Sem seção de depoimentos no catálogo: a foundation trazia depoimentos fabricados com selo de
435
+ verificação e uma nota fixa, e isso foi ao ar. Depoimento real entra pela seção reviews da home. */}
436
+
437
+ {/* newsletter: a chamada é copy (em três partes, para manter o trecho sublinhado); campo e
438
+ placeholder são interface do formulário e ficam fora (README §8) */}
439
+ {/* Captura de e-mail: só com destino real (NEXT_PUBLIC_NEWSLETTER_ACTION). Formulário que
440
+ engole o e-mail em silêncio é pior que não ter formulário. */}
441
+ {NEWSLETTER_ACTION && (
442
+ <Editable.Section id="newsletter" kind="newsletter" label="Newsletter">
443
+ <div className="mt-12 flex min-h-[120px] flex-wrap items-center gap-7 overflow-hidden rounded-xl bg-[var(--store-cta,#D97706)] px-8 py-7">
444
+ <div className="font-display flex-1 text-[20px] font-extrabold leading-tight text-[var(--store-cta-fg,#1C1207)] sm:text-[23px]">
445
+ <Editable.Text path="chamada.1" fallback="Cadastre-se e receba" label="Chamada (início)" />{" "}
446
+ <Editable.Text path="chamada.destaque" fallback="ofertas exclusivas" label="Chamada (trecho sublinhado)" className="underline decoration-2 underline-offset-2" />{" "}
447
+ <Editable.Text path="chamada.2" fallback="e novidades!" label="Chamada (fim)" />
448
+ </div>
449
+ <form method="post" action={NEWSLETTER_ACTION ?? undefined} className="flex w-full max-w-[480px] items-center gap-1.5 rounded-full bg-white p-1.5">
450
+ <input type="email" name="email" required placeholder="Seu melhor e-mail" className="h-11 min-w-0 flex-1 rounded-full bg-transparent px-4 text-sm outline-none" />
451
+ {/* Slot, não Text: o <button> é filho direto de um flex e precisa manter type="submit" */}
452
+ <Editable.Slot path="botao" type="text" fallback="EU QUERO!" label="Texto do botão">
453
+ {(v, attrs, ref, estilo) => (
454
+ <button ref={ref} {...attrs} type="submit" className="font-display h-11 shrink-0 rounded-full bg-[var(--store-primary,#18181B)] px-5 text-[15px] font-extrabold tracking-[0.5px] text-white" style={estilo}>{v}</button>
455
+ )}
456
+ </Editable.Slot>
457
+ </form>
458
+ </div>
459
+ </Editable.Section>
460
+ )}
461
+ </Editable.Sections>
462
+ <div className="h-12" />
463
+ </div>
464
+
465
+ {/* mobile filters drawer: mecânica do filtro, fora do editor */}
466
+ {showMobileFilters && (
467
+ <div data-editor-ignore className="fixed inset-0 z-[70] lg:hidden">
468
+ <div className="absolute inset-0 bg-black/40" onClick={() => setShowMobileFilters(false)} />
469
+ <div className="absolute inset-y-0 left-0 w-[88vw] max-w-[340px] overflow-y-auto bg-white p-5">
470
+ <div className="mb-4 flex items-center justify-between">
471
+ <span className="font-display text-lg font-extrabold">Filtros</span>
472
+ <button type="button" onClick={() => setShowMobileFilters(false)} className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--store-line)]"><X weight="bold" /></button>
473
+ </div>
474
+ {Filters}
475
+ <button type="button" onClick={() => setShowMobileFilters(false)} className="font-display mt-5 w-full rounded-xl bg-[var(--store-primary,#18181B)] py-3.5 text-sm font-bold text-white">Ver {sorted.length} produtos</button>
476
+ </div>
477
+ </div>
478
+ )}
479
+ </div>
480
+ );
481
+ }
482
+
483
+ function Card({ p, list, onAdd, busy, listName }: {
484
+ p: CatalogProductItem; list?: boolean; onAdd: () => void; busy: boolean; listName?: string;
485
+ }) {
486
+ const canAdd = !!p.variantId && p.price > 0 && !p.soldOut;
487
+ const addBtn = canAdd && (
488
+ <button type="button" onClick={onAdd} disabled={busy} aria-label="Adicionar ao carrinho" className="flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-full bg-[var(--store-primary,#18181B)] text-white shadow-[var(--store-shadow-cta-sm)] transition-colors hover:bg-[var(--store-primary-dark,#09090B)] disabled:opacity-60">
489
+ {busy ? <Spinner className="animate-spin text-lg" /> : <Plus weight="bold" className="text-lg" />}
490
+ </button>
491
+ );
492
+ const priceBlock = (
493
+ <div>
494
+ {p.displayOld && (
495
+ <div className="flex items-center gap-1.5">
496
+ <span className="text-xs text-[var(--store-faint)] line-through">{p.displayOld}</span>
497
+ {p.offPct != null && <span className="rounded bg-[var(--store-sale-soft)] px-1.5 py-0.5 text-[10px] font-extrabold text-[var(--store-sale)]">-{p.offPct}%</span>}
498
+ </div>
499
+ )}
500
+ <div className="font-display text-[19px] font-extrabold leading-[1.1] text-[var(--store-primary,#18181B)]">{p.displayPrice}</div>
501
+ </div>
502
+ );
503
+
504
+ // ----- modo LISTA (horizontal) -----
505
+ if (list) {
506
+ return (
507
+ <div className="group flex items-center gap-4 overflow-hidden rounded-2xl border border-[var(--store-line)] bg-white p-3 transition-shadow duration-200 hover:shadow-[var(--store-shadow-hover)]">
508
+ <Link href={`/produto/${encodeURIComponent(p.slug)}`} className="relative h-[96px] w-[96px] shrink-0 overflow-hidden rounded-xl bg-[var(--store-surface)]">
509
+ {p.imageUrl ? <Image src={p.imageUrl} alt={p.title} fill sizes="96px" className="object-contain p-2" /> : <span className="flex h-full items-center justify-center text-xs text-[var(--store-faint)]">sem foto</span>}
510
+ </Link>
511
+ <div className="min-w-0 flex-1">
512
+ {p.badge && <span className="mb-1 inline-block rounded-md px-2 py-0.5 text-[10px] font-extrabold tracking-[0.3px]" style={{ background: p.badge.bg, color: p.badge.fg }}>{p.badge.label}</span>}
513
+ <Link href={`/produto/${encodeURIComponent(p.slug)}`} className="block text-sm font-bold leading-[1.25] text-[var(--store-ink)] no-underline line-clamp-2">{p.title}</Link>
514
+ {p.weight && <div className="mt-0.5 text-xs text-[var(--store-muted)]">{p.weight}</div>}
515
+ </div>
516
+ <div className="flex flex-col items-end gap-2">{priceBlock}{addBtn}</div>
517
+ </div>
518
+ );
519
+ }
520
+
521
+ // ----- modo GRADE (vertical): card compartilhado (catálogo + home) -----
522
+ return <ProductGridCard p={p} onAdd={onAdd} busy={busy} onSelect={() => trackSelectItem(toTrackItem(p), listName ?? "Catálogo")} />;
523
+ }
@@ -0,0 +1,19 @@
1
+ // Skeleton da grade de catálogo — usado pelos loading.tsx de /produtos e /categoria/[tagSlug]
2
+ // para dar feedback instantâneo enquanto o Server Component carrega.
3
+ export function CatalogGridSkeleton({ count = 8 }: { count?: number }) {
4
+ return (
5
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
6
+ {Array.from({ length: count }).map((_, i) => (
7
+ <div key={i} className="flex flex-col overflow-hidden rounded-xl border bg-card">
8
+ <div className="aspect-square animate-pulse bg-muted" />
9
+ <div className="flex flex-1 flex-col gap-2 p-3">
10
+ <div className="h-4 w-full animate-pulse rounded bg-muted" />
11
+ <div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
12
+ <div className="mt-2 h-6 w-1/2 animate-pulse rounded bg-muted" />
13
+ <div className="mt-1 h-9 w-full animate-pulse rounded-md bg-muted" />
14
+ </div>
15
+ </div>
16
+ ))}
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,66 @@
1
+ import Link from "next/link";
2
+ import { CaretLeft, CaretRight } from "@phosphor-icons/react/dist/ssr";
3
+
4
+ // Paginador por offset (server component). hrefForPage é chamado no servidor (não serializado).
5
+ export function Pager({
6
+ page,
7
+ totalPages,
8
+ hrefForPage,
9
+ }: {
10
+ page: number;
11
+ totalPages: number;
12
+ hrefForPage: (page: number) => string;
13
+ }) {
14
+ if (totalPages <= 1) return null;
15
+ const pages = pageWindow(page, totalPages);
16
+
17
+ const arrow = "flex h-10 items-center gap-1.5 rounded-xl border-[1.5px] border-[var(--store-line-2)] bg-white px-3.5 text-[13.5px] font-bold text-[var(--store-ink-2)] no-underline transition-colors hover:border-[var(--store-primary,#18181B)] hover:text-[var(--store-primary,#18181B)]";
18
+ const disabled = "flex h-10 items-center gap-1.5 rounded-xl border-[1.5px] border-[var(--store-surface-2)] bg-[var(--store-surface)] px-3.5 text-[13.5px] font-bold text-[var(--store-faint)]";
19
+
20
+ return (
21
+ <nav className="mt-9 flex items-center justify-center gap-2" aria-label="Paginação">
22
+ {page > 1 ? (
23
+ <Link href={hrefForPage(page - 1)} className={arrow}><CaretLeft weight="bold" /> Anterior</Link>
24
+ ) : (
25
+ <span className={disabled} aria-disabled><CaretLeft weight="bold" /> Anterior</span>
26
+ )}
27
+
28
+ {pages.map((p, i) =>
29
+ p === "…" ? (
30
+ <span key={`gap-${i}`} className="px-1 text-[var(--store-faint)]">…</span>
31
+ ) : (
32
+ <Link
33
+ key={p}
34
+ href={hrefForPage(p as number)}
35
+ aria-current={p === page ? "page" : undefined}
36
+ className="flex h-10 w-10 items-center justify-center rounded-xl border-[1.5px] text-[14px] font-bold no-underline transition-colors"
37
+ style={
38
+ p === page
39
+ ? { background: "var(--store-primary,#18181B)", borderColor: "var(--store-primary,#18181B)", color: "var(--store-surface)" }
40
+ : { background: "var(--store-surface)", borderColor: "var(--store-line-2)", color: "var(--store-ink-2)" }
41
+ }
42
+ >
43
+ {p}
44
+ </Link>
45
+ ),
46
+ )}
47
+
48
+ {page < totalPages ? (
49
+ <Link href={hrefForPage(page + 1)} className={arrow}>Próxima <CaretRight weight="bold" /></Link>
50
+ ) : (
51
+ <span className={disabled} aria-disabled>Próxima <CaretRight weight="bold" /></span>
52
+ )}
53
+ </nav>
54
+ );
55
+ }
56
+
57
+ function pageWindow(page: number, total: number): (number | "…")[] {
58
+ const out: (number | "…")[] = [];
59
+ const add = (n: number) => out.push(n);
60
+ const range = 1;
61
+ for (let p = 1; p <= total; p++) {
62
+ if (p === 1 || p === total || (p >= page - range && p <= page + range)) add(p);
63
+ else if (out[out.length - 1] !== "…") out.push("…");
64
+ }
65
+ return out;
66
+ }