@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,188 @@
|
|
|
1
|
+
// ===== Padrão de enriquecimento de produtos =====
|
|
2
|
+
// Dados que NÃO vêm da Unbox (composição, medidas, origem, EAN, SEO, FAQ, reviews, combos,
|
|
3
|
+
// "compre junto" — e tabela nutricional/alergênicos quando o produto for alimento ou
|
|
4
|
+
// suplemento) ficam aqui, em products.json, indexados pelo SKU. A junção com o produto Unbox
|
|
5
|
+
// é pelo `sku` (produto ou variante). Quem preenche é o briefing, a partir do material real da
|
|
6
|
+
// marca. Todo campo é opcional na prática: o que estiver vazio simplesmente não renderiza.
|
|
7
|
+
//
|
|
8
|
+
// Uso (server-side, ex.: na PDP): getEnrichmentForProduct(product) → mescla com os dados da Unbox.
|
|
9
|
+
// Importar SÓ em Server Components / rotas (o JSON é grande; não use em client components).
|
|
10
|
+
import raw from "./products.json";
|
|
11
|
+
|
|
12
|
+
export interface NutritionTable {
|
|
13
|
+
/** "100g" | "Porção" */
|
|
14
|
+
base: string;
|
|
15
|
+
observacao?: string;
|
|
16
|
+
/** ex.: { valor_energetico_kcal: 364.5, sodio_mg: 3557.99, ... } */
|
|
17
|
+
nutrientes: Record<string, number>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EnrichmentReview {
|
|
21
|
+
author: string;
|
|
22
|
+
stars: number;
|
|
23
|
+
comment: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnrichmentFaq {
|
|
27
|
+
question: string;
|
|
28
|
+
answer: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProductEnrichment {
|
|
32
|
+
/** chave de junção com a Unbox (variant.sku / product.sku) */
|
|
33
|
+
sku: string;
|
|
34
|
+
/** código de família (agrupa variações de tamanho do mesmo produto), ex.: "LINHA_A" */
|
|
35
|
+
familyCode: string;
|
|
36
|
+
/** SKU agrupador de variações, ex.: "PAI-LINHA_A" */
|
|
37
|
+
parentSku: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** descrição curta interna (não SEO) */
|
|
40
|
+
description: string;
|
|
41
|
+
weight: { value: number; unit: string } | null;
|
|
42
|
+
/** composição: ingredientes (alimento), INCI (cosmético), materiais (vestuário/objeto) */
|
|
43
|
+
ingredients: string;
|
|
44
|
+
/** passos de uso do produto, exibidos como "Modo de uso" na PDP. Vazio = bloco não aparece. */
|
|
45
|
+
usage?: string[];
|
|
46
|
+
origin: string;
|
|
47
|
+
containsGluten: boolean | null;
|
|
48
|
+
vegan: boolean | null;
|
|
49
|
+
allergens: { inIngredients: string; mayContain: string };
|
|
50
|
+
ean: string;
|
|
51
|
+
dimensionsCm: { length: number | null; width: number | null; height: number | null } | null;
|
|
52
|
+
nutrition: NutritionTable | null;
|
|
53
|
+
seo: { shortDescription: string; longDescriptionHtml: string; keywords: string[]; title: string };
|
|
54
|
+
/** tags/categorias livres da planilha */
|
|
55
|
+
tags: string[];
|
|
56
|
+
/** categorias normalizadas */
|
|
57
|
+
categoriesOptimized: string[];
|
|
58
|
+
collections: string[];
|
|
59
|
+
/** descrição textual de combo sugerido */
|
|
60
|
+
combo: string;
|
|
61
|
+
/** SKUs para "Compre junto" */
|
|
62
|
+
buyTogetherSkus: string[];
|
|
63
|
+
/** SKUs para "Frequentemente comprados juntos" */
|
|
64
|
+
frequentlyBoughtSkus: string[];
|
|
65
|
+
faq: EnrichmentFaq[];
|
|
66
|
+
reviews: EnrichmentReview[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DATA = raw as unknown as Record<string, ProductEnrichment>;
|
|
70
|
+
|
|
71
|
+
const norm = (sku: unknown): string => String(sku ?? "").trim();
|
|
72
|
+
|
|
73
|
+
/** Enriquecimento por SKU exato. */
|
|
74
|
+
export function getEnrichmentBySku(sku?: string | number | null): ProductEnrichment | null {
|
|
75
|
+
const k = norm(sku);
|
|
76
|
+
return k && DATA[k] ? DATA[k] : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ----- Matching por NOME (a Unbox NÃO expõe SKU/EAN; juntamos pelo nome) -----
|
|
80
|
+
// Estratégia: o título Unbox segue "Nome PT - Nome EN [tamanho]". O nome EN (após o último " - ")
|
|
81
|
+
// é o identificador distintivo. Casamos exigindo que (quase) todos os tokens do nome EN estejam
|
|
82
|
+
// presentes no nome do doc — evita falsos positivos (ex.: "Cilantro Lime" ≠ "Chile & Lime").
|
|
83
|
+
// Adicione aqui palavras a ignorar no matching de nomes (ex.: o nome da sua marca, que
|
|
84
|
+
// costuma aparecer em todos os títulos e por isso não ajuda a diferenciar produtos).
|
|
85
|
+
const STOP = new Set(["de", "do", "da", "com", "e", "em", "para", "the", "a", "o", "no", "na"]);
|
|
86
|
+
const SIZE_RE = /[\d]+[.,]?[\d]*\s*(g|ml|kg|l)\b/gi;
|
|
87
|
+
function nameTokens(s: string): Set<string> {
|
|
88
|
+
const toks = (s || "")
|
|
89
|
+
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[®™]/g, "")
|
|
92
|
+
.replace(SIZE_RE, " ")
|
|
93
|
+
.replace(/[^a-z0-9\s]/g, " ") // mantém o conteúdo dos parênteses (remove só os símbolos)
|
|
94
|
+
.split(/\s+/)
|
|
95
|
+
.filter((t) => t.length > 1 && !STOP.has(t));
|
|
96
|
+
return new Set(toks);
|
|
97
|
+
}
|
|
98
|
+
/** Tokens do nome EN (parte após o PRIMEIRO " - ", já sem tamanho). */
|
|
99
|
+
function enTokens(title: string): Set<string> {
|
|
100
|
+
const clean = (title || "").replace(SIZE_RE, " ");
|
|
101
|
+
const idx = clean.indexOf(" - ");
|
|
102
|
+
return nameTokens(idx >= 0 ? clean.slice(idx + 3) : clean);
|
|
103
|
+
}
|
|
104
|
+
function containment(small: Set<string>, big: Set<string>): number {
|
|
105
|
+
if (small.size === 0) return 0;
|
|
106
|
+
let inter = 0;
|
|
107
|
+
for (const x of small) if (big.has(x)) inter++;
|
|
108
|
+
return inter / small.size;
|
|
109
|
+
}
|
|
110
|
+
// Similaridade simétrica (Jaccard) — usada como desempate para separar a família "base" de uma
|
|
111
|
+
// variante com modificador (ex.: "Base" vs "Base Plus"), que empatam no score de containment.
|
|
112
|
+
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
113
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
114
|
+
let inter = 0;
|
|
115
|
+
for (const x of a) if (b.has(x)) inter++;
|
|
116
|
+
return inter / (a.size + b.size - inter);
|
|
117
|
+
}
|
|
118
|
+
const NAME_INDEX = Object.values(DATA).map((e) => ({ e, toks: nameTokens(e.name) }));
|
|
119
|
+
const NAME_THRESHOLD = 0.7;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enriquecimento pelo NOME (ignora tamanho/acentos). Score = melhor de:
|
|
123
|
+
* - nome EN do produto contido no nome do doc (casos com nome do doc em inglês), ou
|
|
124
|
+
* - nome do doc contido no título completo do produto (casos com nome do doc em PT).
|
|
125
|
+
* Limiar 0.7 evita falsos positivos (ex.: "Cilantro Lime" não casa com "Chile & Lime").
|
|
126
|
+
* Empates de score são desempatados pela similaridade Jaccard(título, nome do doc): assim "Paprika"
|
|
127
|
+
* casa com a família base e não com "Paprika Smoked" (que tem tokens extras).
|
|
128
|
+
*/
|
|
129
|
+
export function getEnrichmentByName(title?: string | null): ProductEnrichment | null {
|
|
130
|
+
if (!title) return null;
|
|
131
|
+
const en = enTokens(title);
|
|
132
|
+
const full = nameTokens(title);
|
|
133
|
+
if (full.size === 0) return null;
|
|
134
|
+
let best: ProductEnrichment | null = null;
|
|
135
|
+
let bestScore = 0;
|
|
136
|
+
let bestTie = 0;
|
|
137
|
+
for (const { e, toks } of NAME_INDEX) {
|
|
138
|
+
const sc = Math.max(containment(en, toks), containment(toks, full));
|
|
139
|
+
const tie = jaccard(full, toks);
|
|
140
|
+
if (sc > bestScore || (sc === bestScore && tie > bestTie)) {
|
|
141
|
+
bestScore = sc; bestTie = tie; best = e;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return bestScore >= NAME_THRESHOLD ? best : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve o enriquecimento de um produto Unbox: tenta SKU (produto/variantes) e cai no nome. */
|
|
148
|
+
export function getEnrichmentForProduct(product: any): ProductEnrichment | null {
|
|
149
|
+
const candidates = [product?.sku, ...((product?.variants ?? []).map((v: any) => v?.sku))].filter(Boolean);
|
|
150
|
+
for (const c of candidates) {
|
|
151
|
+
const e = getEnrichmentBySku(c);
|
|
152
|
+
if (e) return e;
|
|
153
|
+
}
|
|
154
|
+
return getEnrichmentByName(product?.title);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Lista de enriquecimentos a partir de uma lista de SKUs (ignora os não encontrados). */
|
|
158
|
+
export function getEnrichmentBySkus(skus: string[]): ProductEnrichment[] {
|
|
159
|
+
return skus.map((s) => getEnrichmentBySku(s)).filter((e): e is ProductEnrichment => !!e);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ----- Famílias (mesmo familyCode) -----
|
|
163
|
+
// Cada produto Unbox é UM tamanho de uma família; o tamanho vive no título ("297,7 g"), não em
|
|
164
|
+
// variante. Agrupamos pelo `familyCode` da planilha — resolvido por nome, já que a Unbox não expõe
|
|
165
|
+
// SKU e o casamento por nome ignora o tamanho (todos os irmãos compartilham o mesmo nome EN).
|
|
166
|
+
|
|
167
|
+
const ONE_SIZE_RE = /(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l)\b/i;
|
|
168
|
+
|
|
169
|
+
/** Extrai peso/volume de um texto livre (ex.: título "Produto X 250 g" → {value: 250, unit: "g"}). */
|
|
170
|
+
export function parseSize(text?: string | null): { value: number; unit: string } | null {
|
|
171
|
+
if (!text) return null;
|
|
172
|
+
const m = text.match(ONE_SIZE_RE);
|
|
173
|
+
if (!m) return null;
|
|
174
|
+
return { value: parseFloat(m[1].replace(",", ".")), unit: m[2].toLowerCase() };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** familyCode de um produto Unbox (via SKU ou nome). Agrupa as variações de tamanho. */
|
|
178
|
+
export function getFamilyCode(product: any): string | null {
|
|
179
|
+
return getEnrichmentForProduct(product)?.familyCode ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Média de avaliações (para rating real vindo da planilha, quando houver). */
|
|
183
|
+
export function reviewStats(e: ProductEnrichment | null): { count: number; average: number } | null {
|
|
184
|
+
if (!e || e.reviews.length === 0) return null;
|
|
185
|
+
const count = e.reviews.length;
|
|
186
|
+
const average = e.reviews.reduce((s, r) => s + (r.stars || 0), 0) / count;
|
|
187
|
+
return { count, average: Math.round(average * 10) / 10 };
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Sanidade das variáveis de ambiente — roda uma vez no boot do servidor (importado pelo
|
|
2
|
+
// lib/config.ts). Não valida segredo (impossível), valida FORMA: um valor que parece prosa
|
|
3
|
+
// ("chave da loja (x-api-key...)") é sinal de que o comentário do .env virou valor no deploy.
|
|
4
|
+
// Presença truthy não é validade — foi essa confusão que derrubou a /api/capi de uma loja.
|
|
5
|
+
import "server-only";
|
|
6
|
+
|
|
7
|
+
const CHAVES = [
|
|
8
|
+
"UNBOX_PARTNER_API_KEY", "UNBOX_CAPTCHA_BYPASS", "UNBOX_API_KEY", "UNBOX_USER", "UNBOX_PASS",
|
|
9
|
+
"UNBOX_SHOP_ID", "UNBOX_SHOP_SLUG", "UNBOX_WEBHOOK_SECRET", "SESSION_SECRET", "REVALIDATE_SECRET",
|
|
10
|
+
"UNBOX_HOSTED_CHECKOUT_URL", "META_PIXEL_ID", "META_CAPI_TOKEN", "NEXT_PUBLIC_GA_ID",
|
|
11
|
+
"NEXT_PUBLIC_META_PIXEL_ID", "PREVIEW_PASSWORD", "PIPEDRIVE_API_TOKEN", "PREVIEW_WEBHOOK_URL",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Parece comentário/prosa: tem `#`, ou tem espaço, ou é uma frase com acento/parênteses. */
|
|
15
|
+
function pareceProsa(v: string) {
|
|
16
|
+
return /#/.test(v) || /\s/.test(v.trim()) || /[()áéíóúãõçÁÉÍÓÚÃÕÇ]/.test(v);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let avisado = false;
|
|
20
|
+
export function checkEnv() {
|
|
21
|
+
if (avisado) return;
|
|
22
|
+
avisado = true;
|
|
23
|
+
const problemas: string[] = [];
|
|
24
|
+
for (const k of CHAVES) {
|
|
25
|
+
const v = process.env[k];
|
|
26
|
+
// NUNCA imprime o valor: a lista inclui senha e tokens, e o próprio .env.example sugere
|
|
27
|
+
// UNBOX_PASS="#minhaSenha$123" — com o `#` ela cairia aqui e iria inteira pro log da Vercel.
|
|
28
|
+
// Só o nome da variável e o motivo.
|
|
29
|
+
if (v && pareceProsa(v)) problemas.push(`${k}: contém ${/#/.test(v) ? "'#'" : /\s/.test(v.trim()) ? "espaço" : "acento/parêntese"} (parece comentário copiado como valor)`);
|
|
30
|
+
}
|
|
31
|
+
const pixel = process.env.META_PIXEL_ID;
|
|
32
|
+
if (pixel && !/^\d{5,}$/.test(pixel)) problemas.push("META_PIXEL_ID: não é numérico");
|
|
33
|
+
const ga = process.env.NEXT_PUBLIC_GA_ID;
|
|
34
|
+
if (ga && !/^G-[A-Z0-9]{6,}$/i.test(ga)) problemas.push("NEXT_PUBLIC_GA_ID: não tem a forma G-XXXXXXX");
|
|
35
|
+
const site = process.env.NEXT_PUBLIC_SITE_URL ?? "";
|
|
36
|
+
if (process.env.NODE_ENV === "production" && (!site || /localhost|127\.0\.0\.1/.test(site) || !site.startsWith("https://"))) {
|
|
37
|
+
problemas.push("NEXT_PUBLIC_SITE_URL: ausente ou localhost em PRODUÇÃO — canonical, sitemap, robots, Open Graph, JSON-LD e o link de recuperação de carrinho saem apontando para localhost");
|
|
38
|
+
}
|
|
39
|
+
if (process.env.NODE_ENV === "production" && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.includes("dev-insecure"))) {
|
|
40
|
+
problemas.push("SESSION_SECRET: vazio ou default de desenvolvimento em PRODUÇÃO — a posse dos pedidos (cookie unbox_order_*) fica forjável; gere um valor aleatório longo");
|
|
41
|
+
}
|
|
42
|
+
if (problemas.length) {
|
|
43
|
+
console.warn("\n⚠ [env] valores que parecem COMENTÁRIO copiado como valor (o .env tinha `CHAVE= # texto`?):");
|
|
44
|
+
for (const p of problemas) console.warn(" • " + p);
|
|
45
|
+
console.warn(" Corrija no painel de variáveis do deploy. Enquanto isso, a integração correspondente vai falhar.\n");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Utilitários puros de formatação/preço — seguros no client e no server (sem segredos).
|
|
2
|
+
|
|
3
|
+
const BRL = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" });
|
|
4
|
+
|
|
5
|
+
/** Formata um número em BRL. Use o `displayAmount` da API quando existir; isto é fallback. */
|
|
6
|
+
export function formatBRL(amount: number | null | undefined): string {
|
|
7
|
+
if (amount == null || Number.isNaN(amount)) return "—";
|
|
8
|
+
return BRL.format(amount);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResolvedPrice {
|
|
12
|
+
price: number | null;
|
|
13
|
+
displayPrice: string;
|
|
14
|
+
compareAt?: string | null; // preço "de" (riscado), se houver
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve o preço REAL de uma variante. ⚠️ O preço vive na variante (`variant.pricing[0]`),
|
|
19
|
+
* NÃO em `product.pricing` (que é só agregado min/max). Acesso defensivo a array vs objeto.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveVariantPrice(variant: any): ResolvedPrice {
|
|
22
|
+
const p = Array.isArray(variant?.pricing) ? variant.pricing[0] : variant?.pricing;
|
|
23
|
+
return {
|
|
24
|
+
price: p?.price ?? null,
|
|
25
|
+
displayPrice: p?.displayPrice ?? formatBRL(p?.price),
|
|
26
|
+
compareAt: p?.compareAtPrice?.displayAmount ?? null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Preço de vitrine a partir do node do catálogo (usa a 1ª variante; fallback no agregado). */
|
|
31
|
+
export function resolveProductPrice(product: any): ResolvedPrice {
|
|
32
|
+
const v = product?.variants?.[0];
|
|
33
|
+
if (v) return resolveVariantPrice(v);
|
|
34
|
+
const agg = Array.isArray(product?.pricing) ? product.pricing[0] : product?.pricing;
|
|
35
|
+
return { price: agg?.minPrice ?? null, displayPrice: agg?.displayPrice ?? formatBRL(agg?.minPrice), compareAt: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Aplica % de desconto a um valor (ex.: preview de assinatura) e devolve string BRL. */
|
|
39
|
+
export function applyPercentOff(amount: number, percent: number): string {
|
|
40
|
+
return formatBRL(amount * (1 - percent / 100));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Máscara/normalização simples de CEP (8 dígitos). */
|
|
44
|
+
export function onlyDigits(s: string): string {
|
|
45
|
+
return (s ?? "").replace(/\D/g, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function maskCep(s: string): string {
|
|
49
|
+
const d = onlyDigits(s).slice(0, 8);
|
|
50
|
+
return d.length > 5 ? `${d.slice(0, 5)}-${d.slice(5)}` : d;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Máscara de telefone BR — (11) 99999-9999 (celular) ou (11) 9999-9999 (fixo). */
|
|
54
|
+
export function maskPhone(s: string): string {
|
|
55
|
+
const d = onlyDigits(s).slice(0, 11);
|
|
56
|
+
if (d.length <= 2) return d.length ? `(${d}` : "";
|
|
57
|
+
if (d.length <= 6) return `(${d.slice(0, 2)}) ${d.slice(2)}`;
|
|
58
|
+
if (d.length <= 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
|
|
59
|
+
return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Máscara de CPF — 000.000.000-00. */
|
|
63
|
+
export function maskCpf(s: string): string {
|
|
64
|
+
const d = onlyDigits(s).slice(0, 11);
|
|
65
|
+
if (d.length <= 3) return d;
|
|
66
|
+
if (d.length <= 6) return `${d.slice(0, 3)}.${d.slice(3)}`;
|
|
67
|
+
if (d.length <= 9) return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6)}`;
|
|
68
|
+
return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validação de CPF pelos dígitos verificadores (rejeita sequências repetidas). */
|
|
72
|
+
export function isValidCpf(s: string): boolean {
|
|
73
|
+
const c = onlyDigits(s);
|
|
74
|
+
if (c.length !== 11 || /^(\d)\1{10}$/.test(c)) return false;
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (let i = 0; i < 9; i++) sum += parseInt(c[i], 10) * (10 - i);
|
|
77
|
+
let d1 = (sum * 10) % 11;
|
|
78
|
+
if (d1 === 10) d1 = 0;
|
|
79
|
+
if (d1 !== parseInt(c[9], 10)) return false;
|
|
80
|
+
sum = 0;
|
|
81
|
+
for (let i = 0; i < 10; i++) sum += parseInt(c[i], 10) * (11 - i);
|
|
82
|
+
let d2 = (sum * 10) % 11;
|
|
83
|
+
if (d2 === 10) d2 = 0;
|
|
84
|
+
return d2 === parseInt(c[10], 10);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Validação simples de formato de e-mail. */
|
|
88
|
+
export function isValidEmail(s: string): boolean {
|
|
89
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((s ?? "").trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ----- Sanitização de input: bloqueia caracteres que não pertencem ao campo -----
|
|
93
|
+
|
|
94
|
+
/** Nome próprio: apenas letras (com acento), espaço, apóstrofo, ponto e hífen. */
|
|
95
|
+
export function sanitizeName(s: string): string {
|
|
96
|
+
return (s ?? "").replace(/[^\p{L}\s'’.-]/gu, "").replace(/\s{2,}/g, " ").replace(/^\s+/, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** E-mail: sem espaços e só caracteres válidos de e-mail. */
|
|
100
|
+
export function sanitizeEmail(s: string): string {
|
|
101
|
+
return (s ?? "").replace(/\s+/g, "").replace(/[^a-zA-Z0-9@._%+\-]/g, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Texto de endereço (rua, bairro, complemento): letras, números, espaço e pontuação comum. */
|
|
105
|
+
export function sanitizeText(s: string): string {
|
|
106
|
+
return (s ?? "").replace(/[^\p{L}\p{N}\s.,'’ºª°#/-]/gu, "").replace(/\s{2,}/g, " ").replace(/^\s+/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Número do endereço: dígitos, letras e barra (ex.: 123, 123A, S/N). */
|
|
110
|
+
export function sanitizeHouseNumber(s: string): string {
|
|
111
|
+
return (s ?? "").replace(/[^0-9A-Za-z/]/g, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** UF: apenas letras, maiúsculas, no máximo 2. */
|
|
115
|
+
export function sanitizeUf(s: string): string {
|
|
116
|
+
return (s ?? "").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 2);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Número do cartão: dígitos agrupados em blocos de 4 (até 19 dígitos). */
|
|
120
|
+
export function maskCardNumber(s: string): string {
|
|
121
|
+
const d = onlyDigits(s).slice(0, 19);
|
|
122
|
+
return d.replace(/(.{4})/g, "$1 ").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Converte um displayAmount BR ("R$ 1.234,56") em número. Retorna null se inválido. */
|
|
126
|
+
export function parseBRL(s?: string | null): number | null {
|
|
127
|
+
if (!s) return null;
|
|
128
|
+
const n = Number(s.replace(/[^\d,.-]/g, "").replace(/\./g, "").replace(",", "."));
|
|
129
|
+
return Number.isNaN(n) ? null : n;
|
|
130
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registro central de ícones — Phosphor Icons.
|
|
3
|
+
*
|
|
4
|
+
* Usamos o entry `/dist/ssr`, que não depende de React Context e portanto
|
|
5
|
+
* funciona tanto em Server Components quanto em Client Components (App Router).
|
|
6
|
+
*
|
|
7
|
+
* Os nomes são exportados com os aliases usados no código (herdados do lucide)
|
|
8
|
+
* para manter as chamadas JSX existentes; à direita fica o ícone Phosphor real.
|
|
9
|
+
* Para adicionar um ícone novo, importe de `@phosphor-icons/react/dist/ssr`.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
// Navegação / setas
|
|
13
|
+
ArrowRight,
|
|
14
|
+
CaretDown as ChevronDownIcon,
|
|
15
|
+
CaretUp as ChevronUpIcon,
|
|
16
|
+
CaretLeft as ChevronLeft,
|
|
17
|
+
CaretLeft as ChevronLeftIcon,
|
|
18
|
+
CaretRight as ChevronRight,
|
|
19
|
+
CaretRight as ChevronRightIcon,
|
|
20
|
+
DotsThree as MoreHorizontalIcon,
|
|
21
|
+
List as Menu,
|
|
22
|
+
|
|
23
|
+
// Ações
|
|
24
|
+
MagnifyingGlass as Search,
|
|
25
|
+
MagnifyingGlass as SearchIcon,
|
|
26
|
+
Copy,
|
|
27
|
+
Trash as Trash2,
|
|
28
|
+
PencilSimple as Pencil,
|
|
29
|
+
Plus,
|
|
30
|
+
Minus,
|
|
31
|
+
Minus as MinusIcon,
|
|
32
|
+
ArrowsClockwise as RefreshCw,
|
|
33
|
+
X as XIcon,
|
|
34
|
+
SignOut as LogOut,
|
|
35
|
+
|
|
36
|
+
// Status / feedback
|
|
37
|
+
Check,
|
|
38
|
+
Check as CheckIcon,
|
|
39
|
+
CheckCircle as CheckCircle2,
|
|
40
|
+
CheckCircle as CircleCheckIcon,
|
|
41
|
+
XCircle,
|
|
42
|
+
Info as InfoIcon,
|
|
43
|
+
Warning as TriangleAlertIcon,
|
|
44
|
+
WarningOctagon as OctagonXIcon,
|
|
45
|
+
CircleNotch as Loader2,
|
|
46
|
+
CircleNotch as Loader2Icon,
|
|
47
|
+
|
|
48
|
+
// Comércio / conta
|
|
49
|
+
ShoppingBag,
|
|
50
|
+
CreditCard,
|
|
51
|
+
Tag,
|
|
52
|
+
Truck,
|
|
53
|
+
Package,
|
|
54
|
+
Package as PackageOpen,
|
|
55
|
+
MapPin,
|
|
56
|
+
User,
|
|
57
|
+
Lock,
|
|
58
|
+
Key as KeyRound,
|
|
59
|
+
QrCode,
|
|
60
|
+
Envelope as Mail,
|
|
61
|
+
ShieldCheck,
|
|
62
|
+
Gear as Settings,
|
|
63
|
+
SquaresFour as LayoutDashboard,
|
|
64
|
+
ListChecks,
|
|
65
|
+
Pause,
|
|
66
|
+
SkipForward,
|
|
67
|
+
} from "@phosphor-icons/react/dist/ssr";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Serializa dado estruturado para dentro de <script type="application/ld+json">.
|
|
2
|
+
*
|
|
3
|
+
* O `<` PRECISA virar \u003c: qualquer string vinda do catálogo (título, descrição, uma
|
|
4
|
+
* resposta de FAQ) que contenha `</script>` fecharia a tag e derrubaria o resto da página.
|
|
5
|
+
* É a única sanitização necessária aqui, e vale para todo JSON-LD do projeto.
|
|
6
|
+
*/
|
|
7
|
+
export function ldJson(dado: unknown): string {
|
|
8
|
+
return JSON.stringify(dado).replace(/</g, "\\u003c");
|
|
9
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Montagem do /llms.txt. Separado da rota de propósito: aqui é função pura (dado → texto), o que
|
|
2
|
+
// deixa o formato testável sem subir servidor nem bater na API.
|
|
3
|
+
//
|
|
4
|
+
// Formato: seções em LISTA DE LINKS markdown, como pede a especificação do llms.txt. Arquivo sem
|
|
5
|
+
// link nenhum reprova na auditoria Agent Accessibility do PageSpeed, e um agente que não consegue
|
|
6
|
+
// navegar a partir daqui simplesmente não usa o arquivo.
|
|
7
|
+
import type { CatalogProductItem } from "@/components/catalog/catalog-client";
|
|
8
|
+
|
|
9
|
+
export interface OpcoesLlms {
|
|
10
|
+
siteName: string;
|
|
11
|
+
/** Base absoluta (NEXT_PUBLIC_SITE_URL). Vazio = links relativos. */
|
|
12
|
+
siteUrl?: string;
|
|
13
|
+
descricao?: string;
|
|
14
|
+
categorias: { nome: string; slug: string }[];
|
|
15
|
+
pixDescontoPct?: number;
|
|
16
|
+
freteGratisAcimaDe?: number | null;
|
|
17
|
+
/** O arquivo é para leitura, não é o catálogo: acima disso, o agente segue o link de /produtos. */
|
|
18
|
+
maxProdutos?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function montarLlmsTxt(produtos: CatalogProductItem[], o: OpcoesLlms): string {
|
|
22
|
+
const base = (o.siteUrl ?? "").replace(/\/+$/, "");
|
|
23
|
+
const link = (caminho: string) => `${base}${caminho}`;
|
|
24
|
+
const max = o.maxProdutos ?? 60;
|
|
25
|
+
const linhas: string[] = [`# ${o.siteName}`, ""];
|
|
26
|
+
|
|
27
|
+
if (o.descricao?.trim()) linhas.push(`> ${o.descricao.trim()}`, "");
|
|
28
|
+
|
|
29
|
+
const categoriasDosProdutos = [...new Set(produtos.flatMap((p) => p.categories))].slice(0, 12);
|
|
30
|
+
if (categoriasDosProdutos.length) linhas.push(`Categorias: ${categoriasDosProdutos.join(", ")}.`, "");
|
|
31
|
+
|
|
32
|
+
if (produtos.length) {
|
|
33
|
+
linhas.push("## Produtos", "");
|
|
34
|
+
for (const p of produtos.slice(0, max)) {
|
|
35
|
+
// Preço e disponibilidade saem do MESMO mapeamento que desenha a vitrine: o esgotado do
|
|
36
|
+
// painel aparece aqui sozinho, sem ninguém manter uma segunda lista.
|
|
37
|
+
const detalhe = [p.displayPrice, p.soldOut ? "esgotado" : null, p.weight || null].filter(Boolean).join(" · ");
|
|
38
|
+
linhas.push(`- [${p.title}](${link(`/produto/${encodeURIComponent(p.slug)}`)})${detalhe ? `: ${detalhe}` : ""}`);
|
|
39
|
+
}
|
|
40
|
+
if (produtos.length > max) {
|
|
41
|
+
linhas.push(`- [Ver o catálogo completo](${link("/produtos")}): mais ${produtos.length - max} produtos`);
|
|
42
|
+
}
|
|
43
|
+
linhas.push("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (o.categorias.length) {
|
|
47
|
+
linhas.push("## Categorias", "");
|
|
48
|
+
for (const c of o.categorias.slice(0, 30)) {
|
|
49
|
+
linhas.push(`- [${c.nome}](${link(`/categoria/${encodeURIComponent(c.slug)}`)})`);
|
|
50
|
+
}
|
|
51
|
+
linhas.push("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
linhas.push(
|
|
55
|
+
"## Páginas", "",
|
|
56
|
+
`- [Início](${link("/")}): destaques e produtos em evidência`,
|
|
57
|
+
`- [Catálogo](${link("/produtos")}): todos os produtos, com filtros por categoria e preço`,
|
|
58
|
+
`- [Busca](${link("/busca")}): busca por nome de produto`,
|
|
59
|
+
`- [Minha conta](${link("/conta")}): acompanhamento de pedidos e assinaturas`,
|
|
60
|
+
"",
|
|
61
|
+
"## Políticas", "",
|
|
62
|
+
`- [Trocas e devoluções](${link("/devolucoes")}): direito de arrependimento de 7 dias (CDC, art. 49)`,
|
|
63
|
+
`- [Termos de uso](${link("/termos")}): condições de compra`,
|
|
64
|
+
`- [Política de privacidade](${link("/privacidade")}): tratamento de dados pessoais (LGPD)`,
|
|
65
|
+
"",
|
|
66
|
+
"## Como comprar", "",
|
|
67
|
+
`- Pagamento: Pix${(o.pixDescontoPct ?? 0) > 0 ? ` (${o.pixDescontoPct}% de desconto)` : ""} e cartão de crédito`,
|
|
68
|
+
"- Entrega: frete calculado por CEP no checkout, para todo o Brasil",
|
|
69
|
+
);
|
|
70
|
+
// Só afirma o que a loja tem configurado de fato (mesma regra do resto do template).
|
|
71
|
+
if (o.freteGratisAcimaDe != null) linhas.push(`- Frete grátis a partir de R$ ${o.freteGratisAcimaDe}`);
|
|
72
|
+
linhas.push("");
|
|
73
|
+
|
|
74
|
+
return linhas.join("\n");
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { hasUnboxCredentials } from "./config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Único lugar onde "a API falhou" pode virar "resultado vazio". Sem credenciais (modo mockup)
|
|
6
|
+
* devolve o fallback em silêncio: é o comportamento esperado de um scaffold sem .env. COM
|
|
7
|
+
* credenciais, loga e RELANÇA — a falha tem que aparecer.
|
|
8
|
+
*
|
|
9
|
+
* Por que isso importa: os `.catch(() => [])` espalhados nas páginas tratavam "Unbox fora do
|
|
10
|
+
* ar" igual a "sem credenciais". Em produção isso produzia home e catálogo VAZIOS com HTTP 200,
|
|
11
|
+
* que o ISR então CACHEAVA por 5 minutos no lugar da versão boa (o Next só preserva a versão
|
|
12
|
+
* anterior quando a regeneração lança), sem uma linha de log. E nos layouts de produto e
|
|
13
|
+
* categoria, virava `notFound()` — 404 real e cacheado na rota mais indexada da loja. Relançar
|
|
14
|
+
* dá ao ISR a chance de manter a página anterior e ao error.tsx a chance de existir.
|
|
15
|
+
*/
|
|
16
|
+
export async function mockupOr<T, F = T>(promessa: Promise<T>, fallback: F, rotulo: string): Promise<T | F> {
|
|
17
|
+
try {
|
|
18
|
+
return await promessa;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (!hasUnboxCredentials) return fallback;
|
|
21
|
+
console.error(`[unbox] falha em ${rotulo} COM credenciais configuradas — não é modo mockup:`, e instanceof Error ? e.message : e);
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Para onde vai o e-mail capturado. UM lugar só, lido por todo bloco de captura da loja
|
|
3
|
+
* (rodapé "conversão", catálogo e a seção `newsletter` da home).
|
|
4
|
+
*
|
|
5
|
+
* REGRA: sem destino, o bloco NÃO RENDERIZA. Formulário que engole o e-mail em silêncio é pior
|
|
6
|
+
* que não ter formulário: a pessoa acha que se cadastrou e a loja não tem o contato. Foi o que
|
|
7
|
+
* dois blocos desta foundation faziam, com `onSubmit={(e) => e.preventDefault()}`.
|
|
8
|
+
*
|
|
9
|
+
* O valor é a URL que recebe o POST do formulário (endpoint do CRM/ESP: Klaviyo, Revi, Mailchimp,
|
|
10
|
+
* um Route Handler próprio). É `NEXT_PUBLIC_` porque o formulário é HTML no navegador.
|
|
11
|
+
*/
|
|
12
|
+
export const NEWSLETTER_ACTION = process.env.NEXT_PUBLIC_NEWSLETTER_ACTION?.trim() || null;
|