@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,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GATE DE COBERTURA EDITÁVEL — o que a LOJA mostra e o lojista NÃO consegue editar.
|
|
3
|
+
//
|
|
4
|
+
// Mede TODAS as páginas que a loja declara em `GET /api/unbox/paginas`, não só a home. Antes media
|
|
5
|
+
// uma página só, a do `--url`: uma loja com /sobre inteira fora dos primitivos passava com 95%,
|
|
6
|
+
// porque ninguém tinha olhado o /sobre. Gate que não olha aprova sem ter olhado.
|
|
7
|
+
//
|
|
8
|
+
// De cada página abre a loja em modo edição (?unbox_editor=1), espera o manifesto dos primitivos e
|
|
9
|
+
// mede: de todo texto visível e toda imagem, quanto está dentro de um `[data-editor-path]`. Lista o
|
|
10
|
+
// que ficou de fora, com trecho, DIZENDO EM QUE PÁGINA. E lê do manifesto de cada página os dois
|
|
11
|
+
// defeitos de construção que a cobertura sozinha não pega: seção sem `kind`/`label` (o painel
|
|
12
|
+
// mostraria id de código ao lojista) e editável FORA de container (primitivo bom, página que ninguém
|
|
13
|
+
// declarou: ele NÃO é editável, e a loja o denuncia em `manifest.semContainer`).
|
|
14
|
+
//
|
|
15
|
+
// A loja também diz quais páginas são SÓ CABEÇALHO E RODAPÉ POR REGRA (`soChrome: true` na lista; README
|
|
16
|
+
// §8: texto legal, rótulo de formulário/UI e mecânica de compra não viram primitivo). /termos, /privacidade,
|
|
17
|
+
// /devolucoes e /busca medem 22% a 65% POR DESENHO, e um gate que as reprova para sempre é um gate que
|
|
18
|
+
// ninguém lê. Nelas a régua de --minimo não vale: a cobertura é impressa, não julgada. O resto vale igual
|
|
19
|
+
// (zero editável fora de container, zero seção sem tipo/rótulo, manifesto tem de chegar). O gate NÃO infere
|
|
20
|
+
// isso do conteúdo: é a loja que declara, em lib/rotas-editaveis.ts (SO_CHROME), com o motivo ao lado;
|
|
21
|
+
// página que ela não marcou é página comum.
|
|
22
|
+
//
|
|
23
|
+
// E a loja DECLARA OS CONTAINERS DE CADA ROTA (`containers` na lista; tabela CONTAINERS_POR_ROTA em
|
|
24
|
+
// lib/rotas-editaveis.ts): é com isso que o editor avisa "isto também aparece em «Categoria»" antes de alguém
|
|
25
|
+
// abrir /categoria, e que a publicação alcança a página certa na primeira sessão. Uma tabela digitada envelhece
|
|
26
|
+
// em silêncio, então o gate CONFERE: os containers que o manifesto de cada página mostrou (fora o `chrome`, que é
|
|
27
|
+
// do site inteiro) têm de ser exatamente os declarados. Container que aparece sem estar declarado, container
|
|
28
|
+
// declarado que não aparece, ou rota que a loja lista sem declarar nada: REPROVADO, dizendo a rota e o container.
|
|
29
|
+
//
|
|
30
|
+
// Saída: 0 = toda página >= --minimo (default 0.8), fora as que a loja declarou só cabeçalho e rodapé
|
|
31
|
+
// por regra, nenhuma seção sem kind/label, nenhum editável fora de container e a declaração de containers
|
|
32
|
+
// batendo em toda página · 1 = REPROVADO (alguma página comum abaixo do mínimo, seção sem tipo/rótulo,
|
|
33
|
+
// editável fora de container, ou declaração de containers divergente; o relatório diz QUAL página) · 2 = NÃO
|
|
34
|
+
// RODOU (servidor fora, lista de páginas indisponível, rota declarada sem exemplo para abrir, página sem
|
|
35
|
+
// provider, manifesto que nunca chegou). Doutrina da casa: gate que varre o vazio e diz "limpo" aprova sem ter olhado.
|
|
36
|
+
//
|
|
37
|
+
// Uso: node scripts/check-editable.mjs --url http://localhost:3000 [--minimo 0.8] [--json saida.json]
|
|
38
|
+
// node scripts/check-editable.mjs --url http://localhost:3000/sobre
|
|
39
|
+
// (`--url` com caminho mede SÓ aquela página: é a rodada de uma página, para quem está
|
|
40
|
+
// consertando uma rota e não quer esperar as outras.)
|
|
41
|
+
// node scripts/check-editable.mjs --url http://localhost:3000 --paginas /,/sobre,/produtos
|
|
42
|
+
// (`--paginas` é para a loja que ainda não tem /api/unbox/paginas; é uma lista digitada à
|
|
43
|
+
// mão, e o relatório diz isso.)
|
|
44
|
+
import { createRequire } from "node:module";
|
|
45
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
|
|
48
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => (a.startsWith("--") ? [a.slice(2), arr[i + 1] ?? "1"] : [])).filter((p) => p.length));
|
|
49
|
+
const URL_ = args.url ?? "http://localhost:3000";
|
|
50
|
+
const MINIMO = Number(args.minimo ?? 0.8);
|
|
51
|
+
const ALVO = new URL(URL_);
|
|
52
|
+
const ORIGEM = ALVO.origin;
|
|
53
|
+
|
|
54
|
+
function carregarPlaywright() {
|
|
55
|
+
const req = createRequire(import.meta.url);
|
|
56
|
+
const candidatos = [process.cwd(), path.dirname(new URL(import.meta.url).pathname), process.env.UNBOX_RUNNER_SCRIPTS ?? ""].filter(Boolean);
|
|
57
|
+
for (const base of candidatos) {
|
|
58
|
+
try {
|
|
59
|
+
return createRequire(path.join(base, "package.json"))("playwright-core");
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
try { return req("playwright-core"); } catch {}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pw = carregarPlaywright();
|
|
67
|
+
if (!pw) {
|
|
68
|
+
console.error("[check-editable] playwright-core não encontrado (npm i -D playwright-core). NÃO RODOU.");
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── A LISTA DE PÁGINAS ─────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Quem sabe quais páginas a loja tem é a loja (app/api/unbox/paginas/route.ts, contrato no README
|
|
74
|
+
// §9). Rota dinâmica entra UMA vez, pelo exemplo concreto que ela devolve — medir 24 produtos é
|
|
75
|
+
// medir a mesma receita 24 vezes. Rota declarada SEM exemplo (catálogo vazio, sem credenciais, ou
|
|
76
|
+
// fora do ar) não tem como ser medida, e isso é NÃO RODOU: ela não conta como aprovada.
|
|
77
|
+
// E a loja também diz quais páginas são SÓ CABEÇALHO E RODAPÉ POR REGRA (`soChrome: true`; README §8).
|
|
78
|
+
const CAMINHO = /^\/(?![/\\])[^\\\s]*$/;
|
|
79
|
+
/** a entrada como a loja a declara; `soChrome` só conta quando é o `true` literal (é a loja que decide, e só ela);
|
|
80
|
+
* `containers` só existe quando a loja mandou uma lista (ausente = a loja não declarou, e isso reprova) */
|
|
81
|
+
const declaradaPelaLoja = (p) => ({
|
|
82
|
+
rota: p.rota,
|
|
83
|
+
caminho: p.caminho,
|
|
84
|
+
label: p.label,
|
|
85
|
+
soChrome: p.soChrome === true,
|
|
86
|
+
...(Array.isArray(p.containers) ? { containers: p.containers.filter((c) => typeof c === "string") } : {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function listaDaLoja() {
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetch(`${ORIGEM}/api/unbox/paginas`, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { erro: `não consegui perguntar à loja quais páginas ela tem (${ORIGEM}/api/unbox/paginas): ${e?.message ?? e}` };
|
|
95
|
+
}
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
return { erro: `${ORIGEM}/api/unbox/paginas respondeu ${res.status}. Instale a rota na loja (ela deriva as páginas de app/(loja)/ e do DISALLOW do robots) ou passe --paginas /,/sobre,…` };
|
|
98
|
+
}
|
|
99
|
+
const corpo = await res.json().catch(() => null);
|
|
100
|
+
const lista = Array.isArray(corpo?.paginas) ? corpo.paginas : null;
|
|
101
|
+
if (!lista?.length) return { erro: `${ORIGEM}/api/unbox/paginas respondeu sem lista de páginas.` };
|
|
102
|
+
return { lista: lista.filter((p) => typeof p?.rota === "string").map(declaradaPelaLoja) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function descobrirPaginas() {
|
|
106
|
+
if (args.paginas) {
|
|
107
|
+
const lista = String(args.paginas).split(",").map((s) => s.trim()).filter(Boolean);
|
|
108
|
+
if (!lista.length) return { erro: "--paginas veio vazio." };
|
|
109
|
+
const ruins = lista.filter((p) => !CAMINHO.test(p));
|
|
110
|
+
if (ruins.length) return { erro: `--paginas só aceita caminhos absolutos desta loja (começando com "/"): ${ruins.join(", ")}` };
|
|
111
|
+
// lista digitada: a loja não foi ouvida, então nenhuma página conta como só cabeçalho e rodapé por regra,
|
|
112
|
+
// e a declaração de containers não tem como ser conferida
|
|
113
|
+
return { paginas: lista.map((p) => ({ rota: p, caminho: p, label: p, soChrome: false, semDeclaracaoPorque: "a loja não foi consultada (--paginas)" })), fonte: "--paginas (lista digitada à mão; a loja não foi consultada, e nenhuma página conta como só cabeçalho e rodapé por regra)", semExemplo: [] };
|
|
114
|
+
}
|
|
115
|
+
if (ALVO.pathname !== "/") {
|
|
116
|
+
const p = ALVO.pathname;
|
|
117
|
+
// A rodada de uma página só AINDA pergunta à loja se essa página é só cabeçalho e rodapé por regra: sem
|
|
118
|
+
// isso, /termos sozinha reprovaria aqui e passaria na rodada completa, e o gate diria duas coisas. Se a
|
|
119
|
+
// loja não responder a lista, a página é medida como comum, e o relatório diz que a loja não foi ouvida.
|
|
120
|
+
const declarada = await listaDaLoja();
|
|
121
|
+
const marcada = declarada.lista?.find((x) => x.caminho === p || x.rota === p);
|
|
122
|
+
const fonte = declarada.lista
|
|
123
|
+
? "o caminho do --url (uma página só; sem caminho, mede todas as que a loja declara)"
|
|
124
|
+
: "o caminho do --url (uma página só; a loja não respondeu a lista, então esta página conta como comum)";
|
|
125
|
+
// a declaração de containers só é conferível quando a loja respondeu E lista esta página
|
|
126
|
+
const semDeclaracaoPorque = !declarada.lista ? "a loja não respondeu a lista" : !marcada ? "a loja não lista esta página em /api/unbox/paginas" : undefined;
|
|
127
|
+
return {
|
|
128
|
+
paginas: [{ rota: marcada?.rota ?? p, caminho: p, label: marcada?.label ?? p, soChrome: Boolean(marcada?.soChrome), ...(marcada?.containers ? { containers: marcada.containers } : {}), ...(semDeclaracaoPorque ? { semDeclaracaoPorque } : {}) }],
|
|
129
|
+
fonte,
|
|
130
|
+
semExemplo: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const declarada = await listaDaLoja();
|
|
134
|
+
if (declarada.erro) return declarada;
|
|
135
|
+
const comExemplo = declarada.lista.filter((p) => typeof p.caminho === "string" && CAMINHO.test(p.caminho));
|
|
136
|
+
const semExemplo = declarada.lista.filter((p) => !(typeof p.caminho === "string" && CAMINHO.test(p.caminho))).map((p) => p.rota);
|
|
137
|
+
if (!comExemplo.length) return { erro: `${ORIGEM}/api/unbox/paginas não trouxe nenhuma página com caminho para abrir.` };
|
|
138
|
+
return { paginas: comExemplo, fonte: "a própria loja (/api/unbox/paginas)", semExemplo };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const achado = await descobrirPaginas();
|
|
142
|
+
if (achado.erro) {
|
|
143
|
+
console.error(`[check-editable] ${achado.erro} NÃO RODOU.`);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── A MEDIÇÃO, PÁGINA A PÁGINA ─────────────────────────────────────────────────────────────────
|
|
148
|
+
const browser = await pw.chromium.launch({ channel: "chrome", headless: true }).catch(async () => pw.chromium.launch({ headless: true }));
|
|
149
|
+
|
|
150
|
+
async function medir(caminho) {
|
|
151
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
152
|
+
let manifest = null;
|
|
153
|
+
await page.exposeFunction("__unboxManifest", (m) => { manifest = m; });
|
|
154
|
+
await page.addInitScript(() => {
|
|
155
|
+
// o provider fala com window.parent; aqui não há pai, então interceptamos o postMessage do próprio window
|
|
156
|
+
const orig = window.postMessage.bind(window);
|
|
157
|
+
Object.defineProperty(window, "parent", { get: () => ({ postMessage: (m) => { if (m && m.type === "unbox-editor:manifest") window.__unboxManifest(m.manifest); } }) });
|
|
158
|
+
void orig;
|
|
159
|
+
});
|
|
160
|
+
const u = new URL(caminho, ORIGEM);
|
|
161
|
+
u.searchParams.set("unbox_editor", "1");
|
|
162
|
+
const res = await page.goto(u.toString(), { waitUntil: "load", timeout: 60_000 }).catch((e) => ({ erro: e }));
|
|
163
|
+
if (!res || res.erro || (res.status && res.status() >= 400)) {
|
|
164
|
+
await page.close();
|
|
165
|
+
return { naoRodou: `não abriu ${u}: ${res?.erro?.message ?? res?.status?.()}` };
|
|
166
|
+
}
|
|
167
|
+
// porta /acesso no caminho? então o gate não viu a loja
|
|
168
|
+
if (page.url().includes("/acesso")) {
|
|
169
|
+
await page.close();
|
|
170
|
+
return { naoRodou: `${caminho} caiu na porta /acesso; rode com PREVIEW_DISABLED=1 ou passe o token` };
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < 40 && !manifest; i++) await page.waitForTimeout(250);
|
|
173
|
+
if (!manifest) {
|
|
174
|
+
await page.close();
|
|
175
|
+
return { naoRodou: `${caminho} nunca publicou manifesto (EditableProvider ausente nesta rota, sem ?unbox_editor, ou NEXT_PUBLIC_EDITOR_ORIGIN vazio no env da loja; o modo edição exige a origem do editor)` };
|
|
176
|
+
}
|
|
177
|
+
const medido = await page.evaluate(() => {
|
|
178
|
+
const ignorar = (el) => {
|
|
179
|
+
if (!el) return true;
|
|
180
|
+
const cs = getComputedStyle(el);
|
|
181
|
+
if (cs.display === "none" || cs.visibility === "hidden") return true;
|
|
182
|
+
if (el.closest("script,style,noscript,template,[aria-hidden='true'],.sr-only,[data-editor-ignore],[data-nosnippet],dialog,[role='dialog'],nav [role='menu']")) return true;
|
|
183
|
+
return false;
|
|
184
|
+
};
|
|
185
|
+
const dentro = (el) => Boolean(el.closest("[data-editor-path]"));
|
|
186
|
+
const textos = [];
|
|
187
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
188
|
+
let n;
|
|
189
|
+
while ((n = walker.nextNode())) {
|
|
190
|
+
const t = n.textContent.replace(/\s+/g, " ").trim();
|
|
191
|
+
if (t.length < 3) continue;
|
|
192
|
+
const el = n.parentElement;
|
|
193
|
+
if (ignorar(el)) continue;
|
|
194
|
+
// preço, número puro e rótulo de formulário não são copy do lojista
|
|
195
|
+
if (/^(R\$\s?)?[\d.,%\s]+$/.test(t)) continue;
|
|
196
|
+
if (el.closest("input,select,textarea,button[type='submit'],form label")) continue;
|
|
197
|
+
textos.push({ texto: t.slice(0, 80), tag: el.tagName.toLowerCase(), ok: dentro(el), secao: el.closest("[data-editor-section]")?.dataset.editorSection ?? null });
|
|
198
|
+
}
|
|
199
|
+
const imagens = [...document.querySelectorAll("img,video,picture source")].filter((el) => !ignorar(el)).map((el) => ({
|
|
200
|
+
src: (el.currentSrc || el.getAttribute("src") || el.getAttribute("srcset") || "").slice(0, 100),
|
|
201
|
+
ok: dentro(el),
|
|
202
|
+
secao: el.closest("[data-editor-section]")?.dataset.editorSection ?? null,
|
|
203
|
+
decorativa: el.getAttribute("alt") === "" || el.getAttribute("aria-hidden") === "true",
|
|
204
|
+
})).filter((i) => !i.decorativa && i.src && !/\.svg(\?|$)/.test(i.src));
|
|
205
|
+
return { textos, imagens };
|
|
206
|
+
});
|
|
207
|
+
await page.close();
|
|
208
|
+
// texto repetido (marquee com 10 cópias, faixa de anúncio) conta UMA vez: o que se
|
|
209
|
+
// mede é o que o lojista precisaria editar, não quantas vezes a tela repete.
|
|
210
|
+
const unicos = (lista, chave) => {
|
|
211
|
+
const vistos = new Map();
|
|
212
|
+
for (const item of lista) {
|
|
213
|
+
const k = chave(item);
|
|
214
|
+
const prev = vistos.get(k);
|
|
215
|
+
if (!prev || (!prev.ok && item.ok)) vistos.set(k, item);
|
|
216
|
+
}
|
|
217
|
+
return [...vistos.values()];
|
|
218
|
+
};
|
|
219
|
+
const textos = unicos(medido.textos, (t) => `${t.secao}|${t.texto}`);
|
|
220
|
+
const imagens = unicos(medido.imagens, (i) => `${i.secao}|${i.src}`);
|
|
221
|
+
const total = textos.length + imagens.length;
|
|
222
|
+
if (total === 0) return { naoRodou: `${caminho} não tem texto nem imagem visível: nada medido` };
|
|
223
|
+
const cobertos = textos.filter((t) => t.ok).length + imagens.filter((i) => i.ok).length;
|
|
224
|
+
const fora = [
|
|
225
|
+
...textos.filter((t) => !t.ok).map((t) => `texto ${t.secao ?? "(fora de seção)"} <${t.tag}> "${t.texto}"`),
|
|
226
|
+
...imagens.filter((i) => !i.ok).map((i) => `imagem ${i.secao ?? "(fora de seção)"} ${i.src}`),
|
|
227
|
+
];
|
|
228
|
+
return { url: u.toString(), manifest, total, cobertos, cobertura: cobertos / total, fora };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Uma segunda chance quando a página não abriu no prazo: em `next dev` a primeira visita compila a
|
|
233
|
+
* rota, e oito páginas em sequência estouraram os 60 s justamente na home e em /busca numa rodada em
|
|
234
|
+
* que as outras seis abriram (medido numa loja piloto). Reprovar por isso seria reprovar o compilador.
|
|
235
|
+
* Só o prazo de abertura ganha a segunda chance; manifesto que não chegou e página vazia são NÃO RODOU
|
|
236
|
+
* de primeira. E qualquer estouro inesperado do Playwright vira NÃO RODOU daquela página, com o
|
|
237
|
+
* motivo, em vez de derrubar o gate inteiro com exit 1, que seria lido como reprovação.
|
|
238
|
+
*/
|
|
239
|
+
async function medirComSegundaChance(caminho) {
|
|
240
|
+
let r;
|
|
241
|
+
for (let tentativa = 1; tentativa <= 2; tentativa++) {
|
|
242
|
+
try {
|
|
243
|
+
r = await medir(caminho);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
r = { naoRodou: `${caminho}: ${String(e?.message ?? e).split("\n")[0]}` };
|
|
246
|
+
}
|
|
247
|
+
if (!r.naoRodou || !/Timeout \d+ms exceeded/.test(r.naoRodou)) return r;
|
|
248
|
+
if (tentativa === 1) console.log(` (${caminho} não abriu no prazo; segunda tentativa, porque em next dev a primeira visita compila a rota)`);
|
|
249
|
+
}
|
|
250
|
+
return r;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resultados = [];
|
|
254
|
+
for (const p of achado.paginas) {
|
|
255
|
+
const r = await medirComSegundaChance(p.caminho);
|
|
256
|
+
resultados.push({ ...p, ...r });
|
|
257
|
+
}
|
|
258
|
+
await browser.close();
|
|
259
|
+
|
|
260
|
+
// ── O RELATÓRIO ────────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
console.log(`[check-editable] ${ORIGEM} · ${achado.paginas.length} página(s), lista veio de: ${achado.fonte}`);
|
|
262
|
+
const semExemplo = achado.semExemplo ?? [];
|
|
263
|
+
if (semExemplo.length) {
|
|
264
|
+
console.log(`\nROTAS DECLARADAS SEM EXEMPLO PARA ABRIR (${semExemplo.length}), NÃO foram medidas: ${semExemplo.join(", ")}`);
|
|
265
|
+
console.log(" (a loja não devolveu um caminho concreto para elas: catálogo vazio, sem credenciais da Unbox neste ambiente, ou fora do ar)");
|
|
266
|
+
}
|
|
267
|
+
const porRegra = achado.paginas.filter((p) => p.soChrome);
|
|
268
|
+
if (porRegra.length) {
|
|
269
|
+
console.log(`\nSÓ CABEÇALHO E RODAPÉ, POR REGRA (§8), declaradas pela loja (${porRegra.length}): ${porRegra.map((p) => p.caminho).join(", ")}`);
|
|
270
|
+
console.log(" (texto legal, rótulo de formulário e de UI e mecânica de compra não viram primitivo; nelas a cobertura é impressa, não julgada, e o resto do gate vale)");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// TIPO E RÓTULO DE SEÇÃO (07/09): toda seção nasce com `kind` (vocabulário fechado) e `label`.
|
|
274
|
+
// Sem isso o painel mostra id de código para o lojista — reprova. A conta é da UNIÃO das páginas:
|
|
275
|
+
// a mesma seção declarada em duas rotas é uma só, e o relatório diz onde ela foi vista primeiro.
|
|
276
|
+
// O VOCABULÁRIO vem do document.ts da própria loja (lib/editable/document.ts, que a foundation copia para
|
|
277
|
+
// cá): um `kind` novo entra lá e o gate passa a aceitá-lo sem ninguém lembrar de uma segunda lista, que
|
|
278
|
+
// era o que havia (e reprovava seção legítima assim que o vocabulário crescia). A lista interna é só a
|
|
279
|
+
// reserva para quando o arquivo não está onde a foundation o põe, e o relatório diz de onde veio.
|
|
280
|
+
const KINDS_RESERVA = ["cabecalho", "faixa-de-anuncio", "banner", "texto-rolante", "vitrine-de-produtos", "produto-em-destaque", "beneficios", "como-funciona", "depoimentos", "perguntas-frequentes", "galeria", "video", "sobre-a-marca", "comparacao", "newsletter", "contato", "lojas-fisicas", "botao-flutuante", "rodape", "outro"];
|
|
281
|
+
function vocabularioDeKinds() {
|
|
282
|
+
const candidatos = [new URL("../lib/editable/document.ts", import.meta.url), path.join(process.cwd(), "lib", "editable", "document.ts")];
|
|
283
|
+
for (const c of candidatos) {
|
|
284
|
+
try {
|
|
285
|
+
const m = readFileSync(c, "utf8").match(/export const SECTION_KINDS\s*=\s*\[([\s\S]*?)\]\s*as const/);
|
|
286
|
+
const kinds = m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
|
|
287
|
+
if (kinds.length) return { kinds, fonte: "lib/editable/document.ts" };
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
return { kinds: KINDS_RESERVA, fonte: "lista interna do gate (lib/editable/document.ts não encontrado)" };
|
|
291
|
+
}
|
|
292
|
+
const { kinds: SECTION_KINDS, fonte: FONTE_DOS_KINDS } = vocabularioDeKinds();
|
|
293
|
+
const semTipo = new Map();
|
|
294
|
+
for (const r of resultados) {
|
|
295
|
+
for (const s of r.manifest?.sections ?? []) {
|
|
296
|
+
if (s.item || (s.kind && SECTION_KINDS.includes(s.kind) && s.label)) continue;
|
|
297
|
+
if (!semTipo.has(`${s.container}/${s.id}`)) semTipo.set(`${s.container}/${s.id}`, { ...s, onde: r.caminho });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// EDITÁVEL FORA DE CONTAINER (foundation 11): a seção foi escrita com primitivos, mas está sendo
|
|
302
|
+
// renderizada solta, sem `Editable.Sections container=…`/`Editable.Section` em volta. O caminho dela
|
|
303
|
+
// cairia na RAIZ do documento, onde o mesmo nome vale para o site inteiro: o `titulo` do FAQ e o
|
|
304
|
+
// `titulo` da newsletter seriam o MESMO valor, em todas as páginas. A loja recusa (deixa o literal do
|
|
305
|
+
// código na tela) e declara em `manifest.semContainer`. Não é cobertura baixa por falta de primitivo:
|
|
306
|
+
// é primitivo bom em página que ninguém declarou. Reprova sozinho, e diz em que página está.
|
|
307
|
+
// (Manifesto abaixo da foundation 11 não traz o campo: aí a lista fica vazia, e a cobertura por
|
|
308
|
+
// página continua sendo o que pega a seção solta, porque um primitivo fora de container não recebe
|
|
309
|
+
// `data-editor-path`.)
|
|
310
|
+
const foraDeContainer = new Map();
|
|
311
|
+
for (const r of resultados) {
|
|
312
|
+
for (const x of r.manifest?.semContainer ?? []) {
|
|
313
|
+
const onde = x.pagina ?? r.caminho;
|
|
314
|
+
const atual = foraDeContainer.get(x.path);
|
|
315
|
+
if (!atual) foraDeContainer.set(x.path, { ...x, paginas: [onde] });
|
|
316
|
+
else if (!atual.paginas.includes(onde)) atual.paginas.push(onde);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A DECLARAÇÃO DE CONTAINERS (achado 2 do Astra): a loja diz, por rota, quais containers ela renderiza
|
|
321
|
+
// (CONTAINERS_POR_ROTA em lib/rotas-editaveis.ts), e o editor e a publicação confiam nisso antes de a prévia
|
|
322
|
+
// ter aberto a página. O gate confere a declaração contra o que o manifesto mostrou: os containers de primeiro
|
|
323
|
+
// nível das seções e dos caminhos ("home.faq" é o `home`), fora o `chrome`, que é do site inteiro e não se lista.
|
|
324
|
+
// Diverge = REPROVADO, dizendo a rota e o container: é o que impede a tabela de envelhecer em silêncio.
|
|
325
|
+
const CONTAINER_DO_SITE = "chrome";
|
|
326
|
+
const raizDoContainer = (c) => String(c).split(".")[0];
|
|
327
|
+
function containersObservados(manifest) {
|
|
328
|
+
const vistos = new Set();
|
|
329
|
+
for (const s of manifest?.sections ?? []) if (s.container) vistos.add(raizDoContainer(s.container));
|
|
330
|
+
for (const e of manifest?.entries ?? []) if (e.container) vistos.add(raizDoContainer(e.container));
|
|
331
|
+
vistos.delete(CONTAINER_DO_SITE);
|
|
332
|
+
return [...vistos].sort();
|
|
333
|
+
}
|
|
334
|
+
/** por página medida: { declarados, observados, faltouDeclarar, declaradoSemAparecer } ou { naoConferido: motivo } */
|
|
335
|
+
function conferirDeclaracao(r) {
|
|
336
|
+
if (r.semDeclaracaoPorque) return { naoConferido: r.semDeclaracaoPorque };
|
|
337
|
+
const observados = containersObservados(r.manifest);
|
|
338
|
+
if (!r.containers) return { observados, semDeclaracao: true };
|
|
339
|
+
const declarados = [...new Set(r.containers.map(raizDoContainer).filter((c) => c !== CONTAINER_DO_SITE))].sort();
|
|
340
|
+
return {
|
|
341
|
+
declarados,
|
|
342
|
+
observados,
|
|
343
|
+
faltouDeclarar: observados.filter((c) => !declarados.includes(c)),
|
|
344
|
+
declaradoSemAparecer: declarados.filter((c) => !observados.includes(c)),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
for (const r of resultados) if (!r.naoRodou) r.declaracao = conferirDeclaracao(r);
|
|
348
|
+
const semDeclaracao = resultados.filter((r) => r.declaracao?.semDeclaracao);
|
|
349
|
+
const divergentes = resultados.filter((r) => r.declaracao && !r.declaracao.naoConferido && !r.declaracao.semDeclaracao && (r.declaracao.faltouDeclarar.length || r.declaracao.declaradoSemAparecer.length));
|
|
350
|
+
|
|
351
|
+
const naoRodaram = resultados.filter((r) => r.naoRodou);
|
|
352
|
+
const medidas = resultados.filter((r) => !r.naoRodou);
|
|
353
|
+
// a página só cabeçalho e rodapé por regra não entra na régua: a cobertura dela é impressa, não julgada
|
|
354
|
+
const medidasPorRegra = medidas.filter((r) => r.soChrome);
|
|
355
|
+
const descobertas = medidas.filter((r) => !r.soChrome && r.cobertura < MINIMO);
|
|
356
|
+
|
|
357
|
+
console.log("\nCOBERTURA POR PÁGINA:");
|
|
358
|
+
for (const r of resultados) {
|
|
359
|
+
if (r.naoRodou) {
|
|
360
|
+
console.log(` NÃO RODOU ${r.caminho.padEnd(28)} ${r.naoRodou}`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const marca = r.soChrome ? "por regra " : r.cobertura >= MINIMO ? "ok " : "DESCOBERTA";
|
|
364
|
+
const nota = r.soChrome ? " · só cabeçalho e rodapé, por regra (§8)" : "";
|
|
365
|
+
const soltos = (r.manifest.semContainer ?? []).length;
|
|
366
|
+
console.log(` ${marca} ${r.caminho.padEnd(28)} ${(r.cobertura * 100).toFixed(1)}% (${r.cobertos}/${r.total})${nota} · manifesto: ${r.manifest.entries.length} caminhos · ${r.manifest.sections.length} seções${soltos ? ` · ${soltos} editável(is) FORA de container` : ""}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const r of descobertas) {
|
|
370
|
+
console.log(`\nFORA DOS PRIMITIVOS em ${r.caminho} (${r.fora.length}):`);
|
|
371
|
+
for (const f of r.fora.slice(0, 40)) console.log(" " + f);
|
|
372
|
+
if (r.fora.length > 40) console.log(` … +${r.fora.length - 40}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (foraDeContainer.size) {
|
|
376
|
+
console.log(`\nEDITÁVEIS FORA DE CONTAINER (${foraDeContainer.size}): a página os renderiza soltos, então eles NÃO são editáveis:`);
|
|
377
|
+
for (const [p, x] of [...foraDeContainer].slice(0, 40)) console.log(` ${p} · ${x.type}${x.label ? ` · ${x.label}` : ""} · em ${x.paginas.join(", ")}`);
|
|
378
|
+
if (foraDeContainer.size > 40) console.log(` … +${foraDeContainer.size - 40}`);
|
|
379
|
+
console.log(` Envolva as seções dessa(s) página(s) em <Editable.Sections container="<nome-da-página>"> com um <Editable.Section id=…> por seção.`);
|
|
380
|
+
console.log(` A ordem é do container e a copy é do container: duas páginas no MESMO container dividem as duas de propósito; página que quer copy própria declara container próprio.`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (semTipo.size) {
|
|
384
|
+
console.log(`\nSEÇÕES SEM TIPO OU RÓTULO (${semTipo.size}): toda Editable.Section precisa de kind (um de: ${SECTION_KINDS.join(", ")}; vocabulário lido de ${FONTE_DOS_KINDS}) e label:`);
|
|
385
|
+
for (const [chave, s] of semTipo) console.log(` ${chave} · kind=${s.kind ?? "(vazio)"} · label=${s.label ?? "(vazio)"} · visto em ${s.onde}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (medidas.length) {
|
|
389
|
+
const lista = (xs) => (xs.length ? xs.join(", ") : "(nenhum)");
|
|
390
|
+
console.log("\nCONTAINERS POR PÁGINA (o que a loja declara em CONTAINERS_POR_ROTA × o que o manifesto mostrou, fora o chrome):");
|
|
391
|
+
for (const r of medidas) {
|
|
392
|
+
const d = r.declaracao;
|
|
393
|
+
if (d.naoConferido) {
|
|
394
|
+
console.log(` não conferido ${r.caminho.padEnd(24)} observados: ${lista(d.observados ?? containersObservados(r.manifest))} · ${d.naoConferido}`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (d.semDeclaracao) {
|
|
398
|
+
console.log(` SEM DECLARAÇÃO ${r.caminho.padEnd(23)} observados: ${lista(d.observados)} · a loja lista a rota ${r.rota} sem \`containers\`: acrescente-a a CONTAINERS_POR_ROTA em lib/rotas-editaveis.ts`);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const diverge = d.faltouDeclarar.length || d.declaradoSemAparecer.length;
|
|
402
|
+
const detalhe = [d.faltouDeclarar.length ? `faltou declarar: ${d.faltouDeclarar.join(", ")}` : "", d.declaradoSemAparecer.length ? `declarado sem aparecer: ${d.declaradoSemAparecer.join(", ")}` : ""].filter(Boolean).join(" · ");
|
|
403
|
+
console.log(` ${diverge ? "DIVERGE " : "ok "} ${r.caminho.padEnd(24)} declarados: ${lista(d.declarados)} · observados: ${lista(d.observados)}${detalhe ? ` · ${detalhe}` : ""}`);
|
|
404
|
+
}
|
|
405
|
+
if (divergentes.length || semDeclaracao.length) console.log(" A tabela é a promessa que o editor e a publicação usam antes de abrir a página: quem mexeu na página atualiza a linha dela em lib/rotas-editaveis.ts (rota → containers que ela renderiza, fora o chrome).");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (args.json) {
|
|
409
|
+
writeFileSync(args.json, JSON.stringify({
|
|
410
|
+
origem: ORIGEM,
|
|
411
|
+
fonteDaLista: achado.fonte,
|
|
412
|
+
minimo: MINIMO,
|
|
413
|
+
rotasSemExemplo: semExemplo,
|
|
414
|
+
paginas: resultados.map((r) => ({
|
|
415
|
+
rota: r.rota,
|
|
416
|
+
caminho: r.caminho,
|
|
417
|
+
cobertura: r.cobertura ?? null,
|
|
418
|
+
total: r.total ?? 0,
|
|
419
|
+
cobertos: r.cobertos ?? 0,
|
|
420
|
+
fora: r.fora ?? [],
|
|
421
|
+
foraDeContainer: r.manifest?.semContainer ?? [],
|
|
422
|
+
naoRodou: r.naoRodou ?? null,
|
|
423
|
+
...(r.soChrome ? { soChrome: true } : {}),
|
|
424
|
+
...(r.declaracao ? { containers: r.declaracao } : {}),
|
|
425
|
+
})),
|
|
426
|
+
secoesSemTipo: [...semTipo.keys()],
|
|
427
|
+
editaveisForaDeContainer: [...foraDeContainer].map(([p, x]) => ({ path: p, type: x.type, label: x.label ?? null, paginas: x.paginas })),
|
|
428
|
+
}, null, 2));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// O VEREDITO. O que não foi medido vem primeiro, mas sem esconder o que já reprovou: quem lê o
|
|
432
|
+
// relatório conserta as duas coisas numa rodada só, em vez de descobrir a segunda depois de
|
|
433
|
+
// consertar a primeira.
|
|
434
|
+
const reprovacoes = [];
|
|
435
|
+
if (descobertas.length) reprovacoes.push(`página(s) descoberta(s): ${descobertas.map((r) => `${r.caminho} (${(r.cobertura * 100).toFixed(1)}%)`).join(", ")}`);
|
|
436
|
+
if (foraDeContainer.size) reprovacoes.push(`${foraDeContainer.size} editável(is) fora de container em ${[...new Set([...foraDeContainer.values()].flatMap((x) => x.paginas))].join(", ")}`);
|
|
437
|
+
if (semTipo.size) reprovacoes.push(`${semTipo.size} seção(ões) sem tipo ou rótulo`);
|
|
438
|
+
if (divergentes.length) {
|
|
439
|
+
reprovacoes.push(`declaração de containers divergente em ${divergentes.map((r) => {
|
|
440
|
+
const d = r.declaracao;
|
|
441
|
+
const partes = [d.faltouDeclarar.length ? `faltou declarar ${d.faltouDeclarar.join(", ")}` : "", d.declaradoSemAparecer.length ? `declarado sem aparecer ${d.declaradoSemAparecer.join(", ")}` : ""].filter(Boolean);
|
|
442
|
+
return `${r.rota} (${partes.join("; ")})`;
|
|
443
|
+
}).join(", ")}`);
|
|
444
|
+
}
|
|
445
|
+
if (semDeclaracao.length) reprovacoes.push(`rota(s) listada(s) pela loja sem declaração de containers: ${semDeclaracao.map((r) => r.rota).join(", ")}`);
|
|
446
|
+
|
|
447
|
+
if (naoRodaram.length || semExemplo.length) {
|
|
448
|
+
const partes = [];
|
|
449
|
+
if (naoRodaram.length) partes.push(`${naoRodaram.length} de ${resultados.length} página(s) NÃO foram medidas: ${naoRodaram.map((r) => r.caminho).join(", ")}`);
|
|
450
|
+
if (semExemplo.length) partes.push(`${semExemplo.length} rota(s) declarada(s) sem exemplo para abrir: ${semExemplo.join(", ")}`);
|
|
451
|
+
if (reprovacoes.length) partes.push(`e, do que foi medido, já reprovou: ${reprovacoes.join(" · ")}`);
|
|
452
|
+
console.error(`\n[check-editable] ${partes.join(" · ")}. NÃO RODOU.`);
|
|
453
|
+
process.exit(2);
|
|
454
|
+
}
|
|
455
|
+
if (reprovacoes.length) {
|
|
456
|
+
console.error(`\n[check-editable] REPROVADO: ${reprovacoes.join(" · ")}`);
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
// "1 página(s), 1 só cabeçalho e rodapé por regra": a parte comum só aparece quando houve página comum medida
|
|
460
|
+
const comuns = medidas.length - medidasPorRegra.length;
|
|
461
|
+
const resumo = medidasPorRegra.length
|
|
462
|
+
? `${comuns ? `${comuns} >= ${(MINIMO * 100).toFixed(0)}% e ` : ""}${medidasPorRegra.length} só cabeçalho e rodapé por regra (§8)`
|
|
463
|
+
: `todas >= ${(MINIMO * 100).toFixed(0)}%`;
|
|
464
|
+
const conferidas = medidas.filter((r) => r.declaracao && !r.declaracao.naoConferido).length;
|
|
465
|
+
const declaracao = conferidas ? `, declaração de containers batendo em ${conferidas} página(s)` : ", declaração de containers não conferida (a loja não foi ouvida)";
|
|
466
|
+
console.log(`\n[check-editable] aprovado: ${medidas.length} página(s), ${resumo}, nenhuma seção sem tipo, nenhum editável fora de container${declaracao}`);
|
|
467
|
+
process.exit(0);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// GATE DE HONESTIDADE (M5) — o bloqueio é contra fabricação
|
|
3
|
+
// SILENCIOSA, não contra a escolha do lojista. Roda no QA visual (agente 16) e
|
|
4
|
+
// sempre que quiser: npm run unbox:honestidade
|
|
5
|
+
//
|
|
6
|
+
// A foundation NÃO nasce mais com placeholder de prova social ("793 vendidos",
|
|
7
|
+
// "25.347 avaliações", Cliente A/B/C, estoque que desce sozinho): tudo isso foi ao
|
|
8
|
+
// ar em loja real e saiu na v0.15.6. Regra: dado que a loja não tem, o bloco não
|
|
9
|
+
// renderiza. Num scaffold novo este gate sai VERDE. Ele existe para pegar o que o
|
|
10
|
+
// briefing ou alguém adicionar depois sem lastro — e o lojista continua podendo
|
|
11
|
+
// decidir manter algo (a loja é dele), registrando na allowlist abaixo.
|
|
12
|
+
//
|
|
13
|
+
// Decisão tomada (dado real confirmado OU o lojista mandou manter)? Registre a
|
|
14
|
+
// substring exata em marca/honestidade-permitido.txt (uma por linha, com quem
|
|
15
|
+
// decidiu anotado no DESIGN-<MARCA>.md) — a linha para de ser flagrada.
|
|
16
|
+
//
|
|
17
|
+
// ── O QUE ESTE GATE VÊ, E O QUE ELE NÃO VÊ ──────────────────────────────────
|
|
18
|
+
// Ele lê CÓDIGO-FONTE (.ts/.tsx em components, app e lib), não a página renderizada.
|
|
19
|
+
// Isso é de propósito: componente que não renderiza no ambiente atual — o grid do
|
|
20
|
+
// catálogo com a loja em modo mockup, por exemplo — continua sendo varrido. A
|
|
21
|
+
// fabricação que aparece só no dia em que o catálogo real é conectado, depois da
|
|
22
|
+
// revisão humana, é justamente a mais perigosa.
|
|
23
|
+
//
|
|
24
|
+
// O limite real dele é OUTRO: ele só acha o que algum padrão descreve. Toda
|
|
25
|
+
// fabricação que passou até hoje passou por falta de padrão, não por falta de
|
|
26
|
+
// alcance. Achou uma? Some com ela E adicione o padrão genérico aqui — senão ela
|
|
27
|
+
// volta no próximo componente.
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
|
|
35
|
+
// Padrões de fabricação conhecidos da foundation + genéricos suspeitos.
|
|
36
|
+
// [regex, explicação curta do porquê é mentira até prova em contrário]
|
|
37
|
+
const PATTERNS = [
|
|
38
|
+
[/vendidos esta semana/i, "contador de vendas fixo, sem dado real"],
|
|
39
|
+
[/\b25\.?347\b/, "número de avaliações fabricado da foundation"],
|
|
40
|
+
[/Cliente [A-C]\./, "depoimento placeholder (Cliente A/B/C)"],
|
|
41
|
+
[/Quem usa, recomenda/i, "bloco de depoimentos placeholder"],
|
|
42
|
+
[/ratingValue["':\s]*["'`]?\d|reviewCount["':\s]*(String\()?["'`]?\d/, "nota/contagem LITERAL em dado estruturado (Google): só com avaliações reais"],
|
|
43
|
+
[/MAIS VENDIDO/, "selo de venda sem dado de venda"],
|
|
44
|
+
[/devolu[cç][aã]o em (at[eé] )?30 dias|devolvemos seu dinheiro em at[eé] 30 dias/i, "política de devolução: confirme se existe MESMO"],
|
|
45
|
+
[/validade (?:é )?de 24 meses/i, "validade inventada: leia do rótulo real"],
|
|
46
|
+
[/Apenas .*unidades.*neste pre[cç]o/i, "urgência de estoque fabricada"],
|
|
47
|
+
[/ratingCount\s*=\s*"[\d.,]+k?"/, "contagem de avaliações default no código"],
|
|
48
|
+
// ── Promessas e contadores que apareciam nos docs dos módulos (CRO/SEO) sem dado por trás ──
|
|
49
|
+
[/\d+\s*pessoas\s+(?:compraram|est[ãa]o vendo|visualizando|acabaram de)/i, "contador de compras/visitas fabricado"],
|
|
50
|
+
[/compraram\s+(?:hoje|nas [úu]ltimas\s*\d+\s*(?:h|horas))/i, "contador de compras por janela de tempo sem dado real"],
|
|
51
|
+
[/frete gr[áa]tis (?:acima de|a partir de) R\$\s*\d/i, "frete grátis LITERAL: o valor vem de FREE_SHIPPING_THRESHOLD, e só se existir"],
|
|
52
|
+
[/entrega r[áa]pida/i, "promessa de prazo sem SLA real (é do lojista, não da foundation)"],
|
|
53
|
+
[/satisfa[cç][aã]o garantida/i, "garantia sem política real por trás"],
|
|
54
|
+
[/troc(?:a|amos|as)(?: gr[áa]tis)? em (?:at[eé] )?(?!7 dias)\d+ dias/i, "prazo de troca diferente do CDC (7 dias): confirme com o lojista"],
|
|
55
|
+
|
|
56
|
+
// ── Nota/contagem LITERAL na marcação (lacuna que deixou passar o grid da PDP) ──
|
|
57
|
+
// Os padrões acima nomeavam números específicos da foundation ("25.347") ou props
|
|
58
|
+
// conhecidas. Uma nota escrita direto no JSX — <b>4,8</b> ao lado de um ícone de
|
|
59
|
+
// estrela, com "(2,1k)" do lado — não casava com nenhum, e sobreviveu no
|
|
60
|
+
// components/product/pdp/catalog-grid.tsx. Estes são genéricos de propósito: qualquer
|
|
61
|
+
// nota ou volume de avaliação escrito à mão precisa de decisão, não só os da foundation.
|
|
62
|
+
[/<b>\s*[1-5][.,]\d\s*<\/b>/, "nota LITERAL escrita na marcação: sem avaliação real, a loja não exibe nota"],
|
|
63
|
+
[/>\s*\(\s*\d+[.,]?\d*\s*k\s*\)\s*</i, "volume de avaliações abreviado e fixo (ex.: \"(2,1k)\") escrito à mão"],
|
|
64
|
+
// As duas ordens: "avaliações: 12,4 mil" e "12,4 mil avaliações".
|
|
65
|
+
[/(?:avalia[cç][oõ]es|reviews)["'`:\s>]+\d[\d.,]*\s*(?:k|mil)?\b/i, "contagem de avaliações fixa no texto"],
|
|
66
|
+
[/\d[\d.,]*\s*(?:k|mil)\s+(?:avalia[cç][oõ]es|reviews|clientes|compradores)/i, "volume fixo de avaliações/clientes no texto"],
|
|
67
|
+
[/\b[1-5][.,]\d\s*(?:de\s*5|\/\s*5|estrelas)\b/i, "nota fixa em texto corrido"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const SCAN_DIRS = ["components", "app", "lib"];
|
|
71
|
+
const EXTS = new Set([".tsx", ".ts"]);
|
|
72
|
+
|
|
73
|
+
const allowPath = path.join(ROOT, "marca", "honestidade-permitido.txt");
|
|
74
|
+
const allow = fs.existsSync(allowPath)
|
|
75
|
+
? fs.readFileSync(allowPath, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
const findings = [];
|
|
79
|
+
function scan(dir) {
|
|
80
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
81
|
+
const p = path.join(dir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) { if (entry.name !== "node_modules") scan(p); continue; }
|
|
83
|
+
if (!EXTS.has(path.extname(entry.name))) continue;
|
|
84
|
+
const lines = fs.readFileSync(p, "utf8").split("\n");
|
|
85
|
+
lines.forEach((line, i) => {
|
|
86
|
+
// Comentário não chega ao usuário — o gate mira o que a loja EXIBE.
|
|
87
|
+
const t = line.trim();
|
|
88
|
+
if (t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") || t.startsWith("{/*")) return;
|
|
89
|
+
for (const [re, why] of PATTERNS) {
|
|
90
|
+
if (!re.test(line)) continue;
|
|
91
|
+
if (allow.some((a) => line.includes(a))) continue;
|
|
92
|
+
findings.push({ file: path.relative(ROOT, p), line: i + 1, text: line.trim().slice(0, 110), why });
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const d of SCAN_DIRS) { const p = path.join(ROOT, d); if (fs.existsSync(p)) scan(p); }
|
|
99
|
+
|
|
100
|
+
if (findings.length) {
|
|
101
|
+
console.error(`\n✗ HONESTIDADE — ${findings.length} item(ns) fabricado(s) SEM decisão do lojista:\n`);
|
|
102
|
+
for (const f of findings) {
|
|
103
|
+
console.error(` ${f.file}:${f.line} [${f.why}]`);
|
|
104
|
+
console.error(` ${f.text}`);
|
|
105
|
+
}
|
|
106
|
+
console.error(`\n Cada item precisa de UMA decisão: dado real, estado honesto, ou o lojista mandou`);
|
|
107
|
+
console.error(` manter (aí registre a substring em marca/honestidade-permitido.txt). O que não`);
|
|
108
|
+
console.error(` pode é ir pro ar sem ninguém ter sido perguntado.\n`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
console.log("✓ Honestidade ok: nada fabricado sem decisão do lojista.");
|