@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,1333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import Image from "next/image";
|
|
6
|
+
import { useRouter } from "next/navigation";
|
|
7
|
+
import {
|
|
8
|
+
ArrowLeft, ArrowRight, ArrowsClockwise, Alarm, Check, CheckCircle, Copy, CreditCard,
|
|
9
|
+
Gift, Lightning, LockSimple, MapPin, Package, Plus, QrCode, SealCheck, ShieldCheck,
|
|
10
|
+
Spinner, Trash, Truck, Warning, CaretUp,
|
|
11
|
+
} from "@phosphor-icons/react/dist/ssr";
|
|
12
|
+
import QRCode from "qrcode";
|
|
13
|
+
import { useCart } from "@/components/cart/cart-provider";
|
|
14
|
+
import { emptyAddress, type AddressValue } from "@/components/address-fields";
|
|
15
|
+
import { PaymentChips } from "@/components/product/pdp/payment-chips";
|
|
16
|
+
import {
|
|
17
|
+
formatBRL, maskCep, maskCpf, maskPhone, maskCardNumber, onlyDigits, isValidCpf, isValidEmail, parseBRL,
|
|
18
|
+
sanitizeName, sanitizeEmail, sanitizeText, sanitizeHouseNumber, sanitizeUf,
|
|
19
|
+
} from "@/lib/format";
|
|
20
|
+
import { FREE_SHIPPING_THRESHOLD, GIFT_THRESHOLD, PIX_DISCOUNT_PCT } from "@/lib/store-config";
|
|
21
|
+
import { trackBeginCheckout, trackAddPaymentInfo, trackAddShippingInfo, trackPurchase, type TrackItem, type TrackUser } from "@/lib/analytics";
|
|
22
|
+
|
|
23
|
+
const FREE_SHIP_THRESHOLD = FREE_SHIPPING_THRESHOLD;
|
|
24
|
+
|
|
25
|
+
/** Itens do UiCart (sem brindes) → itens de tracking GA4/Meta. */
|
|
26
|
+
// Itens no formato de tracking. O desconto do carrinho (cupom/promoção) é rateado por item
|
|
27
|
+
// proporcionalmente ao subtotal de cada um, para que `discount` seja preenchido e a soma dos
|
|
28
|
+
// itens feche com o valor pago — o GA4 e a Meta esperam isso, e um relatório de cliente mostrou
|
|
29
|
+
// o custo de não fazer: `discount: 0` com preço de tabela numa venda com cupom de 100%.
|
|
30
|
+
function cartToTrackItems(items: any[] = [], discountTotal?: number): TrackItem[] {
|
|
31
|
+
const real = items.filter((it) => !it.isBonus);
|
|
32
|
+
const subtotal = real.reduce((s, it) => s + (it.unitPrice ?? 0) * (it.quantity ?? 1), 0);
|
|
33
|
+
const disc = discountTotal && discountTotal > 0 && subtotal > 0 ? Math.min(discountTotal, subtotal) : 0;
|
|
34
|
+
return real.map((it) => {
|
|
35
|
+
const line = (it.unitPrice ?? 0) * (it.quantity ?? 1);
|
|
36
|
+
const unitDiscount = disc ? (disc * (line / subtotal)) / (it.quantity ?? 1) : 0;
|
|
37
|
+
return { id: it.productId, name: it.title, variant: it.variantTitle, price: it.unitPrice, discount: unitDiscount || undefined, quantity: it.quantity };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Method { name: string; displayName: string; isEnabled: boolean }
|
|
42
|
+
interface ShipOption { methodId: string; displayName: string; price?: string; discountPrice?: string; days?: number }
|
|
43
|
+
interface Group { groupId: string; options: ShipOption[] }
|
|
44
|
+
|
|
45
|
+
export interface UpsellOffer {
|
|
46
|
+
productId: string;
|
|
47
|
+
variantId: string;
|
|
48
|
+
name: string;
|
|
49
|
+
weight: string;
|
|
50
|
+
desc: string;
|
|
51
|
+
imageUrl?: string | null;
|
|
52
|
+
price: number;
|
|
53
|
+
displayPrice: string;
|
|
54
|
+
oldPrice: number | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inputCls =
|
|
58
|
+
"mt-[7px] h-[50px] w-full rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-4 text-[15px] text-[var(--store-ink)] outline-none focus:border-[var(--store-primary,#18181B)]";
|
|
59
|
+
const lockedCls =
|
|
60
|
+
"mt-[7px] h-[50px] w-full rounded-md border-[1.5px] border-[var(--store-line-2)] bg-[var(--store-surface)] px-4 text-[15px] text-[var(--store-ink-2)] outline-none";
|
|
61
|
+
|
|
62
|
+
export function CheckoutClient({
|
|
63
|
+
methods,
|
|
64
|
+
maxInstallments,
|
|
65
|
+
acceptsCard,
|
|
66
|
+
upsells,
|
|
67
|
+
shopName,
|
|
68
|
+
}: {
|
|
69
|
+
methods: Method[];
|
|
70
|
+
maxInstallments: number;
|
|
71
|
+
acceptsCard: boolean;
|
|
72
|
+
upsells: UpsellOffer[];
|
|
73
|
+
shopName: string;
|
|
74
|
+
}) {
|
|
75
|
+
const router = useRouter();
|
|
76
|
+
const { cart, refresh, add, remove, appliedCoupon, applyCoupon, removeCoupon, loading, linkBuilding, linkRestoredAt } = useCart();
|
|
77
|
+
|
|
78
|
+
// Link do checkout segue o padrão real da Unbox (ex.: finalizar-pedido?id=&token=&step=1) —
|
|
79
|
+
// permite reabrir direto numa etapa específica, não só sempre na 1.
|
|
80
|
+
const initialStep = React.useMemo(() => {
|
|
81
|
+
if (typeof window === "undefined") return 1;
|
|
82
|
+
const n = Number(new URLSearchParams(window.location.search).get("step"));
|
|
83
|
+
return n >= 1 && n <= 3 ? n : 1;
|
|
84
|
+
}, []);
|
|
85
|
+
const [step, setStep] = React.useState(initialStep);
|
|
86
|
+
const [maxStep, setMaxStep] = React.useState(initialStep);
|
|
87
|
+
const [email, setEmail] = React.useState("");
|
|
88
|
+
const [address, setAddress] = React.useState<AddressValue>(emptyAddress);
|
|
89
|
+
const [lockedByCep, setLockedByCep] = React.useState<{ address1: boolean; city: boolean; region: boolean }>({
|
|
90
|
+
address1: false, city: false, region: false,
|
|
91
|
+
});
|
|
92
|
+
const [cepLoading, setCepLoading] = React.useState(false);
|
|
93
|
+
|
|
94
|
+
const [groups, setGroups] = React.useState<Group[]>([]);
|
|
95
|
+
const [selections, setSelections] = React.useState<Record<string, string>>({});
|
|
96
|
+
const selectionsRef = React.useRef(selections);
|
|
97
|
+
selectionsRef.current = selections;
|
|
98
|
+
const [shipLoading, setShipLoading] = React.useState(false);
|
|
99
|
+
// COTAR e SALVAR a escolha são coisas diferentes: cotar esconde a lista (não há o que mostrar),
|
|
100
|
+
// escolher não (a opção já está marcada). Um estado só para os dois trocava a lista inteira por
|
|
101
|
+
// "Calculando opções de frete..." a cada clique, e parecia recarregar tudo para marcar uma linha.
|
|
102
|
+
const [shipSaving, setShipSaving] = React.useState(false);
|
|
103
|
+
const [shipError, setShipError] = React.useState<string | null>(null);
|
|
104
|
+
// Assinatura dos itens para a qual o frete foi efetivamente cotado. Se o carrinho muda (ex.: cai
|
|
105
|
+
// abaixo do frete grátis) e ainda não recotou, o frete exibido fica "—" em vez de um valor velho.
|
|
106
|
+
const [shippingSig, setShippingSig] = React.useState("");
|
|
107
|
+
const [cpfError, setCpfError] = React.useState(false);
|
|
108
|
+
// Endereço (e frete) só aparecem depois de buscar o CEP.
|
|
109
|
+
const [addressRevealed, setAddressRevealed] = React.useState(false);
|
|
110
|
+
|
|
111
|
+
const [payType, setPayType] = React.useState<"pix" | "card">("pix");
|
|
112
|
+
const [card, setCard] = React.useState({ cardHolder: "", cardNumber: "", expiry: "", securityCode: "" });
|
|
113
|
+
const [installments, setInstallments] = React.useState(1);
|
|
114
|
+
// Parcelas reais da Unbox (getInstallments) por valor — só p/ cartão e fora de assinatura.
|
|
115
|
+
const [installmentOptions, setInstallmentOptions] = React.useState<{ installment: number; amount: number }[]>([]);
|
|
116
|
+
const [couponCode, setCouponCode] = React.useState("");
|
|
117
|
+
|
|
118
|
+
const [busy, setBusy] = React.useState(false);
|
|
119
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
120
|
+
const [summaryOpen, setSummaryOpen] = React.useState(false); // resumo expandido no mobile
|
|
121
|
+
|
|
122
|
+
// Pix exibido na própria página: loader enquanto gera + QR inline + polling de confirmação.
|
|
123
|
+
const [generatingPix, setGeneratingPix] = React.useState(false);
|
|
124
|
+
const [processingCard, setProcessingCard] = React.useState(false);
|
|
125
|
+
const [pixResult, setPixResult] = React.useState<{ ref: string; emv: string | null; total: string } | null>(null);
|
|
126
|
+
const [pixQr, setPixQr] = React.useState<string | null>(null);
|
|
127
|
+
const [pixStatus, setPixStatus] = React.useState<{ paid: boolean; label: string } | null>(null);
|
|
128
|
+
const [pixExpired, setPixExpired] = React.useState(false);
|
|
129
|
+
// Timeout no placeOrder: NÃO sabemos se o pedido nasceu. O botão de pagar fica travado e a
|
|
130
|
+
// pessoa é mandada conferir em Meus pedidos antes de qualquer nova tentativa.
|
|
131
|
+
const [paymentUnknown, setPaymentUnknown] = React.useState(false);
|
|
132
|
+
const [pixCopied, setPixCopied] = React.useState(false);
|
|
133
|
+
|
|
134
|
+
// gera o QR a partir do copia-e-cola (EMV) retornado pelo placeOrder
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
if (!pixResult?.emv) return;
|
|
137
|
+
QRCode.toDataURL(pixResult.emv, { width: 220, margin: 1 }).then(setPixQr).catch(() => {});
|
|
138
|
+
}, [pixResult?.emv]);
|
|
139
|
+
|
|
140
|
+
// polling de confirmação do pagamento (fallback ao webhook), até 10 min
|
|
141
|
+
React.useEffect(() => {
|
|
142
|
+
if (!pixResult?.ref) return;
|
|
143
|
+
let stop = false;
|
|
144
|
+
const start = Date.now();
|
|
145
|
+
const tick = async () => {
|
|
146
|
+
if (stop) return;
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`/api/order/${pixResult.ref}`, { cache: "no-store" });
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
if (res.ok && data.order) {
|
|
151
|
+
setPixStatus({ paid: data.order.paid, label: data.order.statusLabel });
|
|
152
|
+
if (data.order.paid) return;
|
|
153
|
+
if (["CANCELED", "FAILED"].includes(data.order.status)) { setPixExpired(true); return; }
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
const elapsed = Date.now() - start;
|
|
157
|
+
// Quem expira o Pix é a Unbox (status CANCELED/FAILED acima). Aqui só paramos de
|
|
158
|
+
// consultar depois de 1h pra não ficar batendo na API pra sempre.
|
|
159
|
+
if (elapsed > 60 * 60 * 1000) return;
|
|
160
|
+
// 1s nos 2 primeiros minutos (confirma quase em tempo real) e 5s depois (poupa requisições).
|
|
161
|
+
if (!stop) setTimeout(tick, elapsed < 120_000 ? 1000 : 5000);
|
|
162
|
+
};
|
|
163
|
+
const t = setTimeout(tick, 1000);
|
|
164
|
+
return () => { stop = true; clearTimeout(t); };
|
|
165
|
+
}, [pixResult?.ref]);
|
|
166
|
+
|
|
167
|
+
const [offerTime, setOfferTime] = React.useState(179);
|
|
168
|
+
React.useEffect(() => {
|
|
169
|
+
const t = setInterval(() => setOfferTime((s) => (s > 0 ? s - 1 : 0)), 1000);
|
|
170
|
+
return () => clearInterval(t);
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const hasRecurring = cart?.hasRecurring;
|
|
174
|
+
const pixAllowed = methods.some((m) => m.name === "unboxpay_pix" && m.isEnabled) && !hasRecurring;
|
|
175
|
+
const cardAllowed = acceptsCard && methods.some((m) => m.name === "unboxpay_credit" && m.isEnabled);
|
|
176
|
+
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
if (hasRecurring) { setPayType("card"); setInstallments(1); }
|
|
179
|
+
else if (!pixAllowed && cardAllowed) setPayType("card");
|
|
180
|
+
}, [hasRecurring, pixAllowed, cardAllowed]);
|
|
181
|
+
|
|
182
|
+
// Parcelas reais por valor (getInstallments). Só p/ cartão e fora de assinatura.
|
|
183
|
+
const lastInstAmount = React.useRef(0);
|
|
184
|
+
React.useEffect(() => {
|
|
185
|
+
if (payType !== "card" || hasRecurring) return;
|
|
186
|
+
const amt = parseBRL(cart?.summary.total);
|
|
187
|
+
if (!amt || amt <= 0 || amt === lastInstAmount.current) return;
|
|
188
|
+
lastInstAmount.current = amt;
|
|
189
|
+
let alive = true;
|
|
190
|
+
fetch(`/api/checkout/installments?amount=${amt}`)
|
|
191
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
192
|
+
.then((d) => { if (alive && Array.isArray(d?.installments)) setInstallmentOptions(d.installments); })
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
return () => { alive = false; };
|
|
195
|
+
}, [payType, hasRecurring, cart?.summary.total]);
|
|
196
|
+
|
|
197
|
+
// Contato/endereço salvos no dispositivo (localStorage — não vai pro servidor, evita expor CPF
|
|
198
|
+
// em cookie). Repõe no retorno para o cliente não digitar tudo de novo.
|
|
199
|
+
const persistKey = "store_checkout_v1";
|
|
200
|
+
React.useEffect(() => {
|
|
201
|
+
try {
|
|
202
|
+
const s = JSON.parse(localStorage.getItem(persistKey) || "null");
|
|
203
|
+
if (!s) return;
|
|
204
|
+
if (s.email) setEmail(s.email);
|
|
205
|
+
if (s.address) setAddress((a) => ({ ...a, ...s.address }));
|
|
206
|
+
if (s.address?.postal && onlyDigits(s.address.postal).length === 8 && s.address.city?.trim()) {
|
|
207
|
+
setAddressRevealed(true);
|
|
208
|
+
}
|
|
209
|
+
} catch { /* ignora */ }
|
|
210
|
+
}, []);
|
|
211
|
+
React.useEffect(() => {
|
|
212
|
+
const t = setTimeout(() => {
|
|
213
|
+
try { localStorage.setItem(persistKey, JSON.stringify({ email, address })); } catch { /* quota */ }
|
|
214
|
+
}, 400);
|
|
215
|
+
return () => clearTimeout(t);
|
|
216
|
+
}, [email, address]);
|
|
217
|
+
|
|
218
|
+
// Link de recuperação (?id=&token=): "quem tem o link, manda" — o e-mail/endereço do carrinho
|
|
219
|
+
// restaurado SOBRESCREVE o que veio do localStorage do dispositivo (doc 12, §12.3). Roda depois
|
|
220
|
+
// do restore local (acima) porque linkRestoredAt só muda depois que a restauração via API termina.
|
|
221
|
+
React.useEffect(() => {
|
|
222
|
+
if (!linkRestoredAt || !cart) return;
|
|
223
|
+
if (cart.email) setEmail(cart.email);
|
|
224
|
+
const a = cart.shippingAddress;
|
|
225
|
+
if (a) {
|
|
226
|
+
setAddress((cur) => ({ ...cur, ...a }));
|
|
227
|
+
// Destrava o CEP: o endereço veio do link, não da busca por CEP nesta sessão — se ficar
|
|
228
|
+
// travado (readOnly) sem o campo ter sido preenchido por lookupCep, o cliente não consegue editar.
|
|
229
|
+
setLockedByCep({ address1: false, city: false, region: false });
|
|
230
|
+
if (onlyDigits(a.postal).length === 8 && a.city.trim()) setAddressRevealed(true);
|
|
231
|
+
}
|
|
232
|
+
}, [linkRestoredAt, cart]);
|
|
233
|
+
|
|
234
|
+
// Grava o e-mail no carrinho assim que válido (habilita CARRINHO ABANDONADO no Unbox/CRM, antes
|
|
235
|
+
// do placeOrder). Debounce + uma vez por e-mail distinto. Best-effort: nunca trava o checkout.
|
|
236
|
+
const emailSavedRef = React.useRef("");
|
|
237
|
+
React.useEffect(() => {
|
|
238
|
+
const e = email.trim().toLowerCase();
|
|
239
|
+
if (!isValidEmail(email) || !cart?.items?.length || emailSavedRef.current === e) return;
|
|
240
|
+
const t = setTimeout(() => {
|
|
241
|
+
emailSavedRef.current = e;
|
|
242
|
+
fetch("/api/checkout/email", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ email: e }),
|
|
246
|
+
}).catch(() => {});
|
|
247
|
+
}, 600);
|
|
248
|
+
return () => clearTimeout(t);
|
|
249
|
+
}, [email, cart?.items?.length]);
|
|
250
|
+
|
|
251
|
+
// Dados de correspondência (Meta CAPI: em, ph, fn, ln, zp, external_id). Saem HASHEADOS da
|
|
252
|
+
// camada de tracking; aqui é só a leitura do que a pessoa digitou.
|
|
253
|
+
const trackUser = (): TrackUser => {
|
|
254
|
+
const parts = (address.fullName ?? "").trim().split(/\s+/).filter(Boolean);
|
|
255
|
+
return { email, phone: address.phone, firstName: parts[0], lastName: parts.slice(1).join(" ") || undefined, zip: address.postal };
|
|
256
|
+
};
|
|
257
|
+
const cartDiscount = () => parseBRL(cart?.summary.discountTotal) ?? undefined;
|
|
258
|
+
|
|
259
|
+
// begin_checkout / InitiateCheckout — uma vez, quando o carrinho com itens é exibido no checkout.
|
|
260
|
+
const beganCheckout = React.useRef(false);
|
|
261
|
+
React.useEffect(() => {
|
|
262
|
+
if (beganCheckout.current || !cart?.items?.length) return;
|
|
263
|
+
beganCheckout.current = true;
|
|
264
|
+
trackBeginCheckout(cartToTrackItems(cart.items, cartDiscount()), parseBRL(cart.summary.total) ?? undefined, appliedCoupon?.code, trackUser());
|
|
265
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
266
|
+
}, [cart?.items?.length]);
|
|
267
|
+
|
|
268
|
+
// purchase / Purchase do Pix — dispara quando o pagamento confirma (inline). Dedupe por referenceId.
|
|
269
|
+
React.useEffect(() => {
|
|
270
|
+
if (!pixStatus?.paid || !pixResult?.ref) return;
|
|
271
|
+
trackPurchase({
|
|
272
|
+
transactionId: pixResult.ref,
|
|
273
|
+
items: cartToTrackItems(cart?.items, cartDiscount()),
|
|
274
|
+
value: parseBRL(pixResult.total) ?? parseBRL(cart?.summary.total) ?? undefined,
|
|
275
|
+
coupon: appliedCoupon?.code,
|
|
276
|
+
shipping: parseBRL(cart?.summary.shippingTotal) ?? undefined,
|
|
277
|
+
tax: parseBRL(cart?.summary.taxTotal) ?? undefined,
|
|
278
|
+
user: trackUser(),
|
|
279
|
+
});
|
|
280
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
281
|
+
}, [pixStatus?.paid, pixResult?.ref]);
|
|
282
|
+
|
|
283
|
+
// Subtotal de PRODUTOS (itemTotal) — base das réguas de frete grátis e brinde. Não usar o
|
|
284
|
+
// total (totalAmount) pois ele inclui frete após a seleção, inflando o progresso.
|
|
285
|
+
const subtotalAmount = parseBRL(cart?.summary.itemTotal) ?? cart?.summary.totalAmount ?? 0;
|
|
286
|
+
|
|
287
|
+
// ---------- CEP ----------
|
|
288
|
+
function setAddr(patch: Partial<AddressValue>) {
|
|
289
|
+
setAddress((a) => ({ ...a, ...patch }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const cepFetched = React.useRef("");
|
|
293
|
+
async function lookupCep(rawPostal: string, force = false) {
|
|
294
|
+
const cep = onlyDigits(rawPostal);
|
|
295
|
+
if (cep.length !== 8) return;
|
|
296
|
+
setAddressRevealed(true); // CEP válido → revela endereço + frete (mesmo se a busca falhar)
|
|
297
|
+
if (!force && cepFetched.current === cep) return; // evita buscar o mesmo CEP 2x
|
|
298
|
+
cepFetched.current = cep;
|
|
299
|
+
setCepLoading(true);
|
|
300
|
+
try {
|
|
301
|
+
const res = await fetch(`/api/cep/${cep}`);
|
|
302
|
+
const data = await res.json();
|
|
303
|
+
if (res.ok && data.address) {
|
|
304
|
+
const a = data.address;
|
|
305
|
+
setAddress((cur) => ({
|
|
306
|
+
...cur,
|
|
307
|
+
address1: a.address1 || cur.address1,
|
|
308
|
+
neighborhood: a.neighborhood || cur.neighborhood,
|
|
309
|
+
city: a.city || cur.city,
|
|
310
|
+
region: a.region || cur.region,
|
|
311
|
+
}));
|
|
312
|
+
// trava os campos que o CEP preencheu (rua, cidade, estado)
|
|
313
|
+
setLockedByCep({
|
|
314
|
+
address1: !!a.address1,
|
|
315
|
+
city: !!a.city,
|
|
316
|
+
region: !!a.region,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
setCepLoading(false);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Fallback p/ autofill: quando o CEP fica completo (mesmo sem disparar onChange/blur) e cidade/UF
|
|
325
|
+
// ainda vazias, busca o endereço — garante que o frete cote após o preenchimento automático.
|
|
326
|
+
React.useEffect(() => {
|
|
327
|
+
if (onlyDigits(address.postal).length === 8 && (!address.city.trim() || !address.region.trim())) {
|
|
328
|
+
void lookupCep(address.postal);
|
|
329
|
+
}
|
|
330
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
331
|
+
}, [address.postal]);
|
|
332
|
+
|
|
333
|
+
// ---------- Frete: depende do CEP (postal) ----------
|
|
334
|
+
// Dispara assim que o CEP está completo e cidade/UF preenchidas (pelo CEP ou autofill), SEM
|
|
335
|
+
// exigir nome/CPF/número — esses vão no pedido, não afetam a cotação (que é por CEP/região).
|
|
336
|
+
// O frete é cotado por CEP/cidade/UF (não depende da rua) — assim o frete aparece logo após o CEP,
|
|
337
|
+
// antes do cliente digitar o logradouro.
|
|
338
|
+
const canQuote =
|
|
339
|
+
onlyDigits(address.postal).length === 8 &&
|
|
340
|
+
address.city.trim() !== "" &&
|
|
341
|
+
address.region.trim() !== "";
|
|
342
|
+
|
|
343
|
+
// Endereço com placeholders nos campos ainda vazios — só para COTAR (o pedido usa o real).
|
|
344
|
+
const quoteAddress = () => ({
|
|
345
|
+
...address,
|
|
346
|
+
fullName: address.fullName.trim() || "Cotação",
|
|
347
|
+
taxPayerId: address.taxPayerId.trim() || "",
|
|
348
|
+
address1: address.address1.trim() || "(cotação)",
|
|
349
|
+
number: address.number.trim() || "0",
|
|
350
|
+
neighborhood: address.neighborhood.trim() || "(cotação)",
|
|
351
|
+
phone: address.phone.trim() || "00000000000",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const quoteKey = canQuote
|
|
355
|
+
? JSON.stringify([address.postal, address.city, address.region])
|
|
356
|
+
: "";
|
|
357
|
+
const lastQuoted = React.useRef<string>("");
|
|
358
|
+
|
|
359
|
+
React.useEffect(() => {
|
|
360
|
+
// Mesma proteção do efeito de itemsSig abaixo: espera qualquer mutação de carrinho em curso
|
|
361
|
+
// (add/updateQty/remove) terminar antes de cotar — quoteShipping() termina em applySelections()
|
|
362
|
+
// → refresh(), que sem essa espera podia chegar depois de um remove() e trazer o item de volta.
|
|
363
|
+
if (loading || !quoteKey || quoteKey === lastQuoted.current) return;
|
|
364
|
+
const handle = setTimeout(() => { void quoteShipping(); }, 300);
|
|
365
|
+
return () => clearTimeout(handle);
|
|
366
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
+
}, [quoteKey, loading]);
|
|
368
|
+
|
|
369
|
+
// Recalcula o frete SEMPRE que os itens do carrinho mudam após a cotação (ofertas, "remover"
|
|
370
|
+
// no resumo, etc.). A cotação roda sobre o carrinho completo no servidor (todos os produtos).
|
|
371
|
+
const itemsSig = (cart?.items ?? []).map((it) => `${it.variantId}:${it.quantity}`).join("|");
|
|
372
|
+
const lastItemsSig = React.useRef(itemsSig);
|
|
373
|
+
React.useEffect(() => {
|
|
374
|
+
// Espera qualquer mutação em curso (add/updateQty/remove) terminar antes de recotar — senão
|
|
375
|
+
// o refresh() do recálculo pode chegar DEPOIS da reconciliação do remove e sobrescrever o
|
|
376
|
+
// estado com um carrinho ainda desatualizado, trazendo o item removido de volta.
|
|
377
|
+
if (loading || itemsSig === lastItemsSig.current) return;
|
|
378
|
+
lastItemsSig.current = itemsSig;
|
|
379
|
+
if (groups.length > 0 && canQuote) void recalcShipping();
|
|
380
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
381
|
+
}, [itemsSig, loading]);
|
|
382
|
+
|
|
383
|
+
// Mantém o método escolhido se ainda existir; senão cai na 1ª opção. O frete grátis/promo do
|
|
384
|
+
// backend vem como discountPrice — a opção mais barata (efetiva) é pré-selecionada.
|
|
385
|
+
function pickSelections(gs: Group[], prev: Record<string, string>): Record<string, string> {
|
|
386
|
+
const out: Record<string, string> = {};
|
|
387
|
+
for (const g of gs) {
|
|
388
|
+
const keep = prev[g.groupId] && g.options.some((o) => o.methodId === prev[g.groupId]);
|
|
389
|
+
out[g.groupId] = keep ? prev[g.groupId] : g.options[0]?.methodId;
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Aplica a seleção de frete no servidor (PUT) e relê o carrinho — assim o resumo (frete/total)
|
|
395
|
+
// reflete a regra de frete grátis na hora, sem esperar o "Continuar".
|
|
396
|
+
async function applySelections(sel: Record<string, string>) {
|
|
397
|
+
const entries = Object.entries(sel).filter(([, m]) => m);
|
|
398
|
+
if (!entries.length) return;
|
|
399
|
+
await api("/api/checkout/shipping", { selections: entries.map(([groupId, methodId]) => ({ groupId, methodId })) }, "PUT");
|
|
400
|
+
await refresh();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function quoteShipping() {
|
|
404
|
+
// Cotação inicial/reativa: cota, pré-seleciona a opção efetiva e JÁ aplica (PUT) p/ o resumo bater.
|
|
405
|
+
setShipError(null);
|
|
406
|
+
setShipLoading(true);
|
|
407
|
+
try {
|
|
408
|
+
await api("/api/checkout/address", { address: quoteAddress() });
|
|
409
|
+
const data = await api("/api/checkout/shipping", undefined, "POST");
|
|
410
|
+
lastQuoted.current = quoteKey;
|
|
411
|
+
const next: Group[] = data.groups ?? [];
|
|
412
|
+
const sel = pickSelections(next, selectionsRef.current);
|
|
413
|
+
setGroups(next);
|
|
414
|
+
setSelections(sel);
|
|
415
|
+
await applySelections(sel);
|
|
416
|
+
setShippingSig(itemsSig);
|
|
417
|
+
} catch (e: any) {
|
|
418
|
+
setShipError(e.message);
|
|
419
|
+
setGroups([]);
|
|
420
|
+
} finally {
|
|
421
|
+
setShipLoading(false);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Troca da opção de frete pelo usuário: aplica no servidor na hora p/ o resumo atualizar.
|
|
426
|
+
// Mesmo guard dos efeitos de quoteKey/itemsSig: applySelections() termina em refresh(), que
|
|
427
|
+
// sem essa espera podia chegar depois de um remove() em curso e trazer o item de volta.
|
|
428
|
+
async function chooseShipping(groupId: string, methodId: string) {
|
|
429
|
+
if (loading) return;
|
|
430
|
+
const sel = { ...selectionsRef.current, [groupId]: methodId };
|
|
431
|
+
setSelections(sel);
|
|
432
|
+
setShipSaving(true);
|
|
433
|
+
try {
|
|
434
|
+
await applySelections(sel);
|
|
435
|
+
// add_shipping_info — etapa de frete do funil GA4 (faltava; o checkout tinha a etapa e não marcava)
|
|
436
|
+
const tier = groups.find((g) => g.groupId === groupId)?.options.find((o) => o.methodId === methodId)?.displayName ?? methodId;
|
|
437
|
+
trackAddShippingInfo(cartToTrackItems(cart?.items, cartDiscount()), parseBRL(cart?.summary.total) ?? undefined, tier, appliedCoupon?.code);
|
|
438
|
+
} catch (e: any) {
|
|
439
|
+
setShipError(e.message);
|
|
440
|
+
} finally {
|
|
441
|
+
setShipSaving(false);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Recota o frete após mudança de itens (ex.: upsell) — preços/regra de frete grátis dependem do
|
|
446
|
+
// conteúdo do carrinho. Cota, mantém a seleção, faz o PUT e relê o carrinho (total autoritativo).
|
|
447
|
+
async function recalcShipping() {
|
|
448
|
+
if (!canQuote || groups.length === 0) return;
|
|
449
|
+
setShipError(null);
|
|
450
|
+
setShipLoading(true);
|
|
451
|
+
try {
|
|
452
|
+
await api("/api/checkout/address", { address: quoteAddress() });
|
|
453
|
+
const data = await api("/api/checkout/shipping", undefined, "POST");
|
|
454
|
+
const next: Group[] = data.groups ?? [];
|
|
455
|
+
const sel = pickSelections(next, selectionsRef.current);
|
|
456
|
+
setGroups(next);
|
|
457
|
+
setSelections(sel);
|
|
458
|
+
await applySelections(sel);
|
|
459
|
+
setShippingSig(itemsSig);
|
|
460
|
+
} catch (e: any) {
|
|
461
|
+
setShipError(e.message);
|
|
462
|
+
} finally {
|
|
463
|
+
setShipLoading(false);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function api(url: string, body?: any, method = "POST") {
|
|
468
|
+
const res = await fetch(url, {
|
|
469
|
+
method,
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
472
|
+
});
|
|
473
|
+
const data = await res.json().catch(() => ({}));
|
|
474
|
+
if (!res.ok) {
|
|
475
|
+
const err: any = new Error(data?.error || "Erro inesperado.");
|
|
476
|
+
err.code = data?.code; // ex.: PLACE_ORDER_TIMEOUT (ver app/api/checkout/route.ts)
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
return data;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function go(n: number) {
|
|
483
|
+
setStep(n);
|
|
484
|
+
setMaxStep((m) => Math.max(m, n));
|
|
485
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
486
|
+
// Atualiza ?step= na URL em tempo real — só quando ela já tem id&token (senão não há
|
|
487
|
+
// carrinho pra restaurar, não há o que compartilhar ainda).
|
|
488
|
+
const u = new URL(window.location.href);
|
|
489
|
+
if (u.searchParams.get("id") && u.searchParams.get("token")) {
|
|
490
|
+
u.searchParams.set("step", String(n));
|
|
491
|
+
window.history.replaceState(null, "", `${u.pathname}?${u.searchParams.toString()}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ---------- Navegação dos passos ----------
|
|
496
|
+
async function continueFromStep1() {
|
|
497
|
+
if (loading) return; // mesmo guard — este handler também termina em refresh()
|
|
498
|
+
setError(null);
|
|
499
|
+
// valida em ordem e foca o primeiro campo que precisa preencher
|
|
500
|
+
const fail = (msg: string, id: string) => {
|
|
501
|
+
setError(msg);
|
|
502
|
+
requestAnimationFrame(() => {
|
|
503
|
+
const el = document.getElementById(id);
|
|
504
|
+
if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); (el as HTMLElement).focus({ preventScroll: true }); }
|
|
505
|
+
});
|
|
506
|
+
return false;
|
|
507
|
+
};
|
|
508
|
+
if (!isValidEmail(email)) return fail("Informe um e-mail válido.", "co-email");
|
|
509
|
+
if (address.fullName.trim().length < 2) return fail("Informe seu nome completo.", "co-name");
|
|
510
|
+
if (onlyDigits(address.phone).length < 10) return fail("Informe um celular válido com DDD.", "co-phone");
|
|
511
|
+
if (!isValidCpf(address.taxPayerId)) { setCpfError(true); return fail("CPF inválido. Confira os números digitados.", "co-cpf"); }
|
|
512
|
+
if (onlyDigits(address.postal).length !== 8) return fail("Informe o CEP.", "co-cep");
|
|
513
|
+
if (!address.address1.trim()) return fail("Informe o endereço.", "co-address1");
|
|
514
|
+
if (!address.number.trim()) return fail("Informe o número.", "co-number");
|
|
515
|
+
if (!address.neighborhood.trim()) return fail("Informe o bairro.", "co-neighborhood");
|
|
516
|
+
if (!address.city.trim()) return fail("Informe a cidade.", "co-city");
|
|
517
|
+
if (!address.region.trim()) return fail("Informe a UF.", "co-region");
|
|
518
|
+
const sel = Object.entries(selections);
|
|
519
|
+
if (groups.length === 0 || sel.length < groups.length) {
|
|
520
|
+
setError("Selecione uma forma de envio.");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
setBusy(true);
|
|
524
|
+
try {
|
|
525
|
+
await api("/api/checkout/shipping", { selections: sel.map(([groupId, methodId]) => ({ groupId, methodId })) }, "PUT");
|
|
526
|
+
await refresh();
|
|
527
|
+
go(2);
|
|
528
|
+
} catch (e: any) {
|
|
529
|
+
setError(e.message);
|
|
530
|
+
} finally {
|
|
531
|
+
setBusy(false);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------- Upsell ----------
|
|
536
|
+
function isAdded(u: UpsellOffer) {
|
|
537
|
+
return (cart?.items ?? []).some((it) => it.variantId === u.variantId);
|
|
538
|
+
}
|
|
539
|
+
// Só mostra ofertas que NÃO estão no carrinho (em estoque já é filtrado no servidor).
|
|
540
|
+
const availableUpsells = upsells.filter((u) => !isAdded(u));
|
|
541
|
+
// Adiciona a oferta (silent: não reabre o drawer). O item some da lista de ofertas (já está no
|
|
542
|
+
// carrinho) e o frete é recalculado pelo efeito de mudança de itens.
|
|
543
|
+
async function addUpsell(u: UpsellOffer) {
|
|
544
|
+
await add({ productId: u.productId, variantId: u.variantId, price: u.price, quantity: 1, thumbnail: u.imageUrl ?? undefined, title: u.name }, { silent: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------- Pagamento ----------
|
|
548
|
+
// device (antifraude/3DS Unbox) — 8 campos coletados do navegador real do comprador.
|
|
549
|
+
// Vai no corpo do POST /api/checkout; o BFF repassa ao placeOrder no ROOT do PlaceOrderInput.
|
|
550
|
+
// timezoneOffset em MINUTOS (BRT → 180) — se a Unbox confirmar que espera HORAS, dividir
|
|
551
|
+
// por 60 aqui (um lugar só). Ver lib/unbox/types.ts.
|
|
552
|
+
function collectDevice() {
|
|
553
|
+
try {
|
|
554
|
+
return {
|
|
555
|
+
type: "BROWSER" as const,
|
|
556
|
+
colorDepth: screen.colorDepth,
|
|
557
|
+
javaEnabled: typeof navigator.javaEnabled === "function" ? navigator.javaEnabled() : false,
|
|
558
|
+
userAgent: navigator.userAgent,
|
|
559
|
+
language: navigator.language,
|
|
560
|
+
screenHeight: screen.height,
|
|
561
|
+
screenWidth: screen.width,
|
|
562
|
+
timezoneOffset: new Date().getTimezoneOffset(),
|
|
563
|
+
};
|
|
564
|
+
} catch {
|
|
565
|
+
// best-effort: sem device o servidor cai no fallback { type: "API" } — nunca trava o checkout.
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function pay() {
|
|
571
|
+
setError(null);
|
|
572
|
+
setBusy(true);
|
|
573
|
+
if (payType === "pix") setGeneratingPix(true);
|
|
574
|
+
else setProcessingCard(true);
|
|
575
|
+
try {
|
|
576
|
+
let payment: any;
|
|
577
|
+
if (payType === "pix") {
|
|
578
|
+
payment = { type: "pix" as const };
|
|
579
|
+
} else {
|
|
580
|
+
const exp = onlyDigits(card.expiry);
|
|
581
|
+
const mm = exp.slice(0, 2);
|
|
582
|
+
const yy = exp.slice(2, 4);
|
|
583
|
+
payment = {
|
|
584
|
+
type: "card" as const,
|
|
585
|
+
card: {
|
|
586
|
+
cardHolder: card.cardHolder,
|
|
587
|
+
cardNumber: onlyDigits(card.cardNumber),
|
|
588
|
+
expirationMonth: mm,
|
|
589
|
+
expirationYear: yy ? `20${yy}` : "",
|
|
590
|
+
securityCode: card.securityCode,
|
|
591
|
+
installments,
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
trackAddPaymentInfo(cartToTrackItems(cart?.items, cartDiscount()), parseBRL(cart?.summary.total) ?? undefined, payType, appliedCoupon?.code, trackUser());
|
|
596
|
+
const data = await api("/api/checkout", {
|
|
597
|
+
email,
|
|
598
|
+
address,
|
|
599
|
+
payment,
|
|
600
|
+
recurringItemsFrequencyId: hasRecurring ? cart?.recurringItemsFrequencyId ?? undefined : undefined,
|
|
601
|
+
device: collectDevice(),
|
|
602
|
+
});
|
|
603
|
+
if (data.paymentType === "pix" && data.referenceId) {
|
|
604
|
+
// Exibe o Pix na própria página (QR + copia-e-cola + polling). NÃO damos refresh() aqui
|
|
605
|
+
// porque o pedido já invalidou o carrinho no servidor — manter o estado em memória.
|
|
606
|
+
// Total: o do PEDIDO criado (servidor). A estimativa local só entra se a API não devolver.
|
|
607
|
+
const tNow = parseBRL(cart?.summary.total) ?? 0;
|
|
608
|
+
const subNow = parseBRL(cart?.summary.itemTotal) ?? tNow;
|
|
609
|
+
const pixTotal = (typeof data.total === "string" && data.total) || formatBRL(Math.max(0, tNow - Math.round(subNow * PIX_DISCOUNT_PCT) / 100));
|
|
610
|
+
const emv: string | null = data.pix?.qrCode ?? null;
|
|
611
|
+
setPixResult({ ref: data.referenceId, emv, total: pixTotal });
|
|
612
|
+
// Persistir o copia-e-cola: reload/fechar aba perdia o Pix (o carrinho já foi consumido
|
|
613
|
+
// e o pedido não devolve o EMV). /checkout/pix/[ref] lê daqui.
|
|
614
|
+
try { if (emv) sessionStorage.setItem(`unbox_pix:${data.referenceId}`, emv); } catch {}
|
|
615
|
+
setGeneratingPix(false);
|
|
616
|
+
setBusy(false);
|
|
617
|
+
} else if (data.referenceId) {
|
|
618
|
+
await refresh();
|
|
619
|
+
router.push(`/pedido/${data.referenceId}`);
|
|
620
|
+
}
|
|
621
|
+
} catch (e: any) {
|
|
622
|
+
// NÃO retentar — o pedido pode já ter sido criado (cobrança real).
|
|
623
|
+
setError(e.message);
|
|
624
|
+
setGeneratingPix(false);
|
|
625
|
+
setProcessingCard(false);
|
|
626
|
+
if (e?.code === "PLACE_ORDER_TIMEOUT") {
|
|
627
|
+
// Estado desconhecido: mantém `busy` (botão travado) até a pessoa conferir o pedido.
|
|
628
|
+
setPaymentUnknown(true);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
setBusy(false);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function copyPix() {
|
|
636
|
+
if (!pixResult?.emv) return;
|
|
637
|
+
try { await navigator.clipboard.writeText(pixResult.emv); setPixCopied(true); setTimeout(() => setPixCopied(false), 2000); } catch {}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------- Loader processando pagamento no cartão (inclui assinatura) ----------
|
|
641
|
+
if (processingCard) {
|
|
642
|
+
return (
|
|
643
|
+
<div className="checkout-root store-layout min-h-screen bg-white">
|
|
644
|
+
<CheckoutHeader shopName={shopName} />
|
|
645
|
+
<div className="mx-auto flex max-w-[680px] flex-col items-center px-6 py-24 text-center">
|
|
646
|
+
<span className="h-14 w-14 animate-spin rounded-full border-[4px] border-[var(--store-line-2)] border-t-[var(--store-primary,#18181B)]" />
|
|
647
|
+
<h1 className="font-display mt-6 text-[22px] font-extrabold">{hasRecurring ? "Confirmando sua assinatura…" : "Processando seu pagamento…"}</h1>
|
|
648
|
+
<p className="mt-2 text-[15px] text-[var(--store-muted)]">Estamos autorizando seu cartão. Não feche nem atualize esta página.</p>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// (Pix: gerado/QR/confirmação são exibidos INLINE no passo de pagamento — ver abaixo.)
|
|
655
|
+
|
|
656
|
+
// ---------- Montando carrinho a partir do link (?produtos=/?cupom=) ----------
|
|
657
|
+
if ((!cart || cart.items.length === 0) && (linkBuilding || loading)) {
|
|
658
|
+
return (
|
|
659
|
+
<div className="checkout-root store-layout min-h-screen bg-white">
|
|
660
|
+
<CheckoutHeader shopName={shopName} />
|
|
661
|
+
<div className="mx-auto flex max-w-[680px] flex-col items-center px-6 py-24 text-center">
|
|
662
|
+
<Spinner className="animate-spin text-[34px] text-[var(--store-primary,#18181B)]" />
|
|
663
|
+
<h1 className="font-display mt-4 text-[20px] font-extrabold">{linkBuilding ? "Montando seu carrinho…" : "Carregando…"}</h1>
|
|
664
|
+
{linkBuilding && <p className="mt-1.5 text-[14px] text-[var(--store-muted)]">Só um instante enquanto preparamos seu pedido.</p>}
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---------- Carrinho vazio ----------
|
|
671
|
+
if (!cart || cart.items.length === 0) {
|
|
672
|
+
return (
|
|
673
|
+
<div className="checkout-root store-layout min-h-screen bg-white">
|
|
674
|
+
<CheckoutHeader shopName={shopName} />
|
|
675
|
+
<div className="mx-auto max-w-[680px] px-6 py-24 text-center">
|
|
676
|
+
<span className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white text-[var(--store-faint)]">
|
|
677
|
+
<Package className="text-4xl" />
|
|
678
|
+
</span>
|
|
679
|
+
<h1 className="font-display mt-5 text-[22px] font-extrabold">Seu carrinho está vazio</h1>
|
|
680
|
+
<p className="mt-2 text-[15px] text-[var(--store-muted)]">Adicione produtos antes de finalizar a compra.</p>
|
|
681
|
+
<Link href="/produtos" className="font-display mt-6 inline-flex h-[52px] items-center justify-center gap-2 rounded-xl bg-[var(--store-primary,#18181B)] px-7 text-[15px] font-bold text-white no-underline">
|
|
682
|
+
Ver produtos <ArrowRight weight="bold" />
|
|
683
|
+
</Link>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const offerTimer = `${String(Math.floor(offerTime / 60)).padStart(2, "0")}:${String(offerTime % 60).padStart(2, "0")}`;
|
|
690
|
+
const offerExpiring = offerTime > 0 && offerTime <= 30;
|
|
691
|
+
// null = promoção não configurada → os blocos correspondentes não renderizam (store-config)
|
|
692
|
+
const remainingForGift = GIFT_THRESHOLD != null ? Math.max(0, GIFT_THRESHOLD - subtotalAmount) : 0;
|
|
693
|
+
const remainingForFreeShip = FREE_SHIP_THRESHOLD != null ? Math.max(0, FREE_SHIP_THRESHOLD - subtotalAmount) : 0;
|
|
694
|
+
const freeShipPct = FREE_SHIP_THRESHOLD != null ? Math.min(100, Math.round((subtotalAmount / FREE_SHIP_THRESHOLD) * 100)) : 0;
|
|
695
|
+
const installmentTotal = parseBRL(cart.summary.total) ?? subtotalAmount;
|
|
696
|
+
// "Continuar para ofertas" só habilita com o CEP buscado e uma forma de envio escolhida.
|
|
697
|
+
const shippingChosen = addressRevealed && groups.length > 0 && groups.every((g) => !!selections[g.groupId]);
|
|
698
|
+
// Só libera o endereço de entrega quando a Identificação está completa (todos os campos válidos).
|
|
699
|
+
const identityComplete =
|
|
700
|
+
isValidEmail(email) &&
|
|
701
|
+
address.fullName.trim().length >= 2 &&
|
|
702
|
+
onlyDigits(address.phone).length >= 10 &&
|
|
703
|
+
isValidCpf(address.taxPayerId);
|
|
704
|
+
|
|
705
|
+
// Frete exibido = fulfillmentTotal do servidor (autoritativo, compõe o total cobrado), não o
|
|
706
|
+
// preço cru da opção. Mostra "Grátis" quando o backend zera o frete (regra de frete grátis).
|
|
707
|
+
const shippingSelected = !!cart.selectedShipping;
|
|
708
|
+
const shippingAmount = parseBRL(cart.summary.shippingTotal);
|
|
709
|
+
// Só consideramos o frete "pronto" quando foi cotado para o carrinho ATUAL e não está recotando.
|
|
710
|
+
// Assim, ao remover itens (ex.: cair abaixo do frete grátis), o valor velho some até atualizar.
|
|
711
|
+
const shippingReady = shippingSelected && shippingSig === itemsSig && !shipLoading;
|
|
712
|
+
const freteLabel = !shippingReady
|
|
713
|
+
? "—"
|
|
714
|
+
: shippingAmount === 0
|
|
715
|
+
? "Grátis"
|
|
716
|
+
: cart.summary.shippingTotal ?? cart.selectedShipping?.displayAmount ?? "—";
|
|
717
|
+
|
|
718
|
+
// Desconto Pix: regra REAL do backend (shopSale "5% no Pix"). O cart.summary não inclui esse
|
|
719
|
+
// desconto (é por método de pagamento, aplicado no placeOrder), então estimamos sobre o subtotal
|
|
720
|
+
// de produtos — consistente com a PDP. O valor cobrado continua sendo o do servidor.
|
|
721
|
+
const isPix = payType === "pix";
|
|
722
|
+
const totalNum = parseBRL(cart.summary.total) ?? subtotalAmount;
|
|
723
|
+
const pixDiscount = isPix ? Math.round(subtotalAmount * PIX_DISCOUNT_PCT) / 100 : 0;
|
|
724
|
+
const grandTotalNum = Math.max(0, totalNum - pixDiscount);
|
|
725
|
+
const grandTotalLabel = isPix ? formatBRL(grandTotalNum) : (cart.summary.total ?? "—");
|
|
726
|
+
|
|
727
|
+
// Conteúdo do resumo do pedido — reutilizado no sidebar (desktop) e na folha inferior (mobile).
|
|
728
|
+
const summaryContent = (
|
|
729
|
+
<>
|
|
730
|
+
<div className="font-display border-b border-[var(--store-surface-2)] px-[22px] pb-3.5 pt-5 text-[17px] font-extrabold">Resumo do pedido</div>
|
|
731
|
+
|
|
732
|
+
<div className="flex max-h-[320px] flex-col gap-3.5 overflow-y-auto px-[22px] py-4">
|
|
733
|
+
{cart.items.map((it) => (
|
|
734
|
+
<div key={it.id} className="flex items-center gap-3">
|
|
735
|
+
<span className="relative flex h-14 w-[46px] shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--store-surface)]">
|
|
736
|
+
{it.thumbnail && <Image src={it.thumbnail} alt={it.title} fill sizes="46px" className="object-contain p-0.5" />}
|
|
737
|
+
<span className="absolute -right-[7px] -top-[7px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-[var(--store-chrome-bg)] text-[11px] font-extrabold text-white">{it.quantity}</span>
|
|
738
|
+
</span>
|
|
739
|
+
<div className="min-w-0 flex-1">
|
|
740
|
+
<div className="text-[13.5px] font-bold leading-[1.2] text-[var(--store-ink)]">{it.title}</div>
|
|
741
|
+
{it.variantTitle && <div className="text-[11.5px] text-[var(--store-muted)]">{it.variantTitle}</div>}
|
|
742
|
+
<div className="mt-0.5 text-[11.5px] font-semibold text-[var(--store-primary,#18181B)]">
|
|
743
|
+
Qtd: {it.quantity}{it.quantity > 1 && !it.isBonus ? ` · ${it.displayUnitPrice} cada` : ""}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
<div className="flex flex-col items-end gap-[3px]">
|
|
747
|
+
<span className="whitespace-nowrap text-[13.5px] font-extrabold text-[var(--store-ink)]">{it.isBonus ? "GRÁTIS" : formatBRL(it.unitPrice * it.quantity)}</span>
|
|
748
|
+
{!it.isBonus && (
|
|
749
|
+
<button type="button" disabled={loading} onClick={() => remove(it.id)} className="border-none bg-transparent p-0 text-[10.5px] font-bold text-[var(--store-muted)] disabled:cursor-not-allowed disabled:opacity-50">remover</button>
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
))}
|
|
754
|
+
|
|
755
|
+
{remainingForGift > 0 && (
|
|
756
|
+
<div className="flex items-center gap-2.5 rounded-md border border-dashed border-[var(--store-line-2)] bg-[var(--store-surface)] px-3 py-2.5">
|
|
757
|
+
<Gift className="shrink-0 text-[19px] text-[var(--store-faint)]" />
|
|
758
|
+
<span className="text-[12px] text-[var(--store-muted)]">Faltam <b className="text-[var(--store-sale)]">{formatBRL(remainingForGift)}</b> para ganhar um <b>brinde grátis</b></span>
|
|
759
|
+
</div>
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* cupom */}
|
|
764
|
+
<div className="border-t border-[var(--store-surface-2)] px-[22px] py-3.5">
|
|
765
|
+
{appliedCoupon ? (
|
|
766
|
+
<div className="flex items-center justify-between gap-2.5 rounded-md border border-[var(--store-primary-soft)] bg-[var(--store-primary-soft,#F1F1F3)] px-3.5 py-2.5">
|
|
767
|
+
<span className="flex min-w-0 items-center gap-2"><SealCheck weight="fill" className="shrink-0 text-[17px] text-[var(--store-primary,#18181B)]" /><span className="text-[13px] font-extrabold leading-[1.25] text-[var(--store-primary,#18181B)]">Cupom {appliedCoupon.code?.toUpperCase()} aplicado</span></span>
|
|
768
|
+
<button type="button" onClick={() => removeCoupon()} className="flex shrink-0 items-center gap-1 border-none bg-transparent text-xs font-bold text-[var(--store-sale)]"><Trash className="text-sm" />Remover</button>
|
|
769
|
+
</div>
|
|
770
|
+
) : (
|
|
771
|
+
<div className="flex gap-2">
|
|
772
|
+
<input value={couponCode} onChange={(e) => setCouponCode(e.target.value)} placeholder="Cupom de desconto"
|
|
773
|
+
className="h-11 min-w-0 flex-1 rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-3.5 text-sm outline-none focus:border-[var(--store-primary,#18181B)]" />
|
|
774
|
+
<button type="button" disabled={loading || !couponCode.trim()} onClick={() => applyCoupon(couponCode.trim())}
|
|
775
|
+
className="font-display h-11 shrink-0 rounded-md bg-[var(--store-chrome-bg)] px-[18px] text-[13.5px] font-bold text-white disabled:opacity-60">Aplicar</button>
|
|
776
|
+
</div>
|
|
777
|
+
)}
|
|
778
|
+
</div>
|
|
779
|
+
|
|
780
|
+
{/* totals */}
|
|
781
|
+
<div className="flex flex-col gap-2.5 border-t border-[var(--store-surface-2)] px-[22px] py-3.5 text-[13.5px] text-[var(--store-ink-2)]">
|
|
782
|
+
<div className="flex justify-between"><span>Subtotal</span><span className="font-semibold text-[var(--store-ink)]">{cart.summary.itemTotal ?? "—"}</span></div>
|
|
783
|
+
{cart.summary.discountTotal && cart.summary.discountTotal !== "R$0,00" && (
|
|
784
|
+
<div className="flex justify-between"><span>Desconto</span><span className="font-bold text-[var(--store-primary,#18181B)]">- {cart.summary.discountTotal}</span></div>
|
|
785
|
+
)}
|
|
786
|
+
{isPix && pixDiscount > 0 && (
|
|
787
|
+
<div className="flex justify-between"><span>Desconto Pix ({PIX_DISCOUNT_PCT}%)</span><span className="font-bold text-[var(--store-primary,#18181B)]">- {formatBRL(pixDiscount)}</span></div>
|
|
788
|
+
)}
|
|
789
|
+
<div className="flex justify-between"><span>Frete</span><span className="font-bold" style={{ color: !shippingReady ? "var(--store-muted)" : freteLabel === "Grátis" ? "var(--store-primary,#18181B)" : "var(--store-ink)" }}>{freteLabel}</span></div>
|
|
790
|
+
{/* régua de frete grátis — só com FREE_SHIPPING_THRESHOLD real (senão promete frete que não existe) */}
|
|
791
|
+
{FREE_SHIP_THRESHOLD != null && (remainingForFreeShip > 0 ? (
|
|
792
|
+
<div>
|
|
793
|
+
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-[var(--store-surface-2)]"><div className="h-full rounded-full bg-gradient-to-r from-[var(--store-primary,#18181B)] to-[var(--store-primary)] transition-all" style={{ width: `${freeShipPct}%` }} /></div>
|
|
794
|
+
<div className="mt-1.5 text-[11.5px] text-[var(--store-muted)]">Faltam {formatBRL(remainingForFreeShip)} para Frete Grátis</div>
|
|
795
|
+
</div>
|
|
796
|
+
) : (!shippingSelected || freteLabel === "Grátis") ? (
|
|
797
|
+
<div className="flex items-center gap-1.5 text-[11.5px] font-bold text-[var(--store-primary,#18181B)]"><Truck weight="fill" className="text-[13px]" />Você ganhou Frete Grátis!</div>
|
|
798
|
+
) : null)}
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
<div className="flex items-center justify-between border-t border-[var(--store-surface-2)] bg-[var(--store-surface)] px-[22px] py-4">
|
|
802
|
+
<span className="font-display text-[17px] font-extrabold">Total</span>
|
|
803
|
+
<div className="text-right">
|
|
804
|
+
<div className="font-display text-[22px] font-extrabold leading-none text-[var(--store-primary,#18181B)]">{grandTotalLabel}</div>
|
|
805
|
+
{isPix && pixDiscount > 0 && <div className="text-[11px] font-semibold text-[var(--store-muted)]">no Pix · economia de {formatBRL(pixDiscount)}</div>}
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--store-surface-2)] px-[22px] py-3.5">
|
|
810
|
+
<span className="text-[11px] font-semibold text-[var(--store-muted)]">Pague com:</span>
|
|
811
|
+
<PaymentChips />
|
|
812
|
+
</div>
|
|
813
|
+
</>
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
return (
|
|
817
|
+
<div className="checkout-root store-layout min-h-screen bg-white text-[var(--store-ink)]">
|
|
818
|
+
<CheckoutHeader shopName={shopName} />
|
|
819
|
+
|
|
820
|
+
{/* step indicator */}
|
|
821
|
+
<div className="border-b border-[var(--store-line)] bg-white">
|
|
822
|
+
<div className="mx-auto max-w-[680px] px-6 pb-[22px] pt-5">
|
|
823
|
+
<StepBar step={step} maxStep={maxStep} onGo={go} />
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
|
|
827
|
+
{/* main */}
|
|
828
|
+
<div className="mx-auto grid max-w-[1080px] grid-cols-1 items-start gap-7 px-6 pb-[120px] pt-8 lg:grid-cols-[1.5fr_1fr] lg:pb-16">
|
|
829
|
+
{/* LEFT */}
|
|
830
|
+
<div>
|
|
831
|
+
{error && (
|
|
832
|
+
<div className="mb-4 flex items-start gap-2.5 rounded-md border border-[var(--store-sale-soft)] bg-[var(--store-sale-soft)] px-4 py-3 text-sm font-semibold text-[var(--store-sale)]" role="alert">
|
|
833
|
+
<Warning weight="fill" className="mt-0.5 shrink-0 text-base" />{error}
|
|
834
|
+
</div>
|
|
835
|
+
)}
|
|
836
|
+
{paymentUnknown && (
|
|
837
|
+
<p className="-mt-2 mb-4 text-sm text-[var(--store-ink-2)]">
|
|
838
|
+
Confira se o pedido foi criado em <Link href="/conta/pedidos" className="font-bold underline">Meus pedidos</Link> antes de tentar de novo.
|
|
839
|
+
</p>
|
|
840
|
+
)}
|
|
841
|
+
|
|
842
|
+
{/* ===== STEP 1 ===== */}
|
|
843
|
+
{step === 1 && (
|
|
844
|
+
<div className="flex flex-col gap-[18px]">
|
|
845
|
+
<Card>
|
|
846
|
+
<CardHead n="1" title="Identificação" />
|
|
847
|
+
<div className="flex flex-col gap-3.5">
|
|
848
|
+
<Labeled label="E-mail">
|
|
849
|
+
<input id="co-email" type="email" inputMode="email" autoComplete="email" placeholder="seu@email.com" className={inputCls}
|
|
850
|
+
value={email} onChange={(e) => setEmail(sanitizeEmail(e.target.value))} />
|
|
851
|
+
</Labeled>
|
|
852
|
+
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2">
|
|
853
|
+
<Labeled label="Nome completo">
|
|
854
|
+
<input id="co-name" autoComplete="name" placeholder="Como no documento" className={inputCls}
|
|
855
|
+
value={address.fullName} onChange={(e) => setAddr({ fullName: sanitizeName(e.target.value) })} />
|
|
856
|
+
</Labeled>
|
|
857
|
+
<Labeled label="Celular / WhatsApp">
|
|
858
|
+
<input id="co-phone" inputMode="tel" autoComplete="tel" placeholder="(11) 99999-9999" className={inputCls}
|
|
859
|
+
value={address.phone} onChange={(e) => setAddr({ phone: maskPhone(e.target.value) })} />
|
|
860
|
+
</Labeled>
|
|
861
|
+
</div>
|
|
862
|
+
<Labeled label="CPF">
|
|
863
|
+
<input id="co-cpf" inputMode="numeric" placeholder="000.000.000-00" maxLength={14}
|
|
864
|
+
className={cpfError ? `${inputCls} border-[var(--store-sale)] focus:border-[var(--store-sale)]` : inputCls}
|
|
865
|
+
value={address.taxPayerId}
|
|
866
|
+
onChange={(e) => { setAddr({ taxPayerId: maskCpf(e.target.value) }); if (cpfError) setCpfError(false); }}
|
|
867
|
+
onBlur={(e) => setCpfError(onlyDigits(e.target.value).length === 11 && !isValidCpf(e.target.value))} />
|
|
868
|
+
{cpfError && <span className="mt-1 block text-[11.5px] font-semibold text-[var(--store-sale)]">CPF inválido</span>}
|
|
869
|
+
</Labeled>
|
|
870
|
+
</div>
|
|
871
|
+
</Card>
|
|
872
|
+
|
|
873
|
+
<Card>
|
|
874
|
+
<div className="mb-[18px] flex items-center gap-2.5">
|
|
875
|
+
<MapPin weight="bold" className="text-[21px] text-[var(--store-primary,#18181B)]" />
|
|
876
|
+
<h2 className="font-display m-0 text-[18px] font-extrabold">Endereço de entrega</h2>
|
|
877
|
+
</div>
|
|
878
|
+
<div className="flex flex-col gap-3.5">
|
|
879
|
+
{!identityComplete && (
|
|
880
|
+
<p className="rounded-md border border-dashed border-[var(--store-line-2)] bg-[var(--store-surface)] px-4 py-3 text-[13px] text-[var(--store-muted)]">
|
|
881
|
+
Preencha seus dados acima (e-mail, nome, celular e CPF) para informar o endereço de entrega.
|
|
882
|
+
</p>
|
|
883
|
+
)}
|
|
884
|
+
{/* CEP + Buscar — só após a identificação; os demais campos só após buscar o CEP */}
|
|
885
|
+
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_auto]">
|
|
886
|
+
<Labeled label="CEP">
|
|
887
|
+
<div className="relative">
|
|
888
|
+
<input id="co-cep" inputMode="numeric" autoComplete="postal-code" disabled={!identityComplete}
|
|
889
|
+
className={`${inputCls} disabled:cursor-not-allowed disabled:bg-[var(--store-surface)] disabled:opacity-60`} placeholder="00000-000"
|
|
890
|
+
value={address.postal}
|
|
891
|
+
onChange={(e) => {
|
|
892
|
+
const v = maskCep(e.target.value);
|
|
893
|
+
setAddr({ postal: v });
|
|
894
|
+
setLockedByCep({ address1: false, city: false, region: false });
|
|
895
|
+
if (onlyDigits(v).length === 8) lookupCep(v); // busca automática ao completar
|
|
896
|
+
}}
|
|
897
|
+
onBlur={(e) => lookupCep(e.target.value)} />
|
|
898
|
+
{cepLoading && <Spinner className="absolute right-3.5 top-[calc(50%+3px)] -translate-y-1/2 animate-spin text-[var(--store-muted)]" />}
|
|
899
|
+
</div>
|
|
900
|
+
</Labeled>
|
|
901
|
+
<div className="flex items-end">
|
|
902
|
+
<button type="button" onClick={() => lookupCep(address.postal, true)} disabled={!identityComplete || onlyDigits(address.postal).length !== 8 || cepLoading}
|
|
903
|
+
className="font-display mt-[7px] flex h-[50px] items-center gap-2 rounded-md bg-[var(--store-chrome-bg)] px-6 text-sm font-bold text-white transition-opacity disabled:opacity-50">
|
|
904
|
+
{cepLoading ? <Spinner className="animate-spin" /> : <MapPin weight="bold" />}Buscar
|
|
905
|
+
</button>
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
{addressRevealed ? (
|
|
910
|
+
<>
|
|
911
|
+
<Labeled label="Endereço" locked={lockedByCep.address1}>
|
|
912
|
+
<input id="co-address1" autoComplete="address-line1" placeholder="Rua, avenida..." readOnly={lockedByCep.address1}
|
|
913
|
+
className={lockedByCep.address1 ? lockedCls : inputCls}
|
|
914
|
+
value={address.address1} onChange={(e) => setAddr({ address1: sanitizeText(e.target.value) })} />
|
|
915
|
+
</Labeled>
|
|
916
|
+
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-[120px_1fr_1fr]">
|
|
917
|
+
<Labeled label="Número">
|
|
918
|
+
<input id="co-number" inputMode="numeric" placeholder="123" className={inputCls} maxLength={10}
|
|
919
|
+
value={address.number} onChange={(e) => setAddr({ number: sanitizeHouseNumber(e.target.value) })} />
|
|
920
|
+
</Labeled>
|
|
921
|
+
<Labeled label="Bairro">
|
|
922
|
+
<input id="co-neighborhood" placeholder="Bairro" className={inputCls}
|
|
923
|
+
value={address.neighborhood} onChange={(e) => setAddr({ neighborhood: sanitizeText(e.target.value) })} />
|
|
924
|
+
</Labeled>
|
|
925
|
+
<Labeled label="Complemento">
|
|
926
|
+
<input autoComplete="address-line2" placeholder="Apto, bloco (opcional)" className={inputCls}
|
|
927
|
+
value={address.address2 ?? ""} onChange={(e) => setAddr({ address2: sanitizeText(e.target.value) })} />
|
|
928
|
+
</Labeled>
|
|
929
|
+
</div>
|
|
930
|
+
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_120px]">
|
|
931
|
+
<Labeled label="Cidade" locked={lockedByCep.city}>
|
|
932
|
+
<input id="co-city" autoComplete="address-level2" placeholder="Cidade" readOnly={lockedByCep.city}
|
|
933
|
+
className={lockedByCep.city ? lockedCls : inputCls}
|
|
934
|
+
value={address.city} onChange={(e) => setAddr({ city: sanitizeText(e.target.value) })} />
|
|
935
|
+
</Labeled>
|
|
936
|
+
<Labeled label="UF" locked={lockedByCep.region}>
|
|
937
|
+
<input id="co-region" autoComplete="address-level1" maxLength={2} placeholder="UF" readOnly={lockedByCep.region}
|
|
938
|
+
className={lockedByCep.region ? lockedCls : inputCls}
|
|
939
|
+
value={address.region} onChange={(e) => setAddr({ region: sanitizeUf(e.target.value) })} />
|
|
940
|
+
</Labeled>
|
|
941
|
+
</div>
|
|
942
|
+
</>
|
|
943
|
+
) : (
|
|
944
|
+
<p className="text-[13px] text-[var(--store-muted)]">Digite o CEP para preencher o endereço de entrega.</p>
|
|
945
|
+
)}
|
|
946
|
+
</div>
|
|
947
|
+
|
|
948
|
+
{/* forma de envio — só após buscar o CEP */}
|
|
949
|
+
{addressRevealed && (<>
|
|
950
|
+
<div className="mb-2.5 mt-5 flex items-center gap-2 text-sm font-bold text-[var(--store-ink-2)]">
|
|
951
|
+
Forma de envio
|
|
952
|
+
{shipSaving && (
|
|
953
|
+
<span className="flex items-center gap-1.5 text-[12px] font-semibold text-[var(--store-muted)]">
|
|
954
|
+
<Spinner className="animate-spin text-[13px]" /> atualizando o frete
|
|
955
|
+
</span>
|
|
956
|
+
)}
|
|
957
|
+
</div>
|
|
958
|
+
{shipLoading ? (
|
|
959
|
+
<div className="flex items-center gap-2.5 rounded-md border border-[var(--store-line-2)] bg-[var(--store-surface)] px-4 py-3.5 text-sm font-semibold text-[var(--store-muted)]">
|
|
960
|
+
<Spinner className="animate-spin text-base" /> Calculando opções de frete...
|
|
961
|
+
</div>
|
|
962
|
+
) : groups.length === 0 ? (
|
|
963
|
+
<div className="rounded-md border border-dashed border-[var(--store-line-2)] bg-[var(--store-surface)] px-4 py-3.5 text-[13px] text-[var(--store-muted)]">
|
|
964
|
+
{shipError ?? "Preencha o endereço completo para ver as opções de frete."}
|
|
965
|
+
</div>
|
|
966
|
+
) : (
|
|
967
|
+
<div className="flex flex-col gap-2.5">
|
|
968
|
+
{groups.map((g) => g.options.map((o) => {
|
|
969
|
+
const on = selections[g.groupId] === o.methodId;
|
|
970
|
+
// discountPrice só é desconto REAL quando > 0 e menor que o preço.
|
|
971
|
+
// "R$ 0,00" significa SEM desconto (não "grátis") — não há regra de frete grátis no backend.
|
|
972
|
+
const priceN = parseBRL(o.price);
|
|
973
|
+
const discN = parseBRL(o.discountPrice);
|
|
974
|
+
const hasDiscount = discN != null && discN > 0 && priceN != null && discN < priceN;
|
|
975
|
+
const effN = hasDiscount ? discN : priceN;
|
|
976
|
+
const isFree = effN === 0; // grátis só se o preço do método é realmente 0
|
|
977
|
+
return (
|
|
978
|
+
<button key={`${g.groupId}-${o.methodId}`} type="button"
|
|
979
|
+
disabled={loading}
|
|
980
|
+
onClick={() => chooseShipping(g.groupId, o.methodId)}
|
|
981
|
+
className="flex w-full items-center justify-between gap-3 rounded-md border-[1.5px] px-4 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-50"
|
|
982
|
+
style={{ background: on ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}>
|
|
983
|
+
<span className="flex items-center gap-3">
|
|
984
|
+
<Radio on={on} />
|
|
985
|
+
<span className="flex flex-col items-start">
|
|
986
|
+
<span className="flex items-center gap-1.5 text-sm font-bold">
|
|
987
|
+
<Lightning weight="bold" className="text-[17px] text-[var(--store-primary,#18181B)]" />{o.displayName}
|
|
988
|
+
</span>
|
|
989
|
+
{o.days != null && <span className="mt-0.5 text-xs text-[var(--store-muted)]">Receba em {o.days} dia(s)</span>}
|
|
990
|
+
</span>
|
|
991
|
+
</span>
|
|
992
|
+
<span className="flex items-center gap-2">
|
|
993
|
+
{hasDiscount && !isFree && <span className="text-xs text-[var(--store-faint)] line-through">{o.price}</span>}
|
|
994
|
+
<span className="text-sm font-extrabold" style={{ color: isFree || on ? "var(--store-primary,#18181B)" : "var(--store-ink)" }}>
|
|
995
|
+
{isFree ? "Grátis" : (hasDiscount ? o.discountPrice : o.price)}
|
|
996
|
+
</span>
|
|
997
|
+
</span>
|
|
998
|
+
</button>
|
|
999
|
+
);
|
|
1000
|
+
}))}
|
|
1001
|
+
</div>
|
|
1002
|
+
)}
|
|
1003
|
+
</>)}
|
|
1004
|
+
</Card>
|
|
1005
|
+
|
|
1006
|
+
<PrimaryButton onClick={continueFromStep1} disabled={busy || loading || !shippingChosen}>
|
|
1007
|
+
{busy ? <Spinner className="animate-spin" /> : null} Continuar para ofertas <ArrowRight weight="bold" />
|
|
1008
|
+
</PrimaryButton>
|
|
1009
|
+
</div>
|
|
1010
|
+
)}
|
|
1011
|
+
|
|
1012
|
+
{/* ===== STEP 2 — Ofertas ===== */}
|
|
1013
|
+
{step === 2 && (
|
|
1014
|
+
<div className="flex flex-col gap-[18px]">
|
|
1015
|
+
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-[var(--store-chrome-bg)] to-[var(--store-primary,#18181B)] px-6 py-[22px] text-white">
|
|
1016
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--store-cta,#D97706)] px-2.5 py-[5px] text-[11px] font-extrabold tracking-[0.5px] text-[var(--store-cta-fg,#1C1207)]">
|
|
1017
|
+
<Lightning weight="fill" />OFERTA EXCLUSIVA DESTE PEDIDO
|
|
1018
|
+
</span>
|
|
1019
|
+
<h2 className="font-display mb-1 mt-3 text-[22px] font-extrabold">Leve mais por bem menos</h2>
|
|
1020
|
+
<p className="mb-[15px] text-sm text-[var(--store-primary-soft)]">Desconto exclusivo só nesta etapa, antes de pagar. <b className="text-[var(--store-cta,#D97706)]">Depois disso o preço volta ao normal.</b></p>
|
|
1021
|
+
<div className="flex flex-wrap items-center gap-3.5">
|
|
1022
|
+
<div className="inline-flex items-center gap-3 rounded-xl bg-[var(--store-sale)] px-[18px] py-[11px]">
|
|
1023
|
+
<Alarm weight="fill" className="text-2xl text-white" />
|
|
1024
|
+
<span className="text-[12px] font-extrabold uppercase leading-[1.1] tracking-[1px] text-[var(--store-sale-soft)]">Oferta some<br />em</span>
|
|
1025
|
+
<span className={`font-display text-[30px] font-extrabold tabular-nums text-white ${offerExpiring ? "animate-store-pulse" : ""}`}>{offerTimer}</span>
|
|
1026
|
+
</div>
|
|
1027
|
+
{offerExpiring && (
|
|
1028
|
+
<span className="flex animate-store-pulse items-center gap-1.5 text-[13px] font-extrabold text-[var(--store-cta,#D97706)]"><Warning weight="fill" />Últimos segundos!</span>
|
|
1029
|
+
)}
|
|
1030
|
+
</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
|
|
1033
|
+
{availableUpsells.length === 0 ? (
|
|
1034
|
+
<Card><div className="text-sm text-[var(--store-muted)]">Nenhuma oferta disponível agora.</div></Card>
|
|
1035
|
+
) : (
|
|
1036
|
+
<div className="flex flex-col gap-3">
|
|
1037
|
+
{availableUpsells.map((u) => {
|
|
1038
|
+
const off = u.oldPrice ? Math.round((1 - u.price / u.oldPrice) * 100) : null;
|
|
1039
|
+
return (
|
|
1040
|
+
<div key={u.variantId} className="flex items-center gap-4 rounded-lg border-[1.5px] border-[var(--store-line)] bg-white p-4">
|
|
1041
|
+
<div className="relative flex h-20 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md bg-[var(--store-surface)]">
|
|
1042
|
+
{u.imageUrl && <Image src={u.imageUrl} alt={u.name} fill sizes="64px" className="object-contain p-1" />}
|
|
1043
|
+
</div>
|
|
1044
|
+
<div className="min-w-0 flex-1">
|
|
1045
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1046
|
+
<span className="text-[15px] font-bold text-[var(--store-ink)]">{u.name}</span>
|
|
1047
|
+
{off != null && off > 0 && <span className="rounded-[5px] bg-[var(--store-sale-soft)] px-2 py-[3px] text-[10px] font-extrabold text-[var(--store-sale)]">{off}% OFF</span>}
|
|
1048
|
+
</div>
|
|
1049
|
+
<div className="mt-0.5 text-[12.5px] text-[var(--store-muted)] line-clamp-1">{u.desc}</div>
|
|
1050
|
+
<div className="mt-[7px] flex items-center gap-2">
|
|
1051
|
+
{u.oldPrice && <span className="text-[12.5px] text-[var(--store-faint)] line-through">{formatBRL(u.oldPrice)}</span>}
|
|
1052
|
+
<span className="font-display text-[17px] font-extrabold text-[var(--store-primary,#18181B)]">{u.displayPrice}</span>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
<button type="button" onClick={() => addUpsell(u)} disabled={loading}
|
|
1056
|
+
className="font-display flex h-[42px] shrink-0 items-center gap-1.5 rounded-md border-[1.5px] border-[var(--store-primary,#18181B)] bg-white px-4 text-[13px] font-bold text-[var(--store-primary,#18181B)] disabled:opacity-60">
|
|
1057
|
+
<Plus weight="bold" />Adicionar
|
|
1058
|
+
</button>
|
|
1059
|
+
</div>
|
|
1060
|
+
);
|
|
1061
|
+
})}
|
|
1062
|
+
</div>
|
|
1063
|
+
)}
|
|
1064
|
+
|
|
1065
|
+
<div className="flex items-stretch gap-3">
|
|
1066
|
+
<SecondaryButton onClick={() => go(1)}><ArrowLeft weight="bold" />Voltar</SecondaryButton>
|
|
1067
|
+
<button type="button" onClick={() => go(3)} className="h-[56px] shrink-0 cursor-pointer border-none bg-transparent px-4 text-sm font-bold text-[var(--store-muted)] underline">Pular ofertas</button>
|
|
1068
|
+
<PrimaryButton onClick={() => go(3)} className="flex-1">Ir para pagamento <ArrowRight weight="bold" /></PrimaryButton>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
)}
|
|
1072
|
+
|
|
1073
|
+
{/* ===== STEP 3 — Pagamento ===== */}
|
|
1074
|
+
{step === 3 && (
|
|
1075
|
+
<div className="flex flex-col gap-[18px]">
|
|
1076
|
+
<Card>
|
|
1077
|
+
<CardHead n="3" title="Forma de pagamento" />
|
|
1078
|
+
{hasRecurring && (
|
|
1079
|
+
<div className="mb-4 rounded-md border border-[var(--store-primary-soft)] bg-[var(--store-primary-soft,#F1F1F3)] px-4 py-3 text-[13px] font-semibold text-[var(--store-primary,#18181B)]">
|
|
1080
|
+
Assinaturas exigem pagamento com cartão de crédito.
|
|
1081
|
+
</div>
|
|
1082
|
+
)}
|
|
1083
|
+
<div className="grid grid-cols-2 gap-3">
|
|
1084
|
+
{pixAllowed && (
|
|
1085
|
+
<button type="button" onClick={() => setPayType("pix")}
|
|
1086
|
+
className="flex items-center justify-between gap-2 rounded-md border-[1.5px] px-4 py-3.5"
|
|
1087
|
+
style={{ background: payType === "pix" ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: payType === "pix" ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}>
|
|
1088
|
+
<span className="flex items-center gap-2.5"><Radio on={payType === "pix"} /><QrCode weight="fill" className="text-[22px] text-[var(--store-primary,#18181B)]" /><span className="text-[15px] font-bold">Pix</span></span>
|
|
1089
|
+
{PIX_DISCOUNT_PCT > 0 && <span className="rounded-md bg-[var(--store-primary-soft,#F1F1F3)] px-2 py-1 text-[11px] font-extrabold text-[var(--store-primary,#18181B)]">{PIX_DISCOUNT_PCT}% OFF</span>}
|
|
1090
|
+
</button>
|
|
1091
|
+
)}
|
|
1092
|
+
{cardAllowed && (
|
|
1093
|
+
<button type="button" onClick={() => setPayType("card")}
|
|
1094
|
+
className="flex items-center justify-between gap-2 rounded-md border-[1.5px] px-4 py-3.5"
|
|
1095
|
+
style={{ background: payType === "card" ? "var(--store-primary-soft,#F1F1F3)" : "var(--store-surface)", borderColor: payType === "card" ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }}>
|
|
1096
|
+
<span className="flex items-center gap-2.5"><Radio on={payType === "card"} /><CreditCard weight="fill" className="text-[22px] text-[var(--store-primary,#18181B)]" /><span className="text-[15px] font-bold">Cartão</span></span>
|
|
1097
|
+
<span className="text-[11px] font-bold text-[var(--store-muted)]">até {maxInstallments}x</span>
|
|
1098
|
+
</button>
|
|
1099
|
+
)}
|
|
1100
|
+
</div>
|
|
1101
|
+
|
|
1102
|
+
{payType === "pix" && (
|
|
1103
|
+
<div className="mt-5">
|
|
1104
|
+
{pixStatus?.paid ? (
|
|
1105
|
+
<div className="rounded-2xl border border-[var(--store-primary-soft)] bg-[var(--store-primary-soft,#F1F1F3)] p-6 text-center">
|
|
1106
|
+
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white"><CheckCircle weight="fill" className="text-4xl text-[var(--store-primary,#18181B)]" /></span>
|
|
1107
|
+
<h3 className="font-display mt-3 text-[18px] font-extrabold">Pagamento confirmado!</h3>
|
|
1108
|
+
<p className="mt-1 text-sm text-[var(--store-ink-2)]">Recebemos seu Pix de <b>{pixResult?.total}</b>. Já estamos preparando seu pedido.</p>
|
|
1109
|
+
<Link href={`/pedido/${pixResult?.ref}`} className="font-display mt-4 inline-flex rounded-xl bg-[var(--store-primary,#18181B)] px-5 py-2.5 text-sm font-bold text-white no-underline">Ver pedido</Link>
|
|
1110
|
+
</div>
|
|
1111
|
+
) : pixResult ? (
|
|
1112
|
+
pixExpired ? (
|
|
1113
|
+
<div className="rounded-2xl border border-[var(--store-sale-soft)] bg-[var(--store-sale-soft)] p-5 text-sm font-semibold text-[var(--store-sale)]">
|
|
1114
|
+
O tempo para pagamento terminou. <Link href="/checkout" className="underline">Refaça o pedido</Link> para gerar um novo Pix.
|
|
1115
|
+
</div>
|
|
1116
|
+
) : (
|
|
1117
|
+
<div className="rounded-2xl border border-[var(--store-line)] bg-white p-6 text-center">
|
|
1118
|
+
<div className="text-sm text-[var(--store-muted)]">Valor no Pix</div>
|
|
1119
|
+
<div className="font-display text-[24px] font-extrabold text-[var(--store-primary,#18181B)]">{pixResult.total}</div>
|
|
1120
|
+
<div className="mx-auto mt-4 flex h-[200px] w-[200px] items-center justify-center rounded-lg border border-[var(--store-line-2)] bg-[var(--store-surface)]">
|
|
1121
|
+
{pixQr ? (
|
|
1122
|
+
<Image src={pixQr} alt="QR Code Pix" width={200} height={200} unoptimized className="rounded-md" />
|
|
1123
|
+
) : pixResult.emv ? (
|
|
1124
|
+
<Spinner className="animate-spin text-3xl text-[var(--store-muted)]" />
|
|
1125
|
+
) : (
|
|
1126
|
+
<span className="px-4 text-sm text-[var(--store-muted)]">Use o copia-e-cola abaixo</span>
|
|
1127
|
+
)}
|
|
1128
|
+
</div>
|
|
1129
|
+
{pixResult.emv && (
|
|
1130
|
+
<div className="mt-4 text-left">
|
|
1131
|
+
<div className="mb-1.5 text-xs font-semibold text-[var(--store-muted)]">Pix copia e cola</div>
|
|
1132
|
+
<div className="flex gap-2">
|
|
1133
|
+
<code className="min-w-0 flex-1 truncate rounded-md border border-[var(--store-line-2)] bg-[var(--store-surface)] px-3 py-2.5 text-xs text-[var(--store-ink-2)]">{pixResult.emv}</code>
|
|
1134
|
+
<button type="button" onClick={copyPix} className="font-display flex shrink-0 items-center gap-1.5 rounded-md bg-[var(--store-chrome-bg)] px-4 text-[13px] font-bold text-white">
|
|
1135
|
+
{pixCopied ? <Check weight="bold" /> : <Copy weight="bold" />}{pixCopied ? "Copiado" : "Copiar"}
|
|
1136
|
+
</button>
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
)}
|
|
1140
|
+
<div className="mt-4 flex items-center justify-center gap-2 text-sm font-semibold text-[var(--store-ink-2)]"><Spinner className="animate-spin" /> Aguardando confirmação do pagamento…</div>
|
|
1141
|
+
{pixStatus && <div className="mt-1 text-xs text-[var(--store-muted)]">Status: {pixStatus.label}</div>}
|
|
1142
|
+
<Link href={`/pedido/${pixResult.ref}`} className="mt-3 inline-block text-sm font-bold text-[var(--store-primary,#18181B)] no-underline">Acompanhar pedido</Link>
|
|
1143
|
+
</div>
|
|
1144
|
+
)
|
|
1145
|
+
) : generatingPix ? (
|
|
1146
|
+
<div className="flex items-center justify-center gap-2.5 rounded-2xl border border-[var(--store-line)] bg-[var(--store-surface)] px-5 py-7 text-sm font-semibold text-[var(--store-ink-2)]">
|
|
1147
|
+
<Spinner className="animate-spin text-base" /> Gerando seu Pix…
|
|
1148
|
+
</div>
|
|
1149
|
+
) : (
|
|
1150
|
+
<div>
|
|
1151
|
+
<div className="font-display text-[19px] font-extrabold text-[var(--store-primary,#18181B)]">{grandTotalLabel} <span className="text-[13px] font-bold text-[var(--store-muted)]">no Pix</span></div>
|
|
1152
|
+
<p className="mt-1.5 text-[13px] leading-[1.5] text-[var(--store-ink-2)]">Ao confirmar, geramos seu <b>QR Code Pix</b> aqui mesmo. A aprovação é <b>imediata</b> e o pedido é liberado na hora.</p>
|
|
1153
|
+
{PIX_DISCOUNT_PCT > 0 && <p className="mt-2 text-[12px] text-[var(--store-primary,#18181B)]">Desconto de {PIX_DISCOUNT_PCT}% no Pix aplicado automaticamente.</p>}
|
|
1154
|
+
</div>
|
|
1155
|
+
)}
|
|
1156
|
+
</div>
|
|
1157
|
+
)}
|
|
1158
|
+
|
|
1159
|
+
{payType === "card" && (
|
|
1160
|
+
<div className="mt-5 flex flex-col gap-3.5">
|
|
1161
|
+
<Labeled label="Número do cartão">
|
|
1162
|
+
<div className="relative">
|
|
1163
|
+
<input inputMode="numeric" autoComplete="cc-number" placeholder="0000 0000 0000 0000" className={`${inputCls} pr-12`}
|
|
1164
|
+
value={card.cardNumber} onChange={(e) => setCard({ ...card, cardNumber: maskCardNumber(e.target.value) })} />
|
|
1165
|
+
<CreditCard weight="fill" className="absolute right-4 top-[calc(50%+3px)] -translate-y-1/2 text-xl text-[var(--store-faint)]" />
|
|
1166
|
+
</div>
|
|
1167
|
+
</Labeled>
|
|
1168
|
+
<Labeled label="Nome impresso no cartão">
|
|
1169
|
+
<input autoComplete="cc-name" placeholder="Como está no cartão" className={inputCls}
|
|
1170
|
+
value={card.cardHolder} onChange={(e) => setCard({ ...card, cardHolder: sanitizeName(e.target.value) })} />
|
|
1171
|
+
</Labeled>
|
|
1172
|
+
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-[1fr_1fr_1.3fr]">
|
|
1173
|
+
<Labeled label="Validade">
|
|
1174
|
+
<input inputMode="numeric" autoComplete="cc-exp" placeholder="MM/AA" className={inputCls}
|
|
1175
|
+
value={card.expiry} onChange={(e) => setCard({ ...card, expiry: maskExpiry(e.target.value) })} />
|
|
1176
|
+
</Labeled>
|
|
1177
|
+
<Labeled label="CVV">
|
|
1178
|
+
<input inputMode="numeric" autoComplete="cc-csc" placeholder="123" className={inputCls}
|
|
1179
|
+
value={card.securityCode} onChange={(e) => setCard({ ...card, securityCode: onlyDigits(e.target.value).slice(0, 4) })} />
|
|
1180
|
+
</Labeled>
|
|
1181
|
+
{!hasRecurring && (
|
|
1182
|
+
<Labeled label="Parcelas">
|
|
1183
|
+
<select value={String(installments)} onChange={(e) => setInstallments(Number(e.target.value) || 1)}
|
|
1184
|
+
className="mt-[7px] h-[50px] w-full cursor-pointer rounded-md border-[1.5px] border-[var(--store-line-2)] bg-white px-3 text-sm font-semibold outline-none focus:border-[var(--store-primary,#18181B)]">
|
|
1185
|
+
{(installmentOptions.length
|
|
1186
|
+
? installmentOptions.map((o) => ({ n: o.installment, amt: o.amount }))
|
|
1187
|
+
// Sem resposta de /api/checkout/installments só existe "à vista": a
|
|
1188
|
+
// tabela de parcelas (e se tem juros) é da adquirente, não se inventa.
|
|
1189
|
+
: [{ n: 1, amt: installmentTotal }]
|
|
1190
|
+
).map(({ n, amt }) => (
|
|
1191
|
+
<option key={n} value={n}>{n}x de {formatBRL(amt)}{n === 1 ? " à vista" : Math.abs(amt * n - installmentTotal) < 0.05 ? " sem juros" : ""}</option>
|
|
1192
|
+
))}
|
|
1193
|
+
</select>
|
|
1194
|
+
</Labeled>
|
|
1195
|
+
)}
|
|
1196
|
+
</div>
|
|
1197
|
+
</div>
|
|
1198
|
+
)}
|
|
1199
|
+
</Card>
|
|
1200
|
+
|
|
1201
|
+
{/* Esconde o botão de pagar depois que o Pix foi gerado (já está aguardando pagamento). */}
|
|
1202
|
+
{!pixResult && (
|
|
1203
|
+
<div className="flex items-stretch gap-3">
|
|
1204
|
+
<SecondaryButton onClick={() => go(2)}><ArrowLeft weight="bold" />Voltar</SecondaryButton>
|
|
1205
|
+
<button type="button" onClick={pay} disabled={busy}
|
|
1206
|
+
className="font-display flex h-[56px] flex-1 cursor-pointer items-center justify-center gap-2.5 rounded-lg bg-[var(--store-cta,#D97706)] text-base font-extrabold tracking-[0.4px] text-[var(--store-cta-fg,#1C1207)] transition-colors hover:bg-[var(--store-cta-dark,#B45309)] disabled:opacity-60">
|
|
1207
|
+
{busy ? <Spinner className="animate-spin text-lg" /> : <LockSimple weight="fill" className="text-lg" />}
|
|
1208
|
+
{isPix ? `Pagar ${grandTotalLabel} no Pix` : `Pagar ${cart.summary.total ?? ""}`}
|
|
1209
|
+
</button>
|
|
1210
|
+
</div>
|
|
1211
|
+
)}
|
|
1212
|
+
<div className="flex flex-wrap justify-center gap-5">
|
|
1213
|
+
<span className="flex items-center gap-1.5 text-xs font-bold text-[var(--store-ink-2)]"><ShieldCheck className="text-base text-[var(--store-primary,#18181B)]" />Pagamento criptografado</span>
|
|
1214
|
+
<span className="flex items-center gap-1.5 text-xs font-bold text-[var(--store-ink-2)]"><ArrowsClockwise className="text-base text-[var(--store-primary,#18181B)]" />troca em 7 dias (CDC)</span>
|
|
1215
|
+
</div>
|
|
1216
|
+
</div>
|
|
1217
|
+
)}
|
|
1218
|
+
</div>
|
|
1219
|
+
|
|
1220
|
+
{/* RIGHT — resumo (desktop) */}
|
|
1221
|
+
<aside className="hidden overflow-hidden rounded-2xl border border-[var(--store-line)] bg-white lg:sticky lg:top-6 lg:block">
|
|
1222
|
+
{summaryContent}
|
|
1223
|
+
</aside>
|
|
1224
|
+
</div>
|
|
1225
|
+
{/* resumo do pedido — colapsado/fixo no bottom (mobile) */}
|
|
1226
|
+
<div className="lg:hidden">
|
|
1227
|
+
{summaryOpen && <div className="fixed inset-0 z-[60] bg-black/40" onClick={() => setSummaryOpen(false)} />}
|
|
1228
|
+
<div className="fixed inset-x-0 bottom-0 z-[61] border-t border-[var(--store-line)] bg-white shadow-[0_-6px_24px_rgba(0,0,0,.12)]">
|
|
1229
|
+
{summaryOpen && <div className="max-h-[60vh] overflow-y-auto border-b border-[var(--store-surface-2)]">{summaryContent}</div>}
|
|
1230
|
+
<button type="button" onClick={() => setSummaryOpen((o) => !o)} className="flex w-full items-center justify-between px-5 py-3.5 pb-[max(14px,env(safe-area-inset-bottom))]">
|
|
1231
|
+
<span className="flex items-center gap-2 text-sm font-bold text-[var(--store-ink)]"><CaretUp weight="bold" className={summaryOpen ? "rotate-180 transition-transform" : "transition-transform"} />{summaryOpen ? "Ocultar resumo" : "Resumo do pedido"}</span>
|
|
1232
|
+
<span className="font-display text-lg font-extrabold text-[var(--store-primary,#18181B)]">{grandTotalLabel}</span>
|
|
1233
|
+
</button>
|
|
1234
|
+
</div>
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ---------- helpers ----------
|
|
1241
|
+
function maskExpiry(s: string): string {
|
|
1242
|
+
const d = onlyDigits(s).slice(0, 4);
|
|
1243
|
+
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function CheckoutHeader({ shopName }: { shopName: string }) {
|
|
1247
|
+
return (
|
|
1248
|
+
<div className="border-b border-[var(--store-line)] bg-white">
|
|
1249
|
+
<div className="mx-auto flex max-w-[1080px] items-center justify-between gap-4 px-6 py-3.5">
|
|
1250
|
+
<Link href="/" className="flex min-w-0 flex-1 items-center gap-2.5 text-[13px] font-semibold text-[var(--store-muted)] no-underline">
|
|
1251
|
+
<ArrowLeft weight="bold" className="text-base" /><span className="truncate">Voltar à loja</span>
|
|
1252
|
+
</Link>
|
|
1253
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- logo do chrome: SVG de poucos KB. O next/image marcaria lazy num elemento que aparece em toda página (o preload scanner perde o recurso) e o reencode come o traço fino do lettering. Otimizar poucos KB não paga essas duas contas. */}
|
|
1254
|
+
<img src="/brand/logo.svg" alt={shopName} className="h-[42px] w-auto shrink-0" />
|
|
1255
|
+
<div className="flex flex-1 justify-end">
|
|
1256
|
+
<span className="flex items-center gap-1.5 text-[12.5px] font-bold text-[var(--store-primary,#18181B)]"><LockSimple weight="fill" className="text-base" />Ambiente 100% seguro</span>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function StepBar({ step, maxStep, onGo }: { step: number; maxStep: number; onGo: (n: number) => void }) {
|
|
1264
|
+
const labels = ["Identificação", "Ofertas", "Pagamento"];
|
|
1265
|
+
return (
|
|
1266
|
+
<div className="flex items-center">
|
|
1267
|
+
{labels.map((label, i) => {
|
|
1268
|
+
const n = i + 1;
|
|
1269
|
+
const done = step > n;
|
|
1270
|
+
const active = step === n;
|
|
1271
|
+
const reachable = n <= maxStep;
|
|
1272
|
+
return (
|
|
1273
|
+
<React.Fragment key={label}>
|
|
1274
|
+
<div onClick={() => reachable && onGo(n)} className="flex w-[108px] shrink-0 flex-col items-center gap-2" style={{ cursor: reachable ? "pointer" : "default" }}>
|
|
1275
|
+
<span className="font-display flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full text-sm font-extrabold"
|
|
1276
|
+
style={active ? { background: "var(--store-primary,#18181B)", color: "var(--store-surface)", boxShadow: "0 0 0 4px var(--store-primary-soft)" } : done ? { background: "var(--store-primary,#18181B)", color: "var(--store-surface)" } : { background: "var(--store-surface-2)", color: "var(--store-faint)" }}>
|
|
1277
|
+
{done ? <Check weight="bold" className="text-[17px]" /> : n}
|
|
1278
|
+
</span>
|
|
1279
|
+
<span className="whitespace-nowrap text-[12.5px]" style={{ fontWeight: active || done ? 700 : 500, color: active ? "var(--store-primary,#18181B)" : done ? "var(--store-ink-2)" : "var(--store-faint)" }}>{label}</span>
|
|
1280
|
+
</div>
|
|
1281
|
+
{n < 3 && <div className="mb-[26px] h-[2.5px] flex-1 rounded-full" style={{ background: step > n ? "var(--store-primary,#18181B)" : "var(--store-line)", margin: "0 -10px 26px" }} />}
|
|
1282
|
+
</React.Fragment>
|
|
1283
|
+
);
|
|
1284
|
+
})}
|
|
1285
|
+
</div>
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function Card({ children }: { children: React.ReactNode }) {
|
|
1290
|
+
return <div className="rounded-2xl border border-[var(--store-line)] bg-white p-6">{children}</div>;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function CardHead({ n, title }: { n: string; title: string }) {
|
|
1294
|
+
return (
|
|
1295
|
+
<div className="mb-[18px] flex items-center gap-2.5">
|
|
1296
|
+
<span className="font-display flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[var(--store-primary-soft,#F1F1F3)] text-sm font-extrabold text-[var(--store-primary,#18181B)]">{n}</span>
|
|
1297
|
+
<h2 className="font-display m-0 text-[18px] font-extrabold">{title}</h2>
|
|
1298
|
+
</div>
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function Labeled({ label, locked, children }: { label: string; locked?: boolean; children: React.ReactNode }) {
|
|
1303
|
+
return (
|
|
1304
|
+
<label className="block text-[13px] font-bold text-[var(--store-ink-2)]">
|
|
1305
|
+
<span className="flex items-center gap-1.5">{label}{locked && <LockSimple weight="fill" className="text-[11px] text-[var(--store-muted)]" />}</span>
|
|
1306
|
+
{children}
|
|
1307
|
+
</label>
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function Radio({ on }: { on: boolean }) {
|
|
1312
|
+
return (
|
|
1313
|
+
<span className="inline-block h-5 w-5 shrink-0 rounded-full border-2" style={{ borderColor: on ? "var(--store-primary,#18181B)" : "var(--store-faint)", background: on ? "radial-gradient(var(--store-primary,#18181B) 0 40%, var(--store-surface) 44%)" : "var(--store-surface)" }} />
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function PrimaryButton({ children, onClick, disabled, className }: { children: React.ReactNode; onClick: () => void; disabled?: boolean; className?: string }) {
|
|
1318
|
+
return (
|
|
1319
|
+
<button type="button" onClick={onClick} disabled={disabled}
|
|
1320
|
+
className={`font-display flex h-[56px] w-full cursor-pointer items-center justify-center gap-2.5 rounded-lg bg-[var(--store-primary,#18181B)] 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 ${className ?? ""}`}>
|
|
1321
|
+
{children}
|
|
1322
|
+
</button>
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function SecondaryButton({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
|
1327
|
+
return (
|
|
1328
|
+
<button type="button" onClick={onClick}
|
|
1329
|
+
className="flex h-[56px] shrink-0 cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-[var(--store-line-2)] bg-white px-[22px] text-sm font-bold text-[var(--store-ink-2)]">
|
|
1330
|
+
{children}
|
|
1331
|
+
</button>
|
|
1332
|
+
);
|
|
1333
|
+
}
|