@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.
- package/LICENSE.md +46 -0
- package/README.md +175 -0
- package/bin/cli.js +439 -0
- package/package.json +43 -0
- package/src/colors.js +100 -0
- package/src/presets.js +241 -0
- package/src/theme.js +253 -0
- package/template/.claude/agents/assets-marca.md +40 -0
- package/template/.claude/agents/avaliador-visual.md +48 -0
- package/template/.claude/agents/branding-briefing.md +401 -0
- package/template/.claude/agents/conteudo-secao.md +46 -0
- package/template/.claude/agents/paginas-legais.md +44 -0
- package/template/.claude/settings.json +3 -0
- package/template/.claude/skills/frontend-design/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +55 -0
- package/template/.claude/skills/web-design-guidelines/ATTRIBUTION.md +3 -0
- package/template/.claude/skills/web-design-guidelines/SKILL.md +50 -0
- package/template/.claude/skills/web-design-guidelines/guidelines-snapshot.md +180 -0
- package/template/.env.example +158 -0
- package/template/.mcp.json.example +14 -0
- package/template/.nvmrc +1 -0
- package/template/CLAUDE.md +333 -0
- package/template/COVERAGE.md +80 -0
- package/template/DEPLOY.md +179 -0
- package/template/QA.md +108 -0
- package/template/README.md +152 -0
- package/template/agents/CONSTRUCAO.md +207 -0
- package/template/agents/MANAGER.md +192 -0
- package/template/agents/PADROES.md +255 -0
- package/template/agents/definitions/00-scaffold.md +56 -0
- package/template/agents/definitions/01-layout.md +47 -0
- package/template/agents/definitions/02-homepage.md +70 -0
- package/template/agents/definitions/03-catalog.md +86 -0
- package/template/agents/definitions/04-pdp.md +97 -0
- package/template/agents/definitions/05-cart.md +116 -0
- package/template/agents/definitions/06-checkout.md +146 -0
- package/template/agents/definitions/07-auth.md +130 -0
- package/template/agents/definitions/08-customer.md +55 -0
- package/template/agents/definitions/09-promotions.md +62 -0
- package/template/agents/definitions/10-feedback.md +128 -0
- package/template/agents/definitions/11-seo-infra.md +159 -0
- package/template/agents/definitions/12-deploy.md +173 -0
- package/template/agents/definitions/13-cro.md +275 -0
- package/template/agents/definitions/14-seo.md +388 -0
- package/template/agents/definitions/15-branding.md +26 -0
- package/template/agents/definitions/16-qa-visual.md +138 -0
- package/template/agents/definitions/17-aeo.md +257 -0
- package/template/app/(loja)/busca/page.tsx +72 -0
- package/template/app/(loja)/carrinho/oferta/page.tsx +57 -0
- package/template/app/(loja)/carrinho/page.tsx +160 -0
- package/template/app/(loja)/categoria/[tagSlug]/layout.tsx +31 -0
- package/template/app/(loja)/categoria/[tagSlug]/loading.tsx +17 -0
- package/template/app/(loja)/categoria/[tagSlug]/page.tsx +49 -0
- package/template/app/(loja)/checkout/page.tsx +80 -0
- package/template/app/(loja)/checkout/pix/[ref]/page.tsx +133 -0
- package/template/app/(loja)/conta/assinaturas/[referenceId]/page.tsx +112 -0
- package/template/app/(loja)/conta/assinaturas/page.tsx +51 -0
- package/template/app/(loja)/conta/enderecos/page.tsx +20 -0
- package/template/app/(loja)/conta/entrar/page.tsx +119 -0
- package/template/app/(loja)/conta/page.tsx +45 -0
- package/template/app/(loja)/conta/pedidos/[referenceId]/page.tsx +49 -0
- package/template/app/(loja)/conta/pedidos/page.tsx +104 -0
- package/template/app/(loja)/conta/preferencias/page.tsx +24 -0
- package/template/app/(loja)/devolucoes/page.tsx +36 -0
- package/template/app/(loja)/layout.tsx +22 -0
- package/template/app/(loja)/oferta/page.tsx +57 -0
- package/template/app/(loja)/page.tsx +86 -0
- package/template/app/(loja)/pedido/[referenceId]/page.tsx +78 -0
- package/template/app/(loja)/privacidade/page.tsx +113 -0
- package/template/app/(loja)/produto/[productSlug]/layout.tsx +23 -0
- package/template/app/(loja)/produto/[productSlug]/loading.tsx +20 -0
- package/template/app/(loja)/produto/[productSlug]/page.tsx +372 -0
- package/template/app/(loja)/produtos/loading.tsx +16 -0
- package/template/app/(loja)/produtos/page.tsx +33 -0
- package/template/app/(loja)/termos/page.tsx +104 -0
- package/template/app/acesso/page.tsx +129 -0
- package/template/app/api/account/addresses/route.ts +39 -0
- package/template/app/api/account/exists/route.ts +21 -0
- package/template/app/api/account/me/route.ts +25 -0
- package/template/app/api/account/otp/route.ts +26 -0
- package/template/app/api/account/preferences/route.ts +19 -0
- package/template/app/api/account/signin/route.ts +27 -0
- package/template/app/api/account/signout/route.ts +9 -0
- package/template/app/api/acesso/route.ts +171 -0
- package/template/app/api/capi/route.ts +34 -0
- package/template/app/api/cart/coupon/route.ts +49 -0
- package/template/app/api/cart/items/route.ts +45 -0
- package/template/app/api/cart/link/route.ts +69 -0
- package/template/app/api/cart/route.ts +113 -0
- package/template/app/api/cart/share/route.ts +21 -0
- package/template/app/api/cep/[code]/route.ts +18 -0
- package/template/app/api/checkout/address/route.ts +24 -0
- package/template/app/api/checkout/email/route.ts +41 -0
- package/template/app/api/checkout/installments/route.ts +16 -0
- package/template/app/api/checkout/route.ts +110 -0
- package/template/app/api/checkout/shipping/route.ts +65 -0
- package/template/app/api/checkout-destination/route.ts +31 -0
- package/template/app/api/order/[ref]/route.ts +17 -0
- package/template/app/api/payment-link/route.ts +28 -0
- package/template/app/api/revalidate/route.ts +52 -0
- package/template/app/api/shipping/quote/route.ts +29 -0
- package/template/app/api/subscriptions/[id]/route.ts +52 -0
- package/template/app/api/track/route.ts +28 -0
- package/template/app/api/unbox/catalogo/route.ts +136 -0
- package/template/app/api/unbox/paginas/route.ts +167 -0
- package/template/app/api/unbox/vitrine/route.ts +84 -0
- package/template/app/api/webhooks/unbox/route.ts +97 -0
- package/template/app/apple-icon.svg +5 -0
- package/template/app/error.tsx +15 -0
- package/template/app/globals.css +457 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +90 -0
- package/template/app/llms.txt/route.ts +43 -0
- package/template/app/manifest.ts +21 -0
- package/template/app/not-found.tsx +18 -0
- package/template/app/opengraph-image.tsx +35 -0
- package/template/app/robots.ts +31 -0
- package/template/app/sitemap.ts +42 -0
- package/template/bootstrap.sh +87 -0
- package/template/components/account/account-shell.tsx +47 -0
- package/template/components/account/address-book.tsx +147 -0
- package/template/components/account/preferences-form.tsx +77 -0
- package/template/components/account/reorder-button.tsx +47 -0
- package/template/components/account/signout-button.tsx +20 -0
- package/template/components/account/subscription-actions.tsx +204 -0
- package/template/components/account-nav.tsx +41 -0
- package/template/components/address-fields.tsx +102 -0
- package/template/components/analytics/data-layer-ready.tsx +16 -0
- package/template/components/analytics/purchase-tracker.tsx +32 -0
- package/template/components/brand-search.tsx +34 -0
- package/template/components/cart/cart-provider.tsx +340 -0
- package/template/components/cart/mini-cart.tsx +228 -0
- package/template/components/cart-button.tsx +35 -0
- package/template/components/catalog/catalog-client.tsx +523 -0
- package/template/components/catalog/grid-skeleton.tsx +19 -0
- package/template/components/catalog/pager.tsx +66 -0
- package/template/components/catalog/product-grid-card.tsx +52 -0
- package/template/components/catalog/product-grid.tsx +27 -0
- package/template/components/checkout/checkout-client.tsx +1333 -0
- package/template/components/chrome/announce-bar.tsx +57 -0
- package/template/components/chrome/chrome-recipe.ts +13 -0
- package/template/components/chrome/footers/captura-botao.tsx +28 -0
- package/template/components/chrome/footers/colunas.tsx +110 -0
- package/template/components/chrome/footers/conversao.tsx +90 -0
- package/template/components/chrome/footers/editorial.tsx +98 -0
- package/template/components/chrome/footers/minimal.tsx +55 -0
- package/template/components/chrome/header-bar-mobile.tsx +64 -0
- package/template/components/chrome/headers/centralizado.tsx +71 -0
- package/template/components/chrome/headers/classico.tsx +64 -0
- package/template/components/chrome/headers/compacto.tsx +66 -0
- package/template/components/chrome/headers/equilibrado.tsx +73 -0
- package/template/components/chrome/headers/imersivo.tsx +62 -0
- package/template/components/chrome/registry.ts +70 -0
- package/template/components/chrome/solid-on-scroll.tsx +39 -0
- package/template/components/empty-state.tsx +22 -0
- package/template/components/home/combo-card-compact.tsx +67 -0
- package/template/components/home/combos-home.tsx +64 -0
- package/template/components/home/combos-section.tsx +177 -0
- package/template/components/home/home-recipe.ts +41 -0
- package/template/components/home/sections/attributes-marquee.tsx +178 -0
- package/template/components/home/sections/benefits.tsx +80 -0
- package/template/components/home/sections/bloco-html.tsx +47 -0
- package/template/components/home/sections/catalogo.ts +165 -0
- package/template/components/home/sections/category-pills.tsx +32 -0
- package/template/components/home/sections/combos-carousel.tsx +141 -0
- package/template/components/home/sections/comparison.tsx +73 -0
- package/template/components/home/sections/founder-story.tsx +40 -0
- package/template/components/home/sections/hero.tsx +187 -0
- package/template/components/home/sections/kits.tsx +11 -0
- package/template/components/home/sections/media-cards.tsx +70 -0
- package/template/components/home/sections/newsletter.tsx +62 -0
- package/template/components/home/sections/product-showcase.tsx +175 -0
- package/template/components/home/sections/purchase-hero.tsx +187 -0
- package/template/components/home/sections/quote-banner.tsx +50 -0
- package/template/components/home/sections/registry.ts +186 -0
- package/template/components/home/sections/reviews-carousel.tsx +66 -0
- package/template/components/home/sections/reviews.tsx +68 -0
- package/template/components/home/sections/ritual.tsx +96 -0
- package/template/components/home/sections/savings.tsx +47 -0
- package/template/components/home/sections/social-row.tsx +65 -0
- package/template/components/home/sections/spec-table.tsx +117 -0
- package/template/components/home/sections/stars.tsx +12 -0
- package/template/components/home/sections/stats-grid.tsx +54 -0
- package/template/components/home/sections/trust-bar.tsx +56 -0
- package/template/components/home/sections/trust-strip.tsx +40 -0
- package/template/components/home/sections/video-wall.tsx +59 -0
- package/template/components/landing/landing-recipe.ts +20 -0
- package/template/components/landing/oferta-sections.tsx +40 -0
- package/template/components/landing/product-picker.tsx +257 -0
- package/template/components/mobile-nav.tsx +71 -0
- package/template/components/order-status.tsx +163 -0
- package/template/components/powered-by-unbox.tsx +27 -0
- package/template/components/product/pdp/buy-box.tsx +604 -0
- package/template/components/product/pdp/catalog-grid.tsx +113 -0
- package/template/components/product/pdp/faq-modelo.ts +86 -0
- package/template/components/product/pdp/gallery.tsx +87 -0
- package/template/components/product/pdp/interactive.tsx +200 -0
- package/template/components/product/pdp/newsletter.tsx +64 -0
- package/template/components/product/pdp/payment-chips.tsx +22 -0
- package/template/components/product/pdp/pdp-view.tsx +227 -0
- package/template/components/product/pdp/recommendations.tsx +144 -0
- package/template/components/product/pdp/sections.tsx +173 -0
- package/template/components/quantity-stepper.tsx +36 -0
- package/template/components/search-box.tsx +37 -0
- package/template/components/site-footer.tsx +59 -0
- package/template/components/site-header.tsx +64 -0
- package/template/components/ui/accordion.tsx +72 -0
- package/template/components/ui/alert-dialog.tsx +187 -0
- package/template/components/ui/alert.tsx +76 -0
- package/template/components/ui/aspect-ratio.tsx +22 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +52 -0
- package/template/components/ui/breadcrumb.tsx +125 -0
- package/template/components/ui/button.tsx +58 -0
- package/template/components/ui/card.tsx +103 -0
- package/template/components/ui/carousel.tsx +242 -0
- package/template/components/ui/command.tsx +196 -0
- package/template/components/ui/dialog.tsx +160 -0
- package/template/components/ui/dropdown-menu.tsx +268 -0
- package/template/components/ui/foto.tsx +54 -0
- package/template/components/ui/input-group.tsx +158 -0
- package/template/components/ui/input-otp.tsx +87 -0
- package/template/components/ui/input.tsx +20 -0
- package/template/components/ui/label.tsx +20 -0
- package/template/components/ui/navigation-menu.tsx +168 -0
- package/template/components/ui/pagination.tsx +132 -0
- package/template/components/ui/popover.tsx +90 -0
- package/template/components/ui/progress.tsx +83 -0
- package/template/components/ui/radio-group.tsx +38 -0
- package/template/components/ui/scroll-area.tsx +55 -0
- package/template/components/ui/select.tsx +201 -0
- package/template/components/ui/separator.tsx +25 -0
- package/template/components/ui/sheet.tsx +138 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/sonner.tsx +44 -0
- package/template/components/ui/switch.tsx +32 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +82 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +89 -0
- package/template/components/ui/toggle.tsx +45 -0
- package/template/components/ui/tooltip.tsx +66 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +36 -0
- package/template/gitignore +21 -0
- package/template/lib/analytics.ts +369 -0
- package/template/lib/api.ts +60 -0
- package/template/lib/capi.ts +87 -0
- package/template/lib/cart-link.ts +51 -0
- package/template/lib/cart-normalize.ts +142 -0
- package/template/lib/cart-recovery.ts +23 -0
- package/template/lib/cart-response.ts +48 -0
- package/template/lib/catalog-map.ts +54 -0
- package/template/lib/catalog.ts +3 -0
- package/template/lib/checkout-lock.ts +21 -0
- package/template/lib/checkout-nav.ts +22 -0
- package/template/lib/config.ts +51 -0
- package/template/lib/crm.ts +40 -0
- package/template/lib/customer-session.ts +21 -0
- package/template/lib/dataloader.ts +23 -0
- package/template/lib/editable/config.ts +11 -0
- package/template/lib/editable/document.ts +1762 -0
- package/template/lib/editable/index.ts +3 -0
- package/template/lib/editable/primitives.tsx +821 -0
- package/template/lib/editable/provider.tsx +861 -0
- package/template/lib/editable/rastreio-navegacao.tsx +65 -0
- package/template/lib/editable/rastreio.tsx +168 -0
- package/template/lib/editable/server.ts +189 -0
- package/template/lib/editable/tokens.ts +22 -0
- package/template/lib/editable/verify.ts +24 -0
- package/template/lib/enrichment/combos.ts +184 -0
- package/template/lib/enrichment/index.ts +188 -0
- package/template/lib/enrichment/products.json +1 -0
- package/template/lib/env-check.ts +47 -0
- package/template/lib/format.ts +130 -0
- package/template/lib/icons.ts +67 -0
- package/template/lib/json-ld.ts +9 -0
- package/template/lib/llms-txt.ts +75 -0
- package/template/lib/mockup.ts +24 -0
- package/template/lib/newsletter.ts +12 -0
- package/template/lib/orders.ts +160 -0
- package/template/lib/queries.ts +66 -0
- package/template/lib/ratelimit.ts +47 -0
- package/template/lib/rotas-editaveis.ts +192 -0
- package/template/lib/sanitize.ts +36 -0
- package/template/lib/schemas.ts +78 -0
- package/template/lib/session.ts +98 -0
- package/template/lib/store-config.ts +67 -0
- package/template/lib/unbox/client.ts +829 -0
- package/template/lib/unbox/customer.ts +224 -0
- package/template/lib/unbox/errors.ts +97 -0
- package/template/lib/unbox/index.ts +10 -0
- package/template/lib/unbox/store.ts +113 -0
- package/template/lib/unbox/types.ts +195 -0
- package/template/lib/unbox/webhooks.ts +66 -0
- package/template/lib/utils.ts +6 -0
- package/template/lib/vitrine.ts +242 -0
- package/template/lib/webhook-store.ts +22 -0
- package/template/middleware.ts +178 -0
- package/template/next.config.ts +63 -0
- package/template/package.json +62 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/brand/coll/default.png +0 -0
- package/template/public/brand/hero-desktop.svg +12 -0
- package/template/public/brand/hero-mobile.svg +12 -0
- package/template/public/brand/heros/boutique-desktop.svg +11 -0
- package/template/public/brand/heros/boutique-mobile.svg +11 -0
- package/template/public/brand/heros/editorial-desktop.svg +11 -0
- package/template/public/brand/heros/editorial-mobile.svg +11 -0
- package/template/public/brand/heros/essencial-desktop.svg +12 -0
- package/template/public/brand/heros/essencial-mobile.svg +12 -0
- package/template/public/brand/heros/promocional-desktop.svg +12 -0
- package/template/public/brand/heros/promocional-mobile.svg +12 -0
- package/template/public/brand/logo-chrome.svg +6 -0
- package/template/public/brand/logo-white.svg +6 -0
- package/template/public/brand/logo.svg +7 -0
- package/template/public/brand/pay-amex.webp +0 -0
- package/template/public/brand/pay-elo.webp +0 -0
- package/template/public/brand/pay-mastercard.webp +0 -0
- package/template/public/brand/pay-pix.webp +0 -0
- package/template/public/brand/pay-visa.webp +0 -0
- package/template/public/brand/ph/avatar-a.svg +5 -0
- package/template/public/brand/ph/avatar-b.svg +5 -0
- package/template/public/brand/ph/avatar-c.svg +5 -0
- package/template/public/brand/ph/avatar-d.svg +5 -0
- package/template/public/brand/ph/photo-a.svg +7 -0
- package/template/public/brand/ph/photo-b.svg +6 -0
- package/template/public/brand/ph/photo-c.svg +6 -0
- package/template/public/brand/ph/poster-a.svg +6 -0
- package/template/public/brand/ph/poster-b.svg +6 -0
- package/template/public/brand/ph/poster-c.svg +6 -0
- package/template/public/unbox/powered-by-fundo-preto.png +0 -0
- package/template/public/unbox/powered-by-transparente.png +0 -0
- package/template/public/unbox/powered-by.png +0 -0
- package/template/scripts/abandoned-cart.ts +82 -0
- package/template/scripts/check-editable.mjs +467 -0
- package/template/scripts/check-honestidade.mjs +111 -0
- package/template/scripts/check-placeholder.mjs +200 -0
- package/template/scripts/check-recipe.mjs +76 -0
- package/template/scripts/check-unbox-brand.mjs +298 -0
- package/template/scripts/contraste.js +177 -0
- package/template/scripts/dump-catalog.ts +95 -0
- package/template/scripts/load-env.ts +13 -0
- package/template/scripts/medir-sistema.mjs +56 -0
- package/template/scripts/place-order-pix.ts +61 -0
- package/template/scripts/qa-screenshots.mjs +150 -0
- package/template/scripts/sistema.py +231 -0
- package/template/scripts/subscribe-webhook.ts +36 -0
- package/template/scripts/test-live.ts +186 -0
- package/template/scripts/vocabulario.py +264 -0
- package/template/tsconfig.json +27 -0
- package/tools/LEIA-ME.md +39 -0
- package/tools/check-template-neutro.mjs +111 -0
- package/tools/notion-doc.mjs +191 -0
- package/tools/tokenize-neutrals.mjs +0 -0
- package/tools/tokenize-radius.mjs +52 -0
- 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
|
+
/** `j`, `j`, `:` — 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 `<script>`
|
|
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
|
+
* (`onclick` 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 `javascri\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
|
+
}
|