@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,604 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import Image from "next/image";
|
|
7
|
+
import {
|
|
8
|
+
Truck, MapPin, ShieldCheck, ArrowsClockwise, Package, Tag, Lightning,
|
|
9
|
+
ProhibitInset, GrainsSlash, Leaf, SealCheck, Star, StarHalf, ShoppingCartSimple, GlobeHemisphereWest,
|
|
10
|
+
} from "@phosphor-icons/react/dist/ssr";
|
|
11
|
+
import { useCart } from "@/components/cart/cart-provider";
|
|
12
|
+
import { trackViewItem } from "@/lib/analytics";
|
|
13
|
+
import { formatBRL, maskCep, onlyDigits } from "@/lib/format";
|
|
14
|
+
import { PaymentChips } from "@/components/product/pdp/payment-chips";
|
|
15
|
+
import { FREE_SHIPPING_THRESHOLD, PIX_DISCOUNT_PCT } from "@/lib/store-config";
|
|
16
|
+
import { goToCheckout } from "@/lib/checkout-nav";
|
|
17
|
+
// EDITOR: módulo de cliente ("use client"): aqui o namespace `Editable.*` é a forma de uso (README §4).
|
|
18
|
+
import { Editable } from "@/lib/editable";
|
|
19
|
+
|
|
20
|
+
export interface PdpVariant {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
price: number | null;
|
|
24
|
+
displayPrice: string;
|
|
25
|
+
oldPrice: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Tamanho da MESMA família (outro produto Unbox). Selecionar = navegar para o slug. */
|
|
29
|
+
export interface PdpSizeOption {
|
|
30
|
+
slug: string;
|
|
31
|
+
label: string; // tamanho, ex.: "250 g", "500 ml", "M"
|
|
32
|
+
displayPrice: string;
|
|
33
|
+
isSoldOut: boolean;
|
|
34
|
+
isCurrent: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Produto RELACIONADO (outra família da mesma linha) p/ navegar — ver RELATED_GROUPS. */
|
|
38
|
+
export interface PdpRelatedOption {
|
|
39
|
+
slug: string;
|
|
40
|
+
label: string; // nome do produto
|
|
41
|
+
sublabel?: string; // tamanho, quando houver
|
|
42
|
+
displayPrice: string;
|
|
43
|
+
imageUrl?: string | null;
|
|
44
|
+
isSoldOut: boolean;
|
|
45
|
+
isCurrent: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PdpSubscription {
|
|
49
|
+
percentOff: number | null;
|
|
50
|
+
frequencies: { id: string; title: string; note?: string }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const FREE_SHIP_THRESHOLD = FREE_SHIPPING_THRESHOLD;
|
|
54
|
+
const PIX_FACTOR = 1 - PIX_DISCOUNT_PCT / 100;
|
|
55
|
+
|
|
56
|
+
/** Selo (benefício) derivado dos atributos REAIS do enriquecimento — só os positivos, no máx. 4. */
|
|
57
|
+
export interface PdpBenefit { icon: string; label: string; flag?: string }
|
|
58
|
+
|
|
59
|
+
const BENEFIT_ICONS: Record<string, React.ComponentType<any>> = {
|
|
60
|
+
"no-msg": ProhibitInset,
|
|
61
|
+
"no-gluten": GrainsSlash,
|
|
62
|
+
vegan: Leaf,
|
|
63
|
+
imported: GlobeHemisphereWest,
|
|
64
|
+
};
|
|
65
|
+
// NÃO existe selo default. A foundation mostrava "Sem MSG · Sem glúten · Ingredientes naturais"
|
|
66
|
+
// em todo produto sem enriquecimento — afirmação de alimento aparecendo em loja de cosmético,
|
|
67
|
+
// roupa ou eletrônico, e inventada mesmo em loja de alimento. Sem dado, sem selo.
|
|
68
|
+
|
|
69
|
+
// EDITOR: a PDP é um MOLDE (um valor para todos os produtos). Aqui dentro, nome, preço, variantes,
|
|
70
|
+
// tamanhos, assinatura, quantidade, frete e os botões de compra são DADO do catálogo ou MECÂNICA da
|
|
71
|
+
// loja, e ficam fora do editor (`data-editor-ignore`, README do editor §8). O que é copy da marca
|
|
72
|
+
// vira primitivo: o chapéu acima do nome, os selos de confiança abaixo do botão e o "Pague com:".
|
|
73
|
+
// Os selos são uma lista editável com id por PAPEL (não por posição), como os da faixa de confiança.
|
|
74
|
+
const SELOS = [
|
|
75
|
+
{ id: "selo-seguranca", label: "Selo de compra segura", icon: ShieldCheck, texto: "Compra 100% segura" },
|
|
76
|
+
{ id: "selo-troca", label: "Selo de troca", icon: ArrowsClockwise, texto: "Troca garantida" },
|
|
77
|
+
{ id: "selo-entrega", label: "Selo de entrega", icon: Package, texto: "Entrega para todo o Brasil" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function BuyBox({
|
|
81
|
+
productId,
|
|
82
|
+
title,
|
|
83
|
+
shortDescription,
|
|
84
|
+
imageUrl,
|
|
85
|
+
variants,
|
|
86
|
+
benefits,
|
|
87
|
+
sizeOptions = [],
|
|
88
|
+
relatedTitle,
|
|
89
|
+
relatedOptions = [],
|
|
90
|
+
subscription,
|
|
91
|
+
minQty,
|
|
92
|
+
maxQty,
|
|
93
|
+
isSoldOut,
|
|
94
|
+
ratingAverage,
|
|
95
|
+
ratingCount,
|
|
96
|
+
}: {
|
|
97
|
+
productId: string;
|
|
98
|
+
title: string;
|
|
99
|
+
shortDescription?: string | null;
|
|
100
|
+
imageUrl?: string;
|
|
101
|
+
variants: PdpVariant[];
|
|
102
|
+
benefits?: PdpBenefit[];
|
|
103
|
+
sizeOptions?: PdpSizeOption[];
|
|
104
|
+
relatedTitle?: string;
|
|
105
|
+
relatedOptions?: PdpRelatedOption[];
|
|
106
|
+
subscription: PdpSubscription | null;
|
|
107
|
+
minQty: number;
|
|
108
|
+
maxQty: number | null;
|
|
109
|
+
isSoldOut: boolean;
|
|
110
|
+
/** nota média REAL (reviews do enriquecimento). Sem ela o bloco de avaliação não aparece. */
|
|
111
|
+
ratingAverage?: number;
|
|
112
|
+
ratingCount?: number;
|
|
113
|
+
}) {
|
|
114
|
+
const router = useRouter();
|
|
115
|
+
const { add, setOpen, open: cartOpen } = useCart();
|
|
116
|
+
|
|
117
|
+
const sellable = variants.filter((v) => v.price != null);
|
|
118
|
+
// Cada produto tem uma única variante ("Quantidade: 1 un"); o tamanho é outro produto (sizeOptions).
|
|
119
|
+
const [variantId] = React.useState(sellable[0]?.id ?? variants[0]?.id);
|
|
120
|
+
const [qty, setQty] = React.useState(Math.max(1, minQty || 1));
|
|
121
|
+
const [sub, setSub] = React.useState(false);
|
|
122
|
+
const [freqId, setFreqId] = React.useState(subscription?.frequencies[0]?.id);
|
|
123
|
+
const [busy, setBusy] = React.useState(false);
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
// view_item / ViewContent — uma vez por produto exibido.
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
trackViewItem({ id: productId, name: title, variant: sellable[0]?.label, price: sellable[0]?.price ?? undefined });
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, [productId]);
|
|
131
|
+
|
|
132
|
+
const variant = variants.find((v) => v.id === variantId) ?? variants[0];
|
|
133
|
+
// benefits vindo do enriquecimento (positivos); se ausente, usa o fallback genérico. Máx. 4.
|
|
134
|
+
const benefitList = benefits ?? [];
|
|
135
|
+
// Só existe desconto de assinatura se a política de preço do backend disser quanto. Sem
|
|
136
|
+
// isso, pct = null: assinar custa o preço normal e nenhum "-N%" aparece.
|
|
137
|
+
const pct = subscription?.percentOff ?? null;
|
|
138
|
+
const canBuy = !isSoldOut && variant?.price != null;
|
|
139
|
+
|
|
140
|
+
const base = variant?.price ?? 0;
|
|
141
|
+
// Preço "DE" só quando existe compareAtPrice REAL do backend — sem fabricar desconto.
|
|
142
|
+
const old = variant?.oldPrice ?? null;
|
|
143
|
+
const eff = sub && pct != null ? base * (1 - pct / 100) : base;
|
|
144
|
+
const pix = Math.floor(eff * PIX_FACTOR * 100) / 100;
|
|
145
|
+
const hasRealDiscount = old != null && old > pix;
|
|
146
|
+
const savings = hasRealDiscount ? old - pix : 0;
|
|
147
|
+
const savingsPct = hasRealDiscount ? Math.round((1 - pix / old) * 100) : 0;
|
|
148
|
+
const lineTotal = eff * qty;
|
|
149
|
+
// Régua de frete grátis só existe quando há regra real configurada (threshold null = some tudo).
|
|
150
|
+
const remaining = FREE_SHIP_THRESHOLD != null ? Math.max(0, FREE_SHIP_THRESHOLD - lineTotal) : 0;
|
|
151
|
+
const shipPct = FREE_SHIP_THRESHOLD != null ? Math.min(100, Math.round((lineTotal / FREE_SHIP_THRESHOLD) * 100)) : 0;
|
|
152
|
+
|
|
153
|
+
async function handleAdd(thenCheckout = false) {
|
|
154
|
+
if (!canBuy || !variant) return false;
|
|
155
|
+
setBusy(true);
|
|
156
|
+
const ok = await add({
|
|
157
|
+
productId,
|
|
158
|
+
variantId: variant.id,
|
|
159
|
+
price: variant.price ?? 0,
|
|
160
|
+
quantity: qty,
|
|
161
|
+
thumbnail: imageUrl,
|
|
162
|
+
title,
|
|
163
|
+
isRecurring: sub && !!subscription,
|
|
164
|
+
recurringItemsFrequencyId: sub && subscription ? freqId : undefined,
|
|
165
|
+
});
|
|
166
|
+
setBusy(false);
|
|
167
|
+
if (ok && thenCheckout) {
|
|
168
|
+
setOpen(false);
|
|
169
|
+
void goToCheckout(router);
|
|
170
|
+
}
|
|
171
|
+
return ok;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div>
|
|
176
|
+
<Editable.Text as="div" path="chapeu" fallback="PRODUTO" label="Chapéu acima do nome do produto" className="text-xs font-extrabold tracking-[1.5px] text-[var(--store-primary,#18181B)]" />
|
|
177
|
+
{/* nome do produto: dado do catálogo */}
|
|
178
|
+
<h1 data-editor-ignore className="font-display mt-2 mb-2.5 text-[27px] font-extrabold leading-[1.12] text-[var(--store-ink)] sm:text-[34px]">
|
|
179
|
+
{title}
|
|
180
|
+
</h1>
|
|
181
|
+
|
|
182
|
+
{/* rating — SÓ com avaliação real. A foundation não exibe nota, contagem nem contador de
|
|
183
|
+
vendas sem dado: os valores fixos que existiam aqui foram ao ar em loja real e saíram. */}
|
|
184
|
+
{ratingAverage != null && ratingCount != null && ratingCount > 0 && (
|
|
185
|
+
<div className="mb-4 flex flex-wrap items-center gap-3.5" data-editor-ignore>
|
|
186
|
+
<div className="flex items-center gap-1.5">
|
|
187
|
+
<span className="flex items-center text-[17px] text-[var(--store-cta-dark)]" aria-label={`${ratingAverage.toFixed(1)} de 5`}>
|
|
188
|
+
{[1, 2, 3, 4, 5].map((i) =>
|
|
189
|
+
ratingAverage >= i - 0.25 ? <Star key={i} weight="fill" /> : ratingAverage >= i - 0.75 ? <StarHalf key={i} weight="fill" /> : <Star key={i} weight="regular" />,
|
|
190
|
+
)}
|
|
191
|
+
</span>
|
|
192
|
+
<b className="text-[15px]">{ratingAverage.toFixed(1).replace(".", ",")}</b>
|
|
193
|
+
<a href="#avaliacoes" className="text-sm font-semibold text-[var(--store-primary,#18181B)] no-underline">({ratingCount.toLocaleString("pt-BR")} {ratingCount === 1 ? "avaliação" : "avaliações"})</a>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{shortDescription && (
|
|
199
|
+
<p className="mb-4 text-[15px] leading-[1.6] text-[var(--store-ink-2)]" data-editor-ignore>{shortDescription}</p>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* benefit icons — atributos POSITIVOS do enriquecimento (máx. 4) */}
|
|
203
|
+
{benefitList.length > 0 && (
|
|
204
|
+
<div className="mb-[22px] grid gap-2" style={{ gridTemplateColumns: `repeat(${benefitList.length}, minmax(0,1fr))` }} data-editor-ignore>
|
|
205
|
+
{benefitList.map((b, i) => {
|
|
206
|
+
const Icon = BENEFIT_ICONS[b.icon] ?? SealCheck;
|
|
207
|
+
return (
|
|
208
|
+
<div key={i} className="flex flex-col items-center gap-2 text-center">
|
|
209
|
+
<span className="relative inline-flex">
|
|
210
|
+
<Icon className="text-[34px] text-[var(--store-primary,#18181B)]" />
|
|
211
|
+
{b.flag && (
|
|
212
|
+
<span className="absolute -bottom-1 -right-1.5 text-[16px] not-italic leading-none drop-shadow-sm">{b.flag}</span>
|
|
213
|
+
)}
|
|
214
|
+
</span>
|
|
215
|
+
<span className="text-[13px] font-semibold text-[var(--store-ink-2)]">{b.label}</span>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* price: dado e mecânica, fora do editor */}
|
|
223
|
+
{savings > 0 && (
|
|
224
|
+
<div data-editor-ignore className="mb-2.5 inline-flex items-center gap-1.5 rounded-lg bg-[var(--store-sale-soft)] px-3 py-1.5 text-[13px] font-extrabold text-[var(--store-sale)]">
|
|
225
|
+
<Tag weight="fill" /> Economize {formatBRL(savings)} (-{savingsPct}%)
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
<div className="flex flex-wrap items-end gap-3" data-editor-ignore>
|
|
229
|
+
<span className="font-display text-[33px] font-extrabold leading-none text-[var(--store-primary,#18181B)] sm:text-[40px]">{formatBRL(pix)}</span>
|
|
230
|
+
{hasRealDiscount && <span className="mb-1.5 text-[18px] text-[var(--store-faint)] line-through">{formatBRL(old)}</span>}
|
|
231
|
+
{PIX_DISCOUNT_PCT > 0 && (
|
|
232
|
+
<span className="mb-1.5 rounded-md bg-[var(--store-primary-soft,#F1F1F3)] px-2.5 py-1 text-[13px] font-extrabold text-[var(--store-primary,#18181B)]">no Pix · {PIX_DISCOUNT_PCT}% OFF</span>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
<div className="mt-2 text-sm text-[var(--store-ink-2)]" data-editor-ignore>
|
|
236
|
+
<b>3x de {formatBRL(eff / 3)}</b> sem juros ou <b className="text-base text-[var(--store-ink)]">{formatBRL(eff)}</b> no crédito.
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Não existe "apenas N unidades": era um contador que descia sozinho enquanto a pessoa lia a
|
|
240
|
+
página, sem ler estoque nenhum — dark pattern e afirmação falsa. Se o catálogo expuser
|
|
241
|
+
quantidade real um dia, é ela que entra aqui, e só ela. */}
|
|
242
|
+
|
|
243
|
+
{/* size selector — produtos da MESMA família (cada tamanho é uma página própria) */}
|
|
244
|
+
{sizeOptions.length > 1 && (
|
|
245
|
+
<div className="mt-6" data-editor-ignore>
|
|
246
|
+
<div className="mb-2.5 text-sm font-bold text-[var(--store-ink-2)]">Escolha o tamanho</div>
|
|
247
|
+
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
|
248
|
+
{sizeOptions.map((o) => {
|
|
249
|
+
const sel = o.isCurrent;
|
|
250
|
+
const cls =
|
|
251
|
+
"rounded-xl border-2 px-1.5 py-4 text-center text-[var(--store-ink)] no-underline transition-colors";
|
|
252
|
+
const style = {
|
|
253
|
+
background: sel ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)",
|
|
254
|
+
borderColor: sel ? "var(--store-primary,#18181B)" : "var(--store-line-2)",
|
|
255
|
+
...(o.isSoldOut && !sel ? { opacity: 0.5 } : {}),
|
|
256
|
+
} as React.CSSProperties;
|
|
257
|
+
const inner = (
|
|
258
|
+
<>
|
|
259
|
+
<div className="text-[15px] font-bold">{o.label}</div>
|
|
260
|
+
<div className="mt-1 text-sm font-bold" style={{ color: sel ? "var(--store-primary,#18181B)" : "var(--store-ink-2)" }}>
|
|
261
|
+
{o.isSoldOut ? "Esgotado" : o.displayPrice}
|
|
262
|
+
</div>
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
return sel ? (
|
|
266
|
+
<div key={o.slug} aria-current="true" className={`${cls} cursor-default`} style={style}>
|
|
267
|
+
{inner}
|
|
268
|
+
</div>
|
|
269
|
+
) : (
|
|
270
|
+
<Link key={o.slug} href={`/produto/${o.slug}`} prefetch={false} scroll={false} className={`${cls} cursor-pointer block`} style={style}>
|
|
271
|
+
{inner}
|
|
272
|
+
</Link>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{/* related selector — produtos DIFERENTES da mesma linha (RELATED_GROUPS), p/ navegar entre eles */}
|
|
280
|
+
{relatedOptions.length > 1 && (
|
|
281
|
+
<div className="mt-6" data-editor-ignore>
|
|
282
|
+
<div className="mb-2.5 text-sm font-bold text-[var(--store-ink-2)]">{relatedTitle ?? "Veja também"}</div>
|
|
283
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
284
|
+
{relatedOptions.map((o) => {
|
|
285
|
+
const sel = o.isCurrent;
|
|
286
|
+
const cls = "flex items-center gap-3 rounded-xl border-2 p-2.5 text-left text-[var(--store-ink)] no-underline transition-colors";
|
|
287
|
+
const style = {
|
|
288
|
+
background: sel ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)",
|
|
289
|
+
borderColor: sel ? "var(--store-primary,#18181B)" : "var(--store-line-2)",
|
|
290
|
+
...(o.isSoldOut && !sel ? { opacity: 0.5 } : {}),
|
|
291
|
+
} as React.CSSProperties;
|
|
292
|
+
const inner = (
|
|
293
|
+
<>
|
|
294
|
+
<span className="relative flex h-12 w-12 shrink-0 items-center justify-center">
|
|
295
|
+
{o.imageUrl && <Image src={o.imageUrl} alt={o.label} fill sizes="48px" className="object-contain" />}
|
|
296
|
+
</span>
|
|
297
|
+
<span className="min-w-0 flex-1">
|
|
298
|
+
<span className="block text-[13px] font-bold leading-[1.2] line-clamp-2">{o.label}</span>
|
|
299
|
+
<span className="mt-0.5 block text-[12.5px] font-bold" style={{ color: sel ? "var(--store-primary,#18181B)" : "var(--store-ink-2)" }}>
|
|
300
|
+
{o.isSoldOut ? "Esgotado" : o.displayPrice}{o.sublabel ? ` · ${o.sublabel}` : ""}
|
|
301
|
+
</span>
|
|
302
|
+
</span>
|
|
303
|
+
</>
|
|
304
|
+
);
|
|
305
|
+
return sel ? (
|
|
306
|
+
<div key={o.slug} aria-current="true" className={`${cls} cursor-default`} style={style}>{inner}</div>
|
|
307
|
+
) : (
|
|
308
|
+
<Link key={o.slug} href={`/produto/${o.slug}`} prefetch={false} scroll={false} className={`${cls} cursor-pointer`} style={style}>{inner}</Link>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{/* subscription */}
|
|
316
|
+
{subscription && (
|
|
317
|
+
<div className="mt-[22px] flex flex-col gap-2.5" data-editor-ignore>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setSub(false)}
|
|
321
|
+
className="flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl border-[1.5px] px-4 py-3.5 text-left"
|
|
322
|
+
style={{ background: !sub ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: !sub ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}
|
|
323
|
+
>
|
|
324
|
+
<span className="flex items-center gap-2.5">
|
|
325
|
+
<Radio on={!sub} />
|
|
326
|
+
<span className="text-sm font-bold">Compra única</span>
|
|
327
|
+
</span>
|
|
328
|
+
<span className="text-[15px] font-extrabold">{formatBRL(base)}</span>
|
|
329
|
+
</button>
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
onClick={() => setSub(true)}
|
|
333
|
+
className="flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl border-[1.5px] px-4 py-3.5 text-left"
|
|
334
|
+
style={{ background: sub ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: sub ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}
|
|
335
|
+
>
|
|
336
|
+
<span className="flex items-center gap-2.5">
|
|
337
|
+
<Radio on={sub} />
|
|
338
|
+
<span className="flex flex-col items-start">
|
|
339
|
+
<span className="flex items-center gap-[7px] text-sm font-bold">
|
|
340
|
+
Assinar e economizar
|
|
341
|
+
{pct != null && <span className="rounded-full bg-[var(--store-primary,#18181B)] px-[7px] py-0.5 text-[10px] font-extrabold text-white">-{pct}%</span>}
|
|
342
|
+
</span>
|
|
343
|
+
<span className="text-[11.5px] text-[var(--store-muted)]">Entrega recorrente · cancele quando quiser</span>
|
|
344
|
+
</span>
|
|
345
|
+
</span>
|
|
346
|
+
<span className="text-[15px] font-extrabold text-[var(--store-primary,#18181B)]">{formatBRL(pct != null ? base * (1 - pct / 100) : base)}</span>
|
|
347
|
+
</button>
|
|
348
|
+
|
|
349
|
+
{sub && subscription.frequencies.length > 0 && (
|
|
350
|
+
<div className="mt-0.5 rounded-xl border border-[var(--store-line)] bg-[var(--store-bg)] p-3.5">
|
|
351
|
+
<div className="mb-2.5 text-[12.5px] font-bold text-[var(--store-ink-2)]">Frequência de entrega</div>
|
|
352
|
+
{subscription.frequencies.length > 1 && (
|
|
353
|
+
<p className="mb-2.5 text-[11.5px] leading-[1.4] text-[var(--store-muted)]">A frequência vale para todos os itens assinados deste pedido.</p>
|
|
354
|
+
)}
|
|
355
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
356
|
+
{subscription.frequencies.map((f) => {
|
|
357
|
+
const on = f.id === freqId;
|
|
358
|
+
return (
|
|
359
|
+
<button
|
|
360
|
+
key={f.id}
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => setFreqId(f.id)}
|
|
363
|
+
className="cursor-pointer rounded-md border-[1.5px] px-3 py-3 text-left text-[var(--store-ink)]"
|
|
364
|
+
style={{ background: on ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}
|
|
365
|
+
>
|
|
366
|
+
<span className="flex items-center gap-2">
|
|
367
|
+
<Radio on={on} />
|
|
368
|
+
<span className="text-[13.5px] font-bold">{f.title}</span>
|
|
369
|
+
</span>
|
|
370
|
+
{f.note && <span className="mt-[5px] block text-[11.5px] text-[var(--store-muted)]">{f.note}</span>}
|
|
371
|
+
</button>
|
|
372
|
+
);
|
|
373
|
+
})}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{/* qty + CTA: mecânica de compra ("Adicionar ao carrinho" não é copy, README §8) */}
|
|
381
|
+
<div className="mt-5 flex items-stretch gap-3" data-editor-ignore>
|
|
382
|
+
<div className="flex items-center overflow-hidden rounded-xl border-[1.5px] border-[var(--store-line-2)] bg-white">
|
|
383
|
+
<button type="button" onClick={() => setQty((q) => Math.max(minQty || 1, q - 1))} className="h-[58px] w-[46px] cursor-pointer border-none bg-transparent text-xl text-[var(--store-ink-2)]">−</button>
|
|
384
|
+
<span className="w-[42px] text-center text-base font-extrabold">{qty}</span>
|
|
385
|
+
<button type="button" onClick={() => setQty((q) => (maxQty ? Math.min(maxQty, q + 1) : q + 1))} className="h-[58px] w-[46px] cursor-pointer border-none bg-transparent text-xl text-[var(--store-ink-2)]">+</button>
|
|
386
|
+
</div>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
onClick={() => handleAdd(false)}
|
|
390
|
+
disabled={!canBuy || busy}
|
|
391
|
+
className="font-display flex h-[58px] flex-1 cursor-pointer items-center justify-center rounded-xl bg-[var(--store-primary,#18181B)] text-[15px] font-bold tracking-[0.5px] text-white shadow-[var(--store-shadow-cta)] transition-colors hover:bg-[var(--store-primary-dark,#09090B)] disabled:opacity-60"
|
|
392
|
+
>
|
|
393
|
+
{isSoldOut ? "ESGOTADO" : sub ? "ASSINAR" : "ADICIONAR AO CARRINHO"}
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={() => handleAdd(true)}
|
|
399
|
+
disabled={!canBuy || busy}
|
|
400
|
+
data-editor-ignore
|
|
401
|
+
className="font-display mt-[11px] flex h-[54px] w-full cursor-pointer items-center justify-center rounded-xl bg-[var(--store-cta,#D97706)] text-[15px] font-extrabold tracking-[0.5px] text-[var(--store-cta-fg,#1C1207)] transition-colors hover:bg-[var(--store-cta-dark,#B45309)] disabled:opacity-60"
|
|
402
|
+
>
|
|
403
|
+
COMPRAR AGORA
|
|
404
|
+
</button>
|
|
405
|
+
|
|
406
|
+
{/* free shipping progress — só renderiza com regra real de frete grátis configurada */}
|
|
407
|
+
{FREE_SHIP_THRESHOLD != null && (
|
|
408
|
+
<div className="mt-4 rounded-xl border border-[var(--store-line)] bg-white px-[15px] py-[13px]" data-editor-ignore>
|
|
409
|
+
<div className="flex items-center gap-2 text-[13px] font-semibold text-[var(--store-ink-2)]">
|
|
410
|
+
<Truck weight="bold" className="text-[17px] text-[var(--store-primary,#18181B)]" />
|
|
411
|
+
{remaining > 0 ? (
|
|
412
|
+
<span>Faltam <b className="text-[var(--store-sale)]">{formatBRL(remaining)}</b> para você ganhar <b>Frete Grátis</b></span>
|
|
413
|
+
) : (
|
|
414
|
+
<span>Você ganhou <b className="text-[var(--store-primary,#18181B)]">Frete Grátis</b>!</span>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
<div className="mt-[9px] h-[7px] overflow-hidden rounded-full bg-[var(--store-surface-2)]">
|
|
418
|
+
<div className="h-full rounded-full bg-gradient-to-r from-[var(--store-primary,#18181B)] to-[var(--store-primary)]" style={{ width: `${shipPct}%` }} />
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* shipping calculator */}
|
|
424
|
+
<ShippingCalc productId={productId} variantId={variant?.id ?? ""} price={variant?.price ?? null} />
|
|
425
|
+
|
|
426
|
+
{/* mini trust: selos de confiança são copy da marca, lista editável com id por papel */}
|
|
427
|
+
<div className="mt-4 flex flex-wrap gap-[18px] text-[12.5px] font-semibold text-[var(--store-ink-2)]">
|
|
428
|
+
<Editable.Sections nested>
|
|
429
|
+
{SELOS.map((s) => (
|
|
430
|
+
<Editable.Section key={s.id} item id={s.id} label={s.label}>
|
|
431
|
+
<span className="flex items-center gap-1.5">
|
|
432
|
+
<Editable.Icon path="icone" label="Ícone do selo" size={17} className="text-[17px] text-[var(--store-primary,#18181B)]">
|
|
433
|
+
<s.icon />
|
|
434
|
+
</Editable.Icon>
|
|
435
|
+
<Editable.Text path="texto" fallback={s.texto} label="Texto do selo" />
|
|
436
|
+
</span>
|
|
437
|
+
</Editable.Section>
|
|
438
|
+
))}
|
|
439
|
+
</Editable.Sections>
|
|
440
|
+
</div>
|
|
441
|
+
<div className="mt-3.5 flex flex-wrap items-center gap-2 text-xs text-[var(--store-muted)]">
|
|
442
|
+
<Editable.Text path="pagamento" fallback="Pague com:" label="Texto antes das bandeiras" className="font-semibold" />
|
|
443
|
+
{/* bandeiras: selo de terceiro, fora do editor (o próprio componente se marca) */}
|
|
444
|
+
<PaymentChips />
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
{/* sticky add-to-cart bar — escondida quando o carrinho lateral está aberto */}
|
|
448
|
+
{!cartOpen && <StickyBar
|
|
449
|
+
title={title}
|
|
450
|
+
imageUrl={imageUrl}
|
|
451
|
+
qty={qty}
|
|
452
|
+
total={formatBRL(pix * qty)}
|
|
453
|
+
pixOff={PIX_DISCOUNT_PCT}
|
|
454
|
+
onInc={() => setQty((q) => (maxQty ? Math.min(maxQty, q + 1) : q + 1))}
|
|
455
|
+
onDec={() => setQty((q) => Math.max(minQty || 1, q - 1))}
|
|
456
|
+
onAdd={() => handleAdd(false)}
|
|
457
|
+
disabled={!canBuy || busy}
|
|
458
|
+
/>}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function Radio({ on }: { on: boolean }) {
|
|
464
|
+
return (
|
|
465
|
+
<span
|
|
466
|
+
className="inline-block h-[18px] w-[18px] shrink-0 rounded-full border-2"
|
|
467
|
+
style={{
|
|
468
|
+
borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-faint)",
|
|
469
|
+
background: on ? "radial-gradient(var(--store-primary,#18181B) 0 38%, var(--store-surface) 42%)" : "var(--store-surface)",
|
|
470
|
+
}}
|
|
471
|
+
/>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function ShippingCalc({ productId, variantId, price }: { productId: string; variantId: string; price: number | null }) {
|
|
476
|
+
const [cep, setCep] = React.useState("");
|
|
477
|
+
const [loading, setLoading] = React.useState(false);
|
|
478
|
+
const [options, setOptions] = React.useState<any[] | null>(null);
|
|
479
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
480
|
+
|
|
481
|
+
async function submit(e: React.FormEvent) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
setError(null);
|
|
484
|
+
setOptions(null);
|
|
485
|
+
if (onlyDigits(cep).length !== 8) {
|
|
486
|
+
setError("Informe um CEP válido (8 dígitos).");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
setLoading(true);
|
|
490
|
+
try {
|
|
491
|
+
const res = await fetch("/api/shipping/quote", {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: { "Content-Type": "application/json" },
|
|
494
|
+
body: JSON.stringify({ productId, variantId, price, postal: onlyDigits(cep) }),
|
|
495
|
+
});
|
|
496
|
+
const data = await res.json();
|
|
497
|
+
if (!res.ok) throw new Error(data?.error || "Falha ao calcular o frete.");
|
|
498
|
+
setOptions(data.options ?? []);
|
|
499
|
+
} catch (e: any) {
|
|
500
|
+
setError(e.message);
|
|
501
|
+
} finally {
|
|
502
|
+
setLoading(false);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// calculadora de frete: formulário e resultado da cotação, mecânica da loja
|
|
507
|
+
return (
|
|
508
|
+
<div className="mt-3 rounded-xl border border-[var(--store-line)] bg-white px-[15px] py-3.5" data-editor-ignore>
|
|
509
|
+
<div className="flex items-center justify-between gap-2.5">
|
|
510
|
+
<div className="flex items-center gap-2 text-[13px] font-bold text-[var(--store-ink-2)]">
|
|
511
|
+
<MapPin weight="bold" className="text-[17px] text-[var(--store-primary,#18181B)]" /> Calcular frete e prazo
|
|
512
|
+
</div>
|
|
513
|
+
<a href="https://buscacepinter.correios.com.br/" target="_blank" rel="noreferrer" className="text-xs font-semibold text-[var(--store-muted)] no-underline">Não sei meu CEP</a>
|
|
514
|
+
</div>
|
|
515
|
+
<form onSubmit={submit} className="mt-2.5 flex gap-2.5">
|
|
516
|
+
<input
|
|
517
|
+
value={cep}
|
|
518
|
+
onChange={(e) => setCep(maskCep(e.target.value))}
|
|
519
|
+
inputMode="numeric"
|
|
520
|
+
autoComplete="postal-code"
|
|
521
|
+
placeholder="00000-000"
|
|
522
|
+
aria-label="CEP"
|
|
523
|
+
className="h-11 min-w-0 flex-1 rounded-md border border-[var(--store-line-2)] bg-white px-3.5 text-sm font-semibold text-[var(--store-ink)] outline-none"
|
|
524
|
+
/>
|
|
525
|
+
<button type="submit" disabled={loading} className="font-display h-11 shrink-0 cursor-pointer rounded-md bg-[var(--store-chrome-bg)] px-5 text-[13.5px] font-bold text-white disabled:opacity-60">
|
|
526
|
+
{loading ? "..." : "Calcular"}
|
|
527
|
+
</button>
|
|
528
|
+
</form>
|
|
529
|
+
{error && <p className="mt-2 text-[13px] text-[var(--store-sale)]" role="alert">{error}</p>}
|
|
530
|
+
{options && options.length > 0 && (
|
|
531
|
+
<div className="mt-3 flex flex-col gap-2">
|
|
532
|
+
{options.map((o, i) => (
|
|
533
|
+
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--store-surface-2)] px-3.5 py-[11px]">
|
|
534
|
+
<div className="flex items-center gap-2.5">
|
|
535
|
+
<Lightning weight="bold" className="text-[17px] text-[var(--store-cta,#D97706)]" />
|
|
536
|
+
<div>
|
|
537
|
+
<div className="text-[13px] font-bold text-[var(--store-ink)]">{o.displayName}</div>
|
|
538
|
+
{o.days != null && <div className="text-[11.5px] text-[var(--store-muted)]">Receba em {o.days} dia(s)</div>}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
<span className="text-[13.5px] font-extrabold text-[var(--store-primary,#18181B)]">{o.price}</span>
|
|
542
|
+
</div>
|
|
543
|
+
))}
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
{options && options.length === 0 && <p className="mt-2 text-[13px] text-[var(--store-muted)]">Sem opções para este CEP.</p>}
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function StickyBar({
|
|
552
|
+
title, imageUrl, qty, total, pixOff, onInc, onDec, onAdd, disabled,
|
|
553
|
+
}: {
|
|
554
|
+
title: string; imageUrl?: string; qty: number; total: string; pixOff: number;
|
|
555
|
+
onInc: () => void; onDec: () => void; onAdd: () => void; disabled: boolean;
|
|
556
|
+
}) {
|
|
557
|
+
const [show, setShow] = React.useState(false);
|
|
558
|
+
React.useEffect(() => {
|
|
559
|
+
const onScroll = () => {
|
|
560
|
+
const y = window.scrollY || document.documentElement.scrollTop || 0;
|
|
561
|
+
const nearBottom = window.innerHeight + y > document.body.scrollHeight - 340;
|
|
562
|
+
setShow(y > 600 && !nearBottom);
|
|
563
|
+
};
|
|
564
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
565
|
+
onScroll();
|
|
566
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
// barra fixa de compra: nome, total e botão são dado e mecânica
|
|
570
|
+
return (
|
|
571
|
+
<div
|
|
572
|
+
data-editor-ignore
|
|
573
|
+
className="pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex justify-center px-4 pb-4 transition-all duration-300 ease-out max-md:bottom-[78px]"
|
|
574
|
+
style={{ transform: show ? "translateY(0)" : "translateY(140%)", opacity: show ? 1 : 0 }}
|
|
575
|
+
>
|
|
576
|
+
<div className="pointer-events-auto flex w-full max-w-[680px] items-center gap-[18px] rounded-xl border border-[var(--store-line)] bg-white px-4 py-3 shadow-[0_12px_40px_rgba(0,0,0,.16)] max-md:gap-3 max-md:px-3 max-md:py-2.5">
|
|
577
|
+
<div className="relative h-[58px] w-[58px] shrink-0 overflow-hidden rounded-xl border border-[var(--store-line)] bg-[var(--store-line)] max-md:h-[52px] max-md:w-[52px]">
|
|
578
|
+
{imageUrl && <Image src={imageUrl} alt="" fill sizes="58px" className="object-cover" />}
|
|
579
|
+
</div>
|
|
580
|
+
<div className="min-w-0 flex-1">
|
|
581
|
+
<div className="font-display truncate text-base font-bold leading-[1.15] text-[var(--store-ink)] max-md:text-[14.5px]">{title}</div>
|
|
582
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
|
583
|
+
<span className="font-display text-[15px] font-extrabold text-[var(--store-primary,#18181B)]">{total}</span>
|
|
584
|
+
{pixOff > 0 && (
|
|
585
|
+
<span className="rounded bg-[var(--store-primary-soft,#F1F1F3)] px-1.5 py-0.5 text-[10px] font-extrabold text-[var(--store-primary,#18181B)]">no Pix · {pixOff}% OFF</span>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
<div className="flex shrink-0 items-center overflow-hidden rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white">
|
|
590
|
+
<button type="button" onClick={onDec} className="h-[46px] w-10 cursor-pointer border-none bg-transparent text-[19px] text-[var(--store-ink-2)]">−</button>
|
|
591
|
+
<span className="w-[34px] text-center text-[15px] font-extrabold">{qty}</span>
|
|
592
|
+
<button type="button" onClick={onInc} className="h-[46px] w-10 cursor-pointer border-none bg-transparent text-[19px] text-[var(--store-ink-2)]">+</button>
|
|
593
|
+
</div>
|
|
594
|
+
<button
|
|
595
|
+
type="button"
|
|
596
|
+
onClick={onAdd}
|
|
597
|
+
disabled={disabled}
|
|
598
|
+
className="font-display flex h-[50px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-xl bg-[var(--store-primary,#18181B)] px-[34px] text-[15px] font-bold tracking-[0.4px] text-white shadow-[var(--store-shadow-cta)] transition-colors hover:bg-[var(--store-primary-dark,#09090B)] disabled:opacity-60 max-md:px-[18px]">
|
|
599
|
+
<ShoppingCartSimple weight="bold" />ADICIONAR
|
|
600
|
+
</button>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
);
|
|
604
|
+
}
|