@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,340 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Estado de carrinho no client. Conversa SÓ com o BFF (/api/cart*), nunca com a Unbox.
|
|
4
|
+
// Hidrata via GET /api/cart no mount; cada mutação atualiza o estado com o cart normalizado
|
|
5
|
+
// devolvido pelo BFF e exibe toasts para cartEvents (brinde/cupom) e avisos de quantidade.
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import type { UiCart } from "@/lib/cart-normalize";
|
|
9
|
+
import { cartEventLabel } from "@/lib/unbox/errors";
|
|
10
|
+
import { formatBRL } from "@/lib/format";
|
|
11
|
+
import { trackAddToCart, trackRemoveFromCart, trackViewCart } from "@/lib/analytics";
|
|
12
|
+
|
|
13
|
+
export interface AddItemArgs {
|
|
14
|
+
productId: string;
|
|
15
|
+
variantId: string;
|
|
16
|
+
price: number;
|
|
17
|
+
quantity?: number;
|
|
18
|
+
thumbnail?: string;
|
|
19
|
+
isRecurring?: boolean;
|
|
20
|
+
recurringItemsFrequencyId?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AppliedCoupon { code: string; discountId: string | null }
|
|
25
|
+
|
|
26
|
+
interface CartCtx {
|
|
27
|
+
cart: UiCart | null;
|
|
28
|
+
count: number;
|
|
29
|
+
loading: boolean;
|
|
30
|
+
/** true enquanto monta/restaura o carrinho a partir de um link (?produtos=/?cupom= ou ?id=&token=) */
|
|
31
|
+
linkBuilding: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Incrementa a cada restauração bem-sucedida via ?id=&token= (link de recuperação de
|
|
34
|
+
* carrinho). O checkout usa isso pra saber quando o e-mail/endereço do `cart` deve
|
|
35
|
+
* SOBRESCREVER o que está no localStorage do dispositivo — "quem tem o link, manda".
|
|
36
|
+
*/
|
|
37
|
+
linkRestoredAt: number;
|
|
38
|
+
open: boolean;
|
|
39
|
+
appliedCoupon: AppliedCoupon | null;
|
|
40
|
+
setOpen: (v: boolean) => void;
|
|
41
|
+
add: (item: AddItemArgs, opts?: { silent?: boolean }) => Promise<boolean>;
|
|
42
|
+
updateQty: (cartItemId: string, quantity: number) => Promise<void>;
|
|
43
|
+
remove: (cartItemId: string) => Promise<void>;
|
|
44
|
+
applyCoupon: (code: string) => Promise<boolean>;
|
|
45
|
+
removeCoupon: () => Promise<void>;
|
|
46
|
+
refresh: () => Promise<void>;
|
|
47
|
+
clear: () => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const Ctx = React.createContext<CartCtx | null>(null);
|
|
51
|
+
|
|
52
|
+
function showEvents(cart: UiCart | null) {
|
|
53
|
+
for (const ev of cart?.cartEvents ?? []) toast.info(cartEventLabel(ev.type));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Recalcula o resumo na hora (preço unitário × qtd) p/ subtotal/total atualizarem instantâneo ao
|
|
57
|
+
// mexer na quantidade/remover. Não estima o "total" quando há cupom (mantém o do servidor até reconciliar).
|
|
58
|
+
function estimateSummary(c: UiCart, items: UiCart["items"]): UiCart["summary"] {
|
|
59
|
+
const amt = items.reduce((s, it) => s + it.unitPrice * it.quantity, 0);
|
|
60
|
+
const hasDiscount = (c.discounts?.length ?? 0) > 0 || !!c.summary.discountTotal;
|
|
61
|
+
return { ...c.summary, totalAmount: amt, itemTotal: formatBRL(amt), ...(hasDiscount ? {} : { total: formatBRL(amt) }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function call(url: string, init?: RequestInit) {
|
|
65
|
+
const res = await fetch(url, { headers: { "Content-Type": "application/json" }, ...init });
|
|
66
|
+
const data = await res.json().catch(() => ({}));
|
|
67
|
+
if (!res.ok) throw new Error(data?.error || "Erro inesperado.");
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function CartProvider({ children }: { children: React.ReactNode }) {
|
|
72
|
+
const [cart, setCart] = React.useState<UiCart | null>(null);
|
|
73
|
+
const [loading, setLoading] = React.useState(false);
|
|
74
|
+
const [open, setOpen] = React.useState(false);
|
|
75
|
+
const [appliedCoupon, setAppliedCoupon] = React.useState<AppliedCoupon | null>(null);
|
|
76
|
+
// Espelho do carrinho para os handlers lerem o item ANTES da atualização otimista removê-lo
|
|
77
|
+
// (remove_from_cart precisa do nome/preço do que saiu).
|
|
78
|
+
const cartRef = React.useRef<UiCart | null>(null);
|
|
79
|
+
cartRef.current = cart;
|
|
80
|
+
|
|
81
|
+
// view_cart — toda vez que o mini-carrinho abre com item dentro.
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (!open || !cart?.items?.length) return;
|
|
84
|
+
trackViewCart(
|
|
85
|
+
cart.items.filter((i) => !i.isBonus).map((i) => ({ id: i.productId, name: i.title, variant: i.variantTitle, price: i.unitPrice, quantity: i.quantity })),
|
|
86
|
+
cart.summary.totalAmount,
|
|
87
|
+
);
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [open]);
|
|
90
|
+
|
|
91
|
+
// add/updateQty/remove/applyCoupon/removeCoupon/refresh cada um faz sua própria atualização
|
|
92
|
+
// otimista + chamada de rede + setCart(d.cart), sem coordenação entre eles. Se duas mutações
|
|
93
|
+
// se sobrepõem (remover um item enquanto ajusta a quantidade de outro, duplo-clique em
|
|
94
|
+
// remover), a resposta que chega POR ÚLTIMO vencia mesmo tendo sido emitida antes e já
|
|
95
|
+
// desatualizada — o item removido "voltava". mutationSeq resolve na raiz: cada mutação só
|
|
96
|
+
// aplica sua resposta (ou o refresh() de recuperação de erro) se ainda for a mais recente em
|
|
97
|
+
// curso; senão descarta em silêncio, confiando que a mutação mais nova reconcilia sozinha.
|
|
98
|
+
const mutationSeq = React.useRef(0);
|
|
99
|
+
|
|
100
|
+
const refresh = React.useCallback(async () => {
|
|
101
|
+
const seq = ++mutationSeq.current;
|
|
102
|
+
try {
|
|
103
|
+
const d = await call("/api/cart");
|
|
104
|
+
if (seq === mutationSeq.current) setCart(d.cart);
|
|
105
|
+
} catch {
|
|
106
|
+
/* silencioso na hidratação */
|
|
107
|
+
}
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
refresh();
|
|
112
|
+
}, [refresh]);
|
|
113
|
+
|
|
114
|
+
// Link de carrinho: ?produtos=SKU:qtd&cupom=CODE em QUALQUER URL (inclui /checkout, que fica
|
|
115
|
+
// compartilhável). Monta o carrinho via API, MANTÉM os parâmetros na URL e abre o drawer (exceto
|
|
116
|
+
// no checkout). Constrói uma vez por assinatura/sessão para não sobrescrever edições ao recarregar.
|
|
117
|
+
const [linkBuilding, setLinkBuilding] = React.useState(false);
|
|
118
|
+
const cartLinkRef = React.useRef(false);
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
if (typeof window === "undefined" || cartLinkRef.current) return;
|
|
121
|
+
const u = new URL(window.location.href);
|
|
122
|
+
const produtos = u.searchParams.get("produtos");
|
|
123
|
+
const cupom = u.searchParams.get("cupom");
|
|
124
|
+
if (!produtos && !cupom) return;
|
|
125
|
+
cartLinkRef.current = true;
|
|
126
|
+
const sig = `${produtos ?? ""}|${cupom ?? ""}`;
|
|
127
|
+
try { if (sessionStorage.getItem("cartlink") === sig) return; } catch { /* ignora */ }
|
|
128
|
+
setLinkBuilding(true);
|
|
129
|
+
(async () => {
|
|
130
|
+
try {
|
|
131
|
+
await call("/api/cart/link?" + u.searchParams.toString(), { method: "POST" });
|
|
132
|
+
try { sessionStorage.setItem("cartlink", sig); } catch { /* ignora */ }
|
|
133
|
+
await refresh();
|
|
134
|
+
if (!u.pathname.startsWith("/checkout")) setOpen(true);
|
|
135
|
+
} catch { /* ignora — não trava a navegação */ } finally {
|
|
136
|
+
setLinkBuilding(false);
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
}, [refresh]);
|
|
140
|
+
|
|
141
|
+
// Link de RECUPERAÇÃO de carrinho: ?id=<cartId>&token=<cartToken> em QUALQUER URL (o checkout
|
|
142
|
+
// é o destino típico, mas o provider roda em toda página). Valida no BFF antes de gravar o
|
|
143
|
+
// cookie (rota recusa e não mexe no carrinho atual se o par for inválido/expirado — doc 12).
|
|
144
|
+
const [linkRestoredAt, setLinkRestoredAt] = React.useState(0);
|
|
145
|
+
const cartRestoreRef = React.useRef(false);
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
if (typeof window === "undefined" || cartRestoreRef.current) return;
|
|
148
|
+
const u = new URL(window.location.href);
|
|
149
|
+
const id = u.searchParams.get("id");
|
|
150
|
+
const token = u.searchParams.get("token");
|
|
151
|
+
const freq = u.searchParams.get("freq");
|
|
152
|
+
if (!id || !token) return;
|
|
153
|
+
cartRestoreRef.current = true;
|
|
154
|
+
const sig = `${id}|${token}`;
|
|
155
|
+
try { if (sessionStorage.getItem("cartlink-restore") === sig) return; } catch { /* ignora */ }
|
|
156
|
+
setLinkBuilding(true);
|
|
157
|
+
const seq = ++mutationSeq.current;
|
|
158
|
+
(async () => {
|
|
159
|
+
try {
|
|
160
|
+
let linkUrl = `/api/cart/link?id=${encodeURIComponent(id)}&token=${encodeURIComponent(token)}`;
|
|
161
|
+
if (freq) linkUrl += `&freq=${encodeURIComponent(freq)}`;
|
|
162
|
+
const d = await call(linkUrl, { method: "POST" });
|
|
163
|
+
if (seq === mutationSeq.current) {
|
|
164
|
+
setCart(d.cart);
|
|
165
|
+
setLinkRestoredAt(Date.now());
|
|
166
|
+
if (!u.pathname.startsWith("/checkout")) setOpen(true);
|
|
167
|
+
}
|
|
168
|
+
try { sessionStorage.setItem("cartlink-restore", sig); } catch { /* ignora */ }
|
|
169
|
+
} catch { /* link inválido/expirado — mantém o carrinho atual, sem toast alarmante */ } finally {
|
|
170
|
+
if (seq === mutationSeq.current) setLinkBuilding(false);
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const add = React.useCallback(async (item: AddItemArgs, opts?: { silent?: boolean }) => {
|
|
176
|
+
const qty = item.quantity ?? 1;
|
|
177
|
+
// OTIMISTA: reflete no carrinho imediatamente (badge + drawer), sem esperar o back.
|
|
178
|
+
const tmpItem = {
|
|
179
|
+
id: `tmp-${item.variantId}-${item.price}`,
|
|
180
|
+
productId: item.productId, variantId: item.variantId,
|
|
181
|
+
title: item.title ?? "Produto", quantity: qty,
|
|
182
|
+
unitPrice: item.price, displayUnitPrice: formatBRL(item.price),
|
|
183
|
+
thumbnail: item.thumbnail, isRecurring: item.isRecurring, isBonus: false,
|
|
184
|
+
};
|
|
185
|
+
setCart((c) => {
|
|
186
|
+
// Primeiro item (carrinho ainda não carregado): cria um carrinho otimista p/ aparecer na hora.
|
|
187
|
+
if (!c) {
|
|
188
|
+
const amt = item.price * qty;
|
|
189
|
+
return {
|
|
190
|
+
cartId: "tmp", totalItemQuantity: qty, hasRecurring: !!item.isRecurring,
|
|
191
|
+
items: [tmpItem], discounts: [], fulfillmentGroupIds: [], cartEvents: [],
|
|
192
|
+
summary: { totalAmount: amt, itemTotal: formatBRL(amt), total: formatBRL(amt) },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const items = [...c.items];
|
|
196
|
+
const i = items.findIndex((it) => it.variantId === item.variantId && !it.isBonus && !it.isRecurring === !item.isRecurring);
|
|
197
|
+
if (i >= 0) items[i] = { ...items[i], quantity: items[i].quantity + qty };
|
|
198
|
+
else items.push(tmpItem);
|
|
199
|
+
return { ...c, items, totalItemQuantity: (c.totalItemQuantity ?? 0) + qty };
|
|
200
|
+
});
|
|
201
|
+
if (!opts?.silent) setOpen(true); // abre o drawer na hora
|
|
202
|
+
setLoading(true);
|
|
203
|
+
const seq = ++mutationSeq.current;
|
|
204
|
+
try {
|
|
205
|
+
const d = await call("/api/cart", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
body: JSON.stringify({
|
|
208
|
+
items: [
|
|
209
|
+
{
|
|
210
|
+
productId: item.productId,
|
|
211
|
+
productVariantId: item.variantId,
|
|
212
|
+
price: item.price,
|
|
213
|
+
quantity: qty,
|
|
214
|
+
thumbnail: item.thumbnail,
|
|
215
|
+
isRecurring: item.isRecurring,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
recurringItemsFrequencyId: item.recurringItemsFrequencyId,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
if (seq === mutationSeq.current) {
|
|
222
|
+
setCart(d.cart); // reconcilia com o servidor (autoritativo) — só se ainda for a mais recente
|
|
223
|
+
showEvents(d.cart);
|
|
224
|
+
for (const w of d.warnings ?? []) toast.warning(w);
|
|
225
|
+
}
|
|
226
|
+
toast.success(item.title ? `“${item.title}” adicionado ao carrinho` : "Adicionado ao carrinho"); // confirma
|
|
227
|
+
trackAddToCart({ id: item.productId, name: item.title ?? "Produto", price: item.price, quantity: qty });
|
|
228
|
+
return true;
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
toast.error(e.message);
|
|
231
|
+
if (seq === mutationSeq.current) await refresh(); // reverte ao estado real do servidor
|
|
232
|
+
return false;
|
|
233
|
+
} finally {
|
|
234
|
+
if (seq === mutationSeq.current) setLoading(false);
|
|
235
|
+
}
|
|
236
|
+
}, [refresh]);
|
|
237
|
+
|
|
238
|
+
const updateQty = React.useCallback(async (cartItemId: string, quantity: number) => {
|
|
239
|
+
const cur = cartRef.current?.items.find((i) => i.id === cartItemId);
|
|
240
|
+
if (cur && !cur.isBonus && quantity !== cur.quantity) {
|
|
241
|
+
const delta = quantity - cur.quantity;
|
|
242
|
+
const it = { id: cur.productId, name: cur.title, variant: cur.variantTitle, price: cur.unitPrice, quantity: Math.abs(delta) };
|
|
243
|
+
if (delta > 0) trackAddToCart(it); else trackRemoveFromCart(it);
|
|
244
|
+
}
|
|
245
|
+
// OTIMISTA: atualiza quantidade E subtotal/total na hora; o servidor reconcilia em seguida.
|
|
246
|
+
setCart((c) => {
|
|
247
|
+
if (!c) return c;
|
|
248
|
+
const items = c.items.map((it) => it.id === cartItemId ? { ...it, quantity } : it);
|
|
249
|
+
return { ...c, items, totalItemQuantity: items.reduce((s, it) => s + it.quantity, 0), summary: estimateSummary(c, items) };
|
|
250
|
+
});
|
|
251
|
+
setLoading(true);
|
|
252
|
+
const seq = ++mutationSeq.current;
|
|
253
|
+
try {
|
|
254
|
+
const d = await call("/api/cart/items", { method: "PATCH", body: JSON.stringify({ cartItemId, quantity }) });
|
|
255
|
+
if (seq === mutationSeq.current) { setCart(d.cart); showEvents(d.cart); }
|
|
256
|
+
} catch (e: any) {
|
|
257
|
+
toast.error(e.message);
|
|
258
|
+
if (seq === mutationSeq.current) await refresh();
|
|
259
|
+
} finally {
|
|
260
|
+
if (seq === mutationSeq.current) setLoading(false);
|
|
261
|
+
}
|
|
262
|
+
}, [refresh]);
|
|
263
|
+
|
|
264
|
+
const remove = React.useCallback(async (cartItemId: string) => {
|
|
265
|
+
const gone = cartRef.current?.items.find((i) => i.id === cartItemId);
|
|
266
|
+
if (gone && !gone.isBonus) trackRemoveFromCart({ id: gone.productId, name: gone.title, variant: gone.variantTitle, price: gone.unitPrice, quantity: gone.quantity });
|
|
267
|
+
// OTIMISTA: remove o item E atualiza subtotal/total na hora; o servidor reconcilia depois.
|
|
268
|
+
setCart((c) => {
|
|
269
|
+
if (!c) return c;
|
|
270
|
+
const items = c.items.filter((it) => it.id !== cartItemId);
|
|
271
|
+
return { ...c, items, totalItemQuantity: items.reduce((s, it) => s + it.quantity, 0), summary: estimateSummary(c, items) };
|
|
272
|
+
});
|
|
273
|
+
setLoading(true);
|
|
274
|
+
const seq = ++mutationSeq.current;
|
|
275
|
+
try {
|
|
276
|
+
const d = await call("/api/cart/items", { method: "DELETE", body: JSON.stringify({ cartItemIds: [cartItemId] }) });
|
|
277
|
+
if (seq === mutationSeq.current) { setCart(d.cart); showEvents(d.cart); }
|
|
278
|
+
} catch (e: any) {
|
|
279
|
+
toast.error(e.message);
|
|
280
|
+
if (seq === mutationSeq.current) await refresh();
|
|
281
|
+
} finally {
|
|
282
|
+
if (seq === mutationSeq.current) setLoading(false);
|
|
283
|
+
}
|
|
284
|
+
}, [refresh]);
|
|
285
|
+
|
|
286
|
+
const applyCoupon = React.useCallback(async (code: string) => {
|
|
287
|
+
setLoading(true);
|
|
288
|
+
const seq = ++mutationSeq.current;
|
|
289
|
+
try {
|
|
290
|
+
const d = await call("/api/cart/coupon", { method: "POST", body: JSON.stringify({ code }) });
|
|
291
|
+
if (seq === mutationSeq.current) {
|
|
292
|
+
setCart(d.cart);
|
|
293
|
+
if (d.coupon) setAppliedCoupon(d.coupon);
|
|
294
|
+
showEvents(d.cart);
|
|
295
|
+
}
|
|
296
|
+
toast.success("Cupom aplicado.");
|
|
297
|
+
return true;
|
|
298
|
+
} catch (e: any) {
|
|
299
|
+
toast.error(e.message);
|
|
300
|
+
return false;
|
|
301
|
+
} finally {
|
|
302
|
+
if (seq === mutationSeq.current) setLoading(false);
|
|
303
|
+
}
|
|
304
|
+
}, []);
|
|
305
|
+
|
|
306
|
+
const removeCoupon = React.useCallback(async () => {
|
|
307
|
+
if (!appliedCoupon?.discountId) {
|
|
308
|
+
toast.error("Não foi possível identificar o cupom para remover.");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
setLoading(true);
|
|
312
|
+
const seq = ++mutationSeq.current;
|
|
313
|
+
try {
|
|
314
|
+
const d = await call("/api/cart/coupon", { method: "DELETE", body: JSON.stringify({ discountId: appliedCoupon.discountId }) });
|
|
315
|
+
if (seq === mutationSeq.current) { setCart(d.cart); setAppliedCoupon(null); }
|
|
316
|
+
} catch (e: any) {
|
|
317
|
+
toast.error(e.message);
|
|
318
|
+
} finally {
|
|
319
|
+
if (seq === mutationSeq.current) setLoading(false);
|
|
320
|
+
}
|
|
321
|
+
}, [appliedCoupon]);
|
|
322
|
+
|
|
323
|
+
const clear = React.useCallback(async () => {
|
|
324
|
+
++mutationSeq.current; // invalida qualquer mutação em voo — não pode "reviver" item após o clear
|
|
325
|
+
await call("/api/cart", { method: "DELETE" });
|
|
326
|
+
setCart(null);
|
|
327
|
+
setAppliedCoupon(null);
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
const count = cart?.items?.reduce((s, i) => s + i.quantity, 0) ?? 0;
|
|
331
|
+
|
|
332
|
+
const value: CartCtx = { cart, count, loading, linkBuilding, linkRestoredAt, open, appliedCoupon, setOpen, add, updateQty, remove, applyCoupon, removeCoupon, refresh, clear };
|
|
333
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function useCart() {
|
|
337
|
+
const c = React.useContext(Ctx);
|
|
338
|
+
if (!c) throw new Error("useCart deve ser usado dentro de <CartProvider>");
|
|
339
|
+
return c;
|
|
340
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import {
|
|
7
|
+
X, Trash, ShoppingCartSimple, Repeat, Gift, LockSimple, SealCheck, Truck, CheckCircle,
|
|
8
|
+
} from "@phosphor-icons/react/dist/ssr";
|
|
9
|
+
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetClose } from "@/components/ui/sheet";
|
|
10
|
+
import { useCart } from "@/components/cart/cart-provider";
|
|
11
|
+
import { formatBRL, parseBRL } from "@/lib/format";
|
|
12
|
+
import { FREE_SHIPPING_THRESHOLD, GIFT_TIERS, PIX_DISCOUNT_PCT } from "@/lib/store-config";
|
|
13
|
+
import { goToCheckout } from "@/lib/checkout-nav";
|
|
14
|
+
|
|
15
|
+
// null/vazio = promoção não existe → o bloco correspondente NÃO renderiza (ver store-config).
|
|
16
|
+
const FREE_SHIP = FREE_SHIPPING_THRESHOLD;
|
|
17
|
+
const LADDER_MAX = GIFT_TIERS.length > 0 ? Math.max(...GIFT_TIERS.map((t) => t.threshold)) : null;
|
|
18
|
+
|
|
19
|
+
export function MiniCart() {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const { cart, open, setOpen, updateQty, remove, count, appliedCoupon, applyCoupon, removeCoupon, loading } = useCart();
|
|
22
|
+
const items = cart?.items ?? [];
|
|
23
|
+
const hasItems = items.length > 0;
|
|
24
|
+
// Base do progresso e do total = SUBTOTAL de produtos (não o total do servidor, que pode incluir
|
|
25
|
+
// frete). O frete é decidido no checkout — no carrinho mostramos só produtos (− desconto).
|
|
26
|
+
const net = parseBRL(cart?.summary.itemTotal) ?? cart?.summary.totalAmount ?? 0;
|
|
27
|
+
const discountAmount = parseBRL(cart?.summary.discountTotal) ?? 0;
|
|
28
|
+
// Desconto Pix (5%): mesma regra do checkout (Pix é o pagamento padrão; indisponível p/ assinatura).
|
|
29
|
+
// Calculado sobre o subtotal de produtos, consistente com a PDP/checkout.
|
|
30
|
+
const pixEligible = !cart?.hasRecurring;
|
|
31
|
+
const pixDiscount = pixEligible ? Math.round(net * PIX_DISCOUNT_PCT) / 100 : 0;
|
|
32
|
+
const drawerTotal = formatBRL(Math.max(0, net - discountAmount - pixDiscount));
|
|
33
|
+
|
|
34
|
+
const [code, setCode] = React.useState("");
|
|
35
|
+
const remaining = FREE_SHIP != null ? Math.max(0, FREE_SHIP - net) : 0;
|
|
36
|
+
const shipPct = FREE_SHIP != null ? Math.min(100, Math.round((net / FREE_SHIP) * 100)) : 0;
|
|
37
|
+
const ladderPct = LADDER_MAX != null ? Math.min(100, Math.round((net / LADDER_MAX) * 100)) : 0;
|
|
38
|
+
const reached = GIFT_TIERS.filter((t) => net >= t.threshold);
|
|
39
|
+
const current = reached.length ? reached[reached.length - 1] : null;
|
|
40
|
+
const next = GIFT_TIERS.find((t) => net < t.threshold) ?? null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
44
|
+
<SheetContent side="right" showCloseButton={false} className="store-layout w-screen max-w-full gap-0 bg-[var(--store-surface)] p-0 sm:w-[440px] sm:max-w-[440px]">
|
|
45
|
+
{/* header */}
|
|
46
|
+
<div className="flex shrink-0 items-center justify-between px-[22px] pb-3 pt-[18px]">
|
|
47
|
+
<SheetTitle className="font-display text-[21px] font-extrabold text-[var(--store-ink)]">
|
|
48
|
+
Seu Carrinho {count > 0 && <span className="text-sm font-semibold text-[var(--store-muted)]">({count})</span>}
|
|
49
|
+
</SheetTitle>
|
|
50
|
+
<SheetClose
|
|
51
|
+
render={<button aria-label="Fechar" className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-[var(--store-line)] bg-white" />}
|
|
52
|
+
>
|
|
53
|
+
<X weight="bold" className="text-base text-[var(--store-ink)]" />
|
|
54
|
+
</SheetClose>
|
|
55
|
+
</div>
|
|
56
|
+
<SheetDescription className="sr-only">Itens no seu carrinho</SheetDescription>
|
|
57
|
+
|
|
58
|
+
{/* body */}
|
|
59
|
+
<div className="flex-1 overflow-y-auto px-[22px] pb-4">
|
|
60
|
+
{!hasItems ? (
|
|
61
|
+
<div className="px-5 py-16 text-center text-[var(--store-muted)]">
|
|
62
|
+
<ShoppingCartSimple className="mx-auto text-5xl text-[var(--store-faint)]" />
|
|
63
|
+
<div className="mt-3.5 text-base font-bold text-[var(--store-ink-2)]">Seu carrinho está vazio</div>
|
|
64
|
+
<div className="mt-1 text-[13.5px]">Adicione produtos para continuar.</div>
|
|
65
|
+
</div>
|
|
66
|
+
) : (
|
|
67
|
+
<div className="overflow-hidden rounded-2xl border border-[var(--store-line)] bg-white">
|
|
68
|
+
{items.map((it) => (
|
|
69
|
+
<div key={it.id} className="flex gap-3.5 border-b border-[var(--store-surface-2)] p-4">
|
|
70
|
+
<div className="relative h-20 w-[72px] shrink-0 overflow-hidden rounded-md border border-[var(--store-line)] bg-[var(--store-line)]">
|
|
71
|
+
{it.thumbnail && <Image src={it.thumbnail} alt={it.title} fill sizes="72px" className="object-cover" />}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="min-w-0 flex-1">
|
|
74
|
+
<div className="flex justify-between gap-2.5">
|
|
75
|
+
<div>
|
|
76
|
+
<div className="text-[14.5px] font-bold leading-tight text-[var(--store-ink)]">{it.title}</div>
|
|
77
|
+
{it.variantTitle && <div className="mt-0.5 text-xs text-[var(--store-muted)]">{it.variantTitle}</div>}
|
|
78
|
+
{it.isRecurring && (
|
|
79
|
+
<span className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-[var(--store-primary-soft,#F1F1F3)] px-2 py-[3px] text-[10px] font-extrabold tracking-[0.3px] text-[var(--store-primary,#18181B)]">
|
|
80
|
+
<Repeat weight="fill" />ASSINATURA{cart?.recurringFrequencyTitle ? ` · ${cart.recurringFrequencyTitle}` : ""}
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="font-display whitespace-nowrap text-base font-extrabold text-[var(--store-ink)]">
|
|
85
|
+
{it.isBonus ? "GRÁTIS" : formatBRL(it.unitPrice)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="mt-3 flex items-center justify-between">
|
|
89
|
+
{it.isBonus ? (
|
|
90
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--store-primary-soft,#F1F1F3)] px-2.5 py-1 text-[11px] font-extrabold text-[var(--store-primary,#18181B)]"><Gift weight="fill" />Brinde</span>
|
|
91
|
+
) : (
|
|
92
|
+
<div className="flex items-center overflow-hidden rounded-sm border-[1.5px] border-[var(--store-line-2)]">
|
|
93
|
+
<button type="button" disabled={loading} onClick={() => updateQty(it.id, Math.max(1, it.quantity - 1))} className="h-[34px] w-[34px] cursor-pointer border-none bg-transparent text-[17px] text-[var(--store-ink-2)] disabled:cursor-not-allowed disabled:opacity-50">−</button>
|
|
94
|
+
<span className="w-[30px] text-center text-sm font-extrabold">{it.quantity}</span>
|
|
95
|
+
<button type="button" disabled={loading} onClick={() => updateQty(it.id, it.quantity + 1)} className="h-[34px] w-[34px] cursor-pointer border-none bg-transparent text-[17px] text-[var(--store-ink-2)] disabled:cursor-not-allowed disabled:opacity-50">+</button>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{!it.isBonus && (
|
|
99
|
+
<button type="button" disabled={loading} onClick={() => remove(it.id)} aria-label="Remover" className="flex cursor-pointer items-center border-none bg-transparent p-1.5 text-[var(--store-muted)] disabled:cursor-not-allowed disabled:opacity-50">
|
|
100
|
+
<Trash className="text-lg" />
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
|
|
108
|
+
{/* gift ladder — só com promoção de brinde REAL configurada (GIFT_TIERS) */}
|
|
109
|
+
{GIFT_TIERS.length > 0 && LADDER_MAX != null && (
|
|
110
|
+
<div className="bg-[var(--store-surface)] p-4">
|
|
111
|
+
<div className="mb-4 flex items-center justify-between">
|
|
112
|
+
<span className="flex items-center gap-1.5 text-[13.5px] font-extrabold text-[var(--store-ink)]"><Gift weight="fill" className="text-[17px] text-[var(--store-primary,#18181B)]" />Brinde do pedido</span>
|
|
113
|
+
<span className="text-[11.5px] font-bold text-[var(--store-muted)]">1 brinde por pedido</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="relative mx-1.5 mb-7 h-2 rounded-full bg-[var(--store-surface-2)]">
|
|
116
|
+
<div className="h-full rounded-full bg-gradient-to-r from-[var(--store-primary,#18181B)] to-[var(--store-primary)] transition-all" style={{ width: `${ladderPct}%` }} />
|
|
117
|
+
{GIFT_TIERS.map((t) => {
|
|
118
|
+
const on = net >= t.threshold;
|
|
119
|
+
return (
|
|
120
|
+
<div key={t.threshold} className="absolute top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center" style={{ left: `${(t.threshold / LADDER_MAX) * 100}%` }}>
|
|
121
|
+
<span className="h-4 w-4 rounded-full border-2 border-white shadow-[0_0_0_1px_var(--store-line-2)]" style={{ background: on ? "var(--store-primary,#18181B)" : "var(--store-line-2)" }} />
|
|
122
|
+
<span className="absolute top-5 whitespace-nowrap text-[10.5px] font-extrabold" style={{ color: on ? "var(--store-primary,#18181B)" : "var(--store-muted)" }}>R$ {t.threshold}</span>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
{current ? (
|
|
128
|
+
<div className="flex items-center gap-2.5 rounded-md border border-[var(--store-primary-soft)] bg-white px-3 py-2.5">
|
|
129
|
+
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-[var(--store-primary-soft,#F1F1F3)]"><Gift weight="fill" className="text-xl text-[var(--store-primary,#18181B)]" /></span>
|
|
130
|
+
<div className="min-w-0 flex-1">
|
|
131
|
+
<div className="text-[13px] font-extrabold text-[var(--store-primary,#18181B)]">{current.name}</div>
|
|
132
|
+
<div className="text-[11.5px] text-[var(--store-ink-2)]">Seu brinde atual · liberado em R$ {current.threshold}</div>
|
|
133
|
+
</div>
|
|
134
|
+
<span className="flex items-center gap-1 whitespace-nowrap text-[11.5px] font-extrabold text-[var(--store-primary,#18181B)]"><CheckCircle weight="fill" />GRÁTIS</span>
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<div className="flex items-center gap-2.5 rounded-md border border-dashed border-[var(--store-line-2)] bg-white px-3 py-2.5">
|
|
138
|
+
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-[var(--store-surface-2)]"><Gift className="text-xl text-[var(--store-faint)]" /></span>
|
|
139
|
+
<div className="min-w-0 flex-1 text-[12.5px] text-[var(--store-muted)]">Nenhum brinde ainda. Continue para desbloquear o primeiro.</div>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
{next && (
|
|
143
|
+
<div className="mt-2.5 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">
|
|
144
|
+
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-[var(--store-surface-2)]"><LockSimple className="text-lg text-[var(--store-faint)]" /></span>
|
|
145
|
+
<div className="min-w-0 flex-1">
|
|
146
|
+
<div className="text-[13px] font-bold text-[var(--store-ink-2)]">Próximo brinde</div>
|
|
147
|
+
<div className="text-[11.5px] text-[var(--store-muted)]">Adicione mais <b className="text-[var(--store-sale)]">{formatBRL(next.threshold - net)}</b> para desbloquear</div>
|
|
148
|
+
</div>
|
|
149
|
+
<span className="whitespace-nowrap text-[11px] font-extrabold text-[var(--store-faint)]">R$ {next.threshold}</span>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* footer */}
|
|
159
|
+
{hasItems && (
|
|
160
|
+
<div className="shrink-0 border-t border-[var(--store-line)] bg-white px-[22px] pb-[max(16px,env(safe-area-inset-bottom))] pt-3.5">
|
|
161
|
+
{/* coupon */}
|
|
162
|
+
{appliedCoupon ? (
|
|
163
|
+
<div className="mb-3 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">
|
|
164
|
+
<span className="flex min-w-0 items-center gap-2.5">
|
|
165
|
+
<SealCheck weight="fill" className="shrink-0 text-lg text-[var(--store-primary,#18181B)]" />
|
|
166
|
+
<span className="min-w-0">
|
|
167
|
+
<span className="block text-[13.5px] font-extrabold text-[var(--store-primary,#18181B)]">Cupom {appliedCoupon.code?.toUpperCase()} aplicado</span>
|
|
168
|
+
{cart?.summary.discountTotal && <span className="block text-xs text-[var(--store-ink-2)]">Você economizou {cart.summary.discountTotal}</span>}
|
|
169
|
+
</span>
|
|
170
|
+
</span>
|
|
171
|
+
<button type="button" onClick={() => removeCoupon()} className="flex shrink-0 cursor-pointer items-center gap-1 border-none bg-transparent text-[12.5px] font-bold text-[var(--store-sale)]"><Trash className="text-[15px]" />Remover</button>
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="mb-3 flex gap-2.5">
|
|
175
|
+
<input
|
|
176
|
+
value={code}
|
|
177
|
+
onChange={(e) => setCode(e.target.value)}
|
|
178
|
+
placeholder="Tem um cupom de desconto?"
|
|
179
|
+
className="h-[46px] min-w-0 flex-1 rounded-md border border-[var(--store-line-2)] bg-white px-4 text-sm outline-none"
|
|
180
|
+
/>
|
|
181
|
+
<button type="button" disabled={loading || !code.trim()} onClick={() => applyCoupon(code.trim())} className="font-display h-[46px] shrink-0 cursor-pointer rounded-md bg-[var(--store-chrome-bg)] px-6 text-sm font-bold text-white disabled:opacity-60">Aplicar</button>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* summary */}
|
|
186
|
+
<div className="mb-3.5 flex flex-col gap-2 text-sm text-[var(--store-ink-2)]">
|
|
187
|
+
<div className="flex justify-between"><span>Subtotal ({count})</span><span className="font-semibold text-[var(--store-ink)]">{cart?.summary.itemTotal ?? cart?.summary.total ?? "—"}</span></div>
|
|
188
|
+
{cart?.summary.discountTotal && (
|
|
189
|
+
<div className="flex justify-between"><span>Desconto</span><span className="font-bold text-[var(--store-primary,#18181B)]">- {cart.summary.discountTotal}</span></div>
|
|
190
|
+
)}
|
|
191
|
+
{pixDiscount > 0 && (
|
|
192
|
+
<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>
|
|
193
|
+
)}
|
|
194
|
+
{/* régua de frete grátis — só com FREE_SHIPPING_THRESHOLD real configurado */}
|
|
195
|
+
{FREE_SHIP != null && (
|
|
196
|
+
<div>
|
|
197
|
+
<div className="flex items-center justify-between gap-2">
|
|
198
|
+
<span>Frete</span>
|
|
199
|
+
<span className="text-xs font-bold text-[var(--store-primary,#18181B)]">{remaining > 0 ? `Faltam ${formatBRL(remaining)} para Frete Grátis` : "Você ganhou Frete Grátis!"}</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div className="mt-1.5 h-[7px] 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: `${shipPct}%` }} /></div>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
<div className="my-0.5 h-px bg-[var(--store-line)]" />
|
|
205
|
+
<div className="flex items-center justify-between">
|
|
206
|
+
<span className="font-display text-lg font-extrabold text-[var(--store-ink)]">Total</span>
|
|
207
|
+
<div className="text-right">
|
|
208
|
+
<span className="font-display text-xl font-extrabold text-[var(--store-primary,#18181B)]">{drawerTotal}</span>
|
|
209
|
+
{pixDiscount > 0 && <div className="text-[11px] font-semibold text-[var(--store-muted)]">no Pix · economia de {formatBRL(pixDiscount)}</div>}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="text-[11px] text-[var(--store-muted)]">Frete calculado no checkout</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<button type="button" onClick={() => { setOpen(false); void goToCheckout(router); }} className="font-display flex h-[54px] w-full cursor-pointer items-center justify-center gap-2.5 rounded-xl border-none 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)]">
|
|
216
|
+
<LockSimple weight="fill" className="text-lg" />FINALIZAR COMPRA
|
|
217
|
+
</button>
|
|
218
|
+
<div className="mt-3.5 flex justify-between gap-2">
|
|
219
|
+
<span className="flex items-center gap-1.5 text-[11px] font-extrabold text-[var(--store-ink)]"><LockSimple className="text-[15px] text-[var(--store-primary,#18181B)]" />100% Segura</span>
|
|
220
|
+
<span className="flex items-center gap-1.5 text-[11px] font-extrabold text-[var(--store-ink)]"><SealCheck className="text-[15px] text-[var(--store-primary,#18181B)]" />CDC</span>
|
|
221
|
+
<span className="flex items-center gap-1.5 text-[11px] font-extrabold text-[var(--store-ink)]"><Truck className="text-[15px] text-[var(--store-primary,#18181B)]" />Troca em 7 dias</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</SheetContent>
|
|
226
|
+
</Sheet>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ShoppingCartSimple } from "@phosphor-icons/react/dist/ssr";
|
|
4
|
+
import { useCart } from "@/components/cart/cart-provider";
|
|
5
|
+
|
|
6
|
+
export function CartButton({
|
|
7
|
+
withLabel = false,
|
|
8
|
+
/** "ink" = sobre fundo claro (padrão). "chrome" = sobre a cor de chrome (header sobreposto). */
|
|
9
|
+
tone = "ink",
|
|
10
|
+
}: {
|
|
11
|
+
withLabel?: boolean;
|
|
12
|
+
tone?: "ink" | "chrome";
|
|
13
|
+
}) {
|
|
14
|
+
const { count, setOpen } = useCart();
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
onClick={() => setOpen(true)}
|
|
19
|
+
aria-label="Abrir carrinho"
|
|
20
|
+
className={`relative flex min-h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center gap-2 border-none bg-transparent text-[15px] font-medium ${
|
|
21
|
+
tone === "chrome" ? "text-[var(--store-chrome-text,#ffffff)]" : "text-[var(--store-ink)]"
|
|
22
|
+
}`}
|
|
23
|
+
>
|
|
24
|
+
<span className="relative flex">
|
|
25
|
+
<ShoppingCartSimple className={withLabel ? "text-[23px]" : "text-[25px]"} />
|
|
26
|
+
{count > 0 && (
|
|
27
|
+
<span className="absolute -right-2 -top-2 flex h-[19px] min-w-[19px] items-center justify-center rounded-full border-2 border-white bg-[var(--store-sale)] px-1 text-[11px] font-extrabold text-white">
|
|
28
|
+
{count}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
31
|
+
</span>
|
|
32
|
+
{withLabel && <span className="max-md:hidden">Carrinho</span>}
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|