@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,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// PAGE_VIEW POR ROTA (foundation 12): a parte de CLIENTE do rastreio. Quem monta os scripts é o
|
|
4
|
+
// `<Rastreio>` de rastreio.tsx (servidor); este hook só avisa cada provedor que a página mudou.
|
|
5
|
+
//
|
|
6
|
+
// Dispara o page_view de cada NAVEGAÇÃO do App Router para quem NÃO conta a navegação sozinho: Meta
|
|
7
|
+
// Pixel (fbq), TikTok Pixel (ttq) e Pinterest Tag (pintrk). O snippet de cada um só conta o
|
|
8
|
+
// carregamento inicial; a navegação SPA não recarrega a página, e sem este hook a segunda página não
|
|
9
|
+
// existe para nenhum deles.
|
|
10
|
+
//
|
|
11
|
+
// UM DONO DO PAGE_VIEW POR PROVEDOR. O GA4 fica DE FORA deste hook, de propósito: o gtag.js, com a
|
|
12
|
+
// medição otimizada ligada (é o padrão de todo fluxo de dados do GA4), já manda um page_view a cada
|
|
13
|
+
// mudança de histórico do navegador, que é exatamente o que a navegação do App Router faz (pushState).
|
|
14
|
+
// Mandar outro daqui contava DUAS visualizações por navegação (Astra, B3). `send_page_view: false` não
|
|
15
|
+
// resolveria: ele desliga só o page_view do carregamento, não a medição de mudanças de histórico.
|
|
16
|
+
// Referência: https://developers.google.com/analytics/devguides/collection/ga4/views (page_view em
|
|
17
|
+
// aplicações de página única). Meta, TikTok e Pinterest não acompanham o histórico: cada um precisa do
|
|
18
|
+
// aviso, e é daqui que ele sai.
|
|
19
|
+
//
|
|
20
|
+
// QUEM ESTÁ NA PÁGINA é decidido no servidor (`rastreioEmVigor`): o que o lojista publicou no painel
|
|
21
|
+
// vence o ambiente, e cada provedor é injetado UMA vez. Um provedor que não entrou não existe em
|
|
22
|
+
// `window`, e este hook não o chama. É assim que ele nunca dispara o mesmo provedor duas vezes: um
|
|
23
|
+
// script por provedor na página, uma chamada por provedor por navegação.
|
|
24
|
+
//
|
|
25
|
+
// CARREGAMENTO INICIAL: quem já conta sozinho (gtag 'config', fbq PageView, ttq.page, pintrk page) é
|
|
26
|
+
// pulado. Quando NENHUM deles está na página, o page_view de entrada vai direto no dataLayer, que é de
|
|
27
|
+
// onde os contêineres de GTM leem (o central da Unbox e o próprio da marca). Sem isso, uma loja só com
|
|
28
|
+
// GTM ficava sem o evento de entrada.
|
|
29
|
+
import * as React from "react";
|
|
30
|
+
import { usePathname } from "next/navigation";
|
|
31
|
+
|
|
32
|
+
type Rastreadores = {
|
|
33
|
+
gtag?: (...a: unknown[]) => void;
|
|
34
|
+
fbq?: (...a: unknown[]) => void;
|
|
35
|
+
ttq?: { page?: () => void };
|
|
36
|
+
pintrk?: (...a: unknown[]) => void;
|
|
37
|
+
dataLayer?: unknown[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function PageViewPorRota() {
|
|
41
|
+
const pathname = usePathname();
|
|
42
|
+
const first = React.useRef(true);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
const w = window as unknown as Rastreadores;
|
|
46
|
+
const primeiro = first.current;
|
|
47
|
+
first.current = false;
|
|
48
|
+
|
|
49
|
+
const contadoPorOutro = typeof w.gtag === "function" || typeof w.fbq === "function" || typeof w.ttq?.page === "function" || typeof w.pintrk === "function";
|
|
50
|
+
if (primeiro && contadoPorOutro) return;
|
|
51
|
+
|
|
52
|
+
// o GA4 (gtag) não está aqui de propósito: ele conta a navegação sozinho (ver o topo do arquivo)
|
|
53
|
+
try { w.fbq?.("track", "PageView"); } catch { /* ignora */ }
|
|
54
|
+
try { w.ttq?.page?.(); } catch { /* ignora */ }
|
|
55
|
+
try { w.pintrk?.("page"); } catch { /* ignora */ }
|
|
56
|
+
// sem gtag, o page_view vai direto no dataLayer: é dele que todos os contêineres de GTM leem
|
|
57
|
+
try {
|
|
58
|
+
if (typeof w.gtag !== "function" && Array.isArray(w.dataLayer)) {
|
|
59
|
+
w.dataLayer.push({ event: "page_view", page_path: pathname });
|
|
60
|
+
}
|
|
61
|
+
} catch { /* ignora */ }
|
|
62
|
+
}, [pathname]);
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// RASTREIO E MARKETING DA LOJA: o que entra na página, decidido em UM lugar (foundation 12).
|
|
2
|
+
//
|
|
3
|
+
// O app/layout.tsx da loja chama só isto, numa linha:
|
|
4
|
+
//
|
|
5
|
+
// <Rastreio doc={conteudo} ambiente={process.env} unboxGtmId={UNBOX_GTM_ID} />
|
|
6
|
+
//
|
|
7
|
+
// e não conhece provedor nenhum. Daqui saem o contêiner central da Unbox (contrato: sempre ligado), o
|
|
8
|
+
// contêiner PRÓPRIO do lojista, o Google Analytics 4, o Meta Pixel, o TikTok Pixel, a Pinterest Tag, o
|
|
9
|
+
// page_view de cada navegação e o botão flutuante de WhatsApp. O próximo provedor entra por versão da
|
|
10
|
+
// foundation, sem tocar no layout de loja nenhuma: foi o dono que recusou reescrever os sites a cada
|
|
11
|
+
// capacidade nova.
|
|
12
|
+
//
|
|
13
|
+
// QUEM DECIDE O QUE ENTRA é `rastreioEmVigor` (document.ts): o que o lojista publicou na aba Apps do
|
|
14
|
+
// painel vence o valor do ambiente, provedor a provedor, e cada provedor entra UMA vez. Este arquivo só
|
|
15
|
+
// desenha o snippet de cada valor em vigor. O contêiner da Unbox não passa por essa regra: é contrato,
|
|
16
|
+
// entra sempre, e o contêiner próprio do lojista só entra se for OUTRO (os dois leem o mesmo dataLayer;
|
|
17
|
+
// o mesmo ID duas vezes seria o mesmo contêiner carregado em dobro).
|
|
18
|
+
//
|
|
19
|
+
// SERVIDOR SÓ: lê `process.env` inteiro (as variáveis são conhecidas pelo nome, `VARIAVEIS_DE_RASTREIO`),
|
|
20
|
+
// e o que chega ao navegador é o snippet com o ID, que já é público de qualquer jeito. A parte que
|
|
21
|
+
// precisa de hook de cliente (o page_view por rota) mora em rastreio-navegacao.tsx. Não importe este
|
|
22
|
+
// arquivo de um componente de cliente.
|
|
23
|
+
//
|
|
24
|
+
// Os IDs chegam validados pela régua de formato (`recusaDeRastreio`): só letras, números e hífen, que
|
|
25
|
+
// é o que permite interpolá-los dentro de um script inline sem escapar nada. O número e a mensagem do
|
|
26
|
+
// WhatsApp vão num `href` (o React escapa o atributo) e a mensagem passa por encodeURIComponent em
|
|
27
|
+
// `linkDoWhatsapp` (document.ts), que não lança nem para documento gravado com mensagem quebrada: este
|
|
28
|
+
// componente está no layout de toda página, e uma exceção aqui derrubava a loja inteira.
|
|
29
|
+
//
|
|
30
|
+
// PAGE_VIEW: cada provedor tem UM dono. O GA4 conta a navegação sozinho (medição otimizada de mudanças
|
|
31
|
+
// de histórico, ligada por padrão); Meta, TikTok e Pinterest não, e é `PageViewPorRota`
|
|
32
|
+
// (rastreio-navegacao.tsx) que os avisa. O porquê está no comentário de lá.
|
|
33
|
+
//
|
|
34
|
+
// OS SCRIPTS SÓ MUDAM QUANDO O LOJISTA PUBLICA: é o documento publicado que chega aqui. E a camada herda
|
|
35
|
+
// o comportamento de consentimento que a loja já tem: ela não põe banner, não segura pixel nem libera
|
|
36
|
+
// nada a mais (ver README, "Apps: rastreio e marketing").
|
|
37
|
+
import Script from "next/script";
|
|
38
|
+
import { linkDoWhatsapp, rastreioEmVigor, type Ambiente, type ContentDocument } from "./document";
|
|
39
|
+
import { PageViewPorRota } from "./rastreio-navegacao";
|
|
40
|
+
|
|
41
|
+
const SNIPPET_GTM = (id: string) =>
|
|
42
|
+
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${id}');`;
|
|
43
|
+
|
|
44
|
+
/** um contêiner do Google Tag Manager: o noscript e o loader. `chave` distingue o da Unbox do próprio. */
|
|
45
|
+
function Gtm({ id, chave }: { id: string; chave: string }) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<noscript>
|
|
49
|
+
<iframe src={`https://www.googletagmanager.com/ns.html?id=${id}`} height="0" width="0" style={{ display: "none", visibility: "hidden" }} />
|
|
50
|
+
</noscript>
|
|
51
|
+
<Script id={chave} strategy="afterInteractive">
|
|
52
|
+
{SNIPPET_GTM(id)}
|
|
53
|
+
</Script>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Ga4({ id }: { id: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Script src={`https://www.googletagmanager.com/gtag/js?id=${id}`} strategy="afterInteractive" />
|
|
62
|
+
<Script id="ga4" strategy="afterInteractive">
|
|
63
|
+
{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}');`}
|
|
64
|
+
</Script>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function MetaPixel({ id }: { id: string }) {
|
|
70
|
+
return (
|
|
71
|
+
<Script id="meta-pixel" strategy="afterInteractive">
|
|
72
|
+
{`!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init','${id}');fbq('track','PageView');`}
|
|
73
|
+
</Script>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function TiktokPixel({ id }: { id: string }) {
|
|
78
|
+
return (
|
|
79
|
+
<Script id="tiktok-pixel" strategy="afterInteractive">
|
|
80
|
+
{`!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.partner;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};n=document.createElement("script");n.type="text/javascript",n.async=!0,n.src=r+"?sdkid="+e+"&lib="+t;e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(n,e)};ttq.load('${id}');ttq.page();}(window,document,'ttq');`}
|
|
81
|
+
</Script>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function PinterestTag({ id }: { id: string }) {
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<Script id="pinterest-tag" strategy="afterInteractive">
|
|
89
|
+
{`!function(e){if(!window.pintrk){window.pintrk=function(){window.pintrk.queue.push(Array.prototype.slice.call(arguments))};var n=window.pintrk;n.queue=[],n.version="3.0";var t=document.createElement("script");t.async=!0,t.src=e;var r=document.getElementsByTagName("script")[0];r.parentNode.insertBefore(t,r)}}("https://s.pinimg.com/ct/core.js");pintrk('load','${id}');pintrk('page');`}
|
|
90
|
+
</Script>
|
|
91
|
+
<noscript>
|
|
92
|
+
{/* pixel 1x1 de quem está sem JavaScript: não é imagem da página, e next/image não se aplica */}
|
|
93
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
94
|
+
<img height="1" width="1" style={{ display: "none" }} alt="" src={`https://ct.pinterest.com/v3/?tid=${id}&noscript=1`} />
|
|
95
|
+
</noscript>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── BOTÃO FLUTUANTE DE WHATSAPP ──────────────────────────────────────────────────────────────────
|
|
101
|
+
//
|
|
102
|
+
// Abre wa.me/<numero>?text=<mensagem> numa aba nova. Só existe com número em vigor; sem número o
|
|
103
|
+
// layout fica exatamente como era. Padrão do CTA flutuante de uma loja piloto (components/floating-cta.tsx):
|
|
104
|
+
// fixo, some no checkout junto com o resto do chrome (`.site-chrome`, regra que as lojas já têm em
|
|
105
|
+
// globals.css) e no celular sobe acima da barra de navegação inferior. Canto direito, para não disputar
|
|
106
|
+
// o centro com o CTA de compra.
|
|
107
|
+
//
|
|
108
|
+
// O CSS vai inline, num <style> próprio, de propósito: a foundation é copiada para lojas com Tailwind
|
|
109
|
+
// configurado de jeitos diferentes, e uma classe utilitária que a loja não gera vira botão sem estilo.
|
|
110
|
+
// Ícone só, sem texto visível: o rótulo está em `aria-label`/`title`. Não há copy aqui para o lojista
|
|
111
|
+
// editar (`data-editor-ignore`): número e mensagem são configuração, e se editam na aba Apps.
|
|
112
|
+
const CSS_DO_BOTAO = `.unbox-whatsapp{position:fixed;right:16px;bottom:96px;z-index:40;display:flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:9999px;background:#25D366;color:#fff;box-shadow:0 10px 28px rgba(0,0,0,.28);transition:transform .15s ease}.unbox-whatsapp:hover{transform:scale(1.05)}.unbox-whatsapp:focus-visible{outline:2px solid #25D366;outline-offset:2px}@media (min-width:768px){.unbox-whatsapp{right:24px;bottom:24px}}`;
|
|
113
|
+
const CAMINHO_DO_ICONE =
|
|
114
|
+
"M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z";
|
|
115
|
+
|
|
116
|
+
/** o link do botão mora em document.ts (`linkDoWhatsapp`, testado no runner do editor); sai daqui também */
|
|
117
|
+
export { linkDoWhatsapp };
|
|
118
|
+
|
|
119
|
+
function BotaoDeWhatsapp({ numero, mensagem }: { numero: string; mensagem?: string }) {
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<style>{CSS_DO_BOTAO}</style>
|
|
123
|
+
<a
|
|
124
|
+
href={linkDoWhatsapp(numero, mensagem)}
|
|
125
|
+
target="_blank"
|
|
126
|
+
rel="noopener noreferrer"
|
|
127
|
+
aria-label="Falar no WhatsApp"
|
|
128
|
+
title="Falar no WhatsApp"
|
|
129
|
+
data-editor-ignore
|
|
130
|
+
className="site-chrome unbox-whatsapp"
|
|
131
|
+
>
|
|
132
|
+
<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor" aria-hidden="true">
|
|
133
|
+
<path d={CAMINHO_DO_ICONE} />
|
|
134
|
+
</svg>
|
|
135
|
+
</a>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A LINHA ÚNICA DO LAYOUT. Renderize no `<body>` (o fim dele é o melhor lugar: os scripts entram depois
|
|
142
|
+
* da hidratação em qualquer posição, e o botão de WhatsApp fica por último na ordem de tabulação).
|
|
143
|
+
*
|
|
144
|
+
* - `doc`: o documento publicado (`getPublishedContent()`); `null` = a loja renderiza só o ambiente.
|
|
145
|
+
* - `ambiente`: `process.env`, inteiro. Só as variáveis de `VARIAVEIS_DE_RASTREIO` são lidas.
|
|
146
|
+
* - `unboxGtmId`: o contêiner CONTRATUAL da Unbox, o literal carimbado no layout pelo CLI (o prebuild
|
|
147
|
+
* da loja cobra a presença dele lá). Não é configuração do lojista e não passa pelo documento.
|
|
148
|
+
*/
|
|
149
|
+
export function Rastreio({ doc, ambiente, unboxGtmId }: { doc: ContentDocument | null | undefined; ambiente: Ambiente; unboxGtmId: string }) {
|
|
150
|
+
// `unboxGtmId` vai junto: o contêiner do lojista igual ao da Unbox seria o MESMO contêiner carregado duas
|
|
151
|
+
// vezes, e `rastreioEmVigor` o descarta (e não o conta como Tag Manager próprio para suprimir o Pixel)
|
|
152
|
+
const r = rastreioEmVigor(doc, ambiente, { unboxGtmId });
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
{/* GTM central da Unbox: contrato, sempre ligado, antes de qualquer outro */}
|
|
156
|
+
{unboxGtmId && <Gtm id={unboxGtmId} chave="unbox-gtm" />}
|
|
157
|
+
{r.gtm.valor && <Gtm id={r.gtm.valor} chave="gtm-loja" />}
|
|
158
|
+
{r.ga4.valor && <Ga4 id={r.ga4.valor} />}
|
|
159
|
+
{r.metaPixel.valor && <MetaPixel id={r.metaPixel.valor} />}
|
|
160
|
+
{r.tiktok.valor && <TiktokPixel id={r.tiktok.valor} />}
|
|
161
|
+
{r.pinterest.valor && <PinterestTag id={r.pinterest.valor} />}
|
|
162
|
+
{/* sempre: page_view de cada navegação para quem não o conta sozinho (Meta, TikTok, Pinterest; o GA4
|
|
163
|
+
conta), e o de entrada no dataLayer dos contêineres de GTM quando nenhum outro provedor o conta */}
|
|
164
|
+
<PageViewPorRota />
|
|
165
|
+
{r.whatsapp.valor && <BotaoDeWhatsapp numero={r.whatsapp.valor.numero} mensagem={r.whatsapp.valor.mensagem} />}
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Leitura do conteúdo PUBLICADO no servidor (layout/page, com ISR).
|
|
2
|
+
//
|
|
3
|
+
// Sem EDITOR_URL, nada é buscado e a loja renderiza o que está no código.
|
|
4
|
+
// Falha de rede/5xx devolve null com aviso: a loja nunca cai por causa do
|
|
5
|
+
// editor — no pior caso mostra o conteúdo do repositório até a próxima
|
|
6
|
+
// revalidação (ISR de 300 s, ou publicação que chama /api/revalidate).
|
|
7
|
+
//
|
|
8
|
+
// Quem só RENDERIZA usa `getPublishedContent()`: null = conteúdo do código, seja
|
|
9
|
+
// porque a loja nunca publicou, seja porque a leitura falhou. Quem DECIDE a partir
|
|
10
|
+
// do publicado usa `lerPublicado()`, que diz qual dos dois foi (`falhou`): é o caso
|
|
11
|
+
// da rota /api/capi, que não pode mandar conversão para o Pixel do cadastro só
|
|
12
|
+
// porque a leitura do que o lojista publicou falhou (Astra, segunda rodada, 1).
|
|
13
|
+
import { cache } from "react";
|
|
14
|
+
import { documentoUsavel, isHtmlPath, leituraDoPublicado, presencaNoAmbiente as presencaPura, recusaDeHtml, type Ambiente, type ContentDocument, type LeituraDoPublicado, type ManifestApps } from "./document";
|
|
15
|
+
import { EDITOR_URL, STORE_SLUG } from "./config";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* APPS: RASTREIO E MARKETING (foundation 12). As funções moram em document.ts (puras, testadas no runner
|
|
19
|
+
* do editor) e saem também por aqui porque é daqui que o app/layout.tsx da loja lê o que é do SERVIDOR:
|
|
20
|
+
* `presencaNoAmbiente(process.env, { unboxGtmId })` vai no manifesto pelo `EditableProvider` (só presença,
|
|
21
|
+
* nunca valor, o estado da CAPI e o contêiner contratual da Unbox). `rastreioEmVigor(doc, process.env)` é o
|
|
22
|
+
* que o `<Rastreio>` de rastreio.tsx (a linha
|
|
23
|
+
* única do layout) consulta para decidir o que dispara (documento vence ambiente, um script por
|
|
24
|
+
* provedor). `capiDaLeitura(await lerPublicado(), process.env)` é o que a rota /api/capi da loja obedece: o
|
|
25
|
+
* Pixel em vigor e se o token o atende, ou "leitura-falhou" quando não se conseguiu ler o publicado (aí a
|
|
26
|
+
* rota não envia: não se sabe qual Pixel o lojista publicou). `process.env` é lido inteiro no servidor de
|
|
27
|
+
* propósito: as variáveis são conhecidas pelo nome em `VARIAVEIS_DE_RASTREIO`, e o layout não precisa
|
|
28
|
+
* listá-las uma a uma.
|
|
29
|
+
*/
|
|
30
|
+
export { rastreioEmVigor, capiEmVigor, capiDaLeitura, type RastreioEmVigor, type ManifestApps, type EstadoDoCapi, type EstadoDaRotaCapi, type LeituraDoPublicado } from "./document";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* O DOCUMENTO PUBLICADO DESTE PEDIDO, para quem precisa dele sem o receber por parâmetro. `cache` do React
|
|
34
|
+
* é por pedido: o layout faz `await getPublishedContent()` e, na mesma renderização, chama
|
|
35
|
+
* `presencaNoAmbiente(process.env, { unboxGtmId })` sem passar o documento (é a linha que o CLI carimba em
|
|
36
|
+
* toda loja). O
|
|
37
|
+
* estado da CAPI depende do Pixel EM VIGOR, que pode ser o que o lojista publicou, então a presença tem de
|
|
38
|
+
* ver o documento: ela o pega daqui. Fora de uma renderização (rota de API), `cache` não guarda nada e o
|
|
39
|
+
* slot vem vazio: aí vale só o ambiente, e quem precisa do documento o passa (a rota /api/capi passa).
|
|
40
|
+
*/
|
|
41
|
+
const documentoDoPedido = cache((): { doc?: ContentDocument | null } => ({}));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A presença no ambiente e o estado da CAPI para o manifesto, com o documento publicado que este pedido
|
|
45
|
+
* já leu (ou o `doc` passado, quando quem chama o tem), e o contêiner contratual da Unbox (`unboxGtmId`, o
|
|
46
|
+
* mesmo literal que o layout passa ao `<Rastreio>`), para o painel não anunciar supressão de Pixel que a
|
|
47
|
+
* loja não executa. A linha do layout: `presencaNoAmbiente(process.env, { unboxGtmId: UNBOX_GTM_ID })`. A
|
|
48
|
+
* regra está em `presencaNoAmbiente` de document.ts.
|
|
49
|
+
*/
|
|
50
|
+
export function presencaNoAmbiente(ambiente: Ambiente, opcoes?: { doc?: ContentDocument | null; unboxGtmId?: string }): ManifestApps {
|
|
51
|
+
return presencaPura(ambiente, { doc: opcoes?.doc === undefined ? (documentoDoPedido().doc ?? null) : opcoes.doc, unboxGtmId: opcoes?.unboxGtmId });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* BLOCO DE HTML, CAMADA 2 DE 3 — a conferência da LOJA, na porta de entrada do documento publicado.
|
|
56
|
+
*
|
|
57
|
+
* As outras duas: o EDITOR ao gravar (limpa e RELATA ao lojista o que tirou — é a única que tem alguém
|
|
58
|
+
* na tela para avisar) e o PRIMITIVO no cliente (a única que existe na PRÉVIA, onde o rascunho chega
|
|
59
|
+
* por postMessage sem ver servidor). Esta aqui não tem a quem relatar: ela só derruba, e escreve o
|
|
60
|
+
* motivo no log da loja — silêncio total seria a loja mudando de conteúdo sem ninguém saber por quê.
|
|
61
|
+
*
|
|
62
|
+
* ── POR QUE ELA EXISTE, se `resolveValue` já recusa HTML no caminho `.html` ────────────────────────
|
|
63
|
+
* 1. REGRA NOVA VALE PARA QUEM JÁ PUBLICOU. A lista de recusa (`recusaDeHtml`) mora no código da loja.
|
|
64
|
+
* No dia em que uma tag entrar nela, o documento publicado há seis meses continua o mesmo — é este
|
|
65
|
+
* varrimento, na leitura, que aplica a regra nova sem ninguém precisar republicar. Cobre também
|
|
66
|
+
* "voltar para uma versão antiga do documento", que é justamente um documento escrito sob a lista
|
|
67
|
+
* antiga.
|
|
68
|
+
* 2. O QUE É RECUSADO NÃO CHEGA AO NAVEGADOR. O documento INTEIRO é entregue ao `EditableProvider`
|
|
69
|
+
* (app/layout.tsx) e viaja serializado dentro do HTML da página. `resolveValue` defende o RENDER —
|
|
70
|
+
* o bloco não é injetado —, mas o texto do `<script>` recusado ainda sairia impresso no payload da
|
|
71
|
+
* página, para qualquer um ler ou copiar. Derrubando na porta, ele não sai do servidor.
|
|
72
|
+
* MEDIDO em `next build` + `next start`: o HTML recusado não aparece nenhuma vez na página
|
|
73
|
+
* servida (nem escapado como `\u003cscript\u003e`). Em `next dev` ele AINDA aparece, e não é esta
|
|
74
|
+
* função falhando: o modo de desenvolvimento serializa a resposta crua do `fetch` e ecoa o
|
|
75
|
+
* `console.warn` do servidor no fluxo da página, tudo isso ANTES de o documento chegar aqui. Quem
|
|
76
|
+
* for conferir, confira no build de produção.
|
|
77
|
+
* 3. CAMINHO SEM PRIMITIVO NUNCA PASSA POR `resolveValue`. Um `.html` de seção que saiu do código, ou
|
|
78
|
+
* de uma seção criada cujo tipo deixou o catálogo, não é lido por primitivo nenhum — e mesmo assim
|
|
79
|
+
* viajaria no documento pelo motivo 2.
|
|
80
|
+
*
|
|
81
|
+
* Não depende do editor: `recusaDeHtml` é função pura do `document.ts` desta loja, sem rede e sem
|
|
82
|
+
* biblioteca. A loja não pede opinião a ninguém para decidir o que renderiza.
|
|
83
|
+
*
|
|
84
|
+
* O que ela NÃO faz: a LIMPEZA por allowlist (tirar uma tag fora da lista, um atributo que não serve)
|
|
85
|
+
* continua só no editor, e de propósito — ela roda no momento da colagem, com o relatório do que saiu
|
|
86
|
+
* na tela. Repeti-la aqui, muda, faria a página publicada divergir em silêncio da prévia que o lojista
|
|
87
|
+
* aprovou. Aqui vale a RECUSA, que é a decisão do dono: o bloco inteiro entra ou não entra.
|
|
88
|
+
*/
|
|
89
|
+
function htmlRecusadoDoDocumento(doc: ContentDocument): [string, string][] {
|
|
90
|
+
const recusados: [string, string][] = [];
|
|
91
|
+
for (const [path, v] of Object.entries(doc.values)) {
|
|
92
|
+
if (!isHtmlPath(path)) continue;
|
|
93
|
+
// valor que nem sequer é texto já é lixo neste caminho: `recusaDeHtml` só sabe ler string
|
|
94
|
+
const motivo = typeof v === "string" ? recusaDeHtml(v) : "o valor gravado não é texto";
|
|
95
|
+
if (motivo) recusados.push([path, motivo]);
|
|
96
|
+
}
|
|
97
|
+
return recusados;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function semHtmlRecusado(doc: ContentDocument): ContentDocument {
|
|
101
|
+
const recusados = htmlRecusadoDoDocumento(doc);
|
|
102
|
+
if (recusados.length === 0) return doc; // o caso normal: nada é copiado à toa
|
|
103
|
+
const values = { ...doc.values };
|
|
104
|
+
for (const [path, motivo] of recusados) {
|
|
105
|
+
delete values[path];
|
|
106
|
+
// apagar o caminho = a loja cai no `fallback` do código (bloco de HTML nasce vazio, então some).
|
|
107
|
+
console.warn(`[editable] bloco de HTML recusado na leitura do publicado (${path}): ${motivo}`);
|
|
108
|
+
}
|
|
109
|
+
return { ...doc, values };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A LEITURA do publicado deste pedido, dizendo qual dos dois casos foi: AUSÊNCIA (`doc: null`, `falhou:
|
|
114
|
+
* false`: 404, a loja nunca publicou, ou loja desligada do editor) ou FALHA DE LEITURA (`falhou: true`: rede,
|
|
115
|
+
* 5xx, JSON inválido, documento malformado ou de outra loja). A classificação é `leituraDoPublicado` de
|
|
116
|
+
* document.ts (pura, testada no runner do editor); aqui entram o `fetch` com ISR, o aviso no log e a
|
|
117
|
+
* conferência dos blocos de HTML. É o que a rota /api/capi lê (`capiDaLeitura`).
|
|
118
|
+
*/
|
|
119
|
+
export const lerPublicado = cache(async (): Promise<LeituraDoPublicado> => {
|
|
120
|
+
const leitura = await lerDoEditor();
|
|
121
|
+
documentoDoPedido().doc = leitura.doc; // o que o `presencaNoAmbiente` deste arquivo vê no mesmo pedido
|
|
122
|
+
return leitura;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** o documento publicado, ou null: a loja nunca publicou OU a leitura falhou. Para RENDERIZAR, os dois são "vale o código". */
|
|
126
|
+
export const getPublishedContent = cache(async (): Promise<ContentDocument | null> => (await lerPublicado()).doc);
|
|
127
|
+
|
|
128
|
+
async function lerDoEditor(): Promise<LeituraDoPublicado> {
|
|
129
|
+
// loja desligada do editor: AUSÊNCIA, não falha (não há o que ler)
|
|
130
|
+
if (!EDITOR_URL || !STORE_SLUG) return { doc: null, falhou: false };
|
|
131
|
+
const leitura = await leituraDoPublicado(
|
|
132
|
+
() => fetch(`${EDITOR_URL}/api/content/${encodeURIComponent(STORE_SLUG)}/published`, {
|
|
133
|
+
next: { revalidate: 300, tags: ["unbox-editor-content"] },
|
|
134
|
+
headers: { accept: "application/json" },
|
|
135
|
+
}),
|
|
136
|
+
STORE_SLUG,
|
|
137
|
+
);
|
|
138
|
+
// A loja não cai por causa do editor: é a promessa do topo deste arquivo. Falhou = conteúdo do código, com
|
|
139
|
+
// o motivo no log (e `falhou: true` para quem decide a partir do publicado).
|
|
140
|
+
if (leitura.falhou) {
|
|
141
|
+
console.warn(`[editable] ${leitura.motivo}; usando conteúdo do código`);
|
|
142
|
+
return leitura;
|
|
143
|
+
}
|
|
144
|
+
// o documento só sai daqui depois da conferência dos blocos de HTML (ver `semHtmlRecusado`)
|
|
145
|
+
return { doc: leitura.doc && semHtmlRecusado(leitura.doc), falhou: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* O RECIBO DA REVALIDAÇÃO — o que esta loja está servindo AGORA, para o editor poder (ou não) dizer
|
|
150
|
+
* "a loja já está no ar com a versão N".
|
|
151
|
+
*
|
|
152
|
+
* O editor publica e chama `/api/revalidate` daqui. Até hoje ele concluía "está no ar" porque a
|
|
153
|
+
* resposta trazia a palavra "revalidated" — e isso não prova nada: purgar o cache diz que a PRÓXIMA
|
|
154
|
+
* leitura vai buscar de novo, não que ela aconteceu nem que deu certo. Uma loja sem `EDITOR_URL` (que
|
|
155
|
+
* nunca lê o editor), uma loja que responde 401, ou um documento de outra loja no meio do caminho
|
|
156
|
+
* davam a MESMA frase de sucesso para o lojista.
|
|
157
|
+
*
|
|
158
|
+
* Então esta função vai ver: lê o conteúdo publicado SEM CACHE e devolve o `updatedAt` do documento
|
|
159
|
+
* que passou por todas as portas que a loja usa para renderizar (`documentoUsavel`, a conferência do
|
|
160
|
+
* slug e a dos blocos de HTML). O editor compara com o que acabou de publicar. Se bater, "no ar" é
|
|
161
|
+
* afirmação verificada; se não bater, ele diz o que sabe (atualização pedida).
|
|
162
|
+
*
|
|
163
|
+
* Não substitui `getPublishedContent` nem aquece o cache dele: é uma leitura à parte, curta, feita
|
|
164
|
+
* DEPOIS do `revalidateTag`. O que ela garante é que o conteúdo novo chegou até aqui e é utilizável,
|
|
165
|
+
* e que a próxima renderização vai buscar do mesmo lugar.
|
|
166
|
+
*/
|
|
167
|
+
export type ConteudoAgora = { ok: true; updatedAt?: string; blocosRecusados: number } | { ok: false; motivo: string };
|
|
168
|
+
|
|
169
|
+
export async function conteudoPublicadoAgora(): Promise<ConteudoAgora> {
|
|
170
|
+
if (!EDITOR_URL || !STORE_SLUG) return { ok: false, motivo: "esta loja não está ligada ao editor (sem EDITOR_URL)" };
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(`${EDITOR_URL}/api/content/${encodeURIComponent(STORE_SLUG)}/published`, {
|
|
173
|
+
cache: "no-store",
|
|
174
|
+
headers: { accept: "application/json" },
|
|
175
|
+
signal: AbortSignal.timeout(8_000),
|
|
176
|
+
redirect: "manual",
|
|
177
|
+
});
|
|
178
|
+
if (res.status === 404) return { ok: false, motivo: "o editor ainda não tem conteúdo publicado para esta loja" };
|
|
179
|
+
if (!res.ok) return { ok: false, motivo: `o editor respondeu ${res.status}` };
|
|
180
|
+
const doc = (await res.json()) as ContentDocument;
|
|
181
|
+
if (!documentoUsavel(doc)) return { ok: false, motivo: "o conteúdo publicado veio malformado" };
|
|
182
|
+
if (doc.shop !== STORE_SLUG) return { ok: false, motivo: `o conteúdo publicado é de outra loja (${String(doc.shop)})` };
|
|
183
|
+
// blocos recusados NÃO derrubam o recibo: o resto do documento está no ar, e é isso que o editor
|
|
184
|
+
// afirma. O número sobe junto para o editor poder avisar o lojista do pedaço que ficou de fora.
|
|
185
|
+
return { ok: true, updatedAt: typeof doc.updatedAt === "string" ? doc.updatedAt : undefined, blocosRecusados: htmlRecusadoDoDocumento(doc).length };
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { ok: false, motivo: err instanceof Error ? err.message : String(err) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tokens de cor que o lojista pode mudar. É a ÚNICA lista feita à mão do
|
|
2
|
+
// editor — e é por loja: o construtor acrescenta os tokens próprios da marca.
|
|
3
|
+
// Tudo que não está aqui fica fora do alcance do editor.
|
|
4
|
+
import type { EditableTokenSpec } from "./provider";
|
|
5
|
+
|
|
6
|
+
export const EDITABLE_TOKENS: EditableTokenSpec[] = [
|
|
7
|
+
{ token: "--store-primary", label: "Cor principal" },
|
|
8
|
+
{ token: "--store-cta", label: "Cor do botão de compra" },
|
|
9
|
+
{ token: "--store-cta-fg", label: "Texto do botão de compra" },
|
|
10
|
+
{ token: "--store-bg", label: "Fundo da página" },
|
|
11
|
+
{ token: "--store-ink", label: "Cor do texto" },
|
|
12
|
+
{ token: "--store-surface", label: "Fundo de cartões" },
|
|
13
|
+
{ token: "--store-sale", label: "Cor de oferta" },
|
|
14
|
+
// O lojista pediu "trocar a cor da barra de cima" e não havia como: o cabeçalho pinta com
|
|
15
|
+
// `--store-surface-2` e o rodapé com `--store-chrome-bg`, e nenhum dos dois estava aqui. O chat
|
|
16
|
+
// então mexeu na cor da marca, disse "alterada" e a tela não mudou. Lista curta demais é uma
|
|
17
|
+
// forma de esconder a loja do dono dela.
|
|
18
|
+
{ token: "--store-surface-2", label: "Fundo do cabeçalho" },
|
|
19
|
+
{ token: "--store-chrome-bg", label: "Fundo do rodapé" },
|
|
20
|
+
{ token: "--store-chrome-text", label: "Texto sobre o rodapé" },
|
|
21
|
+
{ token: "--store-line", label: "Cor dos filetes" },
|
|
22
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Verificação dos tokens assinados pelo editor (ES256, chave pública via JWKS).
|
|
2
|
+
// Roda no edge (middleware) e no Node (rotas). A loja NÃO guarda segredo do
|
|
3
|
+
// editor: só busca a chave pública em EDITOR_URL/.well-known/jwks.json.
|
|
4
|
+
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
|
5
|
+
import { EDITOR_URL, STORE_SLUG } from "./config";
|
|
6
|
+
|
|
7
|
+
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
|
8
|
+
|
|
9
|
+
// "catalogo": leitura do catálogo da loja pelo SELETOR de vitrine do editor. É só
|
|
10
|
+
// leitura, e a loja continua sendo a única que fala com a Unbox — o editor nunca
|
|
11
|
+
// vê credencial (ver app/api/unbox/catalogo/route.ts).
|
|
12
|
+
export type EditorTokenPurpose = "preview" | "revalidate" | "catalogo";
|
|
13
|
+
|
|
14
|
+
export async function verifyEditorToken(token: string, purpose: EditorTokenPurpose): Promise<JWTPayload | null> {
|
|
15
|
+
if (!EDITOR_URL || !token) return null;
|
|
16
|
+
try {
|
|
17
|
+
jwks ??= createRemoteJWKSet(new URL(`${EDITOR_URL}/.well-known/jwks.json`), { cooldownDuration: 30_000, cacheMaxAge: 600_000 });
|
|
18
|
+
const { payload } = await jwtVerify(token, jwks, { issuer: "unbox-editor", audience: `loja:${STORE_SLUG}`, algorithms: ["ES256"] });
|
|
19
|
+
if (payload.purpose !== purpose) return null;
|
|
20
|
+
return payload;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// COMBOS: kits temáticos da loja. Substitua pelos combos da nova marca.
|
|
2
|
+
// Cada item usa `family` (familyCode do products.json) para resolver contra o catálogo Unbox.
|
|
3
|
+
// Para usar: popule lib/enrichment/products.json à mão, no formato de lib/enrichment/index.ts.
|
|
4
|
+
//
|
|
5
|
+
// ===== Combos (kits temáticos) =====
|
|
6
|
+
// Bundles curados a partir das famílias do products.json (familyCode). Cada item aponta para uma
|
|
7
|
+
// FAMÍLIA — na resolução escolhemos o produto comprável do catálogo (por nome+tamanho), então o
|
|
8
|
+
// combo nunca quebra se um tamanho específico sair de linha. Preço e imagem vêm do catálogo Unbox;
|
|
9
|
+
// o desconto do combo é distribuído por item (mesmo padrão de `add({ price })` da buy box).
|
|
10
|
+
import { getEnrichmentByName, parseSize } from "./index";
|
|
11
|
+
import { resolveProductPrice } from "../format";
|
|
12
|
+
|
|
13
|
+
/** "min" = menor tamanho da família (combo mais acessível); "max" = maior. */
|
|
14
|
+
export interface ComboItem {
|
|
15
|
+
family: string;
|
|
16
|
+
qty?: number;
|
|
17
|
+
size?: "min" | "max";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ComboHighlight {
|
|
21
|
+
/** chave de ícone resolvida no componente */
|
|
22
|
+
icon: "box" | "fire" | "sparkle" | "star" | "shield" | "heart" | "tag" | "gift";
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ComboDef {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
/** imagem temática do combo, composta a partir das fotos reais dos produtos */
|
|
31
|
+
imageUrl: string;
|
|
32
|
+
/** selo opcional, ex.: "MAIS VENDIDO" */
|
|
33
|
+
badge?: string;
|
|
34
|
+
highlights: ComboHighlight[];
|
|
35
|
+
/** % de desconto sobre a soma dos itens */
|
|
36
|
+
/** @deprecated ignorado: o preço final é sempre o do catálogo (ver resolveCombos). */
|
|
37
|
+
discountPct?: number;
|
|
38
|
+
items: ComboItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// TODO: defina os combos/kits da sua loja aqui.
|
|
42
|
+
// Cada item usa `family` (familyCode do products.json) para resolver contra o catálogo Unbox.
|
|
43
|
+
// Para usar: popule lib/enrichment/products.json com os dados dos produtos e defina os familyCodes.
|
|
44
|
+
//
|
|
45
|
+
// Exemplo de combo:
|
|
46
|
+
// {
|
|
47
|
+
// id: "kit-essencial",
|
|
48
|
+
// name: "Kit Essencial",
|
|
49
|
+
// description: "Tudo o que você precisa para começar.",
|
|
50
|
+
// imageUrl: "/brand/combos/kit-essencial.webp",
|
|
51
|
+
// badge: "MAIS VENDIDO",
|
|
52
|
+
// discountPct: 15,
|
|
53
|
+
// highlights: [
|
|
54
|
+
// { icon: "sparkle", label: "3 itens" },
|
|
55
|
+
// { icon: "box", label: "Pronto para usar" },
|
|
56
|
+
// ],
|
|
57
|
+
// items: [
|
|
58
|
+
// { family: "FAMILIA_A" },
|
|
59
|
+
// { family: "FAMILIA_B" },
|
|
60
|
+
// { family: "FAMILIA_C", size: "min" },
|
|
61
|
+
// ],
|
|
62
|
+
// },
|
|
63
|
+
export const COMBOS: ComboDef[] = [];
|
|
64
|
+
|
|
65
|
+
// ----- Resolução contra o catálogo -----
|
|
66
|
+
|
|
67
|
+
export interface ResolvedComboItem {
|
|
68
|
+
family: string;
|
|
69
|
+
qty: number;
|
|
70
|
+
name: string;
|
|
71
|
+
weight: string;
|
|
72
|
+
imageUrl: string | null;
|
|
73
|
+
slug: string;
|
|
74
|
+
productId: string;
|
|
75
|
+
variantId: string;
|
|
76
|
+
/** preço unitário cheio */
|
|
77
|
+
price: number;
|
|
78
|
+
/** preço unitário com o desconto do combo (para enviar ao carrinho) */
|
|
79
|
+
discountedPrice: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResolvedCombo {
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
description: string;
|
|
86
|
+
imageUrl: string;
|
|
87
|
+
badge?: string;
|
|
88
|
+
highlights: ComboHighlight[];
|
|
89
|
+
discountPct: number;
|
|
90
|
+
items: ResolvedComboItem[];
|
|
91
|
+
itemCount: number;
|
|
92
|
+
subtotal: number;
|
|
93
|
+
total: number;
|
|
94
|
+
save: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
98
|
+
|
|
99
|
+
/** "Nome ( Name in English )" → "Nome"; sem parênteses devolve o nome inteiro. */
|
|
100
|
+
function familyLabel(name: string): string {
|
|
101
|
+
const pt = name.split("(")[0].trim().replace(/\s+/g, " ");
|
|
102
|
+
return pt || name.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fmtWeight(w: { value: number; unit: string } | null): string {
|
|
106
|
+
if (!w) return "";
|
|
107
|
+
const v = Number.isInteger(w.value) ? String(w.value) : String(w.value).replace(".", ",");
|
|
108
|
+
return `${v} ${w.unit}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Indexa os produtos do catálogo por familyCode (ordenados por peso crescente). */
|
|
112
|
+
function indexByFamily(catalogProducts: any[]) {
|
|
113
|
+
const map = new Map<string, { product: any; weight: number }[]>();
|
|
114
|
+
for (const p of catalogProducts) {
|
|
115
|
+
if (!p?.slug) continue;
|
|
116
|
+
const e = getEnrichmentByName(p.title);
|
|
117
|
+
if (!e) continue;
|
|
118
|
+
const w = parseSize(p.title)?.value ?? e.weight?.value ?? 0;
|
|
119
|
+
const arr = map.get(e.familyCode) ?? [];
|
|
120
|
+
arr.push({ product: p, weight: w });
|
|
121
|
+
map.set(e.familyCode, arr);
|
|
122
|
+
}
|
|
123
|
+
for (const arr of map.values()) arr.sort((a, b) => a.weight - b.weight);
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve todos os combos contra a lista de produtos do catálogo. Descarta itens/combos sem match. */
|
|
128
|
+
export function resolveCombos(catalogProducts: any[]): ResolvedCombo[] {
|
|
129
|
+
const byFamily = indexByFamily(catalogProducts);
|
|
130
|
+
const out: ResolvedCombo[] = [];
|
|
131
|
+
|
|
132
|
+
for (const def of COMBOS) {
|
|
133
|
+
const items: ResolvedComboItem[] = [];
|
|
134
|
+
for (const it of def.items) {
|
|
135
|
+
const fam = byFamily.get(it.family);
|
|
136
|
+
if (!fam || fam.length === 0) continue;
|
|
137
|
+
const list = fam;
|
|
138
|
+
const picked = it.size === "max" ? list[list.length - 1] : list[0];
|
|
139
|
+
const cp = picked.product;
|
|
140
|
+
const v0 = cp.variants?.[0];
|
|
141
|
+
const rp = resolveProductPrice(cp);
|
|
142
|
+
if (!v0?._id || rp.price == null) continue;
|
|
143
|
+
const e = getEnrichmentByName(cp.title);
|
|
144
|
+
const qty = it.qty ?? 1;
|
|
145
|
+
// O preço final de um item é sempre o do catálogo: o servidor recalcula o carrinho a
|
|
146
|
+
// partir dele (lib/unbox/types.ts). Um "preço do kit" calculado aqui vira promessa que o
|
|
147
|
+
// carrinho desmente. Desconto de kit real se configura como regra de preço/cupom no painel.
|
|
148
|
+
const discountedPrice = round2(rp.price);
|
|
149
|
+
items.push({
|
|
150
|
+
family: it.family,
|
|
151
|
+
qty,
|
|
152
|
+
name: familyLabel(e?.name ?? cp.title),
|
|
153
|
+
weight: fmtWeight(parseSize(cp.title) ?? e?.weight ?? null),
|
|
154
|
+
imageUrl: cp.imageUrls?.[0] ?? null,
|
|
155
|
+
slug: cp.slug,
|
|
156
|
+
productId: cp.productId,
|
|
157
|
+
variantId: v0._id,
|
|
158
|
+
price: rp.price,
|
|
159
|
+
discountedPrice,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (items.length < 2) continue; // combo precisa de pelo menos 2 itens reais
|
|
164
|
+
|
|
165
|
+
const subtotal = round2(items.reduce((s, i) => s + i.price * i.qty, 0));
|
|
166
|
+
const total = round2(items.reduce((s, i) => s + i.discountedPrice * i.qty, 0));
|
|
167
|
+
out.push({
|
|
168
|
+
id: def.id,
|
|
169
|
+
name: def.name,
|
|
170
|
+
description: def.description,
|
|
171
|
+
imageUrl: def.imageUrl,
|
|
172
|
+
badge: def.badge,
|
|
173
|
+
highlights: def.highlights,
|
|
174
|
+
discountPct: 0,
|
|
175
|
+
items,
|
|
176
|
+
itemCount: items.reduce((s, i) => s + i.qty, 0),
|
|
177
|
+
subtotal,
|
|
178
|
+
total,
|
|
179
|
+
save: round2(subtotal - total),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return out;
|
|
184
|
+
}
|