@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,1762 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // DOCUMENTO DE CONTEÚDO — o que o editor (chat e CMS visual) edita.
3
+ //
4
+ // Funções puras, sem React e sem Node: o mesmo arquivo roda na loja, no editor
5
+ // e no gate. O documento guarda SÓ o que o lojista mudou; o conteúdo original
6
+ // continua no código (é o `fallback` de cada primitivo). Sem documento, a loja
7
+ // renderiza exatamente o que o construtor escreveu — é o que garante que ligar
8
+ // o editor numa loja existente não muda um pixel.
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ export type EditableType = "text" | "image" | "link" | "color" | "vitrine" | "video" | "html";
12
+
13
+ /**
14
+ * Vale para IMAGEM e para VÍDEO: os dois guardam `{ src, alt? }`, e por isso o upload, o `isSafeUrl` e o
15
+ * inspector do painel são os mesmos. O que separa os dois é só o TIPO declarado no manifesto — é ele que
16
+ * diz ao painel se a prévia é um `<img>` ou um `<video>`, e é ele que sabe o limite de arquivo (8 MB de
17
+ * imagem contra 32 MB de vídeo). Consequência: `typeOfValue` não distingue os dois, quem valida precisa
18
+ * olhar o tipo da entrada (ver a exceção em `validateOp`).
19
+ */
20
+ export interface ImageValue {
21
+ src: string;
22
+ alt?: string;
23
+ }
24
+ export interface LinkValue {
25
+ href: string;
26
+ label?: string;
27
+ }
28
+ /**
29
+ * VITRINE: quais produtos uma seção mostra. O documento guarda só a ESCOLHA (categoria da Unbox, lista de
30
+ * produtos ou busca); quem resolve isso em produtos de verdade é a LOJA, no servidor, com o cliente da Unbox
31
+ * que ela já usa. Assim o editor nunca vê credencial nem catálogo, e preço/estoque continuam vindo do painel.
32
+ */
33
+ export type VitrineValue =
34
+ | { modo: "categoria"; tagId: string; nome?: string; limite?: number }
35
+ | { modo: "produtos"; produtos: string[]; limite?: number }
36
+ | { modo: "busca"; texto: string; limite?: number };
37
+
38
+ export type EditableValue = string | ImageValue | LinkValue | StyleValue | VitrineValue;
39
+
40
+ /** Limite de produtos que uma vitrine pode pedir (a loja pode mostrar menos). */
41
+ export const VITRINE_MAX = 24;
42
+
43
+ /** A escolha da vitrine é bem formada? (usado na validação e ao resolver o valor) */
44
+ export function vitrineValida(v: unknown): v is VitrineValue {
45
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
46
+ const o = v as Record<string, unknown>;
47
+ const limiteOk = o.limite === undefined || (typeof o.limite === "number" && Number.isInteger(o.limite) && o.limite >= 1 && o.limite <= VITRINE_MAX);
48
+ if (!limiteOk) return false;
49
+ // ids da Unbox podem ser OPACOS (base64: +, /, =). Recusá-los deixaria o seletor de categorias vazio em
50
+ // toda loja. Continuam sendo só ids — vão em `values`, nunca em caminho.
51
+ const id = (x: unknown) => typeof x === "string" && x.length > 0 && x.length <= 200 && /^[\w:.@+/=-]+$/.test(x);
52
+ if (o.modo === "categoria") return id(o.tagId) && (o.nome === undefined || (typeof o.nome === "string" && o.nome.length <= 120)) && Object.keys(o).every((k) => ["modo", "tagId", "nome", "limite"].includes(k));
53
+ if (o.modo === "produtos") return Array.isArray(o.produtos) && o.produtos.length > 0 && o.produtos.length <= VITRINE_MAX && o.produtos.every(id) && Object.keys(o).every((k) => ["modo", "produtos", "limite"].includes(k));
54
+ if (o.modo === "busca") return typeof o.texto === "string" && o.texto.trim().length > 0 && o.texto.length <= 120 && Object.keys(o).every((k) => ["modo", "texto", "limite"].includes(k));
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Um produto JÁ RESOLVIDO por uma escolha de vitrine — o mesmo contrato, campo por campo, que a
60
+ * loja devolve ao seletor do editor (`/api/unbox/catalogo`) e à prévia (`/api/unbox/vitrine`).
61
+ *
62
+ * `imagem` e `preco` podem ser `null`: é ausência HONESTA (o painel não tem foto, o produto não
63
+ * tem variante com preço). Quem renderiza mostra "—" em vez de inventar zero ou foto genérica.
64
+ */
65
+ export interface VitrineProdutoResolvido {
66
+ /** `productId` da Unbox — é o que o carrinho e o `getProductById` usam. */
67
+ id: string;
68
+ titulo: string;
69
+ slug: string;
70
+ imagem: string | null;
71
+ preco: number | null;
72
+ esgotado: boolean;
73
+ }
74
+
75
+ /**
76
+ * O estado da resolução da vitrine NA PRÉVIA.
77
+ *
78
+ * Em produção quem resolve a escolha em produtos é o SERVIDOR da loja, a partir do documento
79
+ * PUBLICADO, e este objeto não existe (o primitivo passa `undefined` ao render-prop). Na prévia o
80
+ * rascunho vive no CLIENTE — chega por `postMessage` e nunca passou pelo servidor —, então quem
81
+ * pergunta à loja "o que esta escolha vira" é o próprio primitivo, e o resultado chega aqui.
82
+ *
83
+ * `produtos: null` com `erro: null` e `carregando: false` significa "não há nada resolvido daqui":
84
+ * o lojista ainda não escolheu, e a seção continua mostrando o que o CÓDIGO mostra.
85
+ *
86
+ * ⚠️ `erro` NUNCA vira lista vazia. Vitrine vazia lê como "essa escolha não tem produto", e
87
+ * confundir isso com "não consegui ler o catálogo" é o jeito de o lojista publicar às cegas.
88
+ */
89
+ export interface PreviaDaVitrine {
90
+ /** produtos resolvidos AGORA, a partir do rascunho; `null` = nada resolvido pela prévia */
91
+ produtos: VitrineProdutoResolvido[] | null;
92
+ carregando: boolean;
93
+ /** motivo em língua de lojista quando a loja não respondeu; `null` quando não houve falha */
94
+ erro: string | null;
95
+ }
96
+
97
+ /**
98
+ * Lê a resposta da loja (`{ produtos: [...] }`) no contrato acima.
99
+ *
100
+ * Devolve `null` quando a resposta não tem a forma esperada — e `null` é ERRO, não lista vazia:
101
+ * quem chama mostra "a loja respondeu algo que não entendi" em vez de uma grade sem produto.
102
+ * Um ITEM sem id ou sem slug é descartado (não dá para linkar nem para identificar), mas isso não
103
+ * invalida a resposta inteira.
104
+ */
105
+ export function produtosDaVitrine(bruto: unknown): VitrineProdutoResolvido[] | null {
106
+ if (!bruto || typeof bruto !== "object") return null;
107
+ const lista = (bruto as { produtos?: unknown }).produtos;
108
+ if (!Array.isArray(lista)) return null;
109
+ const out: VitrineProdutoResolvido[] = [];
110
+ for (const item of lista) {
111
+ if (!item || typeof item !== "object") continue;
112
+ const o = item as Record<string, unknown>;
113
+ const id = typeof o.id === "string" ? o.id : "";
114
+ const slug = typeof o.slug === "string" ? o.slug : "";
115
+ if (!id || !slug) continue;
116
+ out.push({
117
+ id,
118
+ slug,
119
+ titulo: typeof o.titulo === "string" ? o.titulo : "",
120
+ imagem: typeof o.imagem === "string" && o.imagem ? o.imagem : null,
121
+ preco: typeof o.preco === "number" && Number.isFinite(o.preco) ? o.preco : null,
122
+ esgotado: o.esgotado === true,
123
+ });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * O ÚLTIMO segmento do caminho de TODA vitrine e de todo vínculo de produto
130
+ * (`<container>.<id da seção>.vitrine`).
131
+ *
132
+ * Mora aqui, e não no lado servidor da loja, porque os DOIS lados precisam da mesma string: o
133
+ * cliente a monta a partir do escopo da seção (`joinPath(ctx.scope, CAMPO_VITRINE)`) e o servidor
134
+ * DESCOBRE quais vitrines a página tem varrendo o documento atrás deste sufixo. Enquanto ela vivia
135
+ * só em `lib/vitrine.ts` (que é `server-only`, e portanto invisível para um componente de cliente),
136
+ * cada seção repetia o literal com um comentário pedindo "se mexer aqui, mexa lá" — e mexer nos dois
137
+ * é exatamente o que ninguém lembra de fazer. Trocá-la em UM lugar só descolaria loja e painel EM
138
+ * SILÊNCIO: a loja leria uma caixa e o editor escreveria em outra, sem erro nenhum na tela.
139
+ */
140
+ export const CAMPO_VITRINE = "vitrine";
141
+
142
+ /**
143
+ * VÍNCULO DE UM PRODUTO — a seção presa a UM SKU (o bloco de um variante, um card da grade, o combo).
144
+ *
145
+ * NÃO é um tipo novo: é a MESMA vitrine, no modo `produtos`, com um item e `limite: 1`. O seletor do
146
+ * painel é o mesmo, a rota que resolve é a mesma (`POST /api/unbox/vitrine`) e a seção lê o PRIMEIRO
147
+ * produto que voltar. Um tipo próprio significaria outro seletor, outra rota e outro caminho de
148
+ * falha para manter — tudo para guardar exatamente a mesma informação.
149
+ *
150
+ * `idOuSlug` é o que o CÓDIGO já usa para achar o produto (nas receitas, o slug da receita). É ele
151
+ * que a seção mostra enquanto o lojista não escolher nada, e é para ele que o painel volta quando o
152
+ * lojista clica em "voltar ao original".
153
+ *
154
+ * ⚠️ Só chame com um id/slug de verdade. `vitrineValida` recusa lista vazia, então uma seção que o
155
+ * código NÃO amarrou a produto nenhum não tem vínculo para oferecer — e não deve renderizar o
156
+ * primitivo com um id inventado só para ter um.
157
+ */
158
+ export function vinculoDeProduto(idOuSlug: string): VitrineValue {
159
+ return { modo: "produtos", produtos: [idOuSlug], limite: 1 };
160
+ }
161
+
162
+ /**
163
+ * O produto de um vínculo de UM produto — a regra de precedência, num lugar só, para toda seção
164
+ * presa a um SKU não inventar a sua.
165
+ *
166
+ * `previa` (só existe em modo edição) é o RASCUNHO de agora e ganha do `doServidor`, que é o que a
167
+ * página resolveu a partir do documento PUBLICADO quando foi renderizada. Em produção `previa` é
168
+ * `undefined` e sobra o servidor — que é como sempre foi.
169
+ *
170
+ * Devolve `null` em três situações, e todas as três significam a MESMA coisa para quem renderiza —
171
+ * "mostre o produto do CÓDIGO":
172
+ * · o lojista ainda não escolheu nada (nem prévia nem servidor têm o que dizer);
173
+ * · a escolha dele não achou produto nenhum hoje (produto despublicado, categoria esvaziada);
174
+ * · a loja não respondeu à prévia (o primitivo já põe a tarja vermelha em cima da seção).
175
+ *
176
+ * Cair no produto do código é a única saída aceitável na página do cliente: a seção é copy da marca
177
+ * com um produto dentro, e fazê-la SUMIR levaria junto o texto que o lojista escreveu. Quem precisa
178
+ * saber que a escolha não resolveu é o LOJISTA, e ele é avisado onde dá para consertar: a tarja da
179
+ * prévia, que não existe em produção.
180
+ */
181
+ export function produtoDoVinculo(
182
+ previa: PreviaDaVitrine | undefined,
183
+ doServidor: VitrineProdutoResolvido[] | null | undefined,
184
+ ): VitrineProdutoResolvido | null {
185
+ // lista vazia é resposta LEGÍTIMA ("essa escolha não mostra nada hoje") e vira `null` aqui de
186
+ // propósito; `produtos: null` é erro ou "nada escolhido", e aí ainda vale o que o servidor trouxe
187
+ if (previa?.produtos) return previa.produtos[0] ?? null;
188
+ return doServidor?.[0] ?? null;
189
+ }
190
+
191
+ export const ESTILO = ".estilo";
192
+ export function isStylePath(path: string) {
193
+ return path.endsWith(ESTILO);
194
+ }
195
+
196
+ // ── BLOCO DE HTML: o lojista cola HTML pronto (um selo, uma tabela, o embed que o fornecedor mandou)
197
+
198
+ /**
199
+ * Sufixo RESERVADO do caminho de um bloco de HTML (`home.bloco-1.conteudo.html`) — o mesmo truque do
200
+ * `.estilo`. É ele que permite ao SERVIDOR DA LOJA saber que um valor é HTML: a loja lê o documento
201
+ * publicado sem manifesto nenhum, então sem uma marca NO CAMINHO ela não teria como distinguir um
202
+ * bloco de HTML de um texto qualquer, e publicaria HTML cru sem passar pela lista de recusa.
203
+ * Quem carimba o sufixo é o primitivo (`Editable.Html`): o construtor escreve `path="conteudo"`.
204
+ */
205
+ export const SUFIXO_HTML = ".html";
206
+ export function isHtmlPath(path: string) {
207
+ return path.endsWith(SUFIXO_HTML);
208
+ }
209
+
210
+ /** Teto de UM bloco de HTML (decisão do dono). Acima disso o bloco inteiro é recusado. */
211
+ export const HTML_MAX = 20000;
212
+
213
+ /** `&#106;`, `&#x6a;`, `&colon;` — o navegador decodifica isto no VALOR de um atributo. */
214
+ function umCodigo(n: number): string {
215
+ return Number.isInteger(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
216
+ }
217
+ const NOMEADAS: Record<string, string> = { colon: ":", tab: "\t", newline: "\n", sol: "/", lpar: "(", rpar: ")" };
218
+ function decodificaEntidades(s: string): string {
219
+ return s
220
+ .replace(/&#x([0-9a-f]{1,6});?/gi, (_m, h: string) => umCodigo(parseInt(h, 16)))
221
+ .replace(/&#(\d{1,7});?/g, (_m, d: string) => umCodigo(Number(d)))
222
+ .replace(/&(colon|tab|newline|sol|lpar|rpar);?/gi, (_m, nome: string) => NOMEADAS[nome.toLowerCase()] ?? "");
223
+ }
224
+
225
+ /**
226
+ * Tags recusadas: a regex que ACHA, o nome que aparece na tela e o porquê que o lojista lê.
227
+ *
228
+ * A busca é no HTML CRU de propósito: uma tag de verdade precisa de um `<` literal, e `&lt;script&gt;`
229
+ * é justamente como se MOSTRA a palavra sem executá-la — decodificar antes desta busca recusaria um
230
+ * bloco legítimo que só quer exibir um trecho de código.
231
+ *
232
+ * O PORQUÊ é escrito para todas as formas da tag, não só para a pior. As duas primeiras linhas de um
233
+ * snippet de fornecedor costumam ser `<meta charset="utf-8">` e `<link rel="preconnect" …>`: dizer a
234
+ * quem colou isso que a tag "redireciona a loja inteira" ou que é "folha de estilo de fora" é uma
235
+ * acusação que não corresponde ao que ele tem na mão — ele procura o redirecionamento, não acha, e
236
+ * conclui que o editor está quebrado. A RECUSA continua a mesma (as duas ficam fora do bloco, em
237
+ * qualquer grafia); o que muda é a frase ser verdadeira nos dois casos e o trecho achado ir junto,
238
+ * porque é ele que diz QUAL linha tirar.
239
+ */
240
+ const TAGS_RECUSADAS: ReadonlyArray<readonly [RegExp, string, string]> = [
241
+ // as quatro DECLARADAS pelo dono
242
+ [/<\s*\/?\s*script\b/i, "<script>", "código que roda dentro da sua loja"],
243
+ [/<\s*\/?\s*iframe\b/i, "<iframe>", "página de outro site embutida na sua"],
244
+ [/<\s*\/?\s*form\b/i, "<form>", "formulário que manda dados para fora da loja"],
245
+ [/<\s*\/?\s*style\b/i, "<style>", "CSS que vazaria para o resto da loja"],
246
+ // as quatro seguintes são a MESMA decisão dita por extenso, não uma regra nova: fazem exatamente o
247
+ // que as declaradas fazem, por outra tag. Sem elas a recusa do <style> e do <iframe> é contornável
248
+ // com um copiar-colar, e a promessa "sem <style>, não existe seletor global para vazar" fica falsa.
249
+ [/<\s*\/?\s*(?:object|embed)\b/i, "<object>/<embed>", "carregam página de fora, igual ao <iframe>"],
250
+ [/<\s*\/?\s*link\b/i, "<link>", 'essa tag é de configuração da página, não conteúdo do bloco, e em algumas formas ela mexe na loja inteira (com rel="stylesheet" ela traz CSS de fora, o mesmo vazamento do <style>)'],
251
+ [/<\s*\/?\s*base\b/i, "<base>", "muda o destino de TODOS os links da página"],
252
+ [/<\s*\/?\s*meta\b/i, "<meta>", 'essa tag é de configuração da página, não conteúdo do bloco, e em algumas formas ela mexe na loja inteira (com http-equiv="refresh" ela troca a loja de endereço)'],
253
+ ];
254
+
255
+ /**
256
+ * Quanto do trecho achado cabe na frase da recusa. O bloco tem até 20.000 caracteres e a mensagem vai
257
+ * para a tela: mostrar a tag encontrada é o que responde "qual linha eu tiro?", mostrar o bloco todo
258
+ * seria despejar o que a pessoa colou de volta na cara dela.
259
+ */
260
+ const TRECHO_MAX = 90;
261
+
262
+ /** A tag achada, do `<` até o `>` dela, numa linha só — o que o lojista precisa procurar e apagar. */
263
+ function trechoDaTag(html: string, inicio: number): string {
264
+ const fecha = html.indexOf(">", inicio);
265
+ const inteira = fecha >= 0 && fecha - inicio < TRECHO_MAX;
266
+ const pedaco = html.slice(inicio, inteira ? fecha + 1 : inicio + TRECHO_MAX).replace(/\s+/g, " ").trim();
267
+ return inteira ? pedaco : `${pedaco}…`;
268
+ }
269
+
270
+ /**
271
+ * Atributo de evento (`onclick=`, `onerror=`). O nome do atributo NÃO é decodificado pelo navegador
272
+ * (`&#111;nclick` não vira `onclick`), então a busca é no cru. O que precede é espaço, `/` ou a aspa
273
+ * que fecha o atributo anterior — `<img src="x"onerror=alert(1)>` não tem espaço nenhum e funciona.
274
+ */
275
+ const ATRIBUTO_ON = /["'\/\s]on[a-z][a-z0-9]*\s*=/i;
276
+
277
+ /**
278
+ * O MESMO atributo de evento escrito de outro jeito: `<svg><set attributeName="onload" to="…">` monta
279
+ * um `onload` sem nunca escrever `on…=`. Medido: passava pela lista acima. A regra continua sendo a
280
+ * declarada (nada de atributo de evento), só que dita para esta grafia.
281
+ */
282
+ const ATRIBUTO_ON_POR_SVG = /attributename\s*=\s*["']?\s*on/i;
283
+
284
+ /**
285
+ * O que impede este HTML de ser usado — a frase que o LOJISTA lê —, ou `null` quando o bloco passa.
286
+ *
287
+ * Decisão do dono: recusa o BLOCO INTEIRO, não limpa pedaço. Um bloco que volta "quase igual" é pior
288
+ * que um recusado com o motivo na tela: o lojista publica sem perceber o que sumiu. E limpar tem um
289
+ * modo de falha próprio — tirar o `<script>` do meio de `<scr<script>ipt>` MONTA um `<script>` novo.
290
+ *
291
+ * Pura e sem dependência porque roda em TRÊS lugares, cada um com um trabalho: no editor ao gravar
292
+ * (a única camada que RELATA ao lojista), na loja ao ler o publicado (é o que faz uma regra nova
293
+ * valer para quem já publicou, e cobre "voltar para uma versão antiga") e no primitivo, no cliente —
294
+ * a única camada que existe na PRÉVIA, onde o rascunho chega por postMessage sem ver servidor.
295
+ */
296
+ export function recusaDeHtml(html: string): string | null {
297
+ if (typeof html !== "string") return "bloco de HTML precisa ser texto";
298
+ if (html.length > HTML_MAX) return `bloco com ${html.length} caracteres: o teto é ${HTML_MAX}`;
299
+ for (const [re, tag, porque] of TAGS_RECUSADAS) {
300
+ const achado = re.exec(html);
301
+ if (!achado) continue;
302
+ // o trecho só entra quando diz algo que o nome da tag já não disse: para `<script>alert(1)</script>`
303
+ // ele é `<script>` e repetir seria ruído; para `<meta charset="utf-8">` ele É a resposta
304
+ const trecho = trechoDaTag(html, achado.index);
305
+ const semEspaco = (s: string) => s.replace(/\s+/g, "").toLowerCase();
306
+ const onde = semEspaco(trecho) === semEspaco(tag) ? "" : `. O trecho é «${trecho}»`;
307
+ return `não dá para usar ${tag}: ${porque}${onde}`;
308
+ }
309
+ if (ATRIBUTO_ON.test(html) || ATRIBUTO_ON_POR_SVG.test(html)) return "não dá para usar atributo on… (onclick, onerror): é código que roda no clique ou ao carregar";
310
+ // esquema `javascript:`: aqui SIM decodificando, e sem espaço nenhum. O navegador ignora TAB/LF/CR
311
+ // dentro de uma URL e decodifica entidades no valor do atributo, então `java&#115;cri\tpt:` navega
312
+ // igual a `javascript:` — procurar só o literal deixaria a recusa contornável no copiar-colar.
313
+ const compacto = decodificaEntidades(html).replace(/[\s\u0000-\u0020\u00a0\u1680\u2000-\u200f\u2028\u2029\u202f\u205f\u3000\ufeff]/g, "").toLowerCase();
314
+ if (compacto.includes("javascript:")) return "link javascript: não entra, porque é código que executa ao clicar";
315
+ // `vbscript:` e `data:text/html` são o MESMO `javascript:` com outro nome: um link que abre código
316
+ // em vez de conteúdo. Medido: `data:text/html;base64,…` passava. `data:image/…` continua passando —
317
+ // imagem embutida é uso legítimo e não executa nada.
318
+ if (compacto.includes("vbscript:")) return "link vbscript: não entra, porque é código que executa ao clicar";
319
+ if (compacto.includes("data:text/html")) return "link data:text/html não entra, porque abre uma página inteira no lugar do conteúdo";
320
+ return null;
321
+ }
322
+
323
+ export interface SectionState {
324
+ /** Ordem completa dos ids do container. Ids fora da lista vão pro fim, na ordem do código. */
325
+ order?: string[];
326
+ hidden?: string[];
327
+ /** cópias feitas pelo lojista: id da cópia → id da seção de origem (a cópia renderiza o
328
+ * mesmo componente com outro escopo, então nasce com todos os textos editáveis). */
329
+ clones?: Record<string, string>;
330
+ /** seções ADICIONADAS pelo lojista: id da criada → TIPO do catálogo que a loja declara saber
331
+ * instanciar. Gêmeo de `clones`, com a diferença que custa caro: a cópia tem origem no código (dá
332
+ * para re-derivar caminho por caminho a partir dela), a criada NÃO tem — ela nasce com os literais
333
+ * do componente e nada é escrito em `values`. O id começa sempre em `novo-` (ver `PREFIXO_CRIADA`). */
334
+ criadas?: Record<string, string>;
335
+ }
336
+
337
+ /**
338
+ * Prefixo RESERVADO do id de uma seção criada (`novo-faq-1`). É o que torna impossível POR CONSTRUÇÃO
339
+ * a colisão com um id que o construtor venha a escrever no código depois — e não existe renomear.
340
+ * Também é o que deixa a projeção do manifesto se curar sozinha: uma linha de seção com este prefixo
341
+ * nunca é seção do código, mesmo que o manifesto postado tenha perdido a marca `criada`.
342
+ */
343
+ export const PREFIXO_CRIADA = "novo-";
344
+ export function ehIdDeCriada(id: string): boolean {
345
+ return id.startsWith(PREFIXO_CRIADA);
346
+ }
347
+
348
+ /** Cor/fundo de UM elemento, por cima do token (o "desacoplar do mapa de cores"). Fica em `<caminho>.estilo`. */
349
+ export interface StyleValue {
350
+ color?: string;
351
+ background?: string;
352
+ }
353
+
354
+ /** registro de honestidade: quem declarou, quando, e (numa cópia) de qual caminho veio */
355
+ export interface DeclaredEntry {
356
+ by: string;
357
+ at: string;
358
+ note?: string;
359
+ from?: string;
360
+ }
361
+
362
+ // ── APPS: RASTREIO E MARKETING (foundation 12) ───────────────────────────────────────────────────
363
+ //
364
+ // O que o lojista configura na aba "Apps" do painel: os IDs de rastreio DELE (Google Tag Manager,
365
+ // Google Analytics 4, Meta Pixel, TikTok Pixel, Pinterest Tag) e o botão flutuante de WhatsApp. Ele
366
+ // digita, publica, e a loja passa a disparar. Antes disso tudo vivia só em variável de ambiente.
367
+ //
368
+ // SÓ IDs ENTRAM NO DOCUMENTO. O documento publicado é público (`GET /api/content/<loja>/published`
369
+ // serve sem autenticação), e cada ID daqui já aparece no HTML da página de qualquer jeito. Segredo
370
+ // NUNCA entra: o token da Conversions API do Meta fica no ambiente da loja (`META_CAPI_TOKEN`, só
371
+ // servidor), e o painel só diz o ESTADO dela (`EstadoDoCapi`, ver `capiEmVigor`), nunca o valor.
372
+ //
373
+ // PRECEDÊNCIA, a mesma da copy (`resolveValue`): o valor do documento vence o do ambiente, e o
374
+ // ambiente é o "literal do código". Um provedor dispara UMA vez: com valor no documento E no
375
+ // ambiente, só o do documento entra na página, senão a conversão conta em dobro (`rastreioEmVigor`).
376
+ //
377
+ // O contêiner GTM CONTRATUAL da Unbox não mora aqui: é contrato, não configuração do lojista. Ele
378
+ // continua no código da loja (o literal do app/layout.tsx, que o `<Rastreio>` de rastreio.tsx recebe),
379
+ // sempre ligado, e o `gtm` daqui é o contêiner PRÓPRIO do lojista, que convive com o da Unbox lendo o
380
+ // mesmo dataLayer.
381
+ //
382
+ // QUEM RENDERIZA é a foundation (rastreio.tsx, servidor): o layout da loja chama `<Rastreio>` numa
383
+ // linha e não conhece provedor nenhum. O próximo provedor entra por versão da foundation.
384
+
385
+ export interface Rastreio {
386
+ /** contêiner do Google Tag Manager PRÓPRIO da loja (GTM-XXXXXXX) */
387
+ gtm?: string;
388
+ /** Google Analytics 4: o ID de medição (G-XXXXXXXXXX) */
389
+ ga4?: string;
390
+ /** Meta Pixel: o ID numérico (15 ou 16 dígitos) */
391
+ metaPixel?: string;
392
+ /** TikTok Pixel: o ID que começa com C (cerca de 20 letras e números) */
393
+ tiktok?: string;
394
+ /** Pinterest Tag: o ID numérico (13 dígitos) */
395
+ pinterest?: string;
396
+ /**
397
+ * botão flutuante de WhatsApp: número só dígitos com o código do país, e a mensagem inicial (opcional).
398
+ * `numero` vazio = sem botão; a mensagem fica guardada para quando o número voltar.
399
+ */
400
+ whatsapp?: { numero: string; mensagem?: string };
401
+ }
402
+ export interface Apps {
403
+ rastreio?: Rastreio;
404
+ }
405
+ export type ProvedorDeRastreio = keyof Rastreio;
406
+ export const PROVEDORES_DE_RASTREIO: readonly ProvedorDeRastreio[] = ["gtm", "ga4", "metaPixel", "tiktok", "pinterest", "whatsapp"];
407
+ /** O que o lojista edita, campo a campo: os cinco IDs e as duas partes do WhatsApp. */
408
+ export type CampoDeRastreio = Exclude<ProvedorDeRastreio, "whatsapp"> | "whatsapp.numero" | "whatsapp.mensagem";
409
+ export const CAMPOS_DE_RASTREIO: readonly CampoDeRastreio[] = ["gtm", "ga4", "metaPixel", "tiktok", "pinterest", "whatsapp.numero", "whatsapp.mensagem"];
410
+ /** o nome que o lojista lê, por provedor */
411
+ export const RASTREIO_NOME: Record<ProvedorDeRastreio, string> = {
412
+ gtm: "Google Tag Manager",
413
+ ga4: "Google Analytics",
414
+ metaPixel: "Meta Pixel",
415
+ tiktok: "TikTok Pixel",
416
+ pinterest: "Pinterest Tag",
417
+ whatsapp: "WhatsApp",
418
+ };
419
+ export const WHATSAPP_MENSAGEM_MAX = 300;
420
+ /** teto de qualquer ID ANTES de olhar o formato: um ID tem dezenas de caracteres, não milhares */
421
+ const RASTREIO_VALOR_MAX = 400;
422
+
423
+ /**
424
+ * Um exemplo copiado da documentação ("GTM-XXXXXXX", "todo", "x", zeros) não é ID. Placeholder passa em
425
+ * `if (id)`, o script carrega, inicializa com lixo e não reporta em lugar nenhum: sem sinal no painel do
426
+ * provedor nem no DevTools. Vazio quebra visível; "x" quebra calado.
427
+ */
428
+ const PLACEHOLDER_DE_ID = /^(x+|todo|placeholder|seu[-_]?id|sua[-_]?id|G-X+|GTM-X+|AW-X+|0+)$/i;
429
+ /** caractere de controle (fora de quebra de linha e tab): não tem o que fazer numa mensagem */
430
+ const CARACTERE_DE_CONTROLE = /[\u0000-\u0008\u000b-\u001f\u007f]/;
431
+
432
+ /**
433
+ * O QUE NÃO CABE NUMA URL. Metade de um caractere (um substituto UTF-16 sem o par, o `"\ud800"` de um
434
+ * JSON) passa por `typeof === "string"`, `Array.from` o conta como um caractere, e `encodeURIComponent`
435
+ * LANÇA ao encontrá-lo. A mensagem do WhatsApp vai numa URL montada pelo `<Rastreio>`, que o layout de
436
+ * TODA página da loja chama: uma mensagem dessas gravada no documento derrubava a loja inteira (Astra,
437
+ * B1). A régua pergunta ao próprio `encodeURIComponent`, que é quem vai ler o valor depois: o que ele
438
+ * recusa, a régua recusa, dos dois lados (ao gravar no editor e ao ler o publicado na loja).
439
+ */
440
+ function cabeNumaUrl(valor: string): boolean {
441
+ try {
442
+ encodeURIComponent(valor);
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * O valor como ele é GUARDADO: sem espaço em volta; ID do Google e do TikTok em maiúsculas (é como
451
+ * eles são emitidos, e é o que a URL do script espera); telefone sem a formatação que se digita
452
+ * ("+55 (11) 99999-8888" vira "5511999998888"). A mensagem do WhatsApp só perde o espaço em volta.
453
+ */
454
+ export function normalizaRastreio(campo: CampoDeRastreio, valor: string): string {
455
+ const v = valor.trim();
456
+ if (campo === "gtm" || campo === "ga4" || campo === "tiktok") return v.toUpperCase();
457
+ if (campo === "whatsapp.numero") return v.replace(/[\s().+-]/g, "");
458
+ return v;
459
+ }
460
+
461
+ /**
462
+ * O motivo de recusa, na frase que o LOJISTA lê, ou `null` quando o valor serve. Espera o valor já
463
+ * normalizado (`normalizaRastreio`). A régua é UMA e roda dos dois lados: no editor ao gravar (onde
464
+ * recusa com motivo) e na loja ao ler o publicado (onde um valor que não bate é simplesmente ignorado
465
+ * e vale o do ambiente, para a loja nunca carregar script com ID quebrado).
466
+ */
467
+ export function recusaDeRastreio(campo: CampoDeRastreio, valor: string): string | null {
468
+ if (typeof valor !== "string") return "o valor precisa ser texto";
469
+ // caracteres como o lojista os conta (um emoji é um, não dois): o teto é em pontos de código, não em
470
+ // unidades UTF-16, senão "até 300 caracteres" na tela seria mentira para uma mensagem com emoji
471
+ const caracteres = Array.from(valor).length;
472
+ if (campo === "whatsapp.mensagem") {
473
+ if (caracteres > WHATSAPP_MENSAGEM_MAX) return `a mensagem do WhatsApp tem até ${WHATSAPP_MENSAGEM_MAX} caracteres (esta tem ${caracteres})`;
474
+ if (CARACTERE_DE_CONTROLE.test(valor)) return "a mensagem do WhatsApp tem caracteres de controle que não dá para usar";
475
+ if (!cabeNumaUrl(valor)) return "a mensagem do WhatsApp tem um caractere quebrado (metade de um emoji ou de um símbolo) que não dá para usar";
476
+ return null;
477
+ }
478
+ if (caracteres > RASTREIO_VALOR_MAX) return `valor grande demais (${caracteres} caracteres)`;
479
+ if (!cabeNumaUrl(valor)) return "o valor tem um caractere quebrado (metade de um emoji ou de um símbolo) que não dá para usar";
480
+ if (PLACEHOLDER_DE_ID.test(valor)) return "isso parece um exemplo, não um ID de verdade";
481
+ switch (campo) {
482
+ case "gtm":
483
+ return /^GTM-[A-Z0-9]{5,10}$/.test(valor) ? null : "o ID do Google Tag Manager tem a forma GTM-XXXXXXX: GTM- seguido de 7 letras ou números";
484
+ case "ga4":
485
+ return /^G-[A-Z0-9]{8,12}$/.test(valor) ? null : "o ID do Google Analytics tem a forma G-XXXXXXXXXX: G- seguido de 10 letras ou números (é o ID de medição do fluxo de dados)";
486
+ case "metaPixel":
487
+ return /^\d{15,16}$/.test(valor) ? null : "o ID do Meta Pixel é um número de 15 ou 16 dígitos";
488
+ case "tiktok":
489
+ return /^C[A-Z0-9]{15,30}$/.test(valor) ? null : "o ID do TikTok Pixel começa com C e tem cerca de 20 letras e números";
490
+ case "pinterest":
491
+ return /^\d{13}$/.test(valor) ? null : "o ID da Pinterest Tag é um número de 13 dígitos";
492
+ case "whatsapp.numero":
493
+ return /^\d{10,15}$/.test(valor) ? null : "o número do WhatsApp é só dígitos, com o código do país na frente (55 para o Brasil), de 10 a 15 dígitos, como 5511999998888";
494
+ default:
495
+ return `campo de rastreio desconhecido: ${String(campo)}`;
496
+ }
497
+ }
498
+
499
+ /** um campo do documento, como está gravado; `""` conta como ausente */
500
+ export function campoDeRastreio(r: Rastreio | undefined, campo: CampoDeRastreio): string | undefined {
501
+ if (!r) return undefined;
502
+ const v = campo === "whatsapp.numero" ? r.whatsapp?.numero : campo === "whatsapp.mensagem" ? r.whatsapp?.mensagem : r[campo];
503
+ return typeof v === "string" && v !== "" ? v : undefined;
504
+ }
505
+
506
+ /**
507
+ * `apps` com UM campo trocado (`null` ou vazio = removido). Mapa que ficou vazio SOME: um `apps: {}`
508
+ * no documento seria uma diferença inventada, e a barra passaria a dizer "não publicado" à toa.
509
+ */
510
+ function comCampoDeRastreio(apps: Apps | undefined, campo: CampoDeRastreio, valor: string | null): Apps | undefined {
511
+ const r: Rastreio = { ...(apps?.rastreio ?? {}) };
512
+ const v = valor === null || valor === "" ? null : valor;
513
+ if (campo === "whatsapp.numero" || campo === "whatsapp.mensagem") {
514
+ const w = { ...(r.whatsapp ?? { numero: "" }) };
515
+ if (campo === "whatsapp.numero") w.numero = v ?? "";
516
+ else if (v === null) delete w.mensagem;
517
+ else w.mensagem = v;
518
+ if (!w.numero && !w.mensagem) delete r.whatsapp;
519
+ else r.whatsapp = w;
520
+ } else if (v === null) delete r[campo];
521
+ else r[campo] = v;
522
+ const resto: Apps = { ...(apps ?? {}) };
523
+ if (Object.keys(r).length) resto.rastreio = r;
524
+ else delete resto.rastreio;
525
+ return Object.keys(resto).length ? resto : undefined;
526
+ }
527
+
528
+ export type OrigemDoRastreio = "documento" | "ambiente";
529
+ /**
530
+ * Um valor que HAVIA e deixou de valer. Hoje só o Meta Pixel do ambiente tem isso: com um Tag Manager
531
+ * próprio em vigor (do documento ou do ambiente), o Pixel do cadastro entra pelo contêiner, não pelo
532
+ * código, senão PageView e conversão contam em dobro. A origem vai junto para o painel poder dizer "o do
533
+ * cadastro deixa de valer porque há um Tag Manager".
534
+ */
535
+ export interface SupressaoDeRastreio {
536
+ origem: OrigemDoRastreio;
537
+ por: "gtm";
538
+ }
539
+ export interface ValorDeRastreio<T> {
540
+ valor: T | null;
541
+ /** de onde veio o valor em vigor; `null` = nem o documento nem o ambiente têm */
542
+ origem: OrigemDoRastreio | null;
543
+ /** o valor que havia e não entra (ver `SupressaoDeRastreio`); ausente = nada foi suprimido */
544
+ suprimido?: SupressaoDeRastreio;
545
+ }
546
+ export interface RastreioEmVigor {
547
+ gtm: ValorDeRastreio<string>;
548
+ ga4: ValorDeRastreio<string>;
549
+ metaPixel: ValorDeRastreio<string>;
550
+ tiktok: ValorDeRastreio<string>;
551
+ pinterest: ValorDeRastreio<string>;
552
+ whatsapp: ValorDeRastreio<{ numero: string; mensagem?: string }>;
553
+ }
554
+ /**
555
+ * O ESTADO DA CONVERSIONS API DO META: o que a loja realmente faz com o token. É o que o manifesto leva ao
556
+ * painel E o que a rota /api/capi da loja obedece; os dois saem de `capiEmVigor`, para nunca discordarem.
557
+ * - "ativa": há token no servidor da loja, há um Meta Pixel em vigor, e ele é o Pixel do cadastro da loja,
558
+ * o mesmo para o qual o token foi emitido. A rota envia.
559
+ * - "sem-token": não há token no servidor da loja. A rota não envia.
560
+ * - "sem-pixel": há token, mas nenhum Meta Pixel em vigor (nem publicado, nem no cadastro). Não há o que
561
+ * espelhar; a rota não envia. Era o caso que o painel chamava de "configurada" (Astra, B5).
562
+ * - "pixel-diferente-do-token": há token e há um Pixel em vigor, mas ele não é o do cadastro da loja (o
563
+ * lojista publicou outro, ou o cadastro não declara nenhum). Um token do Meta vale para UM Pixel: a rota
564
+ * não envia, para não mandar conversão de um Pixel com o token de outro (Astra, B4).
565
+ */
566
+ export type EstadoDoCapi = "ativa" | "sem-token" | "sem-pixel" | "pixel-diferente-do-token";
567
+ /** o que a loja tem no AMBIENTE, só presença (nunca o valor), e o estado da CAPI: é o que o manifesto leva ao painel */
568
+ export interface ManifestApps {
569
+ rastreio: {
570
+ ambiente: Partial<Record<ProvedorDeRastreio, true>>;
571
+ /** o estado da Conversions API do Meta (`EstadoDoCapi`). Só o estado: o token nunca sai do servidor. */
572
+ capi: EstadoDoCapi;
573
+ /**
574
+ * o contêiner GTM CONTRATUAL da Unbox: o literal do app/layout.tsx, o mesmo que vai ao `<Rastreio>`. É
575
+ * público (está no HTML de toda página), e o painel precisa dele para não anunciar supressão que a loja
576
+ * não executa: um `gtm` publicado IGUAL a ele não é Tag Manager próprio (`rastreioEmVigor` o descarta e
577
+ * MANTÉM o Pixel do cadastro), então o painel não pode dizer que o Pixel do cadastro deixou de valer
578
+ * (Astra, segunda rodada, 4). Ausente = loja que ainda não o declara; o painel volta a inferir só pela
579
+ * presença.
580
+ */
581
+ unboxGtmId?: string;
582
+ };
583
+ }
584
+
585
+ /** As variáveis de ambiente que a loja lê para cada provedor: o "literal do código" do rastreio. */
586
+ export const VARIAVEIS_DE_RASTREIO = {
587
+ gtm: "NEXT_PUBLIC_GTM_ID",
588
+ ga4: "NEXT_PUBLIC_GA_ID",
589
+ metaPixel: "NEXT_PUBLIC_META_PIXEL_ID",
590
+ tiktok: "NEXT_PUBLIC_TIKTOK_PIXEL_ID",
591
+ pinterest: "NEXT_PUBLIC_PINTEREST_TAG_ID",
592
+ whatsappNumero: "NEXT_PUBLIC_WHATSAPP_NUMERO",
593
+ whatsappMensagem: "NEXT_PUBLIC_WHATSAPP_MENSAGEM",
594
+ capi: "META_CAPI_TOKEN",
595
+ /** o Pixel para o qual o token da CAPI foi emitido; sem ele vale o Pixel do navegador (o cadastro documenta os dois como o mesmo ID) */
596
+ capiPixel: "META_PIXEL_ID",
597
+ } as const;
598
+ export type Ambiente = Record<string, string | undefined>;
599
+
600
+ /** valor aceito pela régua (já normalizado), ou `undefined` */
601
+ function valorAceito(campo: CampoDeRastreio, bruto: unknown): string | undefined {
602
+ if (typeof bruto !== "string") return undefined;
603
+ const v = normalizaRastreio(campo, bruto);
604
+ return v !== "" && !recusaDeRastreio(campo, v) ? v : undefined;
605
+ }
606
+ const IDS_DE_RASTREIO = ["gtm", "ga4", "metaPixel", "tiktok", "pinterest"] as const;
607
+
608
+ function whatsappAceito(numeroBruto: unknown, mensagemBruta: unknown): Rastreio["whatsapp"] | undefined {
609
+ const numero = valorAceito("whatsapp.numero", numeroBruto);
610
+ if (!numero) return undefined;
611
+ const mensagem = valorAceito("whatsapp.mensagem", mensagemBruta);
612
+ return mensagem ? { numero, mensagem } : { numero };
613
+ }
614
+
615
+ /**
616
+ * O LINK DO BOTÃO DE WHATSAPP: wa.me com o número, e a mensagem inicial quando há uma. Mora aqui, e não em
617
+ * rastreio.tsx, para ser testado no runner do editor. Protegido contra documento JÁ GRAVADO com mensagem que
618
+ * `encodeURIComponent` recusa (metade de um caractere, gravada antes de a régua recusá-la): o botão sai
619
+ * sem mensagem, nunca uma exceção, porque este link é montado no layout de toda página da loja (Astra, B1).
620
+ * A régua (`recusaDeRastreio`) já derruba essa mensagem na leitura do publicado; esta é a segunda porta.
621
+ */
622
+ export function linkDoWhatsapp(numero: string, mensagem?: string): string {
623
+ const base = `https://wa.me/${numero}`;
624
+ if (!mensagem) return base;
625
+ try {
626
+ return `${base}?text=${encodeURIComponent(mensagem)}`;
627
+ } catch {
628
+ return base;
629
+ }
630
+ }
631
+
632
+ /**
633
+ * O rastreio que o AMBIENTE oferece, lido com a MESMA régua do documento: placeholder e formato errado
634
+ * são ausência (o script não carrega com ID quebrado). É a OFERTA inteira: o Meta Pixel do ambiente sai
635
+ * daqui mesmo quando há GTM. Quem decide se ele entra na página é `rastreioEmVigor`, porque a regra ("o
636
+ * Pixel do cadastro não entra pelo código quando há um Tag Manager próprio em vigor") depende do Tag
637
+ * Manager EFETIVO, que pode ser o que o lojista publicou, não só o do ambiente (Astra, B2). Decidir aqui,
638
+ * antes da precedência, deixava GTM publicado e Pixel herdado mandarem os dois o mesmo PageView.
639
+ */
640
+ export function rastreioDoAmbiente(ambiente: Ambiente): Rastreio {
641
+ const r: Rastreio = {};
642
+ const V = VARIAVEIS_DE_RASTREIO;
643
+ for (const p of IDS_DE_RASTREIO) {
644
+ const v = valorAceito(p, ambiente[V[p]]);
645
+ if (v) r[p] = v;
646
+ }
647
+ const w = whatsappAceito(ambiente[V.whatsappNumero], ambiente[V.whatsappMensagem]);
648
+ if (w) r.whatsapp = w;
649
+ return r;
650
+ }
651
+
652
+ /** o rastreio que o DOCUMENTO traz, campo a campo pela régua: a loja não confia cegamente no JSON publicado */
653
+ export function rastreioDoDocumento(doc: ContentDocument | null | undefined): Rastreio {
654
+ const bruto = doc?.apps?.rastreio as unknown;
655
+ const r: Rastreio = {};
656
+ if (!bruto || typeof bruto !== "object" || Array.isArray(bruto)) return r;
657
+ const o = bruto as Record<string, unknown>;
658
+ for (const p of IDS_DE_RASTREIO) {
659
+ const v = valorAceito(p, o[p]);
660
+ if (v) r[p] = v;
661
+ }
662
+ const wo = o.whatsapp;
663
+ if (wo && typeof wo === "object" && !Array.isArray(wo)) {
664
+ const w = whatsappAceito((wo as Record<string, unknown>).numero, (wo as Record<string, unknown>).mensagem);
665
+ if (w) r.whatsapp = w;
666
+ }
667
+ return r;
668
+ }
669
+
670
+ /**
671
+ * O QUE DISPARA NA LOJA, por provedor, e de onde veio. Documento vence ambiente, provedor a provedor:
672
+ * quem tem os dois recebe SÓ o do documento, nunca os dois. É esta função que o `<Rastreio>` da
673
+ * foundation (rastreio.tsx) consulta antes de injetar cada script; o layout da loja só o chama. O
674
+ * contêiner da Unbox fica fora dela: é sempre ligado. `unboxGtmId` diz qual é ele, para um `gtm` igual (o
675
+ * lojista digitou o contêiner da Unbox como se fosse o dele) não contar como contêiner próprio: seria o
676
+ * MESMO contêiner carregado duas vezes, e não é um Tag Manager que dispare o Pixel da marca.
677
+ *
678
+ * A SUPRESSÃO DO PIXEL HERDADO vem DEPOIS da precedência, de propósito: o Meta Pixel do AMBIENTE só entra
679
+ * quando NÃO há Tag Manager próprio em vigor, seja ele do documento ou do ambiente (Pixel no contêiner E no
680
+ * código conta PageView e conversão em dobro, em silêncio). Antes ela olhava só o GTM do ambiente, e um GTM
681
+ * publicado no painel convivia com o Pixel herdado (Astra, B2). O Pixel que o lojista DIGITA no painel não
682
+ * passa por ela: é escolha explícita dele. O que foi suprimido sai em `suprimido`, para o painel dizer.
683
+ */
684
+ export function rastreioEmVigor(doc: ContentDocument | null | undefined, ambiente: Ambiente, opcoes?: { unboxGtmId?: string }): RastreioEmVigor {
685
+ const d = rastreioDoDocumento(doc);
686
+ const a = rastreioDoAmbiente(ambiente);
687
+ const unbox = opcoes?.unboxGtmId;
688
+ if (unbox) {
689
+ if (d.gtm === unbox) delete d.gtm;
690
+ if (a.gtm === unbox) delete a.gtm;
691
+ }
692
+ const escolhe = <T,>(p: ProvedorDeRastreio): ValorDeRastreio<T> => {
693
+ if (d[p] !== undefined) return { valor: d[p] as T, origem: "documento" };
694
+ if (a[p] !== undefined) return { valor: a[p] as T, origem: "ambiente" };
695
+ return { valor: null, origem: null };
696
+ };
697
+ const r: RastreioEmVigor = { gtm: escolhe("gtm"), ga4: escolhe("ga4"), metaPixel: escolhe("metaPixel"), tiktok: escolhe("tiktok"), pinterest: escolhe("pinterest"), whatsapp: escolhe("whatsapp") };
698
+ if (r.gtm.valor && r.metaPixel.origem === "ambiente") r.metaPixel = { valor: null, origem: null, suprimido: { origem: "ambiente", por: "gtm" } };
699
+ return r;
700
+ }
701
+
702
+ /**
703
+ * A CONVERSIONS API DO META, decidida em UM lugar: o estado (`EstadoDoCapi`) e, quando "ativa", o Pixel
704
+ * para o qual a rota /api/capi da loja envia. O manifesto leva o mesmo estado ao painel.
705
+ *
706
+ * O Pixel EM VIGOR aqui é o que a loja TEM: o publicado no painel, senão o do cadastro (o do navegador,
707
+ * `NEXT_PUBLIC_META_PIXEL_ID`, senão o declarado ao lado do token, `META_PIXEL_ID`). Ele NÃO passa pela
708
+ * supressão por Tag Manager de `rastreioEmVigor`: um Pixel do cadastro que entra pelo contêiner continua
709
+ * sendo o Pixel da loja, e é ele que a CAPI espelha (é como as lojas já rodavam). O Pixel DO TOKEN é o do
710
+ * cadastro (`META_PIXEL_ID`, senão o do navegador): o token do Meta vale para um Pixel só. Quando o lojista
711
+ * publica outro Pixel, o navegador passa a mandar para o novo e a CAPI para, dizendo por quê, em vez de
712
+ * continuar mandando para o antigo (Astra, B4). Trocar o Pixel no painel não é bloqueado: bloquear é pior
713
+ * que dizer.
714
+ */
715
+ export function capiEmVigor(doc: ContentDocument | null | undefined, ambiente: Ambiente): { estado: EstadoDoCapi; pixel: string | null } {
716
+ const V = VARIAVEIS_DE_RASTREIO;
717
+ if (!ambiente[V.capi]?.trim()) return { estado: "sem-token", pixel: null };
718
+ const doNavegador = valorAceito("metaPixel", ambiente[V.metaPixel]);
719
+ const aoLadoDoToken = valorAceito("metaPixel", ambiente[V.capiPixel]);
720
+ const emVigor = rastreioDoDocumento(doc).metaPixel ?? doNavegador ?? aoLadoDoToken;
721
+ if (!emVigor) return { estado: "sem-pixel", pixel: null };
722
+ const doToken = aoLadoDoToken ?? doNavegador;
723
+ if (emVigor !== doToken) return { estado: "pixel-diferente-do-token", pixel: null };
724
+ return { estado: "ativa", pixel: emVigor };
725
+ }
726
+
727
+ /**
728
+ * A LEITURA DO PUBLICADO, em dois casos que NÃO se confundem (Astra, segunda rodada, 1):
729
+ * - AUSÊNCIA (`falhou: false`, `doc: null`): o editor respondeu 404, a loja nunca publicou. Não há documento,
730
+ * e o que vale é o código, com o rastreio do cadastro.
731
+ * - FALHA DE LEITURA (`falhou: true`): rede, 5xx, corpo que não é JSON, documento malformado ou de outra loja.
732
+ * PODE haver um documento publicado que não se conseguiu ler, e não se sabe o que ele diz.
733
+ * Quem só RENDERIZA trata os dois igual (`doc`; null = conteúdo do código): mostrar o código é o certo lá, e é
734
+ * o contrato de `getPublishedContent` (server.ts). Quem DECIDE a partir do publicado tem de olhar `falhou`: a
735
+ * rota /api/capi escolhe para qual Pixel enviar, e com a leitura falhada "não há documento" viraria "vale o
736
+ * Pixel do cadastro", que o lojista pode ter trocado (ver `capiDaLeitura`).
737
+ *
738
+ * Função pura: recebe a busca (o `fetch` da loja, ou um de mentira no teste) e classifica o que ela devolveu.
739
+ * A conferência dos blocos de HTML (`semHtmlRecusado`, server.ts) roda DEPOIS, sobre o `doc` que sai daqui.
740
+ */
741
+ export type LeituraDoPublicado = { doc: ContentDocument | null; falhou: false } | { doc: null; falhou: true; motivo: string };
742
+ /** o que a leitura usa de uma resposta HTTP: o status e o corpo em JSON (um `Response` serve) */
743
+ export interface RespostaDoPublicado {
744
+ status: number;
745
+ ok: boolean;
746
+ json(): Promise<unknown>;
747
+ }
748
+ export async function leituraDoPublicado(buscar: () => Promise<RespostaDoPublicado>, slug: string): Promise<LeituraDoPublicado> {
749
+ let res: RespostaDoPublicado;
750
+ try {
751
+ res = await buscar();
752
+ } catch (err) {
753
+ return { doc: null, falhou: true, motivo: `editor inacessível (${err instanceof Error ? err.message : String(err)})` };
754
+ }
755
+ if (res.status === 404) return { doc: null, falhou: false };
756
+ if (!res.ok) return { doc: null, falhou: true, motivo: `o editor respondeu ${res.status}` };
757
+ let corpo: unknown;
758
+ try {
759
+ corpo = await res.json();
760
+ } catch {
761
+ return { doc: null, falhou: true, motivo: "o conteúdo publicado não é JSON" };
762
+ }
763
+ if (!documentoUsavel(corpo)) return { doc: null, falhou: true, motivo: "o conteúdo publicado veio malformado" };
764
+ if (corpo.shop !== slug) return { doc: null, falhou: true, motivo: `o conteúdo publicado é de outra loja (${corpo.shop})` };
765
+ return { doc: corpo, falhou: false };
766
+ }
767
+
768
+ /**
769
+ * O QUE A ROTA /api/capi OBEDECE, a partir da LEITURA (não do documento). Com a leitura falhada não há como
770
+ * saber qual Pixel o lojista publicou, e `capiEmVigor(null, ambiente)` diria "ativa" com o Pixel do cadastro:
771
+ * a rota mandaria conversão para um Pixel que ele pode ter trocado. Então a leitura falhada é um estado
772
+ * próprio da rota, "leitura-falhou": ela aceita o evento e NÃO envia (o Pixel do navegador segue sozinho), e
773
+ * volta ao normal na primeira leitura que der certo. Não entra em `EstadoDoCapi` (o estado do manifesto): o
774
+ * manifesto nasce de uma renderização, e este é o estado de UMA resposta da rota.
775
+ */
776
+ export type EstadoDaRotaCapi = EstadoDoCapi | "leitura-falhou";
777
+ export function capiDaLeitura(leitura: LeituraDoPublicado, ambiente: Ambiente): { estado: EstadoDaRotaCapi; pixel: string | null } {
778
+ if (leitura.falhou) return { estado: "leitura-falhou", pixel: null };
779
+ return capiEmVigor(leitura.doc, ambiente);
780
+ }
781
+
782
+ /**
783
+ * SÓ PRESENÇA: o que a loja tem no ambiente, provedor a provedor (nunca o valor), e o estado da Conversions
784
+ * API. É o que o `EditableProvider` leva no manifesto (`apps.rastreio`) para o painel poder dizer "veio do
785
+ * cadastro da loja" e o que a CAPI está fazendo. A presença é a OFERTA do ambiente: o Meta Pixel do cadastro
786
+ * aparece mesmo quando um Tag Manager o suprime na página (ver `rastreioEmVigor`); o painel tem o documento
787
+ * e a presença, e diz "deixa de valer porque há um Tag Manager" com a mesma regra (há `gtm` no documento ou
788
+ * no ambiente, o lojista não digitou Pixel, e o ambiente tem um).
789
+ *
790
+ * `doc` é o documento PUBLICADO: o estado da CAPI depende do Pixel em vigor, e o Pixel em vigor pode ser o
791
+ * que o lojista publicou. Sem `doc`, o estado sai só do ambiente (o `presencaNoAmbiente` de server.ts
792
+ * completa com o documento que `getPublishedContent` leu no mesmo pedido).
793
+ *
794
+ * `unboxGtmId` é o contêiner contratual da Unbox, o mesmo literal que o layout passa ao `<Rastreio>`. Sobe ao
795
+ * manifesto pela MESMA régua dos IDs (`valorAceito`): um literal fora do formato não é contêiner e não sobe.
796
+ * É o que deixa o painel reconhecer um `gtm` publicado igual ao da Unbox, que a loja descarta SEM suprimir o
797
+ * Pixel do cadastro (ver `rastreioEmVigor`), em vez de anunciar uma supressão que não acontece.
798
+ */
799
+ export function presencaNoAmbiente(ambiente: Ambiente, opcoes?: { doc?: ContentDocument | null; unboxGtmId?: string }): ManifestApps {
800
+ const a = rastreioDoAmbiente(ambiente);
801
+ const presentes: Partial<Record<ProvedorDeRastreio, true>> = {};
802
+ for (const p of PROVEDORES_DE_RASTREIO) if (a[p] !== undefined) presentes[p] = true;
803
+ const unboxGtmId = valorAceito("gtm", opcoes?.unboxGtmId);
804
+ return { rastreio: { ambiente: presentes, capi: capiEmVigor(opcoes?.doc, ambiente).estado, ...(unboxGtmId ? { unboxGtmId } : {}) } };
805
+ }
806
+
807
+ export interface ContentDocument {
808
+ schema: 1;
809
+ shop: string;
810
+ updatedAt?: string;
811
+ /** caminho → valor. Só o que foi editado. */
812
+ values: Record<string, EditableValue>;
813
+ /** container (ex.: "home") → ordem/ocultas. */
814
+ sections: Record<string, SectionState>;
815
+ /** token CSS (ex.: "--store-primary") → cor. Só tokens da allowlist da loja. */
816
+ tokens: Record<string, string>;
817
+ /** Registro de honestidade: edições que o lojista declarou sem fonte (nota, prazo, depoimento). */
818
+ declared?: Record<string, DeclaredEntry>;
819
+ /**
820
+ * APPS (foundation 12): a configuração de rastreio e marketing que o lojista fez na aba Apps. OPCIONAL
821
+ * e aditivo: documento antigo sem `apps` continua usável (`documentoUsavel` não o exige), sem migração.
822
+ * Só IDs, nunca segredo: o documento publicado é público.
823
+ */
824
+ apps?: Apps;
825
+ }
826
+
827
+ export function emptyDocument(shop: string): ContentDocument {
828
+ return { schema: 1, shop, values: {}, sections: {}, tokens: {} };
829
+ }
830
+
831
+ export type PatchOp =
832
+ | { op: "set"; path: string; value: EditableValue; /** interno (inverso de desfazer): declaração anterior a devolver */ declaredAnterior?: DeclaredEntry | null }
833
+ | { op: "unset"; path: string; declaredAnterior?: DeclaredEntry | null }
834
+ | { op: "set_token"; token: string; value: string }
835
+ | { op: "unset_token"; token: string }
836
+ | { op: "set_order"; container: string; order: string[] }
837
+ | { op: "unset_order"; container: string }
838
+ | { op: "hide_section"; container: string; id: string }
839
+ | { op: "show_section"; container: string; id: string }
840
+ /** `ordemSemeada` (interno): a cópia entra logo abaixo da origem, então quem duplica num container SEM ordem
841
+ * gravada manda a ordem atual junto — e o inverso devolve a ausência dela (Astra v3.14, achado 2). */
842
+ | { op: "duplicate_section"; container: string; id: string; cloneId: string; ordemSemeada?: string[] }
843
+ /**
844
+ * ADICIONAR seção (o "+" do painel): a loja declara no manifesto os TIPOS que sabe instanciar, e a
845
+ * seção nasce com os literais do componente — nada vai para `values`. O inverso é `remove_section`.
846
+ * `indice` = posição na ordem do container. `ordemSemeada` (interno) tem o mesmo papel que na
847
+ * duplicação: sem ordem gravada, `indice` não teria sobre o que operar e a seção nova cairia no FIM.
848
+ */
849
+ | { op: "add_section"; container: string; id: string; tipo: string; indice?: number; ordemSemeada?: string[] }
850
+ /** `ordemAnterior` (interno, vem do inverso): `null` = o container não tinha ordem antes. */
851
+ | { op: "remove_section"; container: string; id: string; ordemAnterior?: string[] | null }
852
+ /** só como INVERSO de remove_section (desfazer): devolve a cópia (com `source`) ou a seção criada
853
+ * (com `tipo`) — valores, posição e visibilidade juntos. Sem o `tipo`, desfazer a remoção de uma
854
+ * criada devolveria um id sem tipo nenhum, e a loja não saberia o que renderizar ali. */
855
+ | { op: "restore_section"; container: string; id: string; source?: string; tipo?: string; values: Record<string, EditableValue>; index: number | null; hidden: boolean; declared?: Record<string, DeclaredEntry>; cloneIndex?: number; sections?: ContentDocument["sections"] }
856
+ /**
857
+ * APPS (foundation 12): UM campo de rastreio (`gtm`, `ga4`, `metaPixel`, `tiktok`, `pinterest`,
858
+ * `whatsapp.numero`, `whatsapp.mensagem`). `null` ou vazio = remover. O inverso é o mesmo `set_app` com
859
+ * o valor anterior (ou `null`), então desfazer funciona como em tudo. O valor entra NORMALIZADO
860
+ * (`normalizaRastreio`) e só depois de passar em `recusaDeRastreio` (a régua de formato por provedor).
861
+ */
862
+ | { op: "set_app"; app: "rastreio"; campo: CampoDeRastreio; value: string | null }
863
+ /**
864
+ * interno: troca o documento inteiro (usar uma versão publicada como rascunho); inverso = o documento anterior.
865
+ * `apps` (foundation 12): presente troca junto (`null` = a versão não tinha apps); AUSENTE mantém o do
866
+ * rascunho atual, para um cliente que ainda não o manda não apagar a configuração do lojista.
867
+ */
868
+ | { op: "replace_doc"; values: ContentDocument["values"]; sections: ContentDocument["sections"]; tokens: ContentDocument["tokens"]; declared?: ContentDocument["declared"]; apps?: Apps | null };
869
+
870
+ const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
871
+
872
+ /** Aplica UMA operação e devolve o documento novo + a operação inversa (pra desfazer). */
873
+ export function applyOp(doc: ContentDocument, op: PatchOp): { doc: ContentDocument; inverse: PatchOp } {
874
+ const next = clone(doc);
875
+ let inverse: PatchOp;
876
+ switch (op.op) {
877
+ // a declaração de honestidade é DO VALOR: valor novo (ou removido) apaga a declaração antiga;
878
+ // quem grava um valor declarado registra a declaração de novo depois de aplicar
879
+ case "set": {
880
+ const prev = doc.values[op.path];
881
+ const declPrev = doc.declared?.[op.path] ?? null;
882
+ // o inverso leva a declaração anterior: desfazer devolve valor E proveniência
883
+ inverse = prev === undefined ? { op: "unset", path: op.path, declaredAnterior: declPrev } : { op: "set", path: op.path, value: prev, declaredAnterior: declPrev };
884
+ next.values[op.path] = op.value;
885
+ if (next.declared) delete next.declared[op.path];
886
+ if (op.declaredAnterior) next.declared = { ...(next.declared ?? {}), [op.path]: op.declaredAnterior };
887
+ break;
888
+ }
889
+ case "unset": {
890
+ const prev = doc.values[op.path];
891
+ const declPrev = doc.declared?.[op.path] ?? null;
892
+ inverse = prev === undefined ? { op: "unset", path: op.path, declaredAnterior: declPrev } : { op: "set", path: op.path, value: prev, declaredAnterior: declPrev };
893
+ delete next.values[op.path];
894
+ if (next.declared) delete next.declared[op.path];
895
+ if (op.declaredAnterior) next.declared = { ...(next.declared ?? {}), [op.path]: op.declaredAnterior };
896
+ break;
897
+ }
898
+ case "set_token": {
899
+ const prev = doc.tokens[op.token];
900
+ inverse = prev === undefined ? { op: "unset_token", token: op.token } : { op: "set_token", token: op.token, value: prev };
901
+ next.tokens[op.token] = op.value;
902
+ if (next.declared) delete next.declared[op.token];
903
+ break;
904
+ }
905
+ case "unset_token": {
906
+ const prev = doc.tokens[op.token];
907
+ inverse = prev === undefined ? { op: "unset_token", token: op.token } : { op: "set_token", token: op.token, value: prev };
908
+ delete next.tokens[op.token];
909
+ if (next.declared) delete next.declared[op.token];
910
+ break;
911
+ }
912
+ case "set_order": {
913
+ const prev = doc.sections[op.container]?.order;
914
+ inverse = prev ? { op: "set_order", container: op.container, order: prev } : { op: "unset_order", container: op.container };
915
+ next.sections[op.container] = { ...(next.sections[op.container] ?? {}), order: [...op.order] };
916
+ break;
917
+ }
918
+ case "unset_order": {
919
+ const prev = doc.sections[op.container]?.order;
920
+ inverse = prev ? { op: "set_order", container: op.container, order: prev } : { op: "unset_order", container: op.container };
921
+ if (next.sections[op.container]) delete next.sections[op.container].order;
922
+ break;
923
+ }
924
+ case "hide_section": {
925
+ const hidden = new Set(doc.sections[op.container]?.hidden ?? []);
926
+ inverse = hidden.has(op.id) ? { op: "hide_section", container: op.container, id: op.id } : { op: "show_section", container: op.container, id: op.id };
927
+ hidden.add(op.id);
928
+ next.sections[op.container] = { ...(next.sections[op.container] ?? {}), hidden: [...hidden] };
929
+ break;
930
+ }
931
+ case "show_section": {
932
+ const hidden = new Set(doc.sections[op.container]?.hidden ?? []);
933
+ inverse = hidden.has(op.id) ? { op: "hide_section", container: op.container, id: op.id } : { op: "show_section", container: op.container, id: op.id };
934
+ hidden.delete(op.id);
935
+ next.sections[op.container] = { ...(next.sections[op.container] ?? {}), hidden: [...hidden] };
936
+ break;
937
+ }
938
+ case "duplicate_section": {
939
+ const st = next.sections[op.container] ?? {};
940
+ // semeadura reversível da ordem: sem ordem gravada, a cópia iria para o fim
941
+ const semeia = op.ordemSemeada && !st.order?.length ? op.ordemSemeada : null;
942
+ const ordemAntesDaSemeadura = semeia ? (st.order ? [...st.order] : null) : undefined;
943
+ if (semeia) st.order = [...semeia];
944
+ const clones = { ...(st.clones ?? {}), [op.cloneId]: st.clones?.[op.id] ?? op.id };
945
+ // a cópia entra logo depois da origem na ordem
946
+ const base = st.order ?? [];
947
+ const order = base.includes(op.id) ? base.flatMap((x) => (x === op.id ? [x, op.cloneId] : [x])) : undefined;
948
+ next.sections[op.container] = { ...st, clones, ...(order ? { order } : {}) };
949
+ // a cópia nasce com o que o lojista está VENDO (valores do escopo de origem), não com o código
950
+ const de = `${op.container}.${op.id}.`;
951
+ const para = `${op.container}.${op.cloneId}.`;
952
+ for (const [k, v] of Object.entries(doc.values)) if (k.startsWith(de)) next.values[para + k.slice(de.length)] = clone(v);
953
+ // a proveniência acompanha o texto: um dado declarado na original continua declarado na cópia
954
+ for (const [k, d] of Object.entries(doc.declared ?? {})) if (k.startsWith(de)) next.declared = { ...(next.declared ?? {}), [para + k.slice(de.length)]: { ...d, from: d.from ?? k } };
955
+ // os ESTADOS das listas de dentro (itens ocultos, reordenados, adicionados) também vão na cópia —
956
+ // senão a cópia do FAQ volta às perguntas do código (revisão v3 do Astra, achado 3)
957
+ const raizDe = `${op.container}.${op.id}`;
958
+ const raizPara = `${op.container}.${op.cloneId}`;
959
+ for (const [c, estado] of Object.entries(doc.sections)) if (c === raizDe || c.startsWith(raizDe + ".")) next.sections[raizPara + c.slice(raizDe.length)] = clone(estado);
960
+ inverse = { op: "remove_section", container: op.container, id: op.cloneId, ...(ordemAntesDaSemeadura !== undefined ? { ordemAnterior: ordemAntesDaSemeadura } : {}) };
961
+ break;
962
+ }
963
+ case "add_section": {
964
+ const st = next.sections[op.container] ?? {};
965
+ // semeadura reversível da ordem, igual à da duplicação: sem ordem gravada não há onde encaixar
966
+ // `indice`, e a seção nova iria para o fim do container (armadilha medida 4)
967
+ const semeia = op.ordemSemeada && !st.order?.length ? op.ordemSemeada : null;
968
+ const ordemAntesDaSemeadura = semeia ? (st.order ? [...st.order] : null) : undefined;
969
+ if (semeia) st.order = [...semeia];
970
+ const criadas = { ...(st.criadas ?? {}), [op.id]: op.tipo };
971
+ const base = st.order ?? [];
972
+ // com ordem (própria ou semeada), `indice` posiciona; sem ordem nenhuma, a criada renderiza no fim
973
+ let order: string[] | undefined;
974
+ if (base.length) {
975
+ const semId = base.filter((x) => x !== op.id);
976
+ const pos = op.indice != null ? Math.max(0, Math.min(op.indice, semId.length)) : semId.length;
977
+ order = [...semId.slice(0, pos), op.id, ...semId.slice(pos)];
978
+ }
979
+ next.sections[op.container] = { ...st, criadas, ...(order ? { order } : {}) };
980
+ // NADA em `values`: a seção criada mostra os literais do componente (os `fallback` dos primitivos)
981
+ inverse = { op: "remove_section", container: op.container, id: op.id, ...(ordemAntesDaSemeadura !== undefined ? { ordemAnterior: ordemAntesDaSemeadura } : {}) };
982
+ break;
983
+ }
984
+ case "remove_section": {
985
+ const st = next.sections[op.container] ?? {};
986
+ const origem = st.clones?.[op.id];
987
+ // cópia OU seção criada: as duas saem do documento do mesmo jeito, e o inverso devolve
988
+ // `source` (cópia) ou `tipo` (criada) — é o tipo que diz à loja o que renderizar de volta
989
+ const tipoCriada = st.criadas?.[op.id];
990
+ if (!origem && !tipoCriada) {
991
+ inverse = { op: "remove_section", container: op.container, id: op.id };
992
+ break;
993
+ }
994
+ const clones = { ...(st.clones ?? {}) };
995
+ const criadas = { ...(st.criadas ?? {}) };
996
+ // posição no MAPA a que a seção pertence (cópias ou criadas): fora da ordem explícita as duas
997
+ // renderizam na ordem de criação, então desfazer precisa devolvê-la ao mesmo lugar da fila
998
+ const cloneIndex = Object.keys(origem ? clones : criadas).indexOf(op.id);
999
+ if (origem) delete clones[op.id];
1000
+ else delete criadas[op.id];
1001
+ const prefixo = `${op.container}.${op.id}.`;
1002
+ const values: Record<string, EditableValue> = {};
1003
+ for (const k of Object.keys(next.values)) if (k.startsWith(prefixo)) {
1004
+ values[k] = clone(next.values[k]);
1005
+ delete next.values[k];
1006
+ }
1007
+ const declared: Record<string, DeclaredEntry> = {};
1008
+ for (const k of Object.keys(next.declared ?? {})) if (k.startsWith(prefixo)) {
1009
+ declared[k] = { ...next.declared![k] };
1010
+ delete next.declared![k];
1011
+ }
1012
+ // `index` null = a cópia NÃO estava na ordem explícita (renderizava depois das ordenadas); desfazer respeita isso
1013
+ const index = st.order ? st.order.indexOf(op.id) : null;
1014
+ // sem criar chaves `undefined`: um estado com `order: undefined` não é o mesmo objeto que um sem `order`
1015
+ // depois de passar por JSON, e fazia "duplicar → desfazer" parecer que ainda havia mudança para publicar
1016
+ // (verificação v3.13 do Astra, achado 2)
1017
+ // só o mapa que MUDOU entra no estado novo: pendurar um `criadas: {}` num container que nunca
1018
+ // teve criadas (ou vice-versa) inventa uma diferença, e a barra passa a dizer "não publicado" à toa
1019
+ const estadoLimpo: SectionState = origem ? { clones } : { criadas };
1020
+ const ordemSemId = st.order?.filter((x) => x !== op.id);
1021
+ if (ordemSemId) estadoLimpo.order = ordemSemId;
1022
+ const ocultasSemId = st.hidden?.filter((x) => x !== op.id);
1023
+ if (ocultasSemId) estadoLimpo.hidden = ocultasSemId;
1024
+ next.sections[op.container] = { ...st, ...estadoLimpo };
1025
+ // desfazendo uma duplicação que semeou a ordem: a ordem volta a ser o que era (inclusive não existir)
1026
+ if (op.ordemAnterior !== undefined) {
1027
+ if (op.ordemAnterior === null) delete next.sections[op.container].order;
1028
+ else next.sections[op.container].order = [...op.ordemAnterior];
1029
+ }
1030
+ // os estados das listas de dentro da cópia saem com ela (e voltam no desfazer)
1031
+ const raiz = `${op.container}.${op.id}`;
1032
+ const aninhados: ContentDocument["sections"] = {};
1033
+ for (const c of Object.keys(next.sections)) if (c === raiz || c.startsWith(raiz + ".")) { aninhados[c] = clone(next.sections[c]); delete next.sections[c]; }
1034
+ // desfazer devolve a cópia inteira: valores, proveniência, posição, visibilidade e listas de dentro
1035
+ inverse = { op: "restore_section", container: op.container, id: op.id, ...(origem ? { source: origem } : { tipo: tipoCriada }), values, index: index != null && index >= 0 ? index : null, hidden: (st.hidden ?? []).includes(op.id), ...(Object.keys(declared).length ? { declared } : {}), ...(cloneIndex >= 0 ? { cloneIndex } : {}), ...(Object.keys(aninhados).length ? { sections: aninhados } : {}) };
1036
+ break;
1037
+ }
1038
+ case "set_app": {
1039
+ // o inverso é o valor anterior do MESMO campo (ou `null`): um campo por operação, e desfazer devolve
1040
+ // exatamente aquele campo, sem tocar nos outros provedores
1041
+ const anterior = campoDeRastreio(doc.apps?.rastreio, op.campo);
1042
+ inverse = { op: "set_app", app: "rastreio", campo: op.campo, value: anterior ?? null };
1043
+ const novo = comCampoDeRastreio(doc.apps, op.campo, op.value === null ? null : normalizaRastreio(op.campo, op.value));
1044
+ // sem chave `apps: undefined` no documento: depois de passar por JSON ela some, e antes disso seria
1045
+ // uma diferença inventada entre rascunho e publicado
1046
+ if (novo) next.apps = novo;
1047
+ else delete next.apps;
1048
+ break;
1049
+ }
1050
+ case "replace_doc": {
1051
+ // o inverso leva `apps` EXPLÍCITO (`null` quando não havia): é ele que faz desfazer devolver a configuração
1052
+ inverse = { op: "replace_doc", values: clone(doc.values), sections: clone(doc.sections), tokens: clone(doc.tokens), declared: doc.declared ? clone(doc.declared) : undefined, apps: doc.apps ? clone(doc.apps) : null };
1053
+ next.values = clone(op.values);
1054
+ next.sections = clone(op.sections);
1055
+ next.tokens = clone(op.tokens);
1056
+ next.declared = op.declared ? clone(op.declared) : undefined;
1057
+ // `undefined` (ausente, inclusive depois de JSON) = mantém; `null` = remove; objeto = troca
1058
+ if (op.apps !== undefined) {
1059
+ if (op.apps) next.apps = clone(op.apps);
1060
+ else delete next.apps;
1061
+ }
1062
+ break;
1063
+ }
1064
+ case "restore_section": {
1065
+ const st = next.sections[op.container] ?? {};
1066
+ // `tipo` = seção criada (volta para `criadas`); `source` = cópia (volta para `clones`)
1067
+ const ehCriada = op.tipo !== undefined;
1068
+ const mapa = ehCriada ? (st.criadas ?? {}) : (st.clones ?? {});
1069
+ // volta para a MESMA posição da fila (o objeto preserva a ordem de inserção)
1070
+ const pares = Object.entries(mapa).filter(([k]) => k !== op.id);
1071
+ pares.splice(op.cloneIndex != null ? Math.min(op.cloneIndex, pares.length) : pares.length, 0, [op.id, (ehCriada ? op.tipo : op.source) ?? ""]);
1072
+ const refeito = Object.fromEntries(pares);
1073
+ // só volta para a ordem explícita se estava nela; ausente da ordem, continua ausente
1074
+ const order = st.order ? st.order.filter((x) => x !== op.id) : undefined;
1075
+ if (order && op.index != null) order.splice(Math.min(op.index, order.length), 0, op.id);
1076
+ const hidden = op.hidden ? [...new Set([...(st.hidden ?? []), op.id])] : st.hidden;
1077
+ next.sections[op.container] = { ...st, ...(ehCriada ? { criadas: refeito } : { clones: refeito }), ...(order ? { order } : {}), ...(hidden ? { hidden } : {}) };
1078
+ for (const [k, v] of Object.entries(op.values)) next.values[k] = clone(v);
1079
+ if (op.declared) next.declared = { ...(next.declared ?? {}), ...op.declared };
1080
+ if (op.sections) for (const [c, estado] of Object.entries(op.sections)) next.sections[c] = clone(estado);
1081
+ inverse = { op: "remove_section", container: op.container, id: op.id };
1082
+ break;
1083
+ }
1084
+ }
1085
+ next.updatedAt = new Date().toISOString();
1086
+ return { doc: next, inverse };
1087
+ }
1088
+
1089
+ /**
1090
+ * Valor do documento, ou o fallback. A loja NÃO confia cegamente no JSON publicado:
1091
+ * tipo diferente do fallback, `src`/`href` fora do formato → fallback (achado 12 da
1092
+ * revisão adversarial: um valor errado derrubava o SSR da home).
1093
+ */
1094
+ export function resolveValue<T extends EditableValue>(doc: ContentDocument | null | undefined, path: string, fallback: T): T {
1095
+ const v = doc?.values[path];
1096
+ if (v === undefined || v === null) return fallback;
1097
+ if (typeof v !== typeof fallback) return fallback;
1098
+ // BLOCO DE HTML: é o SUFIXO do caminho que diz à loja que este valor é HTML — ela lê o publicado
1099
+ // sem manifesto. Esta é a camada que faz uma regra NOVA valer para quem já publicou, e a que cobre
1100
+ // "voltar para uma versão antiga do documento": o bloco recusado some e a loja mostra o do código.
1101
+ if (typeof v === "string" && isHtmlPath(path) && recusaDeHtml(v)) return fallback;
1102
+ if (typeof v === "object") {
1103
+ // vitrine: o valor só vale se a ESCOLHA for bem formada; senão a loja mostra o que o código traz
1104
+ if ("modo" in (fallback as object) || "modo" in (v as object)) return vitrineValida(v) && "modo" in (fallback as object) ? (v as T) : fallback;
1105
+ if (("src" in v) !== ("src" in (fallback as object))) return fallback;
1106
+ if ("src" in v && (typeof v.src !== "string" || !isSafeUrl(v.src))) return fallback;
1107
+ if ("href" in v && (typeof v.href !== "string" || !isSafeHref(v.href))) return fallback;
1108
+ // campos opcionais só como texto: um objeto em `label`/`alt` viraria filho React inválido
1109
+ const o = v as Record<string, unknown>;
1110
+ if ("alt" in o && o.alt !== undefined && typeof o.alt !== "string") return fallback;
1111
+ if ("label" in o && o.label !== undefined && typeof o.label !== "string") return fallback;
1112
+ }
1113
+ return v as T;
1114
+ }
1115
+
1116
+ /**
1117
+ * O documento vindo do editor é USÁVEL? A loja renderiza com ele; um `tokens`/`sections`/`values` ausente ou
1118
+ * de outro tipo derrubava a página inteira (500) na primeira leitura — e a promessa deste pacote é a
1119
+ * contrária: a loja nunca cai por causa do editor, no pior caso mostra o conteúdo do código.
1120
+ */
1121
+ export function documentoUsavel(d: unknown): d is ContentDocument {
1122
+ const mapa = (v: unknown) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
1123
+ const o = d as { schema?: unknown; shop?: unknown; values?: unknown; sections?: unknown; tokens?: unknown } | null;
1124
+ return Boolean(o) && o!.schema === 1 && typeof o!.shop === "string" && mapa(o!.values) && mapa(o!.sections) && mapa(o!.tokens);
1125
+ }
1126
+
1127
+ /**
1128
+ * A escolha de vitrine que vale para um caminho (documento publicado ou rascunho), com o fallback do código.
1129
+ * É o que a LOJA usa no servidor antes de buscar os produtos na Unbox.
1130
+ */
1131
+ export function escolhaDaVitrine(doc: ContentDocument | null | undefined, path: string, fallback: VitrineValue): VitrineValue {
1132
+ return resolveValue(doc, path, fallback);
1133
+ }
1134
+
1135
+ /**
1136
+ * Ordena/filtra ids conforme o estado do container.
1137
+ *
1138
+ * `tiposRenderizaveis` = os tipos do CATÁLOGO que quem chama sabe instanciar (o catálogo é de cada
1139
+ * loja). Sem ele, as seções criadas ficam de fora — e é o padrão certo: uma lista que não recebeu
1140
+ * catálogo (o eco de uma faixa, por exemplo) só sabe mapear os ids que ela própria renderizou, e
1141
+ * devolver um id sem elemento correspondente produziria um buraco na tela.
1142
+ */
1143
+ export function orderSections(ids: string[], state: SectionState | undefined, tiposRenderizaveis?: Iterable<string>): { visible: string[]; hidden: string[] } {
1144
+ const hidden = new Set(state?.hidden ?? []);
1145
+ const order = state?.order ?? [];
1146
+ // cópias cuja origem existe no código contam como presentes
1147
+ const clones = Object.entries(state?.clones ?? {}).filter(([, src]) => ids.includes(src)).map(([id]) => id);
1148
+ const tipos = tiposRenderizaveis ? new Set(tiposRenderizaveis) : null;
1149
+ const criadas = tipos ? Object.entries(state?.criadas ?? {}).filter(([, tipo]) => tipos.has(tipo)).map(([id]) => id) : [];
1150
+ ids = [...ids, ...clones.filter((c) => !ids.includes(c)), ...criadas.filter((c) => !ids.includes(c) && !clones.includes(c))];
1151
+ const present = new Set(ids);
1152
+ const ordered = [...order.filter((id) => present.has(id)), ...ids.filter((id) => !order.includes(id))];
1153
+ return { visible: ordered.filter((id) => !hidden.has(id)), hidden: ordered.filter((id) => hidden.has(id)) };
1154
+ }
1155
+
1156
+ // ── Manifesto: o que a loja declara como editável (nasce dos primitivos, não de um catálogo)
1157
+ export interface ManifestEntry {
1158
+ path: string;
1159
+ type: EditableType;
1160
+ label?: string;
1161
+ section?: string;
1162
+ container?: string;
1163
+ /**
1164
+ * A PÁGINA em que esta linha foi capturada (o pathname normalizado por `normalizarPagina`:
1165
+ * "/", "/sobre", "/produto/produto"). O editor guarda um manifesto por página e FUNDE manifestos de
1166
+ * páginas diferentes; sem isto, uma linha fundida não sabe mais de onde veio. Ausente = manifesto
1167
+ * de loja anterior à foundation 11.
1168
+ */
1169
+ pagina?: string;
1170
+ fallback: EditableValue;
1171
+ current?: EditableValue;
1172
+ /** cores em uso no elemento (computadas no navegador na seleção): o inspector parte delas */
1173
+ computed?: { color?: string; background?: string };
1174
+ /** false = o elemento existe mas não tem caixa visível agora (resposta fechada, slide escondido, gaveta): a ficha da seção o lista e abre o inspector direto */
1175
+ visible?: boolean;
1176
+ }
1177
+ /**
1178
+ * TIPO DE SEÇÃO — vocabulário fechado, comum a todas as lojas (pedido do Bruno, 07/09): o nome
1179
+ * que qualquer pessoa entende ("banner", "faixa de anúncio", "texto rolante"), ao lado do rótulo
1180
+ * e do trecho de conteúdo daquela loja. Não é catálogo de componentes: a seção continua sendo
1181
+ * escrita livremente; o tipo só nomeia a família para quem edita (e para o chat).
1182
+ */
1183
+ export const SECTION_KINDS = [
1184
+ "cabecalho", "faixa-de-anuncio", "banner", "texto-rolante", "vitrine-de-produtos", "produto-em-destaque",
1185
+ "beneficios", "como-funciona", "depoimentos", "perguntas-frequentes", "galeria", "video", "sobre-a-marca",
1186
+ "comparacao", "newsletter", "contato", "lojas-fisicas", "botao-flutuante", "rodape", "outro",
1187
+ ] as const;
1188
+ export type SectionKind = (typeof SECTION_KINDS)[number];
1189
+ export const SECTION_KIND_LABEL: Record<SectionKind, string> = {
1190
+ cabecalho: "Cabeçalho", "faixa-de-anuncio": "Faixa de anúncio", banner: "Banner", "texto-rolante": "Texto rolante",
1191
+ "vitrine-de-produtos": "Vitrine de produtos", "produto-em-destaque": "Produto em destaque", beneficios: "Benefícios",
1192
+ "como-funciona": "Como funciona", depoimentos: "Depoimentos", "perguntas-frequentes": "Perguntas frequentes", galeria: "Galeria",
1193
+ video: "Vídeo", "sobre-a-marca": "Sobre a marca", comparacao: "Comparação", newsletter: "Newsletter", contato: "Contato",
1194
+ "lojas-fisicas": "Lojas físicas", "botao-flutuante": "Botão flutuante", rodape: "Rodapé", outro: "Seção",
1195
+ };
1196
+
1197
+ export interface ManifestSection {
1198
+ /** posição da seção no CÓDIGO da loja (foundation 5+): não muda quando o lojista reordena */
1199
+ ordemNoCodigo?: number;
1200
+ /**
1201
+ * A PÁGINA em que esta linha foi capturada (pathname normalizado, ver `normalizarPagina`). A mesma
1202
+ * seção pode aparecer no manifesto de várias páginas (o rodapé aparece em todas); ao fundir
1203
+ * manifestos, é este campo que diz de onde cada linha veio. Ausente = manifesto anterior à foundation 11.
1204
+ */
1205
+ pagina?: string;
1206
+ /**
1207
+ * A página onde esta linha foi capturada REAPROVEITA o container sem mandar nele
1208
+ * (`Editable.Sections layout={false}`): a linha serve para achar a seção e editar a copy, nunca para
1209
+ * ordenar nem para ocultar. É por isso que ela vem sem `ordemNoCodigo`, e é o que permite dizer o
1210
+ * motivo VERDADEIRO quando uma reordenação é recusada (ver `recusaDeOrdem`).
1211
+ */
1212
+ semLayout?: boolean;
1213
+ container: string;
1214
+ id: string;
1215
+ label?: string;
1216
+ /** tipo da seção (vocabulário fechado `SECTION_KINDS`) */
1217
+ kind?: SectionKind;
1218
+ /** trecho do conteúdo da seção (primeiro texto que ela mostra, ~60 caracteres): a linha do painel fala o conteúdo, não o id */
1219
+ excerpt?: string;
1220
+ hidden: boolean;
1221
+ /** Seção fixa (cabeçalho, rodapé): não move nem oculta. */
1222
+ fixed?: boolean;
1223
+ /** cópia feita pelo lojista (pode ser removida) */
1224
+ clone?: boolean;
1225
+ /** seção ADICIONADA pelo lojista (não existe no código; pode ser removida) */
1226
+ criada?: boolean;
1227
+ /** tipo do catálogo que a loja instanciou — só faz sentido em `criada` */
1228
+ tipo?: string;
1229
+ /** item de uma LISTA dentro de uma seção (pergunta do FAQ, aviso da faixa): o container é a própria seção */
1230
+ item?: boolean;
1231
+ /** cor de fundo em uso (computada no navegador): o editor parte dela ao trocar o fundo da seção */
1232
+ background?: string;
1233
+ }
1234
+ /**
1235
+ * TIPO DE SEÇÃO QUE A LOJA SABE ADICIONAR — o catálogo CURADO daquela loja (6 a 8 tipos), declarado
1236
+ * por `Editable.Sections catalogo={...}`. O editor NUNCA tem lista própria: 100 lojas, 100 catálogos.
1237
+ * Isto não fura a regra das "4 latas" (não existe catálogo de EDITABILIDADE): o que a loja declara aqui
1238
+ * é o catálogo de COMPONENTES dela, que ela já tem no `registry.ts` — só a fatia que renderiza bem sem
1239
+ * props de receita. O `container` importa: o catálogo é por container (a home tem o dela).
1240
+ */
1241
+ export interface ManifestSectionType {
1242
+ container: string;
1243
+ tipo: string;
1244
+ /** rótulo que o lojista lê no "+" ("Perguntas frequentes") */
1245
+ label: string;
1246
+ /** UMA frase dizendo o que a seção mostra (sem imagem de prévia no v1) */
1247
+ descricao?: string;
1248
+ kind?: SectionKind;
1249
+ }
1250
+ export interface ManifestToken {
1251
+ token: string;
1252
+ label: string;
1253
+ /** valor computado no navegador (cor de fato em uso, com o rascunho aplicado) */
1254
+ current?: string;
1255
+ /** valor do código, sem o rascunho: é o que vale depois de um "reset" */
1256
+ original?: string;
1257
+ }
1258
+ /**
1259
+ * UM EDITÁVEL QUE A LOJA RECUSOU PORQUE ELE CAIU NA RAIZ DO DOCUMENTO (foundation 11+).
1260
+ *
1261
+ * `doc.values` é um mapa PLANO caminho→valor. Um primitivo renderizado fora de qualquer container
1262
+ * (uma seção solta numa página nova, sem `Editable.Sections`/`Editable.Section` em volta) grava um
1263
+ * caminho PELADO: o FAQ grava `titulo`, a newsletter grava `titulo`, e editar um muda o outro em
1264
+ * todas as páginas. Como caminho gravado não tem renomear, a loja não registra esses primitivos: ela
1265
+ * renderiza o literal do código, ignora o documento naquele caminho e os DECLARA aqui. Não é lista
1266
+ * para o lojista: é defeito de quem construiu a loja, e o gate reprova por ela.
1267
+ */
1268
+ export interface ManifestSemContainer {
1269
+ /** o caminho que ele TERIA gravado */
1270
+ path: string;
1271
+ type: EditableType;
1272
+ label?: string;
1273
+ /** a página onde ele foi encontrado (pathname normalizado) */
1274
+ pagina?: string;
1275
+ }
1276
+ export interface Manifest {
1277
+ shop: string;
1278
+ capturedAt: string;
1279
+ url?: string;
1280
+ /** versão da foundation que gerou o manifesto (2 = inline, estilo por elemento, cópias, Icon) */
1281
+ foundation?: number;
1282
+ entries: ManifestEntry[];
1283
+ sections: ManifestSection[];
1284
+ /** tipos que ESTA loja sabe instanciar, por container (foundation 8+). Ausente/vazio = a loja não
1285
+ * oferece "adicionar seção", e `validateOp` recusa todo `add_section`. */
1286
+ tipos?: ManifestSectionType[];
1287
+ /**
1288
+ * DEFEITO DE CONSTRUÇÃO (foundation 11+): os editáveis que esta página tem fora de qualquer
1289
+ * container e que por isso NÃO são editáveis. Lista vazia (ou ausente) = nenhuma porta aberta.
1290
+ */
1291
+ semContainer?: ManifestSemContainer[];
1292
+ tokens: ManifestToken[];
1293
+ /**
1294
+ * APPS (foundation 12): o que a loja tem no AMBIENTE para rastreio, só presença, e o estado da
1295
+ * Conversions API (`EstadoDoCapi`). É o que deixa o painel dizer "veio do cadastro da loja" e o que a
1296
+ * CAPI está fazendo, sem nunca ver o valor. Ausente = loja anterior à 12 (o painel não oferece a
1297
+ * configuração de rastreio para ela).
1298
+ */
1299
+ apps?: ManifestApps;
1300
+ }
1301
+
1302
+ /**
1303
+ * Manifesto + CÓPIAS do documento: uma seção duplicada (ou um item novo de lista) ainda não
1304
+ * repostou o manifesto, mas os caminhos dela são os da origem com o id trocado. Sem isto,
1305
+ * "adicione uma pergunta e escreva X" falhava na segunda ferramenta do mesmo turno.
1306
+ */
1307
+ export function manifestWithClones(manifest: Manifest, doc: ContentDocument): Manifest {
1308
+ const sections: ManifestSection[] = [...manifest.sections];
1309
+ const entries: ManifestEntry[] = [...manifest.entries];
1310
+ const temSecao = (container: string, id: string) => sections.some((x) => x.container === container && x.id === id);
1311
+ const temPath = new Set(entries.map((e) => e.path));
1312
+ // SEÇÕES CRIADAS, antes das cópias. Gêmeas das cópias na projeção, com uma diferença que é a
1313
+ // armadilha 2: não há origem de onde re-derivar. A LINHA da seção nasce aqui, do rascunho (a
1314
+ // verdade sobre o que existe); as ENTRADAS dela chegam quando a loja renderiza a criada e reposta
1315
+ // o manifesto. E a linha postada é RE-CARIMBADA a partir do rascunho: sem isso ela chegaria aqui
1316
+ // parecendo seção do CÓDIGO sem `ordemNoCodigo`, e `ordemPreservaFixas` passaria a recusar TODO
1317
+ // `set_order` daquele container, em silêncio, para sempre (armadilha 1).
1318
+ for (const [container, st] of Object.entries(doc.sections)) {
1319
+ for (const [id, tipo] of Object.entries(st.criadas ?? {})) {
1320
+ const i = sections.findIndex((x) => x.container === container && x.id === id);
1321
+ if (i >= 0) sections[i] = { ...sections[i], criada: true, tipo, clone: undefined, fixed: undefined, ordemNoCodigo: undefined };
1322
+ // a linha nasce do RASCUNHO, não de uma página: a página é a de quem declara aquele container
1323
+ else sections.push({ container, id, hidden: false, criada: true, tipo, pagina: sections.find((x) => x.container === container)?.pagina ?? manifest.url });
1324
+ }
1325
+ }
1326
+ // RECURSIVO (verificação v3.2 do Astra, R1): a cópia de uma seção-mãe (FAQ) leva a SUBÁRVORE inteira —
1327
+ // as seções dos itens (com item/kind/fixed) e os caminhos deles, com container e section remapeados — e as
1328
+ // cópias de dentro dela (pergunta adicionada na cópia) resolvem sobre a base já expandida. Repete até
1329
+ // não haver cópia nova, porque a ordem dos containers no documento é arbitrária.
1330
+ const feitas = new Set<string>();
1331
+ // o laço é limitado pelo NÚMERO DE CÓPIAS (cada volta expande ao menos uma): nunca trunca em silêncio
1332
+ const totalClones = Object.values(doc.sections).reduce((n, st) => n + Object.keys(st.clones ?? {}).length, 0);
1333
+ for (let volta = 0; volta <= totalClones; volta++) {
1334
+ let mudou = false;
1335
+ for (const [container, st] of Object.entries(doc.sections)) {
1336
+ for (const [cloneId, source] of Object.entries(st.clones ?? {})) {
1337
+ const chave = `${container}/${cloneId}`;
1338
+ if (feitas.has(chave)) continue;
1339
+ // a origem tem de ser uma seção que a LOJA renderiza naquele escopo: `Editable.Sections` só aceita
1340
+ // cópia cuja origem está entre os filhos do código (`byId.has(src)`), então cópia-de-cópia encadeada
1341
+ // (b → a → faq) não existe na tela e não pode existir no manifesto (Astra v3.3, achado D).
1342
+ // `duplicate_section` já achata a origem, então documento legítimo nunca encadeia.
1343
+ // `!x.criada` pelo mesmo motivo: a loja não renderiza cópia de seção criada (o filho não está
1344
+ // no JSX), e `validateOp` recusa duplicá-la — então ela também não pode existir aqui.
1345
+ const base = sections.find((x) => x.container === container && x.id === source && !x.clone && !x.criada);
1346
+ if (!base) continue; // origem inexistente, de outra página, ou ela própria uma cópia/criada
1347
+ feitas.add(chave);
1348
+ mudou = true;
1349
+ if (!temSecao(container, cloneId)) sections.push({ container, id: cloneId, label: base.label ? `${base.label} (cópia)` : undefined, hidden: false, clone: true, item: base.item, kind: base.kind, pagina: base.pagina });
1350
+ const raizDe = `${container}.${source}`;
1351
+ const raizPara = `${container}.${cloneId}`;
1352
+ // seções descendentes da origem → mesmas seções sob a cópia (item, kind, fixed, clone preservados)
1353
+ for (const x of [...sections]) {
1354
+ if (x.container !== raizDe && !x.container.startsWith(raizDe + ".")) continue;
1355
+ const novoContainer = raizPara + x.container.slice(raizDe.length);
1356
+ if (!temSecao(novoContainer, x.id)) sections.push({ ...x, container: novoContainer, hidden: false });
1357
+ }
1358
+ // caminhos: prefixo trocado; section/container apontam para a cópia
1359
+ const de = raizDe + ".";
1360
+ const para = raizPara + ".";
1361
+ for (const e of [...entries]) {
1362
+ if (!e.path.startsWith(de)) continue;
1363
+ const path = para + e.path.slice(de.length);
1364
+ if (temPath.has(path)) continue;
1365
+ temPath.add(path);
1366
+ const direto = e.section === source && e.container === container;
1367
+ const containerNovo = !direto && e.container && (e.container === raizDe || e.container.startsWith(raizDe + ".")) ? raizPara + e.container.slice(raizDe.length) : e.container;
1368
+ entries.push({ ...e, path, section: direto ? cloneId : e.section, container: containerNovo, current: doc.values[path] });
1369
+ }
1370
+ }
1371
+ }
1372
+ if (!mudou) break;
1373
+ }
1374
+ // PROJEÇÃO pelo rascunho ATUAL (revisão v3 do Astra, achados 5 e 6; R2): cópia que o rascunho não tem mais
1375
+ // some COM a subárvore (itens e caminhos dela); visibilidade e ordem vêm do documento, não do último
1376
+ // manifesto postado — "oculte o último item e adicione outro" no mesmo turno escolhe o último VISÍVEL
1377
+ // morta = cópia ou criada que o rascunho não tem mais. O prefixo reservado entra na conta para o
1378
+ // caso de a linha ter chegado sem a marca `criada`: id `novo-…` nunca é seção do código.
1379
+ const mortas = sections.filter((x) =>
1380
+ (x.clone && !doc.sections[x.container]?.clones?.[x.id]) ||
1381
+ ((x.criada || ehIdDeCriada(x.id)) && !doc.sections[x.container]?.criadas?.[x.id]),
1382
+ );
1383
+ const raizesMortas = mortas.map((x) => `${x.container}.${x.id}`);
1384
+ const semAncestral = (container: string) => raizesMortas.some((r) => container === r || container.startsWith(r + "."));
1385
+ const vivas = sections
1386
+ .filter((x) => !mortas.includes(x) && !semAncestral(x.container))
1387
+ .map((x) => ({ ...x, hidden: (doc.sections[x.container]?.hidden ?? []).includes(x.id) }));
1388
+ const porContainer = new Map<string, ManifestSection[]>();
1389
+ for (const x of vivas) porContainer.set(x.container, [...(porContainer.get(x.container) ?? []), x]);
1390
+ const ordenadas: ManifestSection[] = [];
1391
+ for (const [container, lista] of porContainer) {
1392
+ const ordem = doc.sections[container]?.order ?? [];
1393
+ const explicitas = ordem.map((id) => lista.find((y) => y.id === id)).filter((y): y is ManifestSection => Boolean(y));
1394
+ ordenadas.push(...explicitas, ...lista.filter((y) => !ordem.includes(y.id)));
1395
+ }
1396
+ const prefixosMortos = raizesMortas.map((r) => r + ".");
1397
+ const entradas = prefixosMortos.length ? entries.filter((e) => !prefixosMortos.some((p) => e.path.startsWith(p))) : entries;
1398
+ return { ...manifest, sections: ordenadas, entries: entradas };
1399
+ }
1400
+
1401
+ /**
1402
+ * A ordem pedida mantém cada seção FIXA no lugar? A conta é sobre a sequência EFETIVA que a loja renderiza
1403
+ * (`orderSections`: o que a ordem cita primeiro, depois o que ela omite, na ordem do código) e sobre a
1404
+ * posição entre as seções do CÓDIGO — adicionar ou mover cópias continua livre.
1405
+ */
1406
+ export function ordemPreservaFixas(order: string[], secoesDoContainer: ManifestSection[]): boolean {
1407
+ return recusaDeOrdem(order, secoesDoContainer) === null;
1408
+ }
1409
+
1410
+ /**
1411
+ * POR QUE esta ordem não vale? `null` = vale. Existe porque a recusa era MUDA em dois casos e o
1412
+ * lojista via só o arrasto voltar para o lugar:
1413
+ *
1414
+ * · a página REAPROVEITA o container sem mandar nele (`Editable.Sections layout={false}`): as linhas
1415
+ * chegam sem `ordemNoCodigo` de propósito, porque a ordem daquela página não é a do container;
1416
+ * · o container não tem lista nenhuma no código (seções soltas, como o cabeçalho e o rodapé em
1417
+ * `chrome`): não existe ordem original contra a qual conferir a posição da seção fixa.
1418
+ *
1419
+ * As frases saem daqui e vão para a tela como estão: são o motivo VERDADEIRO, sem contar encanamento.
1420
+ */
1421
+ export function recusaDeOrdem(order: string[], linhasDoContainer: ManifestSection[]): string | null {
1422
+ // UMA LINHA POR SEÇÃO (foundation 11). O editor funde manifestos de páginas diferentes, e a mesma
1423
+ // seção chega duas vezes quando outra página reaproveita o container (`layout={false}`): uma linha
1424
+ // COM `ordemNoCodigo`, da página que manda na ordem, e uma sem, da página que só reaproveita. Vale
1425
+ // a que manda; sem esta escolha, a linha sem ordem faria a conta abaixo recusar toda reordenação da
1426
+ // home só porque a página de produto também foi aberta.
1427
+ const porId = new Map<string, ManifestSection>();
1428
+ for (const s of linhasDoContainer) {
1429
+ const atual = porId.get(s.id);
1430
+ if (!atual || (typeof atual.ordemNoCodigo !== "number" && typeof s.ordemNoCodigo === "number")) porId.set(s.id, s);
1431
+ }
1432
+ const secoesDoContainer = [...porId.values()];
1433
+ // A sequência de ORIGEM é a do CÓDIGO, não a que o manifesto traz (que já vem reordenada pelo rascunho):
1434
+ // é ela que a loja usa para completar o que a ordem omite, e comparar contra a reordenada deixava passar
1435
+ // ordem parcial que empurra a fixa — e recusava ordem parcial legítima (Astra v3.18, achado 3).
1436
+ // ARMADILHA 1: "não é cópia" NÃO quer dizer "é do código". Uma seção CRIADA não tem posição no
1437
+ // código, e tratá-la como original fazia `every(ordemNoCodigo)` falhar e RECUSAR, em silêncio, todo
1438
+ // `set_order` do container. Criada anda com as cópias — livre para ir a qualquer lugar.
1439
+ const criada = (s: ManifestSection) => Boolean(s.criada) || ehIdDeCriada(s.id);
1440
+ const originaisDoCodigo = secoesDoContainer.filter((s) => !s.clone && !criada(s));
1441
+ const fixas = secoesDoContainer.filter((s) => s.fixed);
1442
+ if (!fixas.length) return null; // nada a proteger neste container
1443
+ // sem a ordem DECLARADA pelo código não dá para validar: a sequência do manifesto já vem reordenada pelo
1444
+ // rascunho, e usá-la deixava passar ordem parcial que empurra a fixa (Astra v3.19, achado 3). Sem
1445
+ // procedência, a operação é RECUSADA — a loja recaptura o manifesto e ela passa a valer.
1446
+ if (!originaisDoCodigo.every((s) => typeof s.ordemNoCodigo === "number")) {
1447
+ // tudo que o código traz aqui é fixo (o `chrome`: cabeçalho e rodapé): não há ordem a mudar
1448
+ if (originaisDoCodigo.every((s) => s.fixed)) return "estas seções ficam onde estão";
1449
+ // a página só reaproveita a lista; quem manda na ordem é a página que a renderiza por inteiro
1450
+ if (secoesDoContainer.some((s) => s.semLayout)) return "esta página reaproveita estas seções; a ordem delas se edita na página de origem";
1451
+ return "não dá para mudar a ordem destas seções por aqui";
1452
+ }
1453
+ const originais = [...originaisDoCodigo].sort((a, b) => (a.ordemNoCodigo as number) - (b.ordemNoCodigo as number)).map((s) => s.id);
1454
+ const copias = secoesDoContainer.filter((s) => s.clone || criada(s)).map((s) => s.id);
1455
+ const base = [...originais, ...copias]; // mesma regra de `orderSections`: código primeiro, cópias e criadas depois
1456
+ const efetiva = [...order.filter((id) => base.includes(id)), ...base.filter((id) => !order.includes(id))];
1457
+ const depois = efetiva.filter((id) => originais.includes(id));
1458
+ return fixas.every((f) => originais.indexOf(f.id) === depois.indexOf(f.id)) ? null : "seção fixa não muda de lugar";
1459
+ }
1460
+
1461
+ /** Valida uma operação contra o manifesto: caminho existe, tipo bate, token permitido. */
1462
+ /**
1463
+ * Teto de UM texto (título, parágrafo, `alt`, rótulo de link). É EXPORTADO porque o painel mostra o
1464
+ * contador de caracteres do campo com este número, e uma cópia dele lá viraria, no primeiro ajuste
1465
+ * feito aqui, um contador que promete o que o servidor recusa.
1466
+ */
1467
+ export const TEXTO_MAX = 4000;
1468
+ /**
1469
+ * Valor bem-formado: string curta, ou objeto SÓ com os campos esperados, todos string.
1470
+ * Não olha o TIPO da entrada de propósito — `{src, alt}` serve tanto a imagem quanto a vídeo;
1471
+ * quem cobra que o tipo bate com o caminho é `validateOp`.
1472
+ *
1473
+ * O `tipo` é a ÚNICA exceção, e existe por causa do HTML: ele é string como o texto, mas tem teto
1474
+ * próprio (20.000 contra 4.000) e lista de recusa própria. Sem receber o tipo, um bloco legítimo de
1475
+ * 6.000 caracteres seria recusado como "texto grande demais" — a mensagem errada para o lojista.
1476
+ */
1477
+ export function validateValueShape(v: unknown, tipo?: EditableType): { ok: true; value: EditableValue } | { ok: false; reason: string } {
1478
+ if (tipo === "html") {
1479
+ if (typeof v !== "string") return { ok: false, reason: "bloco de HTML precisa ser texto" };
1480
+ const recusa = recusaDeHtml(v);
1481
+ return recusa ? { ok: false, reason: recusa } : { ok: true, value: v };
1482
+ }
1483
+ if (typeof v === "string") return v.length <= TEXTO_MAX ? { ok: true, value: v } : { ok: false, reason: `texto acima de ${TEXTO_MAX} caracteres` };
1484
+ if (!v || typeof v !== "object" || Array.isArray(v)) return { ok: false, reason: "valor precisa ser texto ou objeto" };
1485
+ const o = v as Record<string, unknown>;
1486
+ if ("modo" in o) return vitrineValida(o) ? { ok: true, value: o as unknown as EditableValue } : { ok: false, reason: "escolha de vitrine inválida (modo, categoria/produtos/busca, limite)" };
1487
+ const chaves = Object.keys(o);
1488
+ const permitidas = "src" in o ? ["src", "alt"] : "href" in o ? ["href", "label"] : ["color", "background"];
1489
+ for (const k of chaves) {
1490
+ if (!permitidas.includes(k)) return { ok: false, reason: `campo inesperado: ${k}` };
1491
+ if (o[k] !== undefined && (typeof o[k] !== "string" || (o[k] as string).length > TEXTO_MAX)) return { ok: false, reason: `${k} precisa ser texto` };
1492
+ }
1493
+ if ("src" in o && typeof o.src !== "string") return { ok: false, reason: "src precisa ser texto" };
1494
+ if ("href" in o && typeof o.href !== "string") return { ok: false, reason: "href precisa ser texto" };
1495
+ return { ok: true, value: o as unknown as EditableValue };
1496
+ }
1497
+
1498
+ export function validateOp(op: PatchOp, manifest: Manifest): { ok: true } | { ok: false; reason: string } {
1499
+ const byPath = new Map(manifest.entries.map((e) => [e.path, e]));
1500
+ const tokens = new Set(manifest.tokens.map((t) => t.token));
1501
+ const sectionIds = new Set(manifest.sections.map((s) => `${s.container}/${s.id}`));
1502
+ // os containers que este manifesto CONHECE: é contra eles que um editável SEM seção em volta prova que
1503
+ // nasceu de um container. `useEditable` (valor sem elemento) chega sem `container` e é legítimo
1504
+ // (`home.hero.cor`); o `texto.1` de uma newsletter solta, num manifesto anterior à foundation 11,
1505
+ // chega igualzinho (sem container, com ponto) e é raiz. A diferença é o começo do caminho.
1506
+ const containers = new Set<string>([...manifest.sections.map((s) => s.container), ...manifest.entries.map((e) => e.container ?? "")].filter(Boolean));
1507
+ const nasceDeContainer = (path: string) => [...containers].some((c) => path.startsWith(`${c}.`));
1508
+ const foraDeSecao = (e: ManifestEntry | undefined, path: string) => Boolean(e) && !e?.container && !nasceDeContainer(path);
1509
+ const RAIZ = (path: string) => ({ ok: false as const, reason: `este campo está fora de qualquer seção e não pode ser gravado: ${path}` });
1510
+ // campos internos (só o inverso de desfazer os carrega) não vêm do cliente
1511
+ if ((op.op === "set" || op.op === "unset") && "declaredAnterior" in op && op.declaredAnterior !== undefined) return { ok: false, reason: "campo interno (declaredAnterior) não é aceito" };
1512
+ // A RAIZ DO DOCUMENTO NÃO SE GRAVA (foundation 11). `values` é plano e cego a página: um caminho sem
1513
+ // container valeria para o site inteiro e bateria com o de outra página, e caminho gravado não tem
1514
+ // renomear. A loja já não registra esses caminhos; aqui é a porta de quem GRAVA, e ela vale mesmo
1515
+ // contra um manifesto de loja antiga que ainda os liste. Seção sem container é a mesma porta.
1516
+ if ((op.op === "set" || op.op === "unset") && caminhoNaRaiz(op.path)) return { ok: false, reason: `este campo está fora de qualquer seção e não pode ser gravado: ${op.path}` };
1517
+ if ("container" in op && !op.container) return { ok: false, reason: "operação de seção sem container" };
1518
+ switch (op.op) {
1519
+ case "set": {
1520
+ // estilo por elemento: `<caminho>.estilo` existe se o caminho base existe; e por SEÇÃO:
1521
+ // `<container>.<id>.estilo` (só `background`) pinta o fundo da seção inteira
1522
+ if (isStylePath(op.path)) {
1523
+ const base = op.path.slice(0, -ESTILO.length);
1524
+ const be = byPath.get(base);
1525
+ const secao = !be && manifest.sections.find((x) => `${x.container}.${x.id}` === base);
1526
+ if (!be && !secao) return { ok: false, reason: `caminho inexistente: ${base}` };
1527
+ if (foraDeSecao(be, base)) return RAIZ(op.path);
1528
+ if (be && be.type !== "text" && be.type !== "link") return { ok: false, reason: `estilo só em texto e link (${base} é ${be.type})` };
1529
+ if (secao && typeof op.value === "object" && op.value !== null && "color" in op.value) return { ok: false, reason: "em seção, o estilo é só o fundo (background)" };
1530
+ if (typeof op.value !== "object" || op.value === null) return { ok: false, reason: "estilo precisa ser um objeto {color, background}" };
1531
+ const st = op.value as Record<string, unknown>;
1532
+ for (const k of Object.keys(st)) {
1533
+ if (k !== "color" && k !== "background") return { ok: false, reason: `estilo não aceita ${k}` };
1534
+ if (typeof st[k] !== "string" || !isColor(st[k] as string)) return { ok: false, reason: `cor inválida em ${k}` };
1535
+ }
1536
+ return { ok: true };
1537
+ }
1538
+ const e = byPath.get(op.path);
1539
+ if (!e) return { ok: false, reason: `caminho inexistente: ${op.path}` };
1540
+ // manifesto de loja anterior à foundation 11 ainda lista o editável de uma lista solta (`item-3.texto`,
1541
+ // container "") ou de uma seção solta (`texto.1`, sem container nenhum): têm ponto, e são raiz do
1542
+ // mesmo jeito, porque não começam em container que o manifesto conheça
1543
+ if (foraDeSecao(e, op.path)) return RAIZ(op.path);
1544
+ // BLOCO DE HTML — esta é a camada que LIMPA e RELATA: é aqui que o lojista fica sabendo por que
1545
+ // o bloco não entrou. Tipo e sufixo andam JUNTOS, e a coerência é obrigatória, não cosmética:
1546
+ // a loja lê o publicado sem manifesto, então um caminho de tipo html SEM o sufixo `.html`
1547
+ // publicaria HTML que `resolveValue` não reconheceria como HTML — e a defesa da loja e a do
1548
+ // primitivo não valeriam para ele. Na direção inversa, o sufixo em caminho de outro tipo faria
1549
+ // a loja rodar a lista de recusa sobre um texto comum e trocá-lo pelo literal do código.
1550
+ if (e.type === "html" || isHtmlPath(op.path)) {
1551
+ if (e.type !== "html") return { ok: false, reason: `o sufixo "${SUFIXO_HTML}" é reservado ao bloco de HTML: ${op.path}` };
1552
+ if (!isHtmlPath(op.path)) return { ok: false, reason: `caminho de bloco de HTML precisa terminar em "${SUFIXO_HTML}": ${op.path}` };
1553
+ const html = validateValueShape(op.value, "html");
1554
+ return html.ok ? { ok: true } : html;
1555
+ }
1556
+ const forma = validateValueShape(op.value);
1557
+ if (!forma.ok) return forma;
1558
+ const tv = typeOfValue(op.value);
1559
+ // duas exceções, pelo mesmo motivo: a FORMA não carrega o tipo. Cor é uma string como qualquer texto,
1560
+ // e vídeo é `{src, alt}` como qualquer imagem — `typeOfValue` devolve "text"/"image" nos dois casos e
1561
+ // recusaria o valor legítimo do caminho.
1562
+ if (tv !== e.type && !(e.type === "color" && tv === "text") && !(e.type === "video" && tv === "image")) return { ok: false, reason: `tipo ${tv} não serve em ${op.path} (${e.type})` };
1563
+ if (e.type === "color" && typeof op.value === "string" && !isColor(op.value)) return { ok: false, reason: `cor inválida: ${op.value}` };
1564
+ if ((e.type === "image" || e.type === "video") && typeof op.value === "object" && "src" in op.value && !isSafeUrl(op.value.src)) return { ok: false, reason: `${e.type === "video" ? "vídeo" : "imagem"} com URL inválida` };
1565
+ if (e.type === "link" && typeof op.value === "object" && "href" in op.value && !isSafeHref(op.value.href)) return { ok: false, reason: `link inválido` };
1566
+ return { ok: true };
1567
+ }
1568
+ case "unset": {
1569
+ const base = isStylePath(op.path) ? op.path.slice(0, -ESTILO.length) : op.path;
1570
+ if (foraDeSecao(byPath.get(base), base)) return RAIZ(op.path);
1571
+ return byPath.has(op.path) || (isStylePath(op.path) && (byPath.has(base) || manifest.sections.some((x) => `${x.container}.${x.id}` === base))) ? { ok: true } : { ok: false, reason: `caminho inexistente: ${op.path}` };
1572
+ }
1573
+ case "set_token":
1574
+ if (!tokens.has(op.token)) return { ok: false, reason: `token não editável: ${op.token}` };
1575
+ if (!isColor(op.value)) return { ok: false, reason: `cor inválida: ${op.value}` };
1576
+ return { ok: true };
1577
+ case "unset_token":
1578
+ return tokens.has(op.token) ? { ok: true } : { ok: false, reason: `token não editável: ${op.token}` };
1579
+ case "set_order": {
1580
+ const doContainer = manifest.sections.filter((s) => s.container === op.container);
1581
+ const known = doContainer.map((s) => s.id);
1582
+ const bad = op.order.filter((id) => !known.includes(id));
1583
+ if (bad.length) return { ok: false, reason: `seções desconhecidas: ${bad.join(", ")}` };
1584
+ if (new Set(op.order).size !== op.order.length) return { ok: false, reason: "ordem com id repetido" };
1585
+ // seção FIXA não muda de lugar (v3.16, achado 3) — e a conta é sobre a sequência EFETIVA: o que a ordem
1586
+ // OMITE não fica parado, vai para o fim, e era assim que se empurrava o cabeçalho (v3.17, achado 4).
1587
+ const recusa = recusaDeOrdem(op.order, doContainer);
1588
+ if (recusa) return { ok: false, reason: recusa };
1589
+ return { ok: true };
1590
+ }
1591
+ case "unset_order":
1592
+ return { ok: true };
1593
+ case "hide_section": {
1594
+ const sec = manifest.sections.find((x) => x.container === op.container && x.id === op.id);
1595
+ if (!sec) return { ok: false, reason: `seção inexistente: ${op.id}` };
1596
+ if (sec.fixed) return { ok: false, reason: `seção fixa não se oculta: ${op.id}` };
1597
+ return { ok: true };
1598
+ }
1599
+ case "show_section":
1600
+ return sectionIds.has(`${op.container}/${op.id}`) ? { ok: true } : { ok: false, reason: `seção inexistente: ${op.id}` };
1601
+ case "set_app": {
1602
+ if (op.app !== "rastreio") return { ok: false, reason: `app desconhecido: ${String(op.app)}` };
1603
+ if (!CAMPOS_DE_RASTREIO.includes(op.campo)) return { ok: false, reason: `campo de rastreio desconhecido: ${String(op.campo)}` };
1604
+ // a loja precisa LER `apps` para o valor ter efeito: abaixo da foundation 12 ele entraria no documento
1605
+ // e não mudaria nada na página, e o lojista publicaria achando que o rastreio está no ar
1606
+ if ((manifest.foundation ?? 0) < 12) return { ok: false, reason: "esta loja ainda não aceita a configuração de rastreio pelo painel" };
1607
+ if (op.value === null) return { ok: true };
1608
+ if (typeof op.value !== "string") return { ok: false, reason: "o valor precisa ser texto" };
1609
+ const v = normalizaRastreio(op.campo, op.value);
1610
+ if (v === "") return { ok: true }; // vazio = remover
1611
+ const recusa = recusaDeRastreio(op.campo, v);
1612
+ return recusa ? { ok: false, reason: recusa } : { ok: true };
1613
+ }
1614
+ case "restore_section":
1615
+ case "replace_doc":
1616
+ return { ok: false, reason: "operação interna (só como inverso de desfazer)" };
1617
+ case "duplicate_section": {
1618
+ // campo interno: quem semeia a ordem é o servidor, DEPOIS desta validação (Astra v3.15, achado 1)
1619
+ if (op.ordemSemeada !== undefined) return { ok: false, reason: "campo interno não aceito do cliente: ordemSemeada" };
1620
+ const sec = manifest.sections.find((x) => x.container === op.container && x.id === op.id);
1621
+ if (!sec) return { ok: false, reason: `seção inexistente: ${op.id}` };
1622
+ if (sec.fixed) return { ok: false, reason: `seção fixa não se duplica: ${op.id}` };
1623
+ // duplicar uma seção CRIADA fica fora do v1: a cópia renderiza o componente da ORIGEM, e a loja só
1624
+ // acha a origem entre os filhos do JSX — uma criada não está lá, então a cópia não apareceria na tela
1625
+ if (sec.criada || ehIdDeCriada(op.id)) return { ok: false, reason: `esta seção foi adicionada por você; para ter outra igual, adicione outra do mesmo tipo` };
1626
+ if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(op.cloneId)) return { ok: false, reason: `id de cópia inválido: ${op.cloneId}` };
1627
+ // o prefixo `novo-` é reservado às seções adicionadas: uma cópia com esse id seria lida como criada
1628
+ if (ehIdDeCriada(op.cloneId)) return { ok: false, reason: `id de cópia não pode começar com "${PREFIXO_CRIADA}"` };
1629
+ if (sectionIds.has(`${op.container}/${op.cloneId}`)) return { ok: false, reason: `já existe uma seção ${op.cloneId}` };
1630
+ return { ok: true };
1631
+ }
1632
+ case "add_section": {
1633
+ // campo interno: quem semeia a ordem é o servidor, DEPOIS desta validação (como na duplicação)
1634
+ if (op.ordemSemeada !== undefined) return { ok: false, reason: "campo interno não aceito do cliente: ordemSemeada" };
1635
+ // o catálogo é da LOJA — o editor não tem lista própria. Sem tipos declarados, a loja não oferece
1636
+ // adicionar seção (foundation antiga, ou container sem catálogo), e a operação não existe ali.
1637
+ const doCatalogo = (manifest.tipos ?? []).filter((t) => t.container === op.container);
1638
+ if (!doCatalogo.length) return { ok: false, reason: `esta loja não declara seções para adicionar em ${op.container}` };
1639
+ if (!doCatalogo.some((t) => t.tipo === op.tipo)) return { ok: false, reason: `tipo de seção indisponível: ${op.tipo}` };
1640
+ // id RESERVADO: `novo-<tipo>-<n>`. É o que impede a colisão com um id que o construtor escreva no
1641
+ // código depois — e é por ele que a projeção reconhece uma criada mesmo sem a marca no manifesto.
1642
+ if (!ehIdDeCriada(op.id)) return { ok: false, reason: `id de seção adicionada precisa começar com "${PREFIXO_CRIADA}": ${op.id}` };
1643
+ if (!/^[a-z0-9][a-z0-9-]{0,60}$/.test(op.id)) return { ok: false, reason: `id inválido: ${op.id}` };
1644
+ if (sectionIds.has(`${op.container}/${op.id}`)) return { ok: false, reason: `já existe uma seção ${op.id}` };
1645
+ if (op.indice !== undefined && (!Number.isInteger(op.indice) || op.indice < 0)) return { ok: false, reason: `posição inválida: ${op.indice}` };
1646
+ return { ok: true };
1647
+ }
1648
+ case "remove_section": {
1649
+ // campo interno: só o inverso de duplicar o carrega — vindo do cliente, uma ordem forjada quebra o
1650
+ // desfazer e escreve uma ordem inválida (verificação v3.15 do Astra, achado 1)
1651
+ if (op.ordemAnterior !== undefined) return { ok: false, reason: "campo interno não aceito do cliente: ordemAnterior" };
1652
+ const sec = manifest.sections.find((x) => x.container === op.container && x.id === op.id);
1653
+ if (!sec) return { ok: false, reason: `seção inexistente: ${op.id}` };
1654
+ // criada entra aqui junto com a cópia: as duas são do lojista, então saem; a do código se oculta
1655
+ if (!sec.clone && !sec.criada) return { ok: false, reason: `só cópias e seções adicionadas podem ser removidas; a original se oculta` };
1656
+ return { ok: true };
1657
+ }
1658
+ }
1659
+ }
1660
+
1661
+ /**
1662
+ * Tipo DEDUZIDO da forma do valor. Não sabe dizer "video" (mesma forma da imagem), "color" nem "html"
1663
+ * (mesma forma do texto): quem sabe o tipo é o manifesto. `validateOp` abre exceção para os três — a
1664
+ * do html vem antes, no ramo próprio, porque ali o TETO e a lista de recusa também mudam.
1665
+ */
1666
+ export function typeOfValue(v: EditableValue): EditableType {
1667
+ if (typeof v === "string") return "text"; // html e cor caem aqui também — o tipo real vem do manifesto
1668
+ if ("src" in v) return "image"; // vídeo cai aqui também — o tipo real vem da entrada do manifesto
1669
+ if ("href" in v) return "link";
1670
+ if ("modo" in v) return "vitrine"; // escolha de produtos; sem isto o patch do seletor era lido como cor
1671
+ return "color";
1672
+ }
1673
+
1674
+ /** Todo texto visível dentro de um valor (texto, rótulo de link, alt de imagem) — para a verificação de honestidade. */
1675
+ export function textsOfValue(v: EditableValue): string[] {
1676
+ if (typeof v === "string") return [v];
1677
+ const o = v as Record<string, unknown>;
1678
+ return ["label", "alt"].map((k) => o[k]).filter((x): x is string => typeof x === "string" && x.length > 0);
1679
+ }
1680
+
1681
+ /** Estilo de um elemento (só cores válidas passam; o resto é ignorado). */
1682
+ export function resolveStyle(doc: ContentDocument | null | undefined, path: string): { color?: string; background?: string } | undefined {
1683
+ const v = doc?.values[path + ESTILO];
1684
+ if (!v || typeof v !== "object") return undefined;
1685
+ const st = v as Record<string, unknown>;
1686
+ const out: { color?: string; background?: string } = {};
1687
+ if (typeof st.color === "string" && isColor(st.color)) out.color = st.color;
1688
+ if (typeof st.background === "string" && isColor(st.background)) out.background = st.background;
1689
+ return out.color || out.background ? out : undefined;
1690
+ }
1691
+
1692
+ /**
1693
+ * Cor em formato FECHADO. O valor vai parar dentro de um `<style>` (tokens) — por isso
1694
+ * dentro dos parênteses só entram dígitos, letras, ponto, vírgula, %, espaço, barra e
1695
+ * hífen. Nada de `<`, `>`, `;`, `}` ou aspas: `rgb(</style><script>…)` passava por um
1696
+ * `[^)]` e viraria XSS.
1697
+ */
1698
+ export function isColor(v: string): boolean {
1699
+ const t = v.trim();
1700
+ if (t.length > 64) return false;
1701
+ return /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(t) || /^(rgb|hsl|oklch|oklab)a?\([0-9a-z.,%\s\/-]{1,48}\)$/i.test(t);
1702
+ }
1703
+ /** `https://…` ou caminho absoluto. `\` fica fora: `/\evil.com` é lido pelo navegador como `//evil.com`. */
1704
+ export function isSafeUrl(v: string): boolean {
1705
+ return /^(https:\/\/|\/(?![\/\\]))[^\s"'<>\\]{1,2000}$/.test(v);
1706
+ }
1707
+ export function isSafeHref(v: string): boolean {
1708
+ return /^(https?:\/\/|\/(?![\/\\])|#|mailto:|tel:)[^\s"'<>\\]{0,2000}$/.test(v);
1709
+ }
1710
+
1711
+ /** Junta escopo + caminho relativo. Caminho começando com "/" é absoluto. */
1712
+ export function joinPath(scope: string[], path: string): string {
1713
+ if (path.startsWith("/")) return path.slice(1);
1714
+ return [...scope, path].filter(Boolean).join(".");
1715
+ }
1716
+
1717
+ /**
1718
+ * ESTE EDITÁVEL TEM CONTAINER? A porta que esta função fecha (foundation 11):
1719
+ *
1720
+ * `doc.values` é um mapa PLANO caminho→valor, cego a página DE PROPÓSITO (é o que faz o `chrome`
1721
+ * valer no site inteiro com um valor só). O preço disso é que um caminho PELADO, na raiz, também vale
1722
+ * no site inteiro: uma seção renderizada solta numa página nova grava `titulo`, e a seção solta da
1723
+ * página do lado grava `titulo` também. Editar uma muda a outra. E caminho gravado não tem renomear.
1724
+ *
1725
+ * Então a regra é: só é editável o caminho que NASCE de um container em vigor.
1726
+ * · relativo: precisa de um container em vigor (`Editable.Section` dentro de `Editable.Sections
1727
+ * container=…`, ou `Editable.Section container=…`) e o caminho inteiro tem de começar nele. Não
1728
+ * basta "ter ponto": um `Editable.Scope` solto na raiz produz `atributos.titulo`, que parece
1729
+ * endereçado e é raiz do mesmo jeito (medido na /oferta de uma loja piloto: a faixa de atributos solta
1730
+ * gravaria `item-N.texto` com container vazio);
1731
+ * · absoluto (`path="/home.faq.titulo"`): o construtor escreveu o endereço inteiro de propósito → sim,
1732
+ * desde que tenha um container na frente;
1733
+ * · um segmento só (`"titulo"`) = raiz, venha de onde vier → não.
1734
+ */
1735
+ export function caminhoTemContainer(container: string | undefined, scope: string[], path: string): boolean {
1736
+ const full = joinPath(scope, path);
1737
+ if (!full.includes(".")) return false; // chave pelada na raiz do documento
1738
+ if (path.startsWith("/")) return true;
1739
+ return Boolean(container) && full.startsWith(`${container}.`);
1740
+ }
1741
+
1742
+ /**
1743
+ * Caminho de VALOR que cairia na raiz do documento (`titulo`, `titulo.estilo`): sem container na
1744
+ * frente. É a mesma pergunta de `caminhoTemContainer`, feita do lado de quem GRAVA (`validateOp`),
1745
+ * que não tem escopo nem container em mãos, só o caminho.
1746
+ */
1747
+ export function caminhoNaRaiz(path: string): boolean {
1748
+ const base = isStylePath(path) ? path.slice(0, -ESTILO.length) : path;
1749
+ return !base.includes(".");
1750
+ }
1751
+
1752
+ /**
1753
+ * A PÁGINA de um manifesto, na forma em que ela entra em `Manifest.url` e em cada linha (`pagina`):
1754
+ * só o caminho, sempre com "/" na frente, sem query, sem hash e sem barra no fim (a raiz é "/").
1755
+ * Sem isto "/sobre" e "/sobre/" seriam duas páginas diferentes para o editor.
1756
+ */
1757
+ export function normalizarPagina(u: string | null | undefined): string {
1758
+ const bruto = (u ?? "/").split("?")[0].split("#")[0];
1759
+ const comBarra = bruto.startsWith("/") ? bruto : `/${bruto}`;
1760
+ const semFim = comBarra.length > 1 ? comBarra.replace(/\/+$/, "") : comBarra;
1761
+ return semFim || "/";
1762
+ }