@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,116 @@
|
|
|
1
|
+
# Agente 05 — Cart (Carrinho)
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Doc de plano (histórico).** A implementação entregue pela foundation difere em nomes e
|
|
4
|
+
> caminhos de arquivo citados abaixo. O mapa REAL de arquivos por área é a tabela "Agentes
|
|
5
|
+
> 00-11" do `agents/MANAGER.md` — em conflito, valem o MANAGER e o código.
|
|
6
|
+
|
|
7
|
+
## Escopo
|
|
8
|
+
Contexto global do carrinho, drawer lateral, página `/carrinho`, API route server-side e input de cupom.
|
|
9
|
+
|
|
10
|
+
## Dependências
|
|
11
|
+
- Agente 00 (Scaffold) — `lib/unbox.ts`
|
|
12
|
+
|
|
13
|
+
## Arquivos proprietários
|
|
14
|
+
|
|
15
|
+
| Arquivo | Descrição |
|
|
16
|
+
|---------|-----------|
|
|
17
|
+
| `app/api/cart/route.ts` | Route Handler: create, add, update, remove, get, discount |
|
|
18
|
+
| `components/cart/CartContext.tsx` | Context + Provider + useCart hook (client) |
|
|
19
|
+
| `components/cart/CartDrawer.tsx` | Drawer lateral Base UI Dialog |
|
|
20
|
+
| `components/cart/CartItem.tsx` | Item com imagem, qtd, preço, remover |
|
|
21
|
+
| `components/cart/CartSummary.tsx` | Subtotal, desconto, frete, botão checkout |
|
|
22
|
+
| `components/cart/EmptyCart.tsx` | Estado vazio com CTA para catálogo |
|
|
23
|
+
| `components/cart/DiscountCodeInput.tsx` | Input + aplicar cupom |
|
|
24
|
+
| `app/(loja)/carrinho/page.tsx` | Versão full-page do carrinho |
|
|
25
|
+
|
|
26
|
+
## Interface do CartContext
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
interface CartContextValue {
|
|
30
|
+
cartId: string | null
|
|
31
|
+
cartToken: string | null
|
|
32
|
+
items: CartLineItem[]
|
|
33
|
+
totalCount: number // soma de quantities
|
|
34
|
+
subtotal: number
|
|
35
|
+
total: number
|
|
36
|
+
appliedDiscount?: { code: string; amount: number }
|
|
37
|
+
isOpen: boolean
|
|
38
|
+
isLoading: boolean
|
|
39
|
+
openCart(): void
|
|
40
|
+
closeCart(): void
|
|
41
|
+
addItem(params: AddParams): Promise<void>
|
|
42
|
+
updateQuantity(itemId: string, qty: number): Promise<void>
|
|
43
|
+
removeItem(itemId: string): Promise<void>
|
|
44
|
+
applyDiscount(code: string): Promise<{ success: boolean; error?: string }>
|
|
45
|
+
refreshCart(): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Persistência de cookies
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// Browser-side (client component) — cartId e cartToken NÃO são sensíveis
|
|
53
|
+
function saveCartCookies(cartId: string, cartToken: string) {
|
|
54
|
+
const exp = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toUTCString()
|
|
55
|
+
document.cookie = `unbox_cart_id=${cartId}; expires=${exp}; path=/; SameSite=Lax`
|
|
56
|
+
document.cookie = `unbox_cart_token=${cartToken}; expires=${exp}; path=/; SameSite=Lax`
|
|
57
|
+
}
|
|
58
|
+
function readCartCookies() {
|
|
59
|
+
const get = (n: string) => document.cookie.match(new RegExp(`(?:^|; )${n}=([^;]*)`))?.[1]
|
|
60
|
+
return { cartId: get("unbox_cart_id") ?? null, cartToken: get("unbox_cart_token") ?? null }
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API Route /api/cart
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// POST /api/cart — actions: create | add | update | remove | get | discount | clear
|
|
68
|
+
import { getUnboxClient } from "@/lib/unbox"
|
|
69
|
+
|
|
70
|
+
export async function POST(req: Request) {
|
|
71
|
+
const client = await getUnboxClient()
|
|
72
|
+
const body = await req.json()
|
|
73
|
+
|
|
74
|
+
switch (body.action) {
|
|
75
|
+
case "create":
|
|
76
|
+
return Response.json(await client.createCart({ shopId: client.shopId }))
|
|
77
|
+
case "add":
|
|
78
|
+
return Response.json(await client.addCartItems(body.cartId, body.cartToken, [body.item]))
|
|
79
|
+
case "update":
|
|
80
|
+
return Response.json(await client.updateCartItem(body.cartId, body.cartToken, body.itemId, body.qty))
|
|
81
|
+
case "remove":
|
|
82
|
+
return Response.json(await client.removeCartItem(body.cartId, body.cartToken, body.itemId))
|
|
83
|
+
case "get":
|
|
84
|
+
return Response.json(await client.getCart(body.cartId, body.cartToken))
|
|
85
|
+
case "discount":
|
|
86
|
+
return Response.json(await client.applyDiscountCode(body.cartId, body.cartToken, body.code))
|
|
87
|
+
default:
|
|
88
|
+
return Response.json({ error: "Ação inválida" }, { status: 400 })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## CartDrawer (Base UI)
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import * as Dialog from "@base-ui-components/react/dialog"
|
|
97
|
+
|
|
98
|
+
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
|
99
|
+
<Dialog.Backdrop className="fixed inset-0 bg-black/40" />
|
|
100
|
+
<Dialog.Popup render={
|
|
101
|
+
<aside className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-2xl flex flex-col" />
|
|
102
|
+
}>
|
|
103
|
+
<Dialog.Title>Meu Carrinho ({totalCount})</Dialog.Title>
|
|
104
|
+
{/* items */}
|
|
105
|
+
<CartSummary />
|
|
106
|
+
</Dialog.Popup>
|
|
107
|
+
</Dialog.Root>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Cupom — regra crítica
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// discountCodes(first:) recebe ConnectionLimitInt — máximo 20
|
|
114
|
+
// Ao aplicar: mostrar erro via friendlyError() se falhar
|
|
115
|
+
// Erro de cupom inválido: cartEventLabel("COUPON_NOT_APPLICABLE") → "Cupom não aplicável"
|
|
116
|
+
```
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Agente 06 — Checkout (Pagamento)
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Doc de plano (histórico).** A implementação entregue pela foundation difere em nomes e
|
|
4
|
+
> caminhos de arquivo citados abaixo. O mapa REAL de arquivos por área é a tabela "Agentes
|
|
5
|
+
> 00-11" do `agents/MANAGER.md` — em conflito, valem o MANAGER e o código.
|
|
6
|
+
|
|
7
|
+
## Escopo
|
|
8
|
+
Fluxo completo de checkout: endereço → cotação de frete → seleção → pagamento (Pix/Cartão) → pedido.
|
|
9
|
+
|
|
10
|
+
## Dependências
|
|
11
|
+
- Agente 00 (Scaffold)
|
|
12
|
+
- Agente 05 (Cart) — `cartId` e `cartToken` vêm do CartContext
|
|
13
|
+
|
|
14
|
+
## Arquivos proprietários
|
|
15
|
+
|
|
16
|
+
| Arquivo | Descrição |
|
|
17
|
+
|---------|-----------|
|
|
18
|
+
| `app/api/checkout/route.ts` | Route Handler com lock de idempotência por cartId |
|
|
19
|
+
| `app/(loja)/checkout/page.tsx` | Multi-step checkout (client-heavy) |
|
|
20
|
+
| `app/(loja)/pedido/[referenceId]/page.tsx` | Confirmação de pedido |
|
|
21
|
+
| `components/checkout/AddressForm.tsx` | Formulário de endereço |
|
|
22
|
+
| `components/checkout/ShippingSelector.tsx` | Lista de opções de frete |
|
|
23
|
+
| `components/checkout/PaymentSelector.tsx` | Pix ou Cartão |
|
|
24
|
+
| `components/checkout/PixPayment.tsx` | QR code + copia-e-cola + polling |
|
|
25
|
+
| `components/checkout/CreditCardForm.tsx` | Dados do cartão |
|
|
26
|
+
| `components/checkout/OrderSummary.tsx` | Resumo dos itens (sidebar) |
|
|
27
|
+
| `components/checkout/CheckoutProgress.tsx` | Steps: Endereço → Frete → Pagamento |
|
|
28
|
+
|
|
29
|
+
## Fluxo da API (ordem obrigatória)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
1. setShippingAddressOnCart → fulfillmentGroupId
|
|
33
|
+
2. updateFulfillmentOptionsForGroup → availableFulfillmentOptions[]
|
|
34
|
+
3. (opcional) applyDiscountCodeToCart
|
|
35
|
+
4. selectFulfillmentOptionForGroup → total com frete
|
|
36
|
+
5. placeOrder → order (Pix QR | resultado cartão)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## device (antifraude/3DS) — OBRIGATÓRIO no placeOrder
|
|
40
|
+
|
|
41
|
+
Vai no **nível raiz** do `PlaceOrderInput` (irmão de `order` e `payments`), campo único
|
|
42
|
+
com enum `type`:
|
|
43
|
+
|
|
44
|
+
- `type: "BROWSER"` — 8 campos reais do navegador (colorDepth, javaEnabled, userAgent,
|
|
45
|
+
language, screenHeight, screenWidth, timezoneOffset), coletados em
|
|
46
|
+
`components/checkout/checkout-client.tsx` e repassados pelo BFF (`/api/checkout`).
|
|
47
|
+
- `type: "API"` — fallback para pedidos server-side (scripts) — o SDK aplica
|
|
48
|
+
automaticamente quando `device` é omitido.
|
|
49
|
+
|
|
50
|
+
`timezoneOffset` em **minutos** (`getTimezoneOffset()`, BRT → 180) — ponto aberto com a
|
|
51
|
+
Unbox se seria em horas; ver `lib/unbox/types.ts`.
|
|
52
|
+
|
|
53
|
+
## AddressInput — campos obrigatórios
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// NON_NULL no schema: fullName, phone, postal, address1, city, region, country
|
|
57
|
+
// Validados ao vivo (obrigatórios na prática): taxPayerId, number, neighborhood
|
|
58
|
+
interface AddressInput {
|
|
59
|
+
fullName: string; taxPayerId: string; postal: string
|
|
60
|
+
address1: string; number: string; neighborhood: string
|
|
61
|
+
city: string; region: string; phone: string
|
|
62
|
+
country?: string // default "BR"
|
|
63
|
+
address2?: string
|
|
64
|
+
isCommercial?: boolean
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Idempotência (CRÍTICO)
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// Em memória (dev/single-instance) — em prod usar KV/Redis
|
|
72
|
+
const inFlight = new Map<string, true>()
|
|
73
|
+
|
|
74
|
+
export async function POST(req: Request) {
|
|
75
|
+
const { action, cartId, cartToken, ...rest } = await req.json()
|
|
76
|
+
|
|
77
|
+
if (action === "place") {
|
|
78
|
+
if (inFlight.has(cartId)) {
|
|
79
|
+
return Response.json({ error: "pedido_em_andamento" }, { status: 409 })
|
|
80
|
+
}
|
|
81
|
+
inFlight.set(cartId, true)
|
|
82
|
+
try {
|
|
83
|
+
const result = await doPlaceOrder(cartId, cartToken, rest)
|
|
84
|
+
return Response.json(result)
|
|
85
|
+
} finally {
|
|
86
|
+
inFlight.delete(cartId)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ... outros actions
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Pix — polling de status
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
// Polling a cada 5s até status !== "new" e !== "processing"
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!orderId || status === "paid") return
|
|
99
|
+
const id = setInterval(async () => {
|
|
100
|
+
const res = await fetch(`/api/checkout?orderId=${orderId}`)
|
|
101
|
+
const { order } = await res.json()
|
|
102
|
+
if (order.status !== "new" && order.status !== "processing") {
|
|
103
|
+
setStatus(order.status)
|
|
104
|
+
clearInterval(id)
|
|
105
|
+
if (order.status === "paid") router.push(`/pedido/${order.referenceId}`)
|
|
106
|
+
}
|
|
107
|
+
}, 5000)
|
|
108
|
+
return () => clearInterval(id)
|
|
109
|
+
}, [orderId, status])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Múltiplos grupos de frete
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// NUNCA assuma fulfillmentGroups[0] apenas
|
|
116
|
+
// Itere todos os grupos:
|
|
117
|
+
for (const group of cart.checkout.fulfillmentGroups) {
|
|
118
|
+
await updateFulfillmentOptionsForGroup({ cartId, cartToken, fulfillmentGroupId: group._id })
|
|
119
|
+
}
|
|
120
|
+
// Ao selecionar frete, selecionar para CADA grupo
|
|
121
|
+
// No placeOrder, incluir TODOS os grupos
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Página de confirmação (/pedido/[referenceId])
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// Buscar pedido por referenceId
|
|
128
|
+
// Status: use orderStatusLabel(order.status) — NUNCA displayStatus(language)
|
|
129
|
+
// Rastreio: order.fulfillmentGroups.data[0].tracking.trackingCode (não trackingUrl)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Submit do formulário — botão único
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
// Prevenir duplo clique:
|
|
136
|
+
const [submitting, setSubmitting] = useState(false)
|
|
137
|
+
async function handleSubmit() {
|
|
138
|
+
if (submitting) return
|
|
139
|
+
setSubmitting(true)
|
|
140
|
+
try { await placeOrder() }
|
|
141
|
+
finally { setSubmitting(false) }
|
|
142
|
+
}
|
|
143
|
+
<button disabled={submitting} aria-busy={submitting}>
|
|
144
|
+
{submitting ? "Processando..." : "Finalizar Pedido"}
|
|
145
|
+
</button>
|
|
146
|
+
```
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Agente 07 — Auth (OTP Login)
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Doc de plano (histórico).** A implementação entregue pela foundation difere em nomes e
|
|
4
|
+
> caminhos de arquivo citados abaixo. O mapa REAL de arquivos por área é a tabela "Agentes
|
|
5
|
+
> 00-11" do `agents/MANAGER.md` — em conflito, valem o MANAGER e o código.
|
|
6
|
+
|
|
7
|
+
## Escopo
|
|
8
|
+
Login passwordless (OTP por e-mail), gestão de sessão via cookie httpOnly, e proteção de rotas.
|
|
9
|
+
|
|
10
|
+
## Dependências
|
|
11
|
+
- Agente 00 (Scaffold) — `lib/auth.ts` base
|
|
12
|
+
|
|
13
|
+
## Arquivos proprietários
|
|
14
|
+
|
|
15
|
+
| Arquivo | Descrição |
|
|
16
|
+
|---------|-----------|
|
|
17
|
+
| `app/(loja)/conta/entrar/page.tsx` | Página de login (2 steps: email → código) |
|
|
18
|
+
| `app/api/account/otp/route.ts` | Dispara OTP por e-mail |
|
|
19
|
+
| `app/api/account/signin/route.ts` | Troca código OTP pelo token do cliente |
|
|
20
|
+
| `app/api/account/signout/route.ts` | Limpa cookie e redireciona |
|
|
21
|
+
| `lib/auth.ts` | getCustomerToken / setCustomerToken / clearCustomerToken |
|
|
22
|
+
| `middleware.ts` | Protege /conta/pedidos e /conta/assinaturas |
|
|
23
|
+
| `components/auth/OTPForm.tsx` | Formulário em dois steps |
|
|
24
|
+
|
|
25
|
+
## lib/auth.ts (completo)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import "server-only"
|
|
29
|
+
import { cookies } from "next/headers"
|
|
30
|
+
|
|
31
|
+
const COOKIE_NAME = "unbox_customer_token"
|
|
32
|
+
const COOKIE_OPTS = {
|
|
33
|
+
httpOnly: true,
|
|
34
|
+
secure: process.env.NODE_ENV === "production",
|
|
35
|
+
sameSite: "lax" as const,
|
|
36
|
+
path: "/",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function getCustomerToken(): Promise<string | undefined> {
|
|
40
|
+
return (await cookies()).get(COOKIE_NAME)?.value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function setCustomerToken(token: string, maxAge = 7 * 24 * 60 * 60) {
|
|
44
|
+
(await cookies()).set(COOKIE_NAME, token, { ...COOKIE_OPTS, maxAge })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function clearCustomerToken() {
|
|
48
|
+
(await cookies()).delete(COOKIE_NAME)
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## /api/auth/otp (POST) — CRÍTICO
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// ⚠️ Manda e-mail REAL — rate-limit obrigatório
|
|
56
|
+
// Rate-limit simples: Map<email, lastSentAt>
|
|
57
|
+
const rateLimitMap = new Map<string, number>()
|
|
58
|
+
|
|
59
|
+
export async function POST(req: Request) {
|
|
60
|
+
const { email } = await req.json()
|
|
61
|
+
|
|
62
|
+
// Rate-limit: 1 por minuto por email
|
|
63
|
+
const last = rateLimitMap.get(email) ?? 0
|
|
64
|
+
if (Date.now() - last < 60_000) {
|
|
65
|
+
return Response.json({ error: "Aguarde antes de reenviar o código" }, { status: 429 })
|
|
66
|
+
}
|
|
67
|
+
rateLimitMap.set(email, Date.now())
|
|
68
|
+
|
|
69
|
+
const client = await getUnboxClient()
|
|
70
|
+
await client.gql(
|
|
71
|
+
`mutation($i:CustomerOTPRequestInput!){ customerOTPRequest(input:$i){success} }`,
|
|
72
|
+
{ i: { email, shopId: process.env.UNBOX_SHOP_ID } },
|
|
73
|
+
{ captcha: true } // ⚠️ obrigatório — SDK envia x-captcha-verification
|
|
74
|
+
)
|
|
75
|
+
return Response.json({ success: true })
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## /api/auth/signin (POST)
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
export async function POST(req: Request) {
|
|
83
|
+
const { email, code } = await req.json()
|
|
84
|
+
const client = await getUnboxClient()
|
|
85
|
+
const data = await client.gql(
|
|
86
|
+
`mutation($i:CustomerPasswordlessSignInInput){
|
|
87
|
+
customerPasswordlessSignIn(input:$i){
|
|
88
|
+
accessToken firstAccess newShopSignIn
|
|
89
|
+
}
|
|
90
|
+
}`,
|
|
91
|
+
{ i: { email, otpCode: code, shopId: process.env.UNBOX_SHOP_ID } },
|
|
92
|
+
{ captcha: true }
|
|
93
|
+
)
|
|
94
|
+
await setCustomerToken(data.customerPasswordlessSignIn.accessToken)
|
|
95
|
+
return Response.json({ success: true, firstAccess: data.customerPasswordlessSignIn.firstAccess })
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## middleware.ts
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { NextResponse } from "next/server"
|
|
103
|
+
import type { NextRequest } from "next/server"
|
|
104
|
+
|
|
105
|
+
export function middleware(req: NextRequest) {
|
|
106
|
+
const token = req.cookies.get("unbox_customer_token")?.value
|
|
107
|
+
if (!token) {
|
|
108
|
+
const url = req.nextUrl.clone()
|
|
109
|
+
url.pathname = "/conta/entrar"
|
|
110
|
+
url.searchParams.set("next", req.nextUrl.pathname)
|
|
111
|
+
return NextResponse.redirect(url)
|
|
112
|
+
}
|
|
113
|
+
return NextResponse.next()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const config = {
|
|
117
|
+
matcher: ["/conta/pedidos/:path*", "/conta/assinaturas/:path*"],
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## OTPForm — UX
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
Step 1: Campo de email + botão "Enviar código"
|
|
125
|
+
Step 2: Campo de 6 dígitos (autofoco) + botão "Entrar"
|
|
126
|
+
+ botão "Reenviar código" (habilitado após 60s)
|
|
127
|
+
|
|
128
|
+
Erros: via friendlyError() do lib/feedback.ts
|
|
129
|
+
Após sucesso: redirect para ?next ou /conta/pedidos
|
|
130
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Agente 08 — Customer (Área da Conta)
|
|
2
|
+
|
|
3
|
+
## Escopo
|
|
4
|
+
Área logada do cliente: visão geral, pedidos, assinaturas, endereços e preferências. Tudo
|
|
5
|
+
protegido por sessão via cookie httpOnly (token do **cliente**, distinto do token de loja).
|
|
6
|
+
|
|
7
|
+
## Dependências
|
|
8
|
+
- Agente 07 (Auth) — `lib/session.ts` (`getCustomerToken`), fluxo de login OTP
|
|
9
|
+
- Agente 00 (Scaffold) — `lib/unbox/customer.ts` (`UnboxCustomerClient`)
|
|
10
|
+
|
|
11
|
+
## Arquivos proprietários
|
|
12
|
+
|
|
13
|
+
| Arquivo | Descrição |
|
|
14
|
+
|---------|-----------|
|
|
15
|
+
| `app/(loja)/conta/page.tsx` | Visão geral (resumo de pedidos/assinaturas recentes) |
|
|
16
|
+
| `app/(loja)/conta/pedidos/page.tsx` + `[referenceId]/page.tsx` | Lista e detalhe de pedidos |
|
|
17
|
+
| `app/(loja)/conta/assinaturas/page.tsx` + `[referenceId]/page.tsx` | Lista e detalhe de assinaturas (pausar/cancelar/pular ciclo) |
|
|
18
|
+
| `app/(loja)/conta/enderecos/page.tsx` | CRUD de endereços salvos |
|
|
19
|
+
| `app/(loja)/conta/preferencias/page.tsx` | Preferências de marketing/notificação |
|
|
20
|
+
| `components/account/account-shell.tsx` | Shell com sidebar de navegação + breadcrumb, usado por todas as páginas acima |
|
|
21
|
+
| `components/account/address-book.tsx` | Lista + formulário de endereços |
|
|
22
|
+
| `components/account/preferences-form.tsx` | Formulário de preferências |
|
|
23
|
+
| `components/account/subscription-actions.tsx` | Ações de assinatura (pausar/cancelar/pular) |
|
|
24
|
+
| `components/account/reorder-button.tsx` | "Comprar novamente" a partir de um pedido |
|
|
25
|
+
| `components/account/signout-button.tsx` | Logout |
|
|
26
|
+
| `lib/customer-session.ts` | `getCustomerClient()` / `requireCustomerClient()` — client autenticado com o token do cliente |
|
|
27
|
+
| `app/api/account/{me,addresses,preferences,signout}/route.ts` | Route Handlers que usam `requireCustomerClient()` |
|
|
28
|
+
|
|
29
|
+
## Regra de ouro — dois tokens
|
|
30
|
+
`lib/customer-session.ts` usa o token do **cliente** (cookie httpOnly, setado pelo Agente 07),
|
|
31
|
+
nunca o `serverEnv.apiKey`/token de loja do Agente 00 (`lib/unbox/store.ts`). Misturar os dois
|
|
32
|
+
client factories é o erro mais comum nessa área — sempre confirme qual client está sendo importado.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// Server Component de página protegida:
|
|
36
|
+
const client = await getCustomerClient();
|
|
37
|
+
if (!client) redirect("/conta/entrar?next=/conta/pedidos");
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Proteção de rotas
|
|
41
|
+
Páginas sob `/conta/*` (exceto `/conta/entrar`) checam `getCustomerClient()` e redirecionam para
|
|
42
|
+
`/conta/entrar?next=<rota>` se não houver sessão — sem `middleware.ts` global, a checagem é feita
|
|
43
|
+
em cada `page.tsx` (Server Component) para poder usar `AccountShell` mesmo no estado deslogado de
|
|
44
|
+
algumas páginas públicas (ex.: rastreio de pedido pelo `/pedido/[referenceId]` não exige login).
|
|
45
|
+
|
|
46
|
+
## Assinaturas — ações sensíveis
|
|
47
|
+
`subscription-actions.tsx` chama mutations que alteram cobrança recorrente (pausar/cancelar/pular
|
|
48
|
+
ciclo) — sempre exigir confirmação explícita (dialog) antes de disparar, e mostrar o novo estado
|
|
49
|
+
otimisticamente só após resposta 200 da API.
|
|
50
|
+
|
|
51
|
+
## Link de recuperação e frequência de assinatura
|
|
52
|
+
`recurringItemsFrequencyId` é um campo do pedido (`placeOrder`), não do carrinho, e por isso
|
|
53
|
+
vive em cookie no meio do caminho — ao restaurar um carrinho de assinatura por link (`?id=&token=`, doc 05-cart), é
|
|
54
|
+
obrigatório também repassar `&freq=` (ver `app/api/cart/link/route.ts`), senão o cliente reabre o
|
|
55
|
+
checkout sem frequência selecionada e não consegue finalizar.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Agente 09 — Promotions (Kits & Combos)
|
|
2
|
+
|
|
3
|
+
## Escopo
|
|
4
|
+
Sistema de kits/combos promocionais — bundles curados de produtos com desconto, resolvidos
|
|
5
|
+
dinamicamente contra o catálogo real da loja (preço e estoque nunca são hardcoded).
|
|
6
|
+
|
|
7
|
+
## Dependências
|
|
8
|
+
- Agente 03 (Catalog) — usa o mesmo catálogo (`getCatalog`) e `lib/enrichment` para indexar produtos
|
|
9
|
+
- Agente 02 (Homepage) — consome `<CombosSection>`/`<CombosHome>` na home
|
|
10
|
+
|
|
11
|
+
## Arquivos proprietários
|
|
12
|
+
|
|
13
|
+
| Arquivo | Descrição |
|
|
14
|
+
|---------|-----------|
|
|
15
|
+
| `lib/enrichment/combos.ts` | Define `COMBOS` (specs do kit) e `resolveCombos()` (resolve contra o catálogo) |
|
|
16
|
+
| `components/home/combos-section.tsx` | Seção completa da home: carrossel + CTA "Adicionar combo ao carrinho" |
|
|
17
|
+
| `components/home/combos-home.tsx` | Wrapper que busca o catálogo e chama `resolveCombos()` |
|
|
18
|
+
| `components/home/combo-card-compact.tsx` | Card compacto reusado em outros pontos (ex.: cross-sell) |
|
|
19
|
+
|
|
20
|
+
## ⚠️ Estado no template base: vazio por padrão
|
|
21
|
+
`COMBOS: ComboDef[] = []` — a infraestrutura está pronta e testada, mas **sem nenhum kit
|
|
22
|
+
configurado**. Sem isso, `<CombosSection>` não renderiza nada (degrada graciosamente, não é erro),
|
|
23
|
+
mas a loja não tem essa alavanca de AOV até alguém preencher `COMBOS` com kits reais.
|
|
24
|
+
|
|
25
|
+
## Modelo de dados
|
|
26
|
+
Um `ComboDef` referencia **famílias de produto** (`familyCode` do `lib/enrichment/products.json`),
|
|
27
|
+
não SKUs fixos — a resolução escolhe o produto comprável mais barato (ou mais caro, via
|
|
28
|
+
`size: "max"`) daquela família no catálogo atual:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// lib/enrichment/combos.ts — popule com os kits reais da loja
|
|
32
|
+
export const COMBOS: ComboDef[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "kit-essencial",
|
|
35
|
+
name: "Kit Essencial",
|
|
36
|
+
description: "Tudo o que você precisa para começar.",
|
|
37
|
+
imageUrl: "/brand/combos/kit-essencial.webp",
|
|
38
|
+
badge: "MAIS VENDIDO",
|
|
39
|
+
discountPct: 15,
|
|
40
|
+
highlights: [{ icon: "sparkle", label: "3 itens" }],
|
|
41
|
+
items: [{ family: "FAMILIA_A" }, { family: "FAMILIA_B" }, { family: "FAMILIA_C", size: "min" }],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Regra de ouro — nunca hardcode preço
|
|
47
|
+
`resolveCombos()` calcula `subtotal`/`total`/`save` a partir do preço real do catálogo
|
|
48
|
+
(`resolveProductPrice`) multiplicado por `(1 - discountPct/100)` — **o componente nunca recebe
|
|
49
|
+
preço fixo**. Um combo cujos itens não resolvem no catálogo atual (produto fora de linha, sem
|
|
50
|
+
estoque) é descartado silenciosamente (`items.length < 2` → `continue`), nunca quebra a home.
|
|
51
|
+
|
|
52
|
+
## Ao configurar pra uma loja nova
|
|
53
|
+
1. Popular `lib/enrichment/products.json` à mão, no formato de `lib/enrichment/index.ts` (não há script gerador)
|
|
54
|
+
2. Definir `familyCode` para os grupos de produto que formarão kits
|
|
55
|
+
3. Preencher `COMBOS` em `combos.ts` com os kits desejados (`items`, `discountPct`, `highlights`)
|
|
56
|
+
4. Adicionar as imagens temáticas em `public/brand/combos/`
|
|
57
|
+
|
|
58
|
+
## Fora de escopo aqui — não confundir com CRO
|
|
59
|
+
Banners de oferta genéricos ("shopSales" da loja), contadores de urgência e prova social **não**
|
|
60
|
+
são deste agente — isso é escopo do Agente 13 (CRO Package), que hoje só tem o módulo de frete
|
|
61
|
+
grátis (`FREE_SHIPPING_THRESHOLD`) implementado; os outros 9 módulos ainda não existem no template
|
|
62
|
+
base.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Agente 10 — Feedback / UX
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Doc de plano (histórico).** A implementação entregue pela foundation difere em nomes e
|
|
4
|
+
> caminhos de arquivo citados abaixo. O mapa REAL de arquivos por área é a tabela "Agentes
|
|
5
|
+
> 00-11" do `agents/MANAGER.md` — em conflito, valem o MANAGER e o código.
|
|
6
|
+
|
|
7
|
+
## Escopo
|
|
8
|
+
Padrões de UX compartilhados: toasts, skeletons, estados de loading, error boundaries e páginas
|
|
9
|
+
especiais (error.tsx, not-found.tsx).
|
|
10
|
+
|
|
11
|
+
## Dependências
|
|
12
|
+
- Agente 00 (Scaffold) — `lib/feedback.ts` base
|
|
13
|
+
- Reutilizado por todos os outros agentes
|
|
14
|
+
|
|
15
|
+
## Arquivos proprietários
|
|
16
|
+
|
|
17
|
+
| Arquivo | Descrição |
|
|
18
|
+
|---------|-----------|
|
|
19
|
+
| `components/ui/Toast.tsx` | Componente de toast |
|
|
20
|
+
| `components/ui/ToastProvider.tsx` | Provider global com useToast hook |
|
|
21
|
+
| `components/ui/skeleton.tsx` | Skeleton polimórfico com animate-pulse |
|
|
22
|
+
| `components/ui/LoadingSpinner.tsx` | Spinner SVG com aria-label |
|
|
23
|
+
| `components/ui/ErrorBoundary.tsx` | Client error boundary |
|
|
24
|
+
| `components/ui/EmptyState.tsx` | Estado vazio genérico |
|
|
25
|
+
| `app/error.tsx` | Erro global (client component obrigatório) |
|
|
26
|
+
| `app/not-found.tsx` | 404 |
|
|
27
|
+
| `app/loading.tsx` | Fallback de Suspense global |
|
|
28
|
+
| `lib/feedback.ts` | Re-exports + mapa de erros de pagamento |
|
|
29
|
+
|
|
30
|
+
## lib/feedback.ts (completo)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export { friendlyError, cartEventLabel, ERROR_MESSAGES, CART_EVENT_LABELS } from "@payflows/unbox-sdk"
|
|
34
|
+
|
|
35
|
+
export const CHECKOUT_STEPS = {
|
|
36
|
+
address: "Endereço",
|
|
37
|
+
shipping: "Frete",
|
|
38
|
+
payment: "Pagamento",
|
|
39
|
+
confirmation: "Confirmação",
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
export function getPaymentErrorMessage(code: string): string {
|
|
43
|
+
const map: Record<string, string> = {
|
|
44
|
+
INSUFFICIENT_FUNDS_ERROR: "Cartão sem limite disponível",
|
|
45
|
+
CARD_DECLINED: "Cartão recusado pela operadora",
|
|
46
|
+
INVALID_CARD: "Dados do cartão inválidos",
|
|
47
|
+
EXPIRED_CARD: "Cartão expirado",
|
|
48
|
+
PAYMENT_PROCESSING_ERROR: "Erro ao processar pagamento. Tente novamente.",
|
|
49
|
+
}
|
|
50
|
+
return map[code] ?? "Erro no pagamento. Tente outro cartão ou método de pagamento."
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## ToastProvider + useToast
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// Não depender de Radix/shadcn toast — usar Base UI ou implementação própria
|
|
58
|
+
type ToastType = "success" | "error" | "info" | "warning"
|
|
59
|
+
interface ToastItem { id: string; title: string; description?: string; type: ToastType }
|
|
60
|
+
|
|
61
|
+
const ToastContext = createContext<{
|
|
62
|
+
toast(params: Omit<ToastItem, "id">): void
|
|
63
|
+
dismiss(id: string): void
|
|
64
|
+
}>()
|
|
65
|
+
|
|
66
|
+
export function useToast() { return useContext(ToastContext) }
|
|
67
|
+
|
|
68
|
+
// Toast com auto-dismiss em 5s, acessível com role="status" aria-live="polite"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Skeleton — uso
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// Genérico (polimórfico):
|
|
75
|
+
<Skeleton className="h-4 w-32" /> // linha de texto
|
|
76
|
+
<Skeleton className="aspect-square w-full" /> // imagem
|
|
77
|
+
|
|
78
|
+
// Compostos:
|
|
79
|
+
export function ProductCardSkeleton() {
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
<Skeleton className="aspect-square w-full rounded-lg" />
|
|
83
|
+
<Skeleton className="mt-2 h-4 w-3/4" />
|
|
84
|
+
<Skeleton className="mt-1 h-4 w-1/2" />
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
export function ProductGridSkeleton({ count = 8 }: { count?: number }) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
91
|
+
{Array.from({ length: count }).map((_, i) => <ProductCardSkeleton key={i} />)}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## app/error.tsx (CRÍTICO)
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
"use client" // ← obrigatório pelo Next.js
|
|
101
|
+
import { useEffect } from "react"
|
|
102
|
+
|
|
103
|
+
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset(): void }) {
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
// Log interno — nunca exibir ao usuário
|
|
106
|
+
console.error("[Error Boundary]", error.message)
|
|
107
|
+
}, [error])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
|
111
|
+
<h2>Algo deu errado</h2>
|
|
112
|
+
<p>Ocorreu um erro inesperado. Tente novamente.</p>
|
|
113
|
+
<button onClick={reset}>Tentar novamente</button>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Acessibilidade — checklist obrigatório
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
- aria-live="polite" nos toasts (não "assertive" para não interromper)
|
|
123
|
+
- aria-busy="true" em botões durante loading
|
|
124
|
+
- role="status" em mensagens de feedback
|
|
125
|
+
- Não remover outline/foco visível (apenas customizar cor)
|
|
126
|
+
- Focus trap em drawers e dialogs (Base UI já cuida)
|
|
127
|
+
- Skip link: <a href="#main-content" className="sr-only focus:not-sr-only">Pular para conteúdo</a>
|
|
128
|
+
```
|