@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,160 @@
|
|
|
1
|
+
// Acesso a pedido COM verificação de posse (doc 09): cliente logado OU token do placeOrder (cookie).
|
|
2
|
+
import "server-only";
|
|
3
|
+
import { withStoreClient } from "./unbox/store";
|
|
4
|
+
import { getOrderToken } from "./session";
|
|
5
|
+
import { getCustomerClient } from "./customer-session";
|
|
6
|
+
import { orderStatusLabel, paymentStatusLabel } from "./unbox/customer";
|
|
7
|
+
import { formatBRL } from "./format";
|
|
8
|
+
|
|
9
|
+
// O resolver de `displayAmount` no contexto do cliente (customerOrderByReferenceId) é furado e às
|
|
10
|
+
// vezes devolve "R$NaN,undefined". Por isso formatamos SEMPRE a partir do `.amount` numérico.
|
|
11
|
+
const money = (amount: unknown): string | undefined =>
|
|
12
|
+
typeof amount === "number" && !Number.isNaN(amount) ? formatBRL(amount) : undefined;
|
|
13
|
+
|
|
14
|
+
export interface ShapedOrderItem {
|
|
15
|
+
title: string;
|
|
16
|
+
variantTitle?: string;
|
|
17
|
+
quantity: number;
|
|
18
|
+
displayPrice?: string;
|
|
19
|
+
displaySubtotal?: string;
|
|
20
|
+
thumbnail?: string;
|
|
21
|
+
slug?: string;
|
|
22
|
+
/** dados p/ refazer o pedido (adicionar ao carrinho) */
|
|
23
|
+
productId?: string;
|
|
24
|
+
variantId?: string;
|
|
25
|
+
price?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ShapedOrder {
|
|
29
|
+
referenceId: string;
|
|
30
|
+
status: string;
|
|
31
|
+
statusLabel: string;
|
|
32
|
+
paid: boolean;
|
|
33
|
+
payment: { displayName?: string; captured?: boolean; cardBrand?: string; errorMessage?: string; statusLabel?: string; amount?: string } | null;
|
|
34
|
+
fulfillment: { status?: string; type?: string; trackingCode?: string }[];
|
|
35
|
+
items: ShapedOrderItem[];
|
|
36
|
+
address: ShapedAddress | null;
|
|
37
|
+
invoiceIssued?: boolean;
|
|
38
|
+
dispatched?: boolean;
|
|
39
|
+
delivered?: boolean;
|
|
40
|
+
total?: string;
|
|
41
|
+
email?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ShapedAddress {
|
|
45
|
+
fullName?: string;
|
|
46
|
+
line?: string; // "Rua X, 123 · Bairro"
|
|
47
|
+
cityRegion?: string; // "São Paulo/SP · 05724-003"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ShapedOrderSummary {
|
|
51
|
+
referenceId: string;
|
|
52
|
+
status: string;
|
|
53
|
+
statusLabel: string;
|
|
54
|
+
createdAt?: string;
|
|
55
|
+
recurring: boolean;
|
|
56
|
+
dispatched: boolean;
|
|
57
|
+
delivered: boolean;
|
|
58
|
+
total?: string;
|
|
59
|
+
itemCount: number;
|
|
60
|
+
thumbnails: string[];
|
|
61
|
+
firstTitles: string[];
|
|
62
|
+
address: ShapedAddress | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shapeAddress(a: any): ShapedAddress | null {
|
|
66
|
+
if (!a) return null;
|
|
67
|
+
const line = [a.address1, a.number].filter(Boolean).join(", ") + (a.neighborhood ? ` · ${a.neighborhood}` : "");
|
|
68
|
+
const cityRegion = [a.city && a.region ? `${a.city}/${a.region}` : a.city || a.region, a.postal].filter(Boolean).join(" · ");
|
|
69
|
+
return { fullName: a.fullName || undefined, line: line.trim() || undefined, cityRegion: cityRegion || undefined };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resumo de um pedido para a LISTA: endereço, miniaturas, contagem e total. */
|
|
73
|
+
export function shapeOrderSummary(o: any): ShapedOrderSummary {
|
|
74
|
+
const groups: any[] = o.fulfillmentGroups ?? [];
|
|
75
|
+
const items: any[] = groups.flatMap((g: any) => g.items?.nodes ?? []);
|
|
76
|
+
const address = shapeAddress(groups.find((g: any) => g.data?.shippingAddress)?.data?.shippingAddress);
|
|
77
|
+
const itemsSum = items.reduce((s: number, it: any) => s + (it.price?.amount ?? 0) * (it.quantity ?? 1), 0);
|
|
78
|
+
return {
|
|
79
|
+
referenceId: o.referenceId,
|
|
80
|
+
status: o.status,
|
|
81
|
+
statusLabel: orderStatusLabel(o.status),
|
|
82
|
+
createdAt: o.createdAt,
|
|
83
|
+
recurring: !!o.recurringOrderId,
|
|
84
|
+
dispatched: !!o.dispatched,
|
|
85
|
+
delivered: !!o.delivered,
|
|
86
|
+
total: money(o.summary?.total?.amount) ?? money(itemsSum > 0 ? itemsSum : undefined),
|
|
87
|
+
itemCount: items.reduce((s: number, it: any) => s + (it.quantity ?? 1), 0),
|
|
88
|
+
thumbnails: items.map((it: any) => it.thumbnail).filter(Boolean).slice(0, 4),
|
|
89
|
+
firstTitles: items.map((it: any) => it.title).filter(Boolean).slice(0, 3),
|
|
90
|
+
address,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function shapeOrder(o: any): ShapedOrder {
|
|
95
|
+
const pmt = o.payments?.[0];
|
|
96
|
+
const groups: any[] = o.fulfillmentGroups ?? [];
|
|
97
|
+
const items: ShapedOrderItem[] = groups.flatMap((g: any) =>
|
|
98
|
+
(g.items?.nodes ?? []).map((it: any): ShapedOrderItem => {
|
|
99
|
+
const unit = it.price?.amount;
|
|
100
|
+
return {
|
|
101
|
+
title: it.title,
|
|
102
|
+
variantTitle: it.variantTitle ?? undefined,
|
|
103
|
+
quantity: it.quantity,
|
|
104
|
+
displayPrice: money(unit),
|
|
105
|
+
displaySubtotal: typeof unit === "number" ? money(unit * (it.quantity ?? 1)) : undefined,
|
|
106
|
+
thumbnail: it.thumbnail ?? undefined,
|
|
107
|
+
slug: it.productSlug ?? undefined,
|
|
108
|
+
productId: it.productConfiguration?.productId ?? undefined,
|
|
109
|
+
variantId: it.productConfiguration?.productVariantId ?? undefined,
|
|
110
|
+
price: typeof unit === "number" ? unit : undefined,
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
const itemsSum = items.reduce((s, i) => s + (i.price ?? 0) * i.quantity, 0);
|
|
115
|
+
return {
|
|
116
|
+
referenceId: o.referenceId,
|
|
117
|
+
status: o.status,
|
|
118
|
+
statusLabel: orderStatusLabel(o.status),
|
|
119
|
+
paid: o.status === "PROCESSING" || o.status === "COMPLETED" || pmt?.isCaptured === true,
|
|
120
|
+
payment: pmt
|
|
121
|
+
? {
|
|
122
|
+
displayName: pmt.displayName,
|
|
123
|
+
captured: pmt.isCaptured,
|
|
124
|
+
cardBrand: pmt.cardBrand,
|
|
125
|
+
errorMessage: pmt.captureErrorMessage,
|
|
126
|
+
statusLabel: pmt.status?.status ? paymentStatusLabel(pmt.status.status) : undefined,
|
|
127
|
+
amount: money(pmt.amount?.amount),
|
|
128
|
+
}
|
|
129
|
+
: null,
|
|
130
|
+
fulfillment: groups.map((g: any) => ({
|
|
131
|
+
status: g.status,
|
|
132
|
+
type: g.type,
|
|
133
|
+
trackingCode: g.trackingCode,
|
|
134
|
+
})),
|
|
135
|
+
items,
|
|
136
|
+
address: shapeAddress(groups.find((g: any) => g.data?.shippingAddress)?.data?.shippingAddress),
|
|
137
|
+
invoiceIssued: o.invoiceIssued,
|
|
138
|
+
dispatched: o.dispatched,
|
|
139
|
+
delivered: o.delivered,
|
|
140
|
+
total: money(o.summary?.total?.amount) ?? money(pmt?.amount?.amount) ?? money(itemsSum > 0 ? itemsSum : undefined),
|
|
141
|
+
email: o.email,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Retorna o pedido se o solicitante tiver posse; senão null. */
|
|
146
|
+
export async function getOwnedOrder(referenceId: string): Promise<ShapedOrder | null> {
|
|
147
|
+
const customer = await getCustomerClient();
|
|
148
|
+
if (customer) {
|
|
149
|
+
try {
|
|
150
|
+
const o = await customer.order(referenceId);
|
|
151
|
+
if (o) return shapeOrder(o);
|
|
152
|
+
} catch {
|
|
153
|
+
/* cai para o token de guest */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const token = await getOrderToken(referenceId);
|
|
157
|
+
if (!token) return null;
|
|
158
|
+
const o = await withStoreClient((c) => c.getOrder(referenceId, token));
|
|
159
|
+
return o ? shapeOrder(o) : null;
|
|
160
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Fetchers server-side reutilizáveis. Catálogo/loja/tags são públicos e estáveis → cacheados
|
|
2
|
+
// (unstable_cache, revalidável on-demand via /api/revalidate). cache() dedup por request.
|
|
3
|
+
import "server-only";
|
|
4
|
+
import { cache } from "react";
|
|
5
|
+
import { unstable_cache } from "next/cache";
|
|
6
|
+
import { withStoreClient, getShopContext } from "./unbox/store";
|
|
7
|
+
|
|
8
|
+
const TTL = 300; // 5 min
|
|
9
|
+
|
|
10
|
+
export const getShopData = cache(
|
|
11
|
+
unstable_cache(
|
|
12
|
+
async () => {
|
|
13
|
+
const { shopSlug } = await getShopContext();
|
|
14
|
+
return withStoreClient((c) => c.getShop(shopSlug));
|
|
15
|
+
},
|
|
16
|
+
["unbox-shop"],
|
|
17
|
+
{ revalidate: TTL, tags: ["shop"] },
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const getTopTags = cache(
|
|
22
|
+
unstable_cache(
|
|
23
|
+
async () => withStoreClient((c) => c.getTags()),
|
|
24
|
+
["unbox-top-tags"],
|
|
25
|
+
{ revalidate: 600, tags: ["tags"] },
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const getPaymentMethods = cache(
|
|
30
|
+
unstable_cache(
|
|
31
|
+
async () => withStoreClient((c) => c.getPaymentMethods()),
|
|
32
|
+
["unbox-payment-methods"],
|
|
33
|
+
{ revalidate: TTL, tags: ["shop"] },
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lista paginada do catálogo (busca/filtro/ordenação). Cacheado por `opts` — o Next 15 NÃO
|
|
39
|
+
* cacheia fetch() por padrão (diferente do 13/14), então sem unstable_cache explícito toda
|
|
40
|
+
* chamada batia direto na Unbox: /produtos, /categoria/* e as ofertas de upsell do checkout
|
|
41
|
+
* levavam de 1 a 8s por navegação. Com cache: consistentemente < 0.1s em hit.
|
|
42
|
+
*/
|
|
43
|
+
export const getCatalog = cache((opts: {
|
|
44
|
+
first?: number; offset?: number; searchText?: string; tagIds?: string[];
|
|
45
|
+
sortBy?: string; sortOrder?: "asc" | "desc";
|
|
46
|
+
} = {}) =>
|
|
47
|
+
unstable_cache(
|
|
48
|
+
() => withStoreClient((c) => c.getCatalog(opts)),
|
|
49
|
+
["unbox-catalog", JSON.stringify(opts)],
|
|
50
|
+
{ revalidate: TTL, tags: ["catalog"] },
|
|
51
|
+
)(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Cacheado por slug: data cache compartilhado entre requests (revalidável) + dedup por
|
|
55
|
+
// request via cache() — a PDP busca o mesmo slug em generateMetadata e no corpo da página.
|
|
56
|
+
export const getProductBySlug = cache((slug: string) =>
|
|
57
|
+
unstable_cache(
|
|
58
|
+
() => withStoreClient((c) => c.getProductBySlug(slug)),
|
|
59
|
+
["unbox-product", slug],
|
|
60
|
+
{ revalidate: TTL, tags: ["catalog", `product:${slug}`] },
|
|
61
|
+
)(),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export async function getDiscountCodes(first = 50) {
|
|
65
|
+
return withStoreClient((c) => c.listDiscountCodes(first));
|
|
66
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Rate-limit em memória (token-bucket simples por chave). Protege OTP, signin, checkout e
|
|
2
|
+
// verificação de conta contra spam/abuso/enumeração (doc 09 — o x-captcha-verification NÃO é
|
|
3
|
+
// um captcha real).
|
|
4
|
+
//
|
|
5
|
+
// PROD: substituir por @upstash/ratelimit + Vercel KV (compartilhado entre lambdas). Em dev /
|
|
6
|
+
// single-instance, este mapa em memória é suficiente.
|
|
7
|
+
import "server-only";
|
|
8
|
+
|
|
9
|
+
interface Bucket { count: number; resetAt: number }
|
|
10
|
+
const store = new Map<string, Bucket>();
|
|
11
|
+
|
|
12
|
+
export interface RateLimitResult { ok: boolean; remaining: number; resetInMs: number }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param key identificador (ex.: `otp:${ip}` ou `otp:${email}`)
|
|
16
|
+
* @param limit máximo de chamadas na janela
|
|
17
|
+
* @param windowMs tamanho da janela em ms
|
|
18
|
+
*/
|
|
19
|
+
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const b = store.get(key);
|
|
22
|
+
if (!b || now > b.resetAt) {
|
|
23
|
+
store.set(key, { count: 1, resetAt: now + windowMs });
|
|
24
|
+
return { ok: true, remaining: limit - 1, resetInMs: windowMs };
|
|
25
|
+
}
|
|
26
|
+
b.count += 1;
|
|
27
|
+
const ok = b.count <= limit;
|
|
28
|
+
return { ok, remaining: Math.max(0, limit - b.count), resetInMs: b.resetAt - now };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extrai um identificador de IP do request (best-effort, atrás de proxy/Vercel). */
|
|
32
|
+
export function clientIp(req: Request): string {
|
|
33
|
+
const h = req.headers;
|
|
34
|
+
return (
|
|
35
|
+
h.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
36
|
+
h.get("x-real-ip") ||
|
|
37
|
+
"unknown"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Limites pré-configurados por rota sensível. */
|
|
42
|
+
export const LIMITS = {
|
|
43
|
+
otp: { limit: 5, windowMs: 10 * 60 * 1000 }, // 5 / 10min
|
|
44
|
+
signin: { limit: 10, windowMs: 10 * 60 * 1000 }, // 10 / 10min
|
|
45
|
+
checkout: { limit: 8, windowMs: 5 * 60 * 1000 }, // 8 / 5min
|
|
46
|
+
exists: { limit: 20, windowMs: 10 * 60 * 1000 }, // 20 / 10min
|
|
47
|
+
} as const;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// AS PÁGINAS DESTA LOJA — DERIVADAS, NUNCA DIGITADAS.
|
|
3
|
+
//
|
|
4
|
+
// Antes daqui, a lista de páginas que o editor abre morava no `shops.json` do EDITOR: digitada à
|
|
5
|
+
// mão, com slug de produto cravado dentro, e ignorada inteira quando `SHOPS_JSON` existia no
|
|
6
|
+
// ambiente. Para 100 lojas isso não se sustenta — quem sabe quais páginas existem é a loja.
|
|
7
|
+
//
|
|
8
|
+
// A REGRA, uma só, e já mantida por outra pessoa: é editável a página dentro de `app/(loja)/` cujo
|
|
9
|
+
// caminho não casa com nenhum prefixo do `DISALLOW` de `app/robots.ts` (as "áreas privadas/
|
|
10
|
+
// transacionais" que o SEO já declara). Não há segunda lista para alguém esquecer de atualizar: a
|
|
11
|
+
// página nova aparece porque o arquivo dela existe, e some do editor porque o SEO a bloqueou.
|
|
12
|
+
//
|
|
13
|
+
// UMA ENTRADA POR ROTA, não por produto. `/produto/[productSlug]` é UMA página editável (a receita
|
|
14
|
+
// que renderiza todas), e não uma por produto. O exemplo concreto (para a prévia abrir) é resolvido
|
|
15
|
+
// em app/api/unbox/paginas/route.ts, que é quem fala com o catálogo.
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { DISALLOW } from "@/app/robots";
|
|
20
|
+
|
|
21
|
+
const ARQUIVO_DE_PAGINA = /^page\.(tsx|ts|jsx|js)$/;
|
|
22
|
+
|
|
23
|
+
/** `/conta/pedidos` casa com o prefixo `/conta`; `/contato` NÃO casa (prefixo é segmento, não texto). */
|
|
24
|
+
export function bloqueadaPeloRobots(rota: string): boolean {
|
|
25
|
+
return DISALLOW.some((d) => rota === d || rota.startsWith(`${d}/`));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── AS PÁGINAS QUE SÃO SÓ CABEÇALHO E RODAPÉ, POR REGRA ─────────────────────────────────────────
|
|
29
|
+
// A regra da casa (README do editor, §8) diz o que NÃO vira primitivo: texto legal, rótulo de
|
|
30
|
+
// formulário e de UI, mecânica de compra. Nestas rotas o corpo inteiro é isso, então o que o lojista
|
|
31
|
+
// edita nelas é só o `chrome` (cabeçalho e rodapé). Sem esta lista o gate de cobertura as mediria
|
|
32
|
+
// como qualquer outra página, veria 22% a 65% e reprovaria a loja para sempre; e um gate que sempre
|
|
33
|
+
// reprova é um gate que ninguém lê.
|
|
34
|
+
//
|
|
35
|
+
// NÃO é uma segunda lista de páginas: ela não põe nem tira página do editor (isso é o `DISALLOW`).
|
|
36
|
+
// Ela só diz, das páginas editáveis, quais são só cabeçalho e rodapé POR DECISÃO de quem construiu a
|
|
37
|
+
// loja, com o motivo ao lado. Explícita de propósito: o gate não infere isso do conteúdo, e a página
|
|
38
|
+
// que não está aqui continua sob a régua de 80%. Vai para `GET /api/unbox/paginas` como `soChrome: true`.
|
|
39
|
+
//
|
|
40
|
+
// ROTA NOVA que seja só texto legal ou mecânica de compra entra aqui, com o motivo citando o §8.
|
|
41
|
+
export const SO_CHROME: readonly string[] = [
|
|
42
|
+
"/termos", // texto legal (termos de uso): §8, "texto legal (política, CNPJ, termos)"
|
|
43
|
+
"/privacidade", // texto legal (política de privacidade): §8, o mesmo item
|
|
44
|
+
"/devolucoes", // texto legal (política de trocas e devoluções): §8, o mesmo item
|
|
45
|
+
"/busca", // campo de busca e resultados do catálogo: §8, "rótulo de formulário e de UI" e "produto do catálogo"
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** `rota` é uma das páginas que, por regra (§8), só tem cabeçalho e rodapé editáveis. Igualdade exata: `/termos/x` não herda. */
|
|
49
|
+
export function soChrome(rota: string): boolean {
|
|
50
|
+
return SO_CHROME.includes(rota);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── OS CONTAINERS QUE CADA ROTA RENDERIZA ───────────────────────────────────────────────────────
|
|
54
|
+
// A regra do modelo: a ORDEM é do container e a COPY é do container. Então "em que outras páginas esta
|
|
55
|
+
// seção aparece?" tem resposta exata: nas rotas que renderizam o mesmo container. O editor só sabia isso
|
|
56
|
+
// DEPOIS de alguém abrir a outra página na prévia (o manifesto é capturado por página): de /produtos, sem
|
|
57
|
+
// /categoria aberta, ele deixava publicar a copy do catálogo sem avisar que ela vale nas categorias. Aqui a
|
|
58
|
+
// loja DECLARA, igual ao SO_CHROME: rota → containers que ela renderiza, FORA o `chrome` (cabeçalho, faixa,
|
|
59
|
+
// rodapé), que é do site inteiro e não se lista. Lista vazia é declaração ("esta página só tem o chrome"),
|
|
60
|
+
// não ausência. Vai para `GET /api/unbox/paginas` como `containers`.
|
|
61
|
+
//
|
|
62
|
+
// O GATE CONFERE (scripts/check-editable.mjs): os containers que o manifesto de cada página mostrou, fora o
|
|
63
|
+
// chrome, têm de ser exatamente estes. Página que renderiza um container que não está aqui, ou que declara um
|
|
64
|
+
// que não aparece, reprova a loja dizendo a rota e o container. É o que impede esta tabela de envelhecer em
|
|
65
|
+
// silêncio quando alguém mexe numa página. Rota editável que não estiver aqui sai SEM o campo, e o gate reprova.
|
|
66
|
+
//
|
|
67
|
+
// ⚠️ ROTA NOVA EM app/(loja)/ ENTRA NESTA TABELA, OU O GATE REPROVA. O nome do container é o que a página
|
|
68
|
+
// (ou o componente que ela renderiza) passa em `<EditableSections container="…">`: tem de ser o MESMO aqui.
|
|
69
|
+
// Página com container próprio declara o nome dele; página que só tem cabeçalho e rodapé declara `[]`.
|
|
70
|
+
export const CONTAINERS_POR_ROTA: Readonly<Record<string, readonly string[]>> = {
|
|
71
|
+
"/": ["home"], // components/home/combos-home.tsx: a home é a dona do container (manda na ordem, oculta e copia)
|
|
72
|
+
"/produtos": ["catalogo"], // components/catalog/catalog-client.tsx: /produtos é a dona do container (manda na ordem)
|
|
73
|
+
"/categoria/[tagSlug]": ["catalogo"], // a categoria reaproveita o catálogo com layout={false}: mesma copy, ordem editada em /produtos
|
|
74
|
+
"/produto/[productSlug]": ["produto"], // components/product/pdp/*: um molde para todos os produtos
|
|
75
|
+
"/oferta": ["oferta"], // app/(loja)/oferta/page.tsx: a landing tem container PRÓPRIO (mesmos componentes da home, copy própria)
|
|
76
|
+
"/busca": [], // só o chrome: campo de busca e resultados do catálogo não viram primitivo (§8)
|
|
77
|
+
"/termos": [], // só o chrome: texto legal (§8)
|
|
78
|
+
"/privacidade": [], // só o chrome: texto legal (§8)
|
|
79
|
+
"/devolucoes": [], // só o chrome: texto legal (§8)
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** os containers que `rota` declara renderizar, fora o chrome; `undefined` = a rota não está na tabela (a loja não declarou; o gate reprova) */
|
|
83
|
+
export function containersDaRota(rota: string): readonly string[] | undefined {
|
|
84
|
+
return CONTAINERS_POR_ROTA[rota];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** a descoberta das páginas falhou: quem chama recusa a lista INTEIRA, com este motivo (nunca responde uma parcial) */
|
|
88
|
+
export class ErroDeVarredura extends Error {}
|
|
89
|
+
|
|
90
|
+
function varrer(dir: string, prefixo: string, saida: string[]) {
|
|
91
|
+
let itens: fs.Dirent[];
|
|
92
|
+
try {
|
|
93
|
+
itens = fs.readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// NÃO engole: uma subpasta ilegível virava lista parcial com 200, a página sumia do seletor e o gate
|
|
96
|
+
// deixava de medi-la sem dizer NÃO RODOU. O caminho vai relativo: a mensagem é pública.
|
|
97
|
+
const codigo = (e as NodeJS.ErrnoException | null)?.code;
|
|
98
|
+
throw new ErroDeVarredura(`não consegui ler ${path.relative(process.cwd(), dir) || dir}${codigo ? ` (${codigo})` : ""}`);
|
|
99
|
+
}
|
|
100
|
+
for (const item of itens) {
|
|
101
|
+
if (item.isDirectory()) {
|
|
102
|
+
const nome = item.name;
|
|
103
|
+
// pasta privada (`_x`), slot de rota paralela (`@x`) e rota interceptada (`(.)x`) não são páginas
|
|
104
|
+
if (nome.startsWith("_") || nome.startsWith("@")) continue;
|
|
105
|
+
const grupo = /^\(.+\)$/.test(nome); // grupo de rotas: não vira segmento de URL
|
|
106
|
+
if (!grupo && nome.includes("(")) continue;
|
|
107
|
+
varrer(path.join(dir, nome), grupo ? prefixo : `${prefixo}/${nome}`, saida);
|
|
108
|
+
} else if (ARQUIVO_DE_PAGINA.test(item.name)) {
|
|
109
|
+
saida.push(prefixo || "/");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** a lista do ambiente (`UNBOX_ROTAS_EDITAVEIS`, ver .env.example): só vale quando a raiz não existe, e cada defeito dela é dito */
|
|
115
|
+
function rotasDoAmbiente(raizRelativa: string): string[] {
|
|
116
|
+
const bruto = process.env.UNBOX_ROTAS_EDITAVEIS;
|
|
117
|
+
if (!bruto) throw new ErroDeVarredura(`${raizRelativa} não existe (fontes ausentes no pacote da função? confira outputFileTracingIncludes em next.config.ts) e UNBOX_ROTAS_EDITAVEIS não está definida`);
|
|
118
|
+
let lista: unknown;
|
|
119
|
+
try {
|
|
120
|
+
lista = JSON.parse(bruto);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
throw new ErroDeVarredura(`UNBOX_ROTAS_EDITAVEIS não é JSON válido (${e instanceof Error ? e.message : String(e)})`);
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(lista)) throw new ErroDeVarredura(`UNBOX_ROTAS_EDITAVEIS precisa ser uma lista JSON de rotas, como ["/","/sobre","/produto/[productSlug]"]`);
|
|
125
|
+
// CADA ENTRADA É CONFERIDA, E UMA INVÁLIDA RECUSA A LISTA INTEIRA. Filtrar a entrada errada
|
|
126
|
+
// ("produto/[productSlug]" sem a barra, um número) e responder o resto com 200 era a página digitada
|
|
127
|
+
// errada sumindo do seletor e do gate sem ninguém saber. O motivo diz QUAL entrada e POR QUÊ, para consertar
|
|
128
|
+
// de uma vez; a lista nunca é filtrada, nem deduplicada antes de ser conferida.
|
|
129
|
+
const defeitos: string[] = [];
|
|
130
|
+
lista.forEach((entrada, i) => {
|
|
131
|
+
const defeito = defeitoDaRota(entrada);
|
|
132
|
+
if (defeito) defeitos.push(`a entrada ${i + 1} (${mostrarEntrada(entrada)}) ${defeito}`);
|
|
133
|
+
});
|
|
134
|
+
if (defeitos.length) {
|
|
135
|
+
const quantas = defeitos.length === 1 ? "uma entrada inválida" : `${defeitos.length} entradas inválidas`;
|
|
136
|
+
throw new ErroDeVarredura(`UNBOX_ROTAS_EDITAVEIS foi recusada inteira porque tem ${quantas}: ${defeitos.join("; ")}. ${FORMA_DA_ROTA}`);
|
|
137
|
+
}
|
|
138
|
+
if (!lista.length) throw new ErroDeVarredura(`UNBOX_ROTAS_EDITAVEIS está vazia: precisa de pelo menos uma rota, como "/"`);
|
|
139
|
+
return [...new Set(lista as string[])].sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const FORMA_DA_ROTA = `Cada entrada precisa ser uma rota como o Next a escreve: um texto que começa com "/", sem espaço e sem barra dupla, como "/" ou "/produto/[productSlug]"`;
|
|
143
|
+
|
|
144
|
+
/** o defeito de UMA entrada de `UNBOX_ROTAS_EDITAVEIS`, ou `null` quando ela é uma rota como o Next a escreve */
|
|
145
|
+
function defeitoDaRota(entrada: unknown): string | null {
|
|
146
|
+
if (typeof entrada !== "string") return `não é texto (é ${tipoDaEntrada(entrada)})`;
|
|
147
|
+
if (!entrada.startsWith("/")) return `não começa com "/"`;
|
|
148
|
+
if (/\s/.test(entrada)) return "tem espaço";
|
|
149
|
+
if (entrada.includes("//")) return `tem barra dupla ("//")`;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tipoDaEntrada(v: unknown): string {
|
|
154
|
+
if (v === null) return "nulo";
|
|
155
|
+
if (Array.isArray(v)) return "uma lista";
|
|
156
|
+
if (typeof v === "number") return "um número";
|
|
157
|
+
if (typeof v === "boolean") return "um booleano";
|
|
158
|
+
if (typeof v === "object") return "um objeto";
|
|
159
|
+
return typeof v;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** a entrada como foi escrita no JSON, curta: a mensagem é pública e chega à tela do editor */
|
|
163
|
+
function mostrarEntrada(entrada: unknown): string {
|
|
164
|
+
const s = JSON.stringify(entrada) ?? String(entrada);
|
|
165
|
+
return s.length > 60 ? `${s.slice(0, 57)}...` : s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* As rotas de `app/(loja)/`, com os segmentos dinâmicos como o Next os escreve
|
|
170
|
+
* (`/produto/[productSlug]`) — é essa forma que `revalidatePath(rota, "page")` entende.
|
|
171
|
+
*
|
|
172
|
+
* A varredura acontece no servidor, em tempo de execução. Em produção os fontes só existem dentro
|
|
173
|
+
* da função porque `next.config.ts` os inclui (`outputFileTracingIncludes`); se algum dia isso
|
|
174
|
+
* deixar de valer (a raiz não existe), `UNBOX_ROTAS_EDITAVEIS` (um JSON de strings) responde no
|
|
175
|
+
* lugar. Qualquer OUTRA falha (subpasta ilegível, erro de leitura, raiz sem nenhum page.tsx) estoura
|
|
176
|
+
* `ErroDeVarredura` com o motivo, e quem chama recusa a descoberta inteira: uma lista parcial com 200
|
|
177
|
+
* é a página sumindo do seletor e do gate em silêncio, que é pior do que um 500 dizendo por quê.
|
|
178
|
+
*/
|
|
179
|
+
export function rotasDaLoja(): string[] {
|
|
180
|
+
const raizRelativa = path.join("app", "(loja)");
|
|
181
|
+
const raiz = path.join(process.cwd(), raizRelativa);
|
|
182
|
+
if (!fs.existsSync(raiz)) return rotasDoAmbiente(raizRelativa);
|
|
183
|
+
const achadas: string[] = [];
|
|
184
|
+
varrer(raiz, "", achadas); // propaga a falha: lista parcial não é lista
|
|
185
|
+
if (!achadas.length) throw new ErroDeVarredura(`a varredura de ${raizRelativa} não achou nenhum page.tsx`);
|
|
186
|
+
return [...new Set(achadas)].sort();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** As rotas que o lojista pode editar: as de `app/(loja)/` menos as que o robots bloqueia. Estoura `ErroDeVarredura` quando a descoberta falha. */
|
|
190
|
+
export function rotasEditaveis(): string[] {
|
|
191
|
+
return rotasDaLoja().filter((r) => !bloqueadaPeloRobots(r));
|
|
192
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Sanitização de HTML rico vindo do backend (product.description / additionalInformation)
|
|
2
|
+
// antes de injetar via dangerouslySetInnerHTML — proteção contra XSS (doc 02/08).
|
|
3
|
+
import "server-only";
|
|
4
|
+
import sanitizeHtml from "sanitize-html";
|
|
5
|
+
|
|
6
|
+
const OPTIONS: sanitizeHtml.IOptions = {
|
|
7
|
+
allowedTags: [
|
|
8
|
+
"p", "br", "b", "strong", "i", "em", "u", "s", "ul", "ol", "li",
|
|
9
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "a", "span", "div",
|
|
10
|
+
"table", "thead", "tbody", "tr", "th", "td", "img", "hr",
|
|
11
|
+
],
|
|
12
|
+
allowedAttributes: {
|
|
13
|
+
a: ["href", "title", "target", "rel"],
|
|
14
|
+
img: ["src", "alt", "width", "height"],
|
|
15
|
+
span: ["style"],
|
|
16
|
+
div: ["style"],
|
|
17
|
+
"*": ["class"],
|
|
18
|
+
},
|
|
19
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
20
|
+
// força rel seguro em links externos
|
|
21
|
+
transformTags: {
|
|
22
|
+
a: sanitizeHtml.simpleTransform("a", { rel: "nofollow noopener noreferrer" }),
|
|
23
|
+
},
|
|
24
|
+
allowedStyles: {
|
|
25
|
+
"*": {
|
|
26
|
+
"text-align": [/^left$|^right$|^center$|^justify$/],
|
|
27
|
+
"font-weight": [/^bold$|^\d{3}$/],
|
|
28
|
+
color: [/^#[0-9a-fA-F]{3,6}$/],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function sanitize(html: string | null | undefined): string {
|
|
34
|
+
if (!html) return "";
|
|
35
|
+
return sanitizeHtml(html, OPTIONS);
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Schemas de validação (zod) usados no BFF. A fonte da verdade de validação é o servidor.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const digits = (s: string) => s.replace(/\D/g, "");
|
|
5
|
+
|
|
6
|
+
/** DDDs em uso no Brasil (Anatel). A Unbox valida o DDD e recusa o pedido: o comprimento
|
|
7
|
+
* sozinho não basta (testado contra produção, DDD "01" passa aqui e é recusado lá). */
|
|
8
|
+
const DDD_VALIDOS = new Set([
|
|
9
|
+
11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 24, 27, 28, 31, 32, 33, 34, 35, 37, 38,
|
|
10
|
+
41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 53, 54, 55, 61, 62, 63, 64, 65, 66, 67, 68, 69,
|
|
11
|
+
71, 73, 74, 75, 77, 79, 81, 82, 83, 84, 85, 86, 87, 88, 89, 91, 92, 93, 94, 95, 96, 97, 98, 99,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Normaliza o telefone: só dígitos e SEM o zero à esquerda do DDD. Muita gente escreve
|
|
15
|
+
* "011 3333-4444"; com o zero vira 12 dígitos e o DDD chega como "01", que a Unbox recusa.
|
|
16
|
+
* Tirar o zero é entender o que a pessoa quis dizer: "011 99360-3233" vira 11993603233 e
|
|
17
|
+
* "019 8765-4321" vira DDD 19. */
|
|
18
|
+
const telefone = (s: string) => digits(s).replace(/^0+/, "");
|
|
19
|
+
|
|
20
|
+
export const addressSchema = z.object({
|
|
21
|
+
fullName: z.string().min(3, "Informe o nome completo."),
|
|
22
|
+
// CPF/CNPJ: 11 ou 14 dígitos (taxPayerId é necessário na prática — doc 05)
|
|
23
|
+
taxPayerId: z.string().transform(digits).refine((v) => v.length === 11 || v.length === 14, "CPF/CNPJ inválido."),
|
|
24
|
+
postal: z.string().transform(digits).refine((v) => v.length === 8, "CEP inválido."),
|
|
25
|
+
address1: z.string().min(2, "Informe o logradouro."),
|
|
26
|
+
number: z.string().min(1, "Informe o número."),
|
|
27
|
+
neighborhood: z.string().min(1, "Informe o bairro."),
|
|
28
|
+
city: z.string().min(2, "Informe a cidade."),
|
|
29
|
+
region: z.string().length(2, "UF deve ter 2 letras."),
|
|
30
|
+
phone: z.string().transform(telefone)
|
|
31
|
+
.refine((v) => v.length >= 10 && v.length <= 11, "Telefone inválido: informe DDD e número.")
|
|
32
|
+
.refine((v) => DDD_VALIDOS.has(Number(v.slice(0, 2))), "DDD inválido. Confira o número com DDD."),
|
|
33
|
+
country: z.string().default("BR"),
|
|
34
|
+
address2: z.string().optional(),
|
|
35
|
+
cityCode: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
export type AddressForm = z.infer<typeof addressSchema>;
|
|
38
|
+
|
|
39
|
+
export const cardSchema = z.object({
|
|
40
|
+
cardHolder: z.string().min(3, "Nome impresso no cartão."),
|
|
41
|
+
cardNumber: z.string().transform(digits).refine((v) => v.length >= 13 && v.length <= 19, "Número do cartão inválido."),
|
|
42
|
+
expirationMonth: z.string().transform(digits).refine((v) => +v >= 1 && +v <= 12, "Mês inválido."),
|
|
43
|
+
expirationYear: z.string().transform(digits).refine((v) => v.length === 2 || v.length === 4, "Ano inválido."),
|
|
44
|
+
securityCode: z.string().transform(digits).refine((v) => v.length >= 3 && v.length <= 4, "CVV inválido."),
|
|
45
|
+
installments: z.number().int().min(1).max(12).optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const emailSchema = z.string().email("E-mail inválido.");
|
|
49
|
+
|
|
50
|
+
export const otpSchema = z.object({
|
|
51
|
+
email: emailSchema,
|
|
52
|
+
otp: z.string().min(4, "Código inválido."),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// device (antifraude Unbox) coletado no navegador — 8 campos, type BROWSER.
|
|
56
|
+
// timezoneOffset em MINUTOS (getTimezoneOffset(), BRT → 180) — ver ponto aberto em unbox/types.ts.
|
|
57
|
+
export const deviceSchema = z.object({
|
|
58
|
+
type: z.literal("BROWSER"),
|
|
59
|
+
colorDepth: z.number(),
|
|
60
|
+
javaEnabled: z.boolean(),
|
|
61
|
+
userAgent: z.string().max(1024),
|
|
62
|
+
language: z.string().max(64),
|
|
63
|
+
screenHeight: z.number(),
|
|
64
|
+
screenWidth: z.number(),
|
|
65
|
+
timezoneOffset: z.number(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const placeOrderSchema = z.object({
|
|
69
|
+
email: emailSchema,
|
|
70
|
+
address: addressSchema,
|
|
71
|
+
payment: z.union([
|
|
72
|
+
z.object({ type: z.literal("pix") }),
|
|
73
|
+
z.object({ type: z.literal("card"), card: cardSchema }),
|
|
74
|
+
]),
|
|
75
|
+
recurringItemsFrequencyId: z.string().optional(),
|
|
76
|
+
// opcional: se o browser não mandar, o servidor cai no fallback { type: "API" }.
|
|
77
|
+
device: deviceSchema.optional(),
|
|
78
|
+
});
|