@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,200 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// PLACEHOLDER NO AR — npm run unbox:placeholder
|
|
3
|
+
//
|
|
4
|
+
// Roda contra o HTML SERVIDO, não contra o código-fonte. A diferença decide os dois casos que
|
|
5
|
+
// uma varredura de fonte erra:
|
|
6
|
+
//
|
|
7
|
+
// • FALSO POSITIVO: quase todo componente tem um "TODO" em comentário, e componente que não
|
|
8
|
+
// está na receita nunca chega à tela. O fonte acusa o que ninguém vê.
|
|
9
|
+
// • FALSO NEGATIVO: o texto que vaza pode não existir como string no código. Numa loja em
|
|
10
|
+
// produção, "[NOME DA LOJA], inscrita no CNPJ sob o nº [CNPJ]" foi ao ar em /termos e
|
|
11
|
+
// /privacidade, indexáveis; e o logotipo escrito "NOME DA LOJA" no cabeçalho do checkout
|
|
12
|
+
// era um ARQUIVO DE IMAGEM. Nenhuma busca por string em .tsx encontra nenhum dos dois.
|
|
13
|
+
//
|
|
14
|
+
// Precisa de BUILD DE PRODUÇÃO: em desenvolvimento o Next não gera as páginas estáticas, e é
|
|
15
|
+
// justamente o HTML final que interessa. Se o servidor não estiver de pé, o script sobe um
|
|
16
|
+
// sozinho na porta livre, mede e derruba.
|
|
17
|
+
//
|
|
18
|
+
// CÓDIGOS DE SAÍDA
|
|
19
|
+
// 0 nada de placeholder no ar
|
|
20
|
+
// 1 achou placeholder servido ao público
|
|
21
|
+
// 2 O GATE NÃO RODOU (sem build, servidor não subiu, nenhuma rota respondeu)
|
|
22
|
+
//
|
|
23
|
+
// O 2 segue a doutrina do check-unbox-brand.mjs: gate que varre o vazio e diz "limpo" aprova
|
|
24
|
+
// sem ter olhado.
|
|
25
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import net from "node:net";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
const RAIZ = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
const BASE_EXTERNA = process.env.PLACEHOLDER_BASE ?? process.env.QA_BASE ?? null;
|
|
34
|
+
|
|
35
|
+
// Cada padrão precisa ser algo que NÃO pode aparecer legitimamente numa loja pronta. É por isso
|
|
36
|
+
// que "TODO" exige os dois-pontos e "preencher" exige a frase inteira: a loja pode dizer
|
|
37
|
+
// "preencha o CEP" sem estar quebrada.
|
|
38
|
+
const PADROES = [
|
|
39
|
+
[/\[NOME DA LOJA\]/i, "nome da loja não preenchido"],
|
|
40
|
+
[/\[CNPJ\]/i, "CNPJ não preenchido (e /termos e /privacidade são indexáveis)"],
|
|
41
|
+
[/\[[A-ZÁÉÍÓÚÂÊÔÃÕÇ][A-ZÁÉÍÓÚÂÊÔÃÕÇ0-9 ,._/-]{3,}\]/, "campo entre colchetes em CAIXA ALTA: placeholder do template"],
|
|
42
|
+
[/\bTODO:/, "TODO: visível na página"],
|
|
43
|
+
[/preencher este arquivo|preencha este arquivo/i, "instrução de preenchimento servida ao público"],
|
|
44
|
+
[/lorem ipsum/i, "lorem ipsum"],
|
|
45
|
+
[/Minha Loja/, 'nome placeholder da foundation ("Minha Loja")'],
|
|
46
|
+
[/sua marca/i, 'logo placeholder da foundation ("sua marca")'],
|
|
47
|
+
[/Cliente [A-C]\./, "depoimento placeholder (Cliente A/B/C)"],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// As rotas que mais carregam placeholder: as legais (texto longo que ninguém relê), o checkout
|
|
51
|
+
// (onde o logo do caso real vazou) e as de catálogo.
|
|
52
|
+
const ROTAS_FIXAS = [
|
|
53
|
+
"/", "/produtos", "/busca", "/carrinho", "/checkout",
|
|
54
|
+
"/termos", "/privacidade", "/devolucoes", "/conta/entrar", "/llms.txt",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function portaLivre() {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const s = net.createServer();
|
|
60
|
+
s.listen(0, () => {
|
|
61
|
+
const p = s.address().port;
|
|
62
|
+
s.close(() => resolve(p));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function noAr(base) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(base, { signal: AbortSignal.timeout(3000) });
|
|
70
|
+
return r.ok || r.status < 500;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function subirServidor() {
|
|
77
|
+
if (!fs.existsSync(path.join(RAIZ, ".next"))) return null;
|
|
78
|
+
const porta = await portaLivre();
|
|
79
|
+
const proc = spawn("npx", ["next", "start", "-p", String(porta)], { cwd: RAIZ, stdio: "ignore" });
|
|
80
|
+
const base = `http://localhost:${porta}`;
|
|
81
|
+
for (let i = 0; i < 40; i++) {
|
|
82
|
+
if (await noAr(base)) return { base, proc };
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
+
}
|
|
85
|
+
proc.kill();
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Descobre uma PDP e uma categoria reais no sitemap: são as rotas que o gate não pode adivinhar. */
|
|
90
|
+
async function rotasDoSitemap(base) {
|
|
91
|
+
try {
|
|
92
|
+
const xml = await (await fetch(`${base}/sitemap.xml`, { signal: AbortSignal.timeout(8000) })).text();
|
|
93
|
+
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
|
94
|
+
const caminho = (u) => { try { return new URL(u).pathname; } catch { return null; } };
|
|
95
|
+
const pdp = urls.map(caminho).find((p) => p?.startsWith("/produto/"));
|
|
96
|
+
const cat = urls.map(caminho).find((p) => p?.startsWith("/categoria/"));
|
|
97
|
+
return [pdp, cat].filter(Boolean);
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** SVG é texto: um logotipo com o nome errado dentro é encontrável. PNG e JPG não são. */
|
|
104
|
+
function varrerAssetsDeMarca() {
|
|
105
|
+
const achados = [];
|
|
106
|
+
const dir = path.join(RAIZ, "public", "brand");
|
|
107
|
+
if (!fs.existsSync(dir)) return achados;
|
|
108
|
+
const pilha = [dir];
|
|
109
|
+
let raster = 0;
|
|
110
|
+
while (pilha.length) {
|
|
111
|
+
const atual = pilha.pop();
|
|
112
|
+
for (const e of fs.readdirSync(atual, { withFileTypes: true })) {
|
|
113
|
+
const p = path.join(atual, e.name);
|
|
114
|
+
if (e.isDirectory()) { pilha.push(p); continue; }
|
|
115
|
+
if (/\.(png|jpe?g|webp|avif)$/i.test(e.name)) { raster++; continue; }
|
|
116
|
+
if (!/\.svg$/i.test(e.name)) continue;
|
|
117
|
+
const t = fs.readFileSync(p, "utf8");
|
|
118
|
+
for (const [rx, motivo] of PADROES) {
|
|
119
|
+
const m = t.match(rx);
|
|
120
|
+
if (m) achados.push({ onde: path.relative(RAIZ, p), motivo, trecho: m[0].slice(0, 60) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { achados, raster };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ⚠️ NÃO usar "o que estiver na 3000". No primeiro uso deste gate, a porta 3000 tinha OUTRO
|
|
128
|
+
// projeto de pé e o gate mediu a loja errada: teria aprovado esta e reprovado aquela, com a
|
|
129
|
+
// mesma confiança. Sem base explícita, ele sobe o PRÓPRIO servidor a partir do .next daqui.
|
|
130
|
+
let base = null;
|
|
131
|
+
let servidor = null;
|
|
132
|
+
if (BASE_EXTERNA) {
|
|
133
|
+
base = (await noAr(BASE_EXTERNA)) ? BASE_EXTERNA : null;
|
|
134
|
+
if (!base) {
|
|
135
|
+
console.error(`\n✗ o gate NÃO RODOU: ${BASE_EXTERNA} não respondeu.\n`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
servidor = await subirServidor();
|
|
140
|
+
base = servidor?.base ?? null;
|
|
141
|
+
}
|
|
142
|
+
if (!base) {
|
|
143
|
+
console.error(
|
|
144
|
+
"\n✗ o gate NÃO RODOU: não há build de produção para subir (.next ausente).\n" +
|
|
145
|
+
" Rode `npm run build` antes, ou aponte para um servidor já de pé:\n" +
|
|
146
|
+
" PLACEHOLDER_BASE=https://sua-loja.vercel.app npm run unbox:placeholder\n",
|
|
147
|
+
);
|
|
148
|
+
process.exit(2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`Varrendo o HTML servido em ${base}${servidor ? " (servidor próprio, subido para esta medição)" : " (base externa informada)"}`);
|
|
152
|
+
const rotas = [...ROTAS_FIXAS, ...(await rotasDoSitemap(base))];
|
|
153
|
+
const achados = [];
|
|
154
|
+
let respondidas = 0;
|
|
155
|
+
|
|
156
|
+
for (const rota of rotas) {
|
|
157
|
+
let html;
|
|
158
|
+
try {
|
|
159
|
+
const r = await fetch(`${base}${rota}`, { signal: AbortSignal.timeout(15000), redirect: "follow" });
|
|
160
|
+
html = await r.text();
|
|
161
|
+
if (r.status >= 500) continue;
|
|
162
|
+
respondidas++;
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const [rx, motivo] of PADROES) {
|
|
167
|
+
const m = html.match(rx);
|
|
168
|
+
if (m) achados.push({ onde: rota, motivo, trecho: m[0].slice(0, 60) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Os assets varridos são os DESTE projeto, mesmo quando a base é externa: um é o que está no
|
|
173
|
+
// ar, o outro é o que está no disco, e misturar os dois sem dizer confunde o diagnóstico.
|
|
174
|
+
const assets = varrerAssetsDeMarca();
|
|
175
|
+
achados.push(...assets.achados);
|
|
176
|
+
if (servidor) servidor.proc.kill();
|
|
177
|
+
|
|
178
|
+
if (respondidas === 0) {
|
|
179
|
+
console.error("\n✗ o gate NÃO RODOU: nenhuma rota respondeu. O build está de pé?\n");
|
|
180
|
+
process.exit(2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (achados.length) {
|
|
184
|
+
console.error(`\n✗ PLACEHOLDER NO AR — ${achados.length} ocorrência(s) em ${respondidas} rotas varridas:\n`);
|
|
185
|
+
for (const a of achados) console.error(` ${a.onde.startsWith("/") ? base + a.onde : a.onde + " (arquivo local)"}\n [${a.motivo}] "${a.trecho}"`);
|
|
186
|
+
console.error(
|
|
187
|
+
"\n Isto é o que o público vê, não o que está no código. Página legal com colchete vazio é\n" +
|
|
188
|
+
" indexável, e nome de loja errado no logo do checkout aparece na hora de pagar.\n",
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`✓ Sem placeholder no ar (${respondidas} rotas + ${assets.achados.length === 0 ? "assets SVG" : "assets"} varridos)`);
|
|
194
|
+
if (assets.raster) {
|
|
195
|
+
console.log(
|
|
196
|
+
` nota: ${assets.raster} imagem(ns) raster em public/brand não podem ser varridas por texto.\n` +
|
|
197
|
+
" Logotipo com o nome errado dentro de um PNG só aparece olhando. Confira o do checkout.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
process.exit(0);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// MEDIDOR DE COMPOSIÇÃO DA RECEITA — npm run unbox:receita
|
|
3
|
+
//
|
|
4
|
+
// Converte "gosto" em número: conta seções, famílias de layout, repetição
|
|
5
|
+
// consecutiva da mesma família (o "zigzag" que faz a home parecer um sanfona)
|
|
6
|
+
// e a diversidade geral. Ideia vinda da taste-skill; os limites são NOSSOS.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Hoje ele só MEDE e reporta (sempre exit 0). Vira gate no prebuild depois
|
|
9
|
+
// que os limites estiverem calibrados contra os 4 estilos — um check que
|
|
10
|
+
// reprova a própria foundation no dia 1 não serviria pra nada.
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
|
|
18
|
+
// Família = o ESQUELETO visual da seção. Duas seções da mesma família em
|
|
19
|
+
// sequência repetem a mesma silhueta, mesmo com conteúdo diferente.
|
|
20
|
+
const FAMILIA = {
|
|
21
|
+
hero: "hero",
|
|
22
|
+
"purchase-hero": "hero",
|
|
23
|
+
"trust-bar": "faixa", "trust-strip": "faixa", "attributes-marquee": "faixa",
|
|
24
|
+
"category-pills": "chips",
|
|
25
|
+
"combos-carousel": "carrossel", "reviews-carousel": "carrossel",
|
|
26
|
+
"media-cards": "carrossel", "product-showcase": "grade",
|
|
27
|
+
kits: "grade", reviews: "grade", benefits: "grade", "stats-grid": "grade",
|
|
28
|
+
"social-row": "grade", "video-wall": "grade",
|
|
29
|
+
savings: "comparativo", comparison: "comparativo", "spec-table": "tabela",
|
|
30
|
+
"founder-story": "split", ritual: "split", "quote-banner": "split",
|
|
31
|
+
newsletter: "captura",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const arquivos = [
|
|
35
|
+
["home", "components/home/home-recipe.ts"],
|
|
36
|
+
["oferta", "components/landing/landing-recipe.ts"],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
let algumAviso = false;
|
|
40
|
+
|
|
41
|
+
for (const [nome, rel] of arquivos) {
|
|
42
|
+
const p = path.join(ROOT, rel);
|
|
43
|
+
if (!fs.existsSync(p)) continue;
|
|
44
|
+
const src = fs.readFileSync(p, "utf8");
|
|
45
|
+
const secoes = [...src.matchAll(/section:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
46
|
+
if (!secoes.length) continue;
|
|
47
|
+
|
|
48
|
+
const familias = secoes.map((s) => FAMILIA[s] ?? "?");
|
|
49
|
+
const distintas = new Set(familias);
|
|
50
|
+
|
|
51
|
+
// maior sequência da MESMA família
|
|
52
|
+
let maiorSeq = 1, seqAtual = 1;
|
|
53
|
+
for (let i = 1; i < familias.length; i++) {
|
|
54
|
+
seqAtual = familias[i] === familias[i - 1] ? seqAtual + 1 : 1;
|
|
55
|
+
if (seqAtual > maiorSeq) maiorSeq = seqAtual;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`\n── receita "${nome}" — ${secoes.length} seções, ${distintas.size} famílias`);
|
|
59
|
+
console.log(` famílias: ${[...distintas].join(", ")}`);
|
|
60
|
+
console.log(` maior sequência da mesma família: ${maiorSeq}`);
|
|
61
|
+
|
|
62
|
+
if (maiorSeq >= 3) {
|
|
63
|
+
algumAviso = true;
|
|
64
|
+
console.log(` ⚠ ${maiorSeq} seções seguidas com a mesma silhueta — intercale outra família.`);
|
|
65
|
+
}
|
|
66
|
+
if (secoes.length >= 14 && distintas.size <= 5) {
|
|
67
|
+
algumAviso = true;
|
|
68
|
+
console.log(` ⚠ home longa (${secoes.length}) com pouca variedade (${distintas.size} famílias) — corte ou diversifique.`);
|
|
69
|
+
}
|
|
70
|
+
const desconhecidas = secoes.filter((s) => !FAMILIA[s]);
|
|
71
|
+
if (desconhecidas.length) {
|
|
72
|
+
console.log(` ℹ seções sem família mapeada (adicione em scripts/check-recipe.mjs): ${[...new Set(desconhecidas)].join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(algumAviso ? "\nMedição concluída — há avisos acima.\n" : "\nMedição concluída — composição equilibrada.\n");
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// GATE DE BUILD — CONTRATO UNBOX. Roda automaticamente no `prebuild` (ou seja,
|
|
3
|
+
// em todo `npm run build`, inclusive no deploy da Vercel). Se qualquer item
|
|
4
|
+
// obrigatório estiver ausente, o build FALHA e o site não deploya:
|
|
5
|
+
// 1. Selo "Powered by Unbox" no rodapé (componente + asset + uso no footer)
|
|
6
|
+
// 2. GTM central da Unbox (GTM-PZLT336) no app/layout.tsx
|
|
7
|
+
// NÃO remova este script nem o hook "prebuild" do package.json.
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const read = (p) => {
|
|
15
|
+
try { return fs.readFileSync(path.join(ROOT, p), "utf8"); } catch { return null; }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const errors = [];
|
|
19
|
+
|
|
20
|
+
// 1. Powered by Unbox
|
|
21
|
+
const poweredBy = read("components/powered-by-unbox.tsx");
|
|
22
|
+
if (!poweredBy) errors.push("components/powered-by-unbox.tsx não existe.");
|
|
23
|
+
else if (!poweredBy.includes("unbox.com.br")) errors.push("powered-by-unbox.tsx perdeu o link pra unbox.com.br.");
|
|
24
|
+
if (!fs.existsSync(path.join(ROOT, "public", "unbox", "powered-by.png"))) {
|
|
25
|
+
errors.push("asset public/unbox/powered-by.png não existe.");
|
|
26
|
+
}
|
|
27
|
+
// exige a RENDERIZAÇÃO (<PoweredByUnbox), não só o import — senão remover o uso passaria
|
|
28
|
+
const footer = read("components/site-footer.tsx");
|
|
29
|
+
if (!footer || !footer.includes("<PoweredByUnbox")) {
|
|
30
|
+
errors.push("o rodapé (components/site-footer.tsx) não renderiza <PoweredByUnbox />.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. GTM central
|
|
34
|
+
const layout = read("app/layout.tsx");
|
|
35
|
+
if (!layout || !layout.includes("GTM-PZLT336")) {
|
|
36
|
+
errors.push("o GTM central da Unbox (GTM-PZLT336) sumiu do app/layout.tsx.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Contrato da URL do checkout — ?id=&token= do carrinho
|
|
40
|
+
//
|
|
41
|
+
// Bloqueia porque a recuperação de carrinho abandonado depende INTEIRAMENTE disso: o CRM e o
|
|
42
|
+
// pixel capturam a URL navegada, não o cookie httpOnly. Sem os params na URL, a recuperação
|
|
43
|
+
// não existe — e some sem nenhum sinal visível, porque a loja continua funcionando e vendendo.
|
|
44
|
+
// Já se perdeu uma vez por viver só na aplicação; agora o build reprova.
|
|
45
|
+
const destino = read("app/api/checkout-destination/route.ts");
|
|
46
|
+
if (!destino) {
|
|
47
|
+
errors.push("app/api/checkout-destination/route.ts não existe — é ele que monta a URL do checkout com ?id=&token=.");
|
|
48
|
+
} else if (!/id=\$\{encodeURIComponent\(ref\.cartId\)\}/.test(destino) || !/token=\$\{encodeURIComponent\(ref\.cartToken\)\}/.test(destino)) {
|
|
49
|
+
errors.push("app/api/checkout-destination/route.ts parou de montar ?id=&token= na URL do checkout (recuperação de carrinho depende disso).");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Só no modo checkout CUSTOMIZADO: existe rota /checkout neste projeto, então o middleware
|
|
53
|
+
// precisa garantir os params em qualquer forma de chegada. No modo hospedado a página não
|
|
54
|
+
// existe e a garantia vive só na rota acima.
|
|
55
|
+
if (fs.existsSync(path.join(ROOT, "app", "(loja)", "checkout", "page.tsx"))) {
|
|
56
|
+
const mw = read("middleware.ts");
|
|
57
|
+
if (!mw || !mw.includes("urlDoCheckoutComPonteiro")) {
|
|
58
|
+
errors.push("middleware.ts perdeu a garantia do ?id=&token= no /checkout (função urlDoCheckoutComPonteiro).");
|
|
59
|
+
} else if (!/pathname !== "\/checkout"/.test(mw)) {
|
|
60
|
+
errors.push("a garantia do checkout no middleware.ts não aponta mais para a rota /checkout.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Contrato do dataLayer — o que o GTM central da Unbox lê
|
|
65
|
+
//
|
|
66
|
+
// Medido no JS do container GTM-PZLT336: ele lê `ecommerce.items` (GA4), `ecommerce.purchase.*`
|
|
67
|
+
// e `transactionId/transactionTotal` (gerações anteriores) e `dataLayerReady` com `pageType` e
|
|
68
|
+
// `products[]` (remarketing do Ads). A camada de tracking emite as três. Se alguém "limpar"
|
|
69
|
+
// isso para deixar só o GA4 puro, a compra chega vazia no GA4 da Unbox — e ninguém vê.
|
|
70
|
+
const analytics = read("lib/analytics.ts");
|
|
71
|
+
if (!analytics) {
|
|
72
|
+
errors.push("lib/analytics.ts não existe — é a camada única de tracking.");
|
|
73
|
+
} else {
|
|
74
|
+
for (const [marca, why] of [
|
|
75
|
+
["ecommerce.purchase", "compra no formato que o GTM central lê (ecommerce.purchase.*)"],
|
|
76
|
+
["transactionTotal", "chaves clássicas (transactionId/transactionTotal) que o GTM central lê"],
|
|
77
|
+
['"dataLayerReady"', "evento dataLayerReady (pageType + products[]) do remarketing"],
|
|
78
|
+
["{ ecommerce: null }", "limpeza do objeto ecommerce antes de cada push"],
|
|
79
|
+
]) {
|
|
80
|
+
if (!analytics.includes(marca)) errors.push(`lib/analytics.ts perdeu: ${why}.`);
|
|
81
|
+
}
|
|
82
|
+
if (/if \(typeof w\.gtag === "function"\) w\.gtag\("event"[^\n]*\n\s*else if \(Array\.isArray\(w\.dataLayer\)\)/.test(analytics)) {
|
|
83
|
+
errors.push("lib/analytics.ts voltou a priorizar o gtag sobre o dataLayer — o GTM central fica cego com GA4 próprio.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `dataLayerReady` é o ÚNICO gatilho de tipo de página do container central (medido no JS dele:
|
|
88
|
+
// não existe gatilho de custom event `page_view`). Página principal que não emite o evento é
|
|
89
|
+
// invisível pro remarketing do Ads — e é justamente onde cai o tráfego de campanha.
|
|
90
|
+
for (const [arquivo, rotulo] of [
|
|
91
|
+
["app/(loja)/page.tsx", "home"],
|
|
92
|
+
["app/(loja)/produtos/page.tsx", "catálogo"],
|
|
93
|
+
["app/(loja)/categoria/[tagSlug]/page.tsx", "categoria"],
|
|
94
|
+
["app/(loja)/busca/page.tsx", "busca"],
|
|
95
|
+
["app/(loja)/produto/[productSlug]/page.tsx", "PDP"],
|
|
96
|
+
["app/(loja)/carrinho/page.tsx", "carrinho"],
|
|
97
|
+
]) {
|
|
98
|
+
const src = read(arquivo);
|
|
99
|
+
if (src && !/DataLayerReady|trackPageType/.test(src)) {
|
|
100
|
+
errors.push(`${arquivo} (${rotulo}) não emite dataLayerReady — o GTM central perde o tipo de página e o remarketing.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 5. Tom sobre a plataforma ────────────────────────────────────────────────
|
|
105
|
+
// A loja é de um cliente que comprou a Unbox e recebe este código-fonte inteiro. Restrição de
|
|
106
|
+
// plataforma se DESCREVE (a regra, o efeito, a decisão); julgar a plataforma dentro do produto
|
|
107
|
+
// que ela vende não informa nada que a frase neutra não informe. Nasceu de um caso real: o
|
|
108
|
+
// agente explicou a frequência de assinatura como "é pior do que parece / limitação da
|
|
109
|
+
// plataforma" para o lojista e levou esse enquadramento para a interface da loja.
|
|
110
|
+
// Detalhe e exemplos: CLAUDE.md → "Como falar da plataforma".
|
|
111
|
+
const JULGAMENTO = [
|
|
112
|
+
[/limita[çc][ãa]o d[ao]s? (plataforma|Unbox|sistema)/i, '"limitação da plataforma"'],
|
|
113
|
+
[/pior do que parece/i, '"pior do que parece"'],
|
|
114
|
+
[/(culpa|problema|falha|defeito|bug) d[ao] Unbox/i, "culpa/problema/falha atribuídos à Unbox"],
|
|
115
|
+
[/a Unbox n[ãa]o (deixa|permite|aceita|suporta|consegue|tem como)/i, '"a Unbox não deixa/aceita/suporta"'],
|
|
116
|
+
[/n[ãa]o d[áa] pra resolver/i, '"não dá pra resolver"'],
|
|
117
|
+
[/infelizmente/i, '"infelizmente"'],
|
|
118
|
+
[/por limita[çc][ãa]o d[oa] sistema/i, '"por limitação do sistema" (copy do comprador)'],
|
|
119
|
+
];
|
|
120
|
+
const arquivosDoProjeto = [];
|
|
121
|
+
(function varre(dir) {
|
|
122
|
+
for (const nome of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
123
|
+
if (nome.name === "node_modules" || nome.name === ".next" || nome.name.startsWith(".git")) continue;
|
|
124
|
+
const caminho = path.join(dir, nome.name);
|
|
125
|
+
if (nome.isDirectory()) varre(caminho);
|
|
126
|
+
else if (/\.(tsx?|mjs|md)$/.test(nome.name)) arquivosDoProjeto.push(caminho);
|
|
127
|
+
}
|
|
128
|
+
})(ROOT);
|
|
129
|
+
for (const caminho of arquivosDoProjeto) {
|
|
130
|
+
const rel = path.relative(ROOT, caminho);
|
|
131
|
+
// O próprio gate e a seção do CLAUDE.md CITAM as frases proibidas para ensinar a regra.
|
|
132
|
+
if (rel === "scripts/check-unbox-brand.mjs" || rel === "CLAUDE.md" || rel === "agents/CONSTRUCAO.md" || rel === ".claude/agents/branding-briefing.md") continue;
|
|
133
|
+
const src = fs.readFileSync(caminho, "utf8");
|
|
134
|
+
for (const [rx, rotulo] of JULGAMENTO) {
|
|
135
|
+
const m = src.match(rx);
|
|
136
|
+
if (m) {
|
|
137
|
+
const linha = src.slice(0, m.index).split("\n").length;
|
|
138
|
+
errors.push(`${rel}:${linha} julga a plataforma (${rotulo}) — descreva a regra e o efeito, sem juízo de valor (CLAUDE.md → "Como falar da plataforma").`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 6. Travessão na copy ─────────────────────────────────────────────────────
|
|
144
|
+
// A regra "nada de travessão na copy" existia só no prompt do briefing e não se sustentou: a
|
|
145
|
+
// própria foundation entregava travessão em título de aba, página legal, catálogo e carrinho, e
|
|
146
|
+
// exemplo vale mais que instrução. Aqui ela vira build quebrado.
|
|
147
|
+
// Fora do escopo, de propósito: comentário (nota de engenharia, não copy) e o "—" sozinho como
|
|
148
|
+
// marcador de valor vazio numa tabela (`?? "—"`), que é uso tipográfico e não prosa.
|
|
149
|
+
/** Devolve só a parte de CÓDIGO+STRING da linha: tira comentário de linha e de bloco inline,
|
|
150
|
+
* sem se enganar com "//" dentro de string (URL) nem com aspas dentro de comentário. */
|
|
151
|
+
function semComentario(linha, dentroDeBloco) {
|
|
152
|
+
let out = "";
|
|
153
|
+
let aspas = null;
|
|
154
|
+
let bloco = dentroDeBloco;
|
|
155
|
+
for (let i = 0; i < linha.length; i++) {
|
|
156
|
+
const c = linha[i];
|
|
157
|
+
const prox = linha[i + 1];
|
|
158
|
+
if (bloco) {
|
|
159
|
+
if (c === "*" && prox === "/") { bloco = false; i++; }
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (aspas) {
|
|
163
|
+
if (c === "\\") { out += c + (prox ?? ""); i++; continue; }
|
|
164
|
+
if (c === aspas) aspas = null;
|
|
165
|
+
out += c;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === '"' || c === "'" || c === "`") { aspas = c; out += c; continue; }
|
|
169
|
+
if (c === "/" && prox === "/") break; // comentário até o fim da linha
|
|
170
|
+
if (c === "/" && prox === "*") { bloco = true; i++; continue; }
|
|
171
|
+
out += c;
|
|
172
|
+
}
|
|
173
|
+
return { texto: out, bloco };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const arquivosDeCopy = [];
|
|
177
|
+
function varreCopy(dir) {
|
|
178
|
+
let entradas;
|
|
179
|
+
try { entradas = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
180
|
+
for (const e of entradas) {
|
|
181
|
+
if (e.name === "node_modules" || e.name === ".next") continue;
|
|
182
|
+
const caminho = path.join(dir, e.name);
|
|
183
|
+
if (e.isDirectory()) varreCopy(caminho);
|
|
184
|
+
else if (/\.tsx?$/.test(e.name)) arquivosDeCopy.push(caminho);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
varreCopy(path.join(ROOT, "app"));
|
|
188
|
+
varreCopy(path.join(ROOT, "components"));
|
|
189
|
+
|
|
190
|
+
for (const caminho of arquivosDeCopy) {
|
|
191
|
+
const rel = path.relative(ROOT, caminho);
|
|
192
|
+
let bloco = false;
|
|
193
|
+
fs.readFileSync(caminho, "utf8").split("\n").forEach((linha, idx) => {
|
|
194
|
+
const r = semComentario(linha, bloco);
|
|
195
|
+
bloco = r.bloco;
|
|
196
|
+
if (!r.texto.includes("—")) return;
|
|
197
|
+
const semPlaceholder = r.texto.replace(/(["'`])\s*—\s*\1/g, ""); // `?? "—"` é marcador de vazio
|
|
198
|
+
if (!semPlaceholder.includes("—")) return;
|
|
199
|
+
errors.push(`${rel}:${idx + 1} usa travessão na copy: troque por vírgula, dois-pontos, ponto ou "·" (CLAUDE.md, "Copy sem travessão").`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── 7. Destino do checkout: só pelo checkout-nav ─────────────────────────────
|
|
204
|
+
// Com UNBOX_HOSTED_CHECKOUT_URL preenchida o destino é EXTERNO e só o servidor sabe montar a URL
|
|
205
|
+
// (o cartToken é httpOnly). Uma tela que navega para "/checkout" no braço fura isso: metade dos
|
|
206
|
+
// botões vai para o checkout hospedado e metade para uma rota que, no modo hospedado, nem existe.
|
|
207
|
+
// Ficam de fora as telas do próprio checkout customizável, que o CLI remove no modo hospedado.
|
|
208
|
+
const CHECKOUT_PROPRIO = ["app/(loja)/checkout", "components/checkout", "lib/checkout-nav.ts"];
|
|
209
|
+
for (const caminho of arquivosDeCopy) {
|
|
210
|
+
const rel = path.relative(ROOT, caminho);
|
|
211
|
+
if (CHECKOUT_PROPRIO.some((dir) => rel.startsWith(dir))) continue;
|
|
212
|
+
let bloco = false;
|
|
213
|
+
fs.readFileSync(caminho, "utf8").split("\n").forEach((linha, idx) => {
|
|
214
|
+
const r = semComentario(linha, bloco);
|
|
215
|
+
bloco = r.bloco;
|
|
216
|
+
if (/(push|replace)\(\s*["'`]\/checkout|href\s*=\s*["'`]\/checkout|location\.href\s*=\s*["'`]\/checkout/.test(r.texto)) {
|
|
217
|
+
errors.push(`${rel}:${idx + 1} navega para "/checkout" direto: use goToCheckout() de lib/checkout-nav (o destino pode ser o checkout hospedado).`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 8. /llms.txt é rota, e nada pode escondê-la ──────────────────────────────
|
|
223
|
+
// Arquivo em public/ tem precedência sobre rota de mesmo caminho: um `public/llms.txt` deixaria
|
|
224
|
+
// a rota morta e voltaria a servir texto estático (foi assim que uma loja publicou "TODO:
|
|
225
|
+
// preencher" para os crawlers). O conteúdo tem que vir do catálogo real.
|
|
226
|
+
if (!fs.existsSync(path.join(ROOT, "app/llms.txt/route.ts"))) {
|
|
227
|
+
errors.push("app/llms.txt/route.ts sumiu: o /llms.txt precisa ser gerado do catálogo real, não escrito à mão.");
|
|
228
|
+
}
|
|
229
|
+
if (fs.existsSync(path.join(ROOT, "public/llms.txt"))) {
|
|
230
|
+
errors.push("public/llms.txt existe e ESCONDE a rota app/llms.txt (arquivo estático tem precedência): apague o arquivo.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── 9. Formulário sem destino ────────────────────────────────────────────────
|
|
234
|
+
// Campo que recebe dado e joga fora é pior que campo nenhum: a pessoa acha que se cadastrou e a
|
|
235
|
+
// loja não tem o contato. Dois blocos desta foundation faziam isso com
|
|
236
|
+
// `onSubmit={(e) => e.preventDefault()}` e um <input type="email"> dentro. O padrão é preciso o
|
|
237
|
+
// bastante para virar regra: preventDefault sozinho, sem handler que faça algo, é engolir.
|
|
238
|
+
for (const caminho of arquivosDeCopy) {
|
|
239
|
+
const rel = path.relative(ROOT, caminho);
|
|
240
|
+
const src = fs.readFileSync(caminho, "utf8");
|
|
241
|
+
if (!/<input[^>]*type=["']email["']/.test(src)) continue;
|
|
242
|
+
const linhas = src.split("\n");
|
|
243
|
+
linhas.forEach((linha, idx) => {
|
|
244
|
+
if (/onSubmit=\{\s*\(?\s*e\s*\)?\s*=>\s*e\.preventDefault\(\)\s*\}/.test(linha)) {
|
|
245
|
+
errors.push(`${rel}:${idx + 1} formulário de e-mail que descarta o envio (preventDefault sozinho): dê um destino real ou não renderize o bloco.`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Avisos NÃO bloqueantes (acabamento de marca) ──────────────────────────────
|
|
251
|
+
const warnings = [];
|
|
252
|
+
if (layout && /description:\s*(undefined|""|process\.env\.NEXT_PUBLIC_SITE_DESCRIPTION \|\| undefined)/.test(layout) && !process.env.NEXT_PUBLIC_SITE_DESCRIPTION) {
|
|
253
|
+
warnings.push('a loja ainda não tem meta description: escreva uma frase com o que ela vende e para quem, em app/layout.tsx (busca e IA extraem daí).');
|
|
254
|
+
}
|
|
255
|
+
for (const f of ["app/icon.svg", "app/apple-icon.svg"]) {
|
|
256
|
+
const svg = read(f);
|
|
257
|
+
if (svg && svg.includes("unbox-placeholder")) {
|
|
258
|
+
warnings.push(`${f} ainda é o placeholder da foundation — troque pelo ícone da marca.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Logos da marca ainda com o placeholder da foundation. São DOIS arquivos usados em
|
|
262
|
+
// lugares diferentes (logo.svg: header claro, menu mobile, checkout, porta de preview;
|
|
263
|
+
// logo-chrome.svg: rodapé e header sobreposto) — trocar só um deixa o outro placeholder
|
|
264
|
+
// no ar, e ninguém percebe até um cliente ver.
|
|
265
|
+
for (const f of ["public/brand/logo.svg", "public/brand/logo-chrome.svg"]) {
|
|
266
|
+
const svg = read(f);
|
|
267
|
+
if (svg && svg.includes("sua marca")) {
|
|
268
|
+
warnings.push(`${f} ainda é o logo placeholder da foundation (quadrado cinza "sua marca").`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Paleta ainda no default da foundation. O cinza neutro é canvas provisória: entregar a
|
|
272
|
+
// loja assim é entregar loja sem identidade. Aviso, não bloqueio — pode ser deliberado
|
|
273
|
+
// numa demo ou num scaffold recém-criado.
|
|
274
|
+
const css = read("app/globals.css");
|
|
275
|
+
if (css && /--store-primary:\s*#18181B/i.test(css) && /--store-cta:\s*#D97706/i.test(css)) {
|
|
276
|
+
warnings.push("a loja ainda usa a PALETA DEFAULT da foundation (tinta + âmbar) — aplique as cores reais da marca.");
|
|
277
|
+
}
|
|
278
|
+
const manifest = read("app/manifest.ts");
|
|
279
|
+
if (manifest && /"Minha Loja"|"Loja"/.test(manifest)) {
|
|
280
|
+
warnings.push('app/manifest.ts ainda tem nome placeholder ("Minha Loja"/"Loja") — o PWA instala com esse nome.');
|
|
281
|
+
}
|
|
282
|
+
if (warnings.length) {
|
|
283
|
+
console.warn("\n⚠ Acabamento de marca pendente (não bloqueia o build):");
|
|
284
|
+
for (const w of warnings) console.warn(` • ${w}`);
|
|
285
|
+
console.warn(" Dicas: apple-icon precisa de fundo OPACO (iOS não aplica transparência) e ícone");
|
|
286
|
+
console.warn(" maskable precisa de ~20% de margem de segurança. Detalhes no QA.md.\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (errors.length) {
|
|
290
|
+
console.error("\n✗ BUILD BLOQUEADO — itens obrigatórios do contrato Unbox ausentes:\n");
|
|
291
|
+
for (const e of errors) console.error(` • ${e}`);
|
|
292
|
+
console.error("\n Toda loja criada com o create-unbox-store precisa do selo \"Powered by Unbox\"");
|
|
293
|
+
console.error(" no rodapé, do GTM central ativo e do ?id=&token= na URL do checkout (sem ele a");
|
|
294
|
+
console.error(" recuperação de carrinho abandonado não funciona). Restaure e rode o build de novo.");
|
|
295
|
+
console.error(" (Eles fazem parte do contrato da plataforma e não podem ser removidos.)\n");
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
console.log("✓ Contrato Unbox ok (Powered by + GTM + ?id=&token= + dataLayer)");
|