@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,1207 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'unbox-storefront-builder',
|
|
3
|
+
description: 'Constrói um storefront headless Unbox completo: Next.js 15 + shadcn/ui + Tailwind v4',
|
|
4
|
+
phases: [
|
|
5
|
+
{ title: 'Scaffold', detail: 'Cria estrutura Next.js, dependências e fundação compartilhada' },
|
|
6
|
+
{ title: 'Implement', detail: 'Implementa as 10 seções do storefront em paralelo' },
|
|
7
|
+
{ title: 'Integrate', detail: 'Integra seções, resolve imports e configura providers' },
|
|
8
|
+
{ title: 'Review', detail: 'Valida regras de ouro, segurança e qualidade do código' },
|
|
9
|
+
],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Configuração ─────────────────────────────────────────────────────────────
|
|
13
|
+
const TARGET = args?.projectDir ?? '/tmp/unbox-storefront'
|
|
14
|
+
const SDK_PATH = args?.sdkPath ?? '/Users/brunoapereira/Documents/Claude/unbox-store-kit-v0.3/unbox-sdk'
|
|
15
|
+
const DOCS_PATH = '/Users/brunoapereira/Documents/Claude/unbox-store-kit-v0.3/unbox-agent-store-front v.0.2'
|
|
16
|
+
|
|
17
|
+
// ─── Contexto compartilhado (injetado em todos os agentes) ───────────────────
|
|
18
|
+
const SHARED = `
|
|
19
|
+
## Projeto
|
|
20
|
+
- Diretório: ${TARGET}
|
|
21
|
+
- Stack: Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui sobre Base UI
|
|
22
|
+
- SDK Unbox: ${SDK_PATH}/src/index.ts → import from "@payflows/unbox-sdk"
|
|
23
|
+
- Docs: ${DOCS_PATH}/
|
|
24
|
+
|
|
25
|
+
## Regras de Ouro (NUNCA viole)
|
|
26
|
+
1. UNBOX_API_KEY só no servidor — nunca NEXT_PUBLIC_, nunca client component, nunca logs
|
|
27
|
+
2. Dois tokens: token de loja (catálogo/checkout) ≠ token do cliente (área /conta via OTP)
|
|
28
|
+
3. placeOrder não é idempotente — lock por cartId no BFF; nunca retentar cegamente
|
|
29
|
+
4. customerOTPRequest dispara e-mail real — jamais em testes, CI ou loops automáticos
|
|
30
|
+
5. Preço e estoque vêm do servidor — nunca computar preço no cliente
|
|
31
|
+
6. Menu de navegação: use tags() — navigation tree dá erro com token de loja
|
|
32
|
+
7. displayStatus(language) está quebrado — use status + orderStatusLabel() do SDK
|
|
33
|
+
8. shadcn/ui usa Base UI (@base-ui/react) — prop render, não asChild
|
|
34
|
+
9. Tailwind v4 — use tokens/utilitários do tema; evite style inline quando há utilitário
|
|
35
|
+
10. Erros GraphQL chegam no body HTTP 200 — sempre cheque errors[] antes de usar data
|
|
36
|
+
11. recurringItemsFrequencyId não existe no live — use isRecurring:true + frequência no placeOrder
|
|
37
|
+
|
|
38
|
+
## Padrão de arquivo server-only
|
|
39
|
+
\`\`\`ts
|
|
40
|
+
// lib/unbox.ts
|
|
41
|
+
import "server-only"
|
|
42
|
+
import { UnboxClient } from "@payflows/unbox-sdk"
|
|
43
|
+
let _client: UnboxClient | null = null
|
|
44
|
+
export function getUnboxClient(): UnboxClient { ... }
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
## Contratos entre seções
|
|
48
|
+
- lib/unbox.ts → UnboxClient singleton (server-only)
|
|
49
|
+
- lib/auth.ts → getCustomerToken(): cookie httpOnly do cliente
|
|
50
|
+
- lib/metadata.ts → buildMetadata(product) → Metadata
|
|
51
|
+
- lib/feedback.ts → re-exporta friendlyError, cartEventLabel do SDK
|
|
52
|
+
- components/cart/ → CartDrawer + useCart hook (client)
|
|
53
|
+
- components/promotions/ → ShopSalesBanner (server component)
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
|
57
|
+
const FILE_SCHEMA = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
files: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
path: { type: 'string', description: 'Caminho relativo ao diretório do projeto' },
|
|
66
|
+
content: { type: 'string', description: 'Conteúdo completo do arquivo' },
|
|
67
|
+
},
|
|
68
|
+
required: ['path', 'content'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
packages: { type: 'array', items: { type: 'string' }, description: 'npm packages extras além do base' },
|
|
72
|
+
notes: { type: 'string', description: 'Notas de implementação, gotchas, próximos passos' },
|
|
73
|
+
},
|
|
74
|
+
required: ['files'],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const REVIEW_SCHEMA = {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
violations: {
|
|
81
|
+
type: 'array',
|
|
82
|
+
items: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
rule: { type: 'string' },
|
|
86
|
+
file: { type: 'string' },
|
|
87
|
+
line: { type: 'string' },
|
|
88
|
+
fix: { type: 'string' },
|
|
89
|
+
},
|
|
90
|
+
required: ['rule', 'file', 'fix'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
approved: { type: 'boolean' },
|
|
94
|
+
summary: { type: 'string' },
|
|
95
|
+
},
|
|
96
|
+
required: ['violations', 'approved', 'summary'],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Helper: escreve arquivos retornados pelo agente ─────────────────────────
|
|
100
|
+
async function writeSection(result, label) {
|
|
101
|
+
if (!result?.files?.length) { log(`⚠️ ${label}: nenhum arquivo retornado`); return }
|
|
102
|
+
const writer = await agent(
|
|
103
|
+
`Escreva os seguintes arquivos no disco. Para cada arquivo, use o caminho exato fornecido
|
|
104
|
+
(se o caminho for relativo, prefixe com "${TARGET}/"). Crie os diretórios necessários.
|
|
105
|
+
Arquivos:
|
|
106
|
+
${JSON.stringify(result.files, null, 2)}
|
|
107
|
+
Após escrever todos os arquivos, retorne apenas "OK: <N> arquivos escritos".`,
|
|
108
|
+
{ label: `write:${label}`, effort: 'low' },
|
|
109
|
+
)
|
|
110
|
+
log(`✓ ${label}: ${writer}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
114
|
+
// FASE 1 — SCAFFOLD
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
116
|
+
phase('Scaffold')
|
|
117
|
+
log(`Scaffolding projeto em ${TARGET}...`)
|
|
118
|
+
|
|
119
|
+
const scaffold = await agent(
|
|
120
|
+
`${SHARED}
|
|
121
|
+
|
|
122
|
+
## Tarefa: Scaffold do projeto Next.js
|
|
123
|
+
|
|
124
|
+
Crie a estrutura completa de um projeto Next.js 15 em "${TARGET}".
|
|
125
|
+
|
|
126
|
+
### O que criar:
|
|
127
|
+
|
|
128
|
+
**1. package.json**
|
|
129
|
+
\`\`\`json
|
|
130
|
+
{
|
|
131
|
+
"name": "unbox-storefront",
|
|
132
|
+
"version": "0.1.0",
|
|
133
|
+
"private": true,
|
|
134
|
+
"scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint" },
|
|
135
|
+
"dependencies": {
|
|
136
|
+
"next": "^15.0.0",
|
|
137
|
+
"react": "^19.0.0",
|
|
138
|
+
"react-dom": "^19.0.0",
|
|
139
|
+
"@base-ui-components/react": "^1.0.0",
|
|
140
|
+
"server-only": "^0.0.1",
|
|
141
|
+
"clsx": "^2.0.0",
|
|
142
|
+
"tailwind-merge": "^2.0.0"
|
|
143
|
+
},
|
|
144
|
+
"devDependencies": {
|
|
145
|
+
"typescript": "^5.0.0",
|
|
146
|
+
"@types/node": "^22.0.0",
|
|
147
|
+
"@types/react": "^19.0.0",
|
|
148
|
+
"@types/react-dom": "^19.0.0",
|
|
149
|
+
"tailwindcss": "^4.0.0",
|
|
150
|
+
"@tailwindcss/postcss": "^4.0.0",
|
|
151
|
+
"eslint": "^9.0.0",
|
|
152
|
+
"eslint-config-next": "^15.0.0"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
\`\`\`
|
|
156
|
+
|
|
157
|
+
**2. tsconfig.json** — padrão Next.js 15 com paths: { "@/*": ["./src/*"] } mas SEM src dir
|
|
158
|
+
|
|
159
|
+
**3. next.config.ts** — minimal, com images.domains vazio (será preenchido depois)
|
|
160
|
+
|
|
161
|
+
**4. app/globals.css** — import do Tailwind v4: \`@import "tailwindcss"\`
|
|
162
|
+
|
|
163
|
+
**5. app/layout.tsx** — RootLayout shell com html/body, Geist font, CartProvider placeholder
|
|
164
|
+
|
|
165
|
+
**6. lib/unbox.ts** — UnboxClient singleton server-only:
|
|
166
|
+
\`\`\`ts
|
|
167
|
+
import "server-only"
|
|
168
|
+
import { UnboxClient } from "@payflows/unbox-sdk"
|
|
169
|
+
// configura com env vars UNBOX_API_KEY, UNBOX_SHOP_ID
|
|
170
|
+
// implementa signInIfNeeded() com token cacheado (unstable_cache ou módulo-level)
|
|
171
|
+
\`\`\`
|
|
172
|
+
|
|
173
|
+
**7. lib/auth.ts** — placeholder para getCustomerToken():
|
|
174
|
+
\`\`\`ts
|
|
175
|
+
import { cookies } from "next/headers"
|
|
176
|
+
export async function getCustomerToken(): Promise<string | undefined> {
|
|
177
|
+
return (await cookies()).get("unbox_customer_token")?.value
|
|
178
|
+
}
|
|
179
|
+
\`\`\`
|
|
180
|
+
|
|
181
|
+
**8. lib/feedback.ts** — re-exporta do SDK:
|
|
182
|
+
\`\`\`ts
|
|
183
|
+
export { friendlyError, cartEventLabel } from "@payflows/unbox-sdk"
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
**9. lib/metadata.ts** — buildMetadata(product) → Metadata do Next.js
|
|
187
|
+
|
|
188
|
+
**10. Estrutura de rotas (arquivos com "// TODO: implementado pelo agente X"):**
|
|
189
|
+
- app/page.tsx
|
|
190
|
+
- app/produtos/page.tsx
|
|
191
|
+
- app/c/[tagSlug]/page.tsx
|
|
192
|
+
- app/p/[productSlug]/page.tsx
|
|
193
|
+
- app/p/[productSlug]/loading.tsx
|
|
194
|
+
- app/carrinho/page.tsx
|
|
195
|
+
- app/checkout/page.tsx
|
|
196
|
+
- app/conta/entrar/page.tsx
|
|
197
|
+
- app/conta/pedidos/page.tsx
|
|
198
|
+
- app/conta/assinaturas/page.tsx
|
|
199
|
+
- app/pedido/[referenceId]/page.tsx
|
|
200
|
+
- app/busca/page.tsx
|
|
201
|
+
- app/sitemap.ts
|
|
202
|
+
- app/robots.ts
|
|
203
|
+
- app/error.tsx
|
|
204
|
+
- app/not-found.tsx
|
|
205
|
+
- app/loading.tsx
|
|
206
|
+
- app/api/cart/route.ts
|
|
207
|
+
- app/api/checkout/route.ts
|
|
208
|
+
- app/api/auth/otp/route.ts
|
|
209
|
+
- app/api/auth/signin/route.ts
|
|
210
|
+
- app/api/webhooks/unbox/route.ts
|
|
211
|
+
|
|
212
|
+
**11. .env.local.example:**
|
|
213
|
+
\`\`\`
|
|
214
|
+
UNBOX_API_KEY=
|
|
215
|
+
UNBOX_SHOP_ID=
|
|
216
|
+
UNBOX_USERNAME=
|
|
217
|
+
UNBOX_PASSWORD=
|
|
218
|
+
UNBOX_WEBHOOK_SECRET=
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
**12. Diretórios (criar com .gitkeep):**
|
|
222
|
+
components/layout/, components/catalog/, components/pdp/,
|
|
223
|
+
components/cart/, components/checkout/, components/customer/,
|
|
224
|
+
components/promotions/, components/ui/
|
|
225
|
+
|
|
226
|
+
Retorne TODOS os arquivos com conteúdo completo (não apenas placeholders vazios para lib/ e app/layout.tsx).`,
|
|
227
|
+
{ label: 'scaffold', schema: FILE_SCHEMA, effort: 'high' },
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
await writeSection(scaffold, 'scaffold')
|
|
231
|
+
const extraPackages = scaffold?.packages ?? []
|
|
232
|
+
log(`Scaffold completo. Packages extras: ${extraPackages.join(', ') || 'nenhum'}`)
|
|
233
|
+
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
235
|
+
// FASE 2 — IMPLEMENT (paralelo)
|
|
236
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
237
|
+
phase('Implement')
|
|
238
|
+
log('Iniciando implementação paralela das 10 seções...')
|
|
239
|
+
|
|
240
|
+
const SECTION_AGENTS = [
|
|
241
|
+
{
|
|
242
|
+
id: 'layout',
|
|
243
|
+
label: 'Layout (Header/Footer/Nav)',
|
|
244
|
+
prompt: `${SHARED}
|
|
245
|
+
|
|
246
|
+
## Tarefa: Agente de Layout
|
|
247
|
+
|
|
248
|
+
Implemente o header, footer e navegação do storefront.
|
|
249
|
+
|
|
250
|
+
### Arquivos a criar:
|
|
251
|
+
- components/layout/Header.tsx — header com logo, menu de categorias (via tags), carrinho, login
|
|
252
|
+
- components/layout/Footer.tsx — links institucionais, redes sociais, copyright
|
|
253
|
+
- components/layout/Navigation.tsx — menu mobile + desktop (usa tags como categorias)
|
|
254
|
+
- components/layout/CartButton.tsx — ícone de carrinho com badge de quantidade (client component)
|
|
255
|
+
|
|
256
|
+
### Dados (SDK):
|
|
257
|
+
\`\`\`ts
|
|
258
|
+
// Dentro de um Server Component ou generateStaticParams:
|
|
259
|
+
import { getUnboxClient } from "@/lib/unbox"
|
|
260
|
+
const client = await getUnboxClient()
|
|
261
|
+
const tags = await client.getTags(true) // categorias top-level para o menu
|
|
262
|
+
\`\`\`
|
|
263
|
+
|
|
264
|
+
### Regras críticas:
|
|
265
|
+
- getTags usa token de loja — correto para header
|
|
266
|
+
- Navigation deve ter loading state e ser Suspense-friendly
|
|
267
|
+
- CartButton é um client component que lê o contexto do carrinho
|
|
268
|
+
- Header fixo (sticky) com z-index adequado para o CartDrawer
|
|
269
|
+
- Links de categoria: href="/c/[tag.slug]"
|
|
270
|
+
- Acessibilidade: role="navigation", aria-label, foco visível
|
|
271
|
+
|
|
272
|
+
### shadcn/ui (Base UI):
|
|
273
|
+
\`\`\`tsx
|
|
274
|
+
// Exemplo de Dropdown correto com Base UI:
|
|
275
|
+
import * as DropdownMenu from "@base-ui-components/react/menu"
|
|
276
|
+
<DropdownMenu.Root>
|
|
277
|
+
<DropdownMenu.Trigger render={<button>Menu</button>} />
|
|
278
|
+
<DropdownMenu.Positioner>
|
|
279
|
+
<DropdownMenu.Popup>...</DropdownMenu.Popup>
|
|
280
|
+
</DropdownMenu.Positioner>
|
|
281
|
+
</DropdownMenu.Root>
|
|
282
|
+
\`\`\``,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'homepage',
|
|
286
|
+
label: 'Homepage',
|
|
287
|
+
prompt: `${SHARED}
|
|
288
|
+
|
|
289
|
+
## Tarefa: Agente de Homepage
|
|
290
|
+
|
|
291
|
+
Implemente a página inicial do storefront (app/page.tsx).
|
|
292
|
+
|
|
293
|
+
### Arquivos a criar:
|
|
294
|
+
- app/page.tsx — Server Component com Suspense por seção
|
|
295
|
+
- components/home/HeroBanner.tsx — banner principal com CTA
|
|
296
|
+
- components/home/FeaturedProducts.tsx — grade de produtos em destaque
|
|
297
|
+
- components/home/ShopSalesSection.tsx — promoções ativas da loja
|
|
298
|
+
- components/home/CategoryGrid.tsx — grade de categorias (tags)
|
|
299
|
+
|
|
300
|
+
### Dados (SDK):
|
|
301
|
+
\`\`\`ts
|
|
302
|
+
const client = await getUnboxClient()
|
|
303
|
+
const [shop, featuredItems, tags] = await Promise.all([
|
|
304
|
+
client.getShop(), // shopSales (promoções automáticas)
|
|
305
|
+
client.getCatalog({ first: 8, sortBy: "updatedAt", sortOrder: "desc" }),
|
|
306
|
+
client.getTags(true), // categorias
|
|
307
|
+
])
|
|
308
|
+
const shopSales = shop.shopSales ?? []
|
|
309
|
+
\`\`\`
|
|
310
|
+
|
|
311
|
+
### ShopSales (promoções automáticas):
|
|
312
|
+
\`\`\`ts
|
|
313
|
+
interface ShopSale {
|
|
314
|
+
_id: string
|
|
315
|
+
name: string
|
|
316
|
+
description?: string
|
|
317
|
+
discountType: string // "percentage" | "fixed"
|
|
318
|
+
discountAmount: number
|
|
319
|
+
triggerType?: string // "all" | "payment_method" (ex: "pix")
|
|
320
|
+
}
|
|
321
|
+
\`\`\`
|
|
322
|
+
|
|
323
|
+
### SEO:
|
|
324
|
+
\`\`\`ts
|
|
325
|
+
export const metadata: Metadata = {
|
|
326
|
+
title: "Loja | Unbox Store",
|
|
327
|
+
description: "...",
|
|
328
|
+
openGraph: { ... }
|
|
329
|
+
}
|
|
330
|
+
\`\`\`
|
|
331
|
+
|
|
332
|
+
### Padrões:
|
|
333
|
+
- generateStaticParams NÃO se aplica à homepage
|
|
334
|
+
- Use unstable_cache ou Next.js fetch cache para dados públicos
|
|
335
|
+
- HeroBanner pode ser um image carrossel simples com Tailwind
|
|
336
|
+
- FeaturedProducts renderiza ProductCard (componente genérico reutilizado pelo Catalog)
|
|
337
|
+
- Cada seção deve ter loading.tsx com skeleton adequado`,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'catalog',
|
|
341
|
+
label: 'Catalog (Listagem)',
|
|
342
|
+
prompt: `${SHARED}
|
|
343
|
+
|
|
344
|
+
## Tarefa: Agente de Catalog
|
|
345
|
+
|
|
346
|
+
Implemente as páginas de listagem de produtos, categoria e busca.
|
|
347
|
+
|
|
348
|
+
### Arquivos a criar:
|
|
349
|
+
- app/produtos/page.tsx — catálogo completo, paginado
|
|
350
|
+
- app/c/[tagSlug]/page.tsx — categoria/tag filtrada
|
|
351
|
+
- app/busca/page.tsx — busca por texto (?q=)
|
|
352
|
+
- app/produtos/loading.tsx — skeleton de grid
|
|
353
|
+
- components/catalog/ProductGrid.tsx — grade responsiva de produtos
|
|
354
|
+
- components/catalog/ProductCard.tsx — card com imagem, título, preço, badge assinatura
|
|
355
|
+
- components/catalog/Pagination.tsx — paginação com offset
|
|
356
|
+
- components/catalog/FilterBar.tsx — filtros de categoria e ordenação
|
|
357
|
+
- components/catalog/SearchBar.tsx — campo de busca (client, redireciona para /busca)
|
|
358
|
+
|
|
359
|
+
### Dados (SDK):
|
|
360
|
+
\`\`\`ts
|
|
361
|
+
// Listagem com filtros
|
|
362
|
+
const result = await client.getCatalog({
|
|
363
|
+
first: 24,
|
|
364
|
+
offset: page * 24, // page vem de searchParams
|
|
365
|
+
tagIds: tagId ? [tagId] : undefined,
|
|
366
|
+
searchText: q || undefined,
|
|
367
|
+
sortBy: "updatedAt",
|
|
368
|
+
sortOrder: "desc",
|
|
369
|
+
})
|
|
370
|
+
// result.nodes.map(n => n.product)
|
|
371
|
+
// result.totalCount
|
|
372
|
+
// result.pageInfo.hasNextPage
|
|
373
|
+
|
|
374
|
+
// Categoria por slug
|
|
375
|
+
const tags = await client.getTags(true)
|
|
376
|
+
const tag = tags.find(t => t.slug === tagSlug)
|
|
377
|
+
\`\`\`
|
|
378
|
+
|
|
379
|
+
### ProductCard — campos a exibir:
|
|
380
|
+
- Imagem: product.imageUrls?.[0]
|
|
381
|
+
- Título: product.title
|
|
382
|
+
- Preço: product.pricing?.[0]?.displayPrice
|
|
383
|
+
- Preço de: product.variants?.[0]?.pricing?.[0]?.compareAtPrice?.displayAmount
|
|
384
|
+
- Badge "Assine e Poupe" se product.recurrenceAllowed
|
|
385
|
+
- Badge "Esgotado" se product.isSoldOut
|
|
386
|
+
- Badge "Pré-venda" se product.isBackorder
|
|
387
|
+
- Link: /p/[product.slug]
|
|
388
|
+
|
|
389
|
+
### Paginação:
|
|
390
|
+
- Use URL searchParams (?page=2) — stateful via Link
|
|
391
|
+
- Não usar state do cliente para paginação (SSR-friendly)
|
|
392
|
+
|
|
393
|
+
### SEO de categoria:
|
|
394
|
+
\`\`\`ts
|
|
395
|
+
export async function generateMetadata({ params }) {
|
|
396
|
+
const tag = await getTag(params.tagSlug)
|
|
397
|
+
return { title: tag.displayTitle ?? tag.name }
|
|
398
|
+
}
|
|
399
|
+
export async function generateStaticParams() {
|
|
400
|
+
const tags = await getTags()
|
|
401
|
+
return tags.map(t => ({ tagSlug: t.slug }))
|
|
402
|
+
}
|
|
403
|
+
\`\`\``,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: 'pdp',
|
|
407
|
+
label: 'PDP (Product Detail)',
|
|
408
|
+
prompt: `${SHARED}
|
|
409
|
+
|
|
410
|
+
## Tarefa: Agente de PDP — Product Detail Page
|
|
411
|
+
|
|
412
|
+
Implemente a página de detalhe de produto (/p/[productSlug]).
|
|
413
|
+
|
|
414
|
+
### Arquivos a criar:
|
|
415
|
+
- app/p/[productSlug]/page.tsx — Server Component com SSG + ISR
|
|
416
|
+
- components/pdp/ProductImages.tsx — galeria de imagens (main + thumbs)
|
|
417
|
+
- components/pdp/ProductInfo.tsx — título, preço, badges, descrição
|
|
418
|
+
- components/pdp/VariantSelector.tsx — seleção de variante (client component)
|
|
419
|
+
- components/pdp/AddToCartForm.tsx — quantidade + botão "Adicionar ao Carrinho" (client)
|
|
420
|
+
- components/pdp/SubscriptionToggle.tsx — toggle "Avulso / Assine e Poupe" (client)
|
|
421
|
+
- components/pdp/ProductDescription.tsx — descrição e informações adicionais (accordion)
|
|
422
|
+
|
|
423
|
+
### Dados (SDK):
|
|
424
|
+
\`\`\`ts
|
|
425
|
+
const client = await getUnboxClient()
|
|
426
|
+
const item = await client.getProductBySlug(params.productSlug)
|
|
427
|
+
// item.product.variants[] — seleção de variante
|
|
428
|
+
// item.product.recurrenceAllowed — mostrar toggle de assinatura
|
|
429
|
+
// item.product.isSoldOut / isBackorder / isLowQuantity — badges
|
|
430
|
+
// item.shortDescription — descrição curta (acima do fold)
|
|
431
|
+
// item.product.description — descrição longa (abaixo)
|
|
432
|
+
// item.product.additionalInformation — informações adicionais (accordion)
|
|
433
|
+
\`\`\`
|
|
434
|
+
|
|
435
|
+
### ISR:
|
|
436
|
+
\`\`\`ts
|
|
437
|
+
export const revalidate = 3600 // 1h
|
|
438
|
+
export async function generateStaticParams() {
|
|
439
|
+
const catalog = await client.getCatalog({ first: 200 })
|
|
440
|
+
return catalog.nodes.map(n => ({ productSlug: n.product.slug }))
|
|
441
|
+
}
|
|
442
|
+
\`\`\`
|
|
443
|
+
|
|
444
|
+
### SubscriptionToggle:
|
|
445
|
+
- Só exibir se product.recurrenceAllowed
|
|
446
|
+
- Ao selecionar "Assinar", mostrar frequências disponíveis
|
|
447
|
+
- As frequências vêm de getShop().recurringOrdersPolicy.frequencyOptions
|
|
448
|
+
- No AddToCartForm: enviar isRecurring: true no cartItem
|
|
449
|
+
- A frequência só vai no placeOrder, não no cartItem (regra 11 do AGENTS.md)
|
|
450
|
+
|
|
451
|
+
### VariantSelector:
|
|
452
|
+
- Produto pode ter N variantes (ex.: tamanhos, sabores)
|
|
453
|
+
- Cada variante tem pricing próprio
|
|
454
|
+
- isSoldOut pode ser no produto OU por variante (checar ambos)
|
|
455
|
+
|
|
456
|
+
### AddToCartForm (client):
|
|
457
|
+
\`\`\`ts
|
|
458
|
+
// Chama a API Route /api/cart (POST) com:
|
|
459
|
+
// { action: "add", productId, productVariantId, price, quantity, isRecurring }
|
|
460
|
+
// Após sucesso, atualiza contexto do carrinho e mostra CartDrawer
|
|
461
|
+
\`\`\`
|
|
462
|
+
|
|
463
|
+
### SEO:
|
|
464
|
+
\`\`\`ts
|
|
465
|
+
export async function generateMetadata({ params }) {
|
|
466
|
+
const item = await client.getProductBySlug(params.productSlug)
|
|
467
|
+
return buildMetadata(item.product) // usa lib/metadata.ts
|
|
468
|
+
}
|
|
469
|
+
\`\`\``,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
id: 'cart',
|
|
473
|
+
label: 'Cart (Carrinho)',
|
|
474
|
+
prompt: `${SHARED}
|
|
475
|
+
|
|
476
|
+
## Tarefa: Agente de Cart — Carrinho
|
|
477
|
+
|
|
478
|
+
Implemente o carrinho completo: contexto, drawer e API routes.
|
|
479
|
+
|
|
480
|
+
### Arquivos a criar:
|
|
481
|
+
- app/api/cart/route.ts — Route Handler (server-only): create, add, update, remove, get
|
|
482
|
+
- components/cart/CartContext.tsx — Context + Provider (client, lê cookie cartId/cartToken)
|
|
483
|
+
- components/cart/CartDrawer.tsx — drawer lateral (Base UI Dialog)
|
|
484
|
+
- components/cart/CartItem.tsx — item com imagem, título, quantidade, preço, remover
|
|
485
|
+
- components/cart/CartSummary.tsx — total, desconto, frete estimado, botão checkout
|
|
486
|
+
- components/cart/EmptyCart.tsx — estado vazio
|
|
487
|
+
- app/carrinho/page.tsx — página full do carrinho (mesmos componentes, layout expandido)
|
|
488
|
+
- components/cart/DiscountCodeInput.tsx — input + botão aplicar cupom
|
|
489
|
+
|
|
490
|
+
### Persistência:
|
|
491
|
+
- cartId e cartToken em cookies do browser (httpOnly=false para leitura no cliente)
|
|
492
|
+
- CartContext lê os cookies e sincroniza estado
|
|
493
|
+
- Ao criar novo carrinho, salvar cookies via document.cookie
|
|
494
|
+
|
|
495
|
+
### API Route (/api/cart):
|
|
496
|
+
\`\`\`ts
|
|
497
|
+
// POST /api/cart
|
|
498
|
+
// body: { action: "create" | "add" | "update" | "remove" | "get" | "discount" | "clear", ...params }
|
|
499
|
+
import { getUnboxClient } from "@/lib/unbox"
|
|
500
|
+
export async function POST(req: Request) {
|
|
501
|
+
const client = await getUnboxClient()
|
|
502
|
+
const { action, cartId, cartToken, ...rest } = await req.json()
|
|
503
|
+
// Rotear por action e chamar o método SDK correto
|
|
504
|
+
// create → client.createCart()
|
|
505
|
+
// add → client.addCartItems(cartId, cartToken, [item])
|
|
506
|
+
// update → client.updateCartItem(cartId, cartToken, itemId, qty)
|
|
507
|
+
// remove → client.removeCartItem(cartId, cartToken, itemId)
|
|
508
|
+
// get → client.getCart(cartId, cartToken)
|
|
509
|
+
// discount → client.applyDiscountCode(cartId, cartToken, code)
|
|
510
|
+
}
|
|
511
|
+
\`\`\`
|
|
512
|
+
|
|
513
|
+
### CartDrawer (Base UI):
|
|
514
|
+
\`\`\`tsx
|
|
515
|
+
import * as Dialog from "@base-ui-components/react/dialog"
|
|
516
|
+
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
|
517
|
+
<Dialog.Backdrop />
|
|
518
|
+
<Dialog.Popup render={<aside className="fixed inset-y-0 right-0 w-96" />}>
|
|
519
|
+
...
|
|
520
|
+
</Dialog.Popup>
|
|
521
|
+
</Dialog.Root>
|
|
522
|
+
\`\`\`
|
|
523
|
+
|
|
524
|
+
### DiscountCode:
|
|
525
|
+
- SDK: client.applyDiscountCode(cartId, cartToken, code) → CartResult
|
|
526
|
+
- Mostrar erro amigável via friendlyError() do lib/feedback.ts
|
|
527
|
+
- Mostrar desconto aplicado no CartSummary
|
|
528
|
+
|
|
529
|
+
### CartContext — interface mínima:
|
|
530
|
+
\`\`\`ts
|
|
531
|
+
interface CartContextValue {
|
|
532
|
+
cartId: string | null
|
|
533
|
+
cartToken: string | null
|
|
534
|
+
items: CartItem[]
|
|
535
|
+
totalCount: number
|
|
536
|
+
total: number
|
|
537
|
+
isOpen: boolean
|
|
538
|
+
isLoading: boolean
|
|
539
|
+
openCart(): void
|
|
540
|
+
closeCart(): void
|
|
541
|
+
addItem(item: CartItemInput & { isRecurring?: boolean }): Promise<void>
|
|
542
|
+
updateQuantity(itemId: string, qty: number): Promise<void>
|
|
543
|
+
removeItem(itemId: string): Promise<void>
|
|
544
|
+
applyDiscount(code: string): Promise<void>
|
|
545
|
+
refreshCart(): Promise<void>
|
|
546
|
+
}
|
|
547
|
+
\`\`\``,
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: 'checkout',
|
|
551
|
+
label: 'Checkout (Pagamento)',
|
|
552
|
+
prompt: `${SHARED}
|
|
553
|
+
|
|
554
|
+
## Tarefa: Agente de Checkout
|
|
555
|
+
|
|
556
|
+
Implemente o fluxo completo de checkout: endereço → frete → pagamento → pedido.
|
|
557
|
+
|
|
558
|
+
### Arquivos a criar:
|
|
559
|
+
- app/checkout/page.tsx — página de checkout (multi-step, client-heavy)
|
|
560
|
+
- app/api/checkout/route.ts — Route Handler para todas as etapas
|
|
561
|
+
- app/pedido/[referenceId]/page.tsx — confirmação de pedido
|
|
562
|
+
- components/checkout/AddressForm.tsx — formulário de endereço completo
|
|
563
|
+
- components/checkout/ShippingSelector.tsx — seleção de opção de frete
|
|
564
|
+
- components/checkout/PaymentSelector.tsx — Pix ou Cartão de Crédito
|
|
565
|
+
- components/checkout/PixPayment.tsx — QR code + copia-e-cola + polling de status
|
|
566
|
+
- components/checkout/CreditCardForm.tsx — dados do cartão (número, nome, validade, CVV)
|
|
567
|
+
- components/checkout/OrderSummary.tsx — resumo do pedido (sidebar)
|
|
568
|
+
- components/checkout/CheckoutProgress.tsx — indicador de etapas
|
|
569
|
+
|
|
570
|
+
### Fluxo (API Unbox):
|
|
571
|
+
\`\`\`
|
|
572
|
+
1. setShippingAddressOnCart → fulfillmentGroupId
|
|
573
|
+
2. updateFulfillmentOptionsForGroup → availableFulfillmentOptions[]
|
|
574
|
+
3. (opcional) applyDiscountCodeToCart
|
|
575
|
+
4. selectFulfillmentOptionForGroup → total com frete
|
|
576
|
+
5. placeOrder → order (Pix: QR + copia-e-cola | Cartão: aprovado/recusado)
|
|
577
|
+
\`\`\`
|
|
578
|
+
|
|
579
|
+
### API Route (/api/checkout):
|
|
580
|
+
\`\`\`ts
|
|
581
|
+
// POST /api/checkout
|
|
582
|
+
// { action: "address" | "quote" | "select-shipping" | "place" | "apply-discount" }
|
|
583
|
+
// IDEMPOTÊNCIA: lock por cartId (use Map em memória ou KV store)
|
|
584
|
+
// Desabilitar botão de submit + token de uso único no form
|
|
585
|
+
\`\`\`
|
|
586
|
+
|
|
587
|
+
### Idempotência (CRÍTICO):
|
|
588
|
+
\`\`\`ts
|
|
589
|
+
const inFlight = new Map<string, Promise<any>>()
|
|
590
|
+
export async function POST(req: Request) {
|
|
591
|
+
const { cartId, action } = await req.json()
|
|
592
|
+
if (action === "place") {
|
|
593
|
+
if (inFlight.has(cartId)) return Response.json({ error: "pedido_em_andamento" }, { status: 409 })
|
|
594
|
+
const promise = doPlaceOrder(...)
|
|
595
|
+
inFlight.set(cartId, promise)
|
|
596
|
+
try { return Response.json(await promise) }
|
|
597
|
+
finally { inFlight.delete(cartId) }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
\`\`\`
|
|
601
|
+
|
|
602
|
+
### Pix:
|
|
603
|
+
- placeOrder retorna paymentMethod.data.pixQrCode e pixQrCodeText
|
|
604
|
+
- Exibir QR code (como <img src="data:image/png;base64,..."> ou via qrcode lib)
|
|
605
|
+
- Mostrar copia-e-cola para Pix
|
|
606
|
+
- Polling de status: GET /api/checkout?orderId=... a cada 5s (usa client.getOrder())
|
|
607
|
+
- Redirecionar para /pedido/[referenceId] quando status mudar de PENDING
|
|
608
|
+
|
|
609
|
+
### Cartão de Crédito (UnboxPay):
|
|
610
|
+
\`\`\`ts
|
|
611
|
+
// No placeOrder, paymentInput:
|
|
612
|
+
{
|
|
613
|
+
paymentMethodId: "unboxpay_credit",
|
|
614
|
+
amount: totalAmount,
|
|
615
|
+
data: {
|
|
616
|
+
cardNumber: "...",
|
|
617
|
+
cardholderName: "...",
|
|
618
|
+
expiryMonth: "12",
|
|
619
|
+
expiryYear: "2027",
|
|
620
|
+
cvv: "..."
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
\`\`\`
|
|
624
|
+
|
|
625
|
+
### AddressInput (campos obrigatórios):
|
|
626
|
+
fullName, phone, postal, address1, city, region, country, taxPayerId, number, neighborhood
|
|
627
|
+
|
|
628
|
+
### Página de confirmação (/pedido/[referenceId]):
|
|
629
|
+
- Busca pedido por referenceId + token (ou session do cliente)
|
|
630
|
+
- Mostra status, itens, tracking se disponível
|
|
631
|
+
- status: use orderStatusLabel() do SDK (não displayStatus)`,
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
id: 'auth',
|
|
635
|
+
label: 'Auth (OTP Login)',
|
|
636
|
+
prompt: `${SHARED}
|
|
637
|
+
|
|
638
|
+
## Tarefa: Agente de Autenticação
|
|
639
|
+
|
|
640
|
+
Implemente login passwordless (OTP) e gestão de sessão do cliente.
|
|
641
|
+
|
|
642
|
+
### Arquivos a criar:
|
|
643
|
+
- app/conta/entrar/page.tsx — página de login (2 etapas: email → código)
|
|
644
|
+
- app/api/auth/otp/route.ts — POST: dispara OTP; GET/DELETE: status/logout
|
|
645
|
+
- app/api/auth/signin/route.ts — troca OTP pelo token do cliente
|
|
646
|
+
- lib/auth.ts — getCustomerToken(), setCustomerToken(), clearCustomerToken()
|
|
647
|
+
- components/auth/OTPForm.tsx — formulário de email + campo de código OTP
|
|
648
|
+
- middleware.ts — protege rotas /conta/pedidos e /conta/assinaturas
|
|
649
|
+
|
|
650
|
+
### lib/auth.ts:
|
|
651
|
+
\`\`\`ts
|
|
652
|
+
import { cookies } from "next/headers"
|
|
653
|
+
const COOKIE = "unbox_customer_token"
|
|
654
|
+
const OPTS = { httpOnly: true, secure: true, sameSite: "lax", path: "/" } as const
|
|
655
|
+
|
|
656
|
+
export async function getCustomerToken() {
|
|
657
|
+
return (await cookies()).get(COOKIE)?.value
|
|
658
|
+
}
|
|
659
|
+
export async function setCustomerToken(token: string, maxAge = 86400 * 7) {
|
|
660
|
+
(await cookies()).set(COOKIE, token, { ...OPTS, maxAge })
|
|
661
|
+
}
|
|
662
|
+
export async function clearCustomerToken() {
|
|
663
|
+
(await cookies()).delete(COOKIE)
|
|
664
|
+
}
|
|
665
|
+
\`\`\`
|
|
666
|
+
|
|
667
|
+
### /api/auth/otp (POST):
|
|
668
|
+
\`\`\`ts
|
|
669
|
+
// Dispara OTP por email
|
|
670
|
+
// ⚠️ Exige x-captcha-verification no SDK (já implementado internamente)
|
|
671
|
+
// ⚠️ NUNCA chamar em testes — manda e-mail real
|
|
672
|
+
// Rate-limit: máximo 1 requisição por email a cada 60s
|
|
673
|
+
const client = await getUnboxClient()
|
|
674
|
+
await client.gql(\`mutation($i:CustomerOTPRequestInput!){ customerOTPRequest(input:$i){success} }\`,
|
|
675
|
+
{ i: { email, shopId: process.env.UNBOX_SHOP_ID } },
|
|
676
|
+
{ captcha: true }
|
|
677
|
+
)
|
|
678
|
+
\`\`\`
|
|
679
|
+
|
|
680
|
+
### /api/auth/signin (POST):
|
|
681
|
+
\`\`\`ts
|
|
682
|
+
// Troca o código OTP pelo token do cliente
|
|
683
|
+
const data = await client.gql(\`
|
|
684
|
+
mutation($i:CustomerPasswordlessSignInInput){
|
|
685
|
+
customerPasswordlessSignIn(input:$i){
|
|
686
|
+
accessToken idToken firstAccess newShopSignIn
|
|
687
|
+
}
|
|
688
|
+
}\`,
|
|
689
|
+
{ i: { email, otpCode: code, shopId } },
|
|
690
|
+
{ captcha: true }
|
|
691
|
+
)
|
|
692
|
+
// Salvar accessToken em cookie httpOnly via setCustomerToken()
|
|
693
|
+
// Redirecionar para /conta/pedidos
|
|
694
|
+
\`\`\`
|
|
695
|
+
|
|
696
|
+
### middleware.ts:
|
|
697
|
+
\`\`\`ts
|
|
698
|
+
import { NextResponse } from "next/server"
|
|
699
|
+
import type { NextRequest } from "next/server"
|
|
700
|
+
export function middleware(req: NextRequest) {
|
|
701
|
+
const token = req.cookies.get("unbox_customer_token")?.value
|
|
702
|
+
if (!token) return NextResponse.redirect(new URL("/conta/entrar", req.url))
|
|
703
|
+
return NextResponse.next()
|
|
704
|
+
}
|
|
705
|
+
export const config = { matcher: ["/conta/pedidos/:path*", "/conta/assinaturas/:path*"] }
|
|
706
|
+
\`\`\`
|
|
707
|
+
|
|
708
|
+
### OTPForm (client component):
|
|
709
|
+
- Step 1: campo de email → POST /api/auth/otp
|
|
710
|
+
- Step 2: campo de 6 dígitos → POST /api/auth/signin
|
|
711
|
+
- Auto-foco no step 2
|
|
712
|
+
- Botão "Reenviar código" com cooldown de 60s
|
|
713
|
+
- Erro amigável via friendlyError()`,
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
id: 'customer',
|
|
717
|
+
label: 'Customer Area (Conta)',
|
|
718
|
+
prompt: `${SHARED}
|
|
719
|
+
|
|
720
|
+
## Tarefa: Agente de Área do Cliente
|
|
721
|
+
|
|
722
|
+
Implemente a área logada: pedidos, rastreio e gerenciamento de assinaturas.
|
|
723
|
+
|
|
724
|
+
### Arquivos a criar:
|
|
725
|
+
- app/conta/pedidos/page.tsx — lista de pedidos do cliente
|
|
726
|
+
- app/conta/pedidos/[orderId]/page.tsx — detalhe de pedido + rastreio
|
|
727
|
+
- app/conta/assinaturas/page.tsx — lista de assinaturas ativas
|
|
728
|
+
- app/conta/assinaturas/[subscriptionId]/page.tsx — detalhe + ações da assinatura
|
|
729
|
+
- app/conta/layout.tsx — layout compartilhado da área logada (sidebar de navegação)
|
|
730
|
+
- components/customer/OrderCard.tsx — card de pedido na listagem
|
|
731
|
+
- components/customer/OrderDetail.tsx — detalhe completo com itens e status
|
|
732
|
+
- components/customer/SubscriptionCard.tsx — card de assinatura
|
|
733
|
+
- components/customer/SubscriptionActions.tsx — pausar/retomar/pular/cancelar
|
|
734
|
+
|
|
735
|
+
### Token do cliente (DIFERENTE do token de loja):
|
|
736
|
+
\`\`\`ts
|
|
737
|
+
import { getCustomerToken } from "@/lib/auth"
|
|
738
|
+
import { UnboxCustomerClient } from "@payflows/unbox-sdk"
|
|
739
|
+
|
|
740
|
+
export async function getCustomerClient() {
|
|
741
|
+
const token = await getCustomerToken()
|
|
742
|
+
if (!token) throw new Error("Não autenticado")
|
|
743
|
+
return new UnboxCustomerClient({
|
|
744
|
+
apiKey: process.env.UNBOX_API_KEY!,
|
|
745
|
+
shopId: process.env.UNBOX_SHOP_ID!,
|
|
746
|
+
customerToken: token,
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
\`\`\`
|
|
750
|
+
|
|
751
|
+
### Pedidos:
|
|
752
|
+
\`\`\`ts
|
|
753
|
+
const client = await getCustomerClient()
|
|
754
|
+
const orders = await client.getOrders({ first: 20 }) // paginado
|
|
755
|
+
// order.status → use orderStatusLabel(order.status) para texto PT-BR
|
|
756
|
+
// order.referenceId → ID curto para exibição
|
|
757
|
+
// order.totalPrice?.amount → total pago
|
|
758
|
+
// ⚠️ NÃO use totalItemQuantity (campo quebrado)
|
|
759
|
+
// ⚠️ NÃO use displayStatus(language) (resolver quebrado)
|
|
760
|
+
\`\`\`
|
|
761
|
+
|
|
762
|
+
### Rastreio:
|
|
763
|
+
\`\`\`ts
|
|
764
|
+
// Em contexto de cliente: use trackingCode, não trackingUrl
|
|
765
|
+
const tracking = order.fulfillmentGroups?.data?.[0]?.tracking
|
|
766
|
+
// tracking.trackingCode — código de rastreio
|
|
767
|
+
// tracking.carrier — transportadora
|
|
768
|
+
\`\`\`
|
|
769
|
+
|
|
770
|
+
### Assinaturas (RecurringOrders):
|
|
771
|
+
\`\`\`ts
|
|
772
|
+
const subscriptions = await client.getRecurringOrders({ first: 10 })
|
|
773
|
+
// subscription.status: "active" | "paused" | "cancelled"
|
|
774
|
+
// subscription.nextOrderDate — próxima data de cobrança
|
|
775
|
+
// subscription.items[] — produtos
|
|
776
|
+
// subscription.recurrencePolicy.frequencyOptions[] — frequências
|
|
777
|
+
|
|
778
|
+
// Ações disponíveis:
|
|
779
|
+
await client.pauseRecurringOrder(subscriptionId)
|
|
780
|
+
await client.resumeRecurringOrder(subscriptionId)
|
|
781
|
+
await client.skipRecurringOrderCycle(subscriptionId)
|
|
782
|
+
await client.cancelRecurringOrder(subscriptionId)
|
|
783
|
+
// Status: use subscriptionStatusLabel() do SDK
|
|
784
|
+
\`\`\`
|
|
785
|
+
|
|
786
|
+
### Status labels:
|
|
787
|
+
\`\`\`ts
|
|
788
|
+
import { orderStatusLabel, paymentStatusLabel, subscriptionStatusLabel } from "@payflows/unbox-sdk"
|
|
789
|
+
orderStatusLabel("new") // "Novo"
|
|
790
|
+
subscriptionStatusLabel("active") // "Ativa"
|
|
791
|
+
\`\`\`
|
|
792
|
+
|
|
793
|
+
### Paginação de pedidos:
|
|
794
|
+
- Implementar paginação com cursor (pageInfo.endCursor)
|
|
795
|
+
- "Carregar mais" (não paginação por página — UX mais suave para histórico)`,
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
id: 'promotions',
|
|
799
|
+
label: 'Promotions (Promoções)',
|
|
800
|
+
prompt: `${SHARED}
|
|
801
|
+
|
|
802
|
+
## Tarefa: Agente de Promoções
|
|
803
|
+
|
|
804
|
+
Implemente os componentes de promoções da loja (shopSales e cupons).
|
|
805
|
+
|
|
806
|
+
### Arquivos a criar:
|
|
807
|
+
- components/promotions/ShopSalesBanner.tsx — banner de promoções automáticas (topo do site)
|
|
808
|
+
- components/promotions/SalesBadge.tsx — badge numa listagem/card de produto
|
|
809
|
+
- components/promotions/DiscountBanner.tsx — banner de desconto em destaque (homepage)
|
|
810
|
+
- components/promotions/CouponInput.tsx — input de cupom reutilizável (usado no Checkout e Cart)
|
|
811
|
+
- components/promotions/ActiveDiscount.tsx — exibe desconto aplicado com valor e remoção
|
|
812
|
+
- app/api/promotions/route.ts — GET shopSales cached
|
|
813
|
+
|
|
814
|
+
### shopSales (promoções automáticas — sem código):
|
|
815
|
+
\`\`\`ts
|
|
816
|
+
const shop = await client.getShop()
|
|
817
|
+
interface ShopSale {
|
|
818
|
+
_id: string
|
|
819
|
+
name: string
|
|
820
|
+
description?: string
|
|
821
|
+
discountType: "percentage" | "fixed"
|
|
822
|
+
discountAmount: number
|
|
823
|
+
triggerType?: string // "all" | "payment_method" (ex: "5% no Pix")
|
|
824
|
+
conditions?: object
|
|
825
|
+
}
|
|
826
|
+
\`\`\`
|
|
827
|
+
|
|
828
|
+
### ShopSalesBanner — lógica:
|
|
829
|
+
- Se há shopSale com triggerType "payment_method" para Pix → banner "5% de desconto no Pix"
|
|
830
|
+
- Se há shopSale geral → banner de desconto global
|
|
831
|
+
- Banner deve ser dismissível (localStorage) e acessível (role="banner")
|
|
832
|
+
- Server Component por default (dados em cache), com parte client só para dismiss
|
|
833
|
+
|
|
834
|
+
### Cupons (discountCodes):
|
|
835
|
+
\`\`\`ts
|
|
836
|
+
// Listar cupons disponíveis (para exibir na homepage se quiser)
|
|
837
|
+
const codes = await client.listDiscountCodes({ first: 20 })
|
|
838
|
+
// ⚠️ first deve ser ≤ 20 (ConnectionLimitInt, não Int comum)
|
|
839
|
+
|
|
840
|
+
// Aplicar cupom (via CartContext / API Route /api/cart)
|
|
841
|
+
await client.applyDiscountCode(cartId, cartToken, "CODIGO10")
|
|
842
|
+
// Resultado vem no CartResult — exibir via ActiveDiscount
|
|
843
|
+
\`\`\`
|
|
844
|
+
|
|
845
|
+
### SalesBadge — uso nos ProductCards:
|
|
846
|
+
\`\`\`tsx
|
|
847
|
+
// Recebe shopSales como prop e exibe o desconto aplicável ao produto
|
|
848
|
+
// Ex: "5% OFF no Pix" ou "10% OFF assinatura"
|
|
849
|
+
<SalesBadge sales={shopSales} isRecurring={product.recurrenceAllowed} />
|
|
850
|
+
\`\`\`
|
|
851
|
+
|
|
852
|
+
### Cache:
|
|
853
|
+
- shopSales: cache de 5min (dados mudam pouco, mas são dinâmicos)
|
|
854
|
+
- discountCodes: não cachear (podem expirar)
|
|
855
|
+
\`\`\`ts
|
|
856
|
+
import { unstable_cache } from "next/cache"
|
|
857
|
+
const getShopSales = unstable_cache(
|
|
858
|
+
async () => { const s = await client.getShop(); return s.shopSales ?? [] },
|
|
859
|
+
["shop-sales"],
|
|
860
|
+
{ revalidate: 300 }
|
|
861
|
+
)
|
|
862
|
+
\`\`\``,
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
id: 'feedback',
|
|
866
|
+
label: 'Feedback/UX',
|
|
867
|
+
prompt: `${SHARED}
|
|
868
|
+
|
|
869
|
+
## Tarefa: Agente de Feedback e UX
|
|
870
|
+
|
|
871
|
+
Implemente os padrões de UX: estados de loading, erros, toasts e páginas especiais.
|
|
872
|
+
|
|
873
|
+
### Arquivos a criar:
|
|
874
|
+
- components/ui/Toast.tsx — toast notifications (Base UI Toast ou custom)
|
|
875
|
+
- components/ui/ToastProvider.tsx — provider global
|
|
876
|
+
- components/ui/LoadingSpinner.tsx — spinner genérico
|
|
877
|
+
- components/ui/Skeleton.tsx — skeleton genérico (polimórfico com className)
|
|
878
|
+
- components/ui/ErrorBoundary.tsx — error boundary client component
|
|
879
|
+
- components/ui/EmptyState.tsx — estado vazio genérico
|
|
880
|
+
- app/error.tsx — error page global (client component)
|
|
881
|
+
- app/not-found.tsx — 404 page
|
|
882
|
+
- app/loading.tsx — loading page global (Suspense fallback)
|
|
883
|
+
- lib/feedback.ts — re-exporta + mapeia erros Unbox para mensagens PT-BR
|
|
884
|
+
|
|
885
|
+
### lib/feedback.ts:
|
|
886
|
+
\`\`\`ts
|
|
887
|
+
export { friendlyError, cartEventLabel } from "@payflows/unbox-sdk"
|
|
888
|
+
|
|
889
|
+
// Mapas de UX extras:
|
|
890
|
+
export const CHECKOUT_STEP_LABELS = {
|
|
891
|
+
address: "Endereço",
|
|
892
|
+
shipping: "Frete",
|
|
893
|
+
payment: "Pagamento",
|
|
894
|
+
confirmation: "Confirmação",
|
|
895
|
+
} as const
|
|
896
|
+
|
|
897
|
+
export function getPaymentErrorMessage(error: string): string {
|
|
898
|
+
const map: Record<string, string> = {
|
|
899
|
+
INSUFFICIENT_FUNDS_ERROR: "Cartão sem limite disponível",
|
|
900
|
+
CARD_DECLINED: "Cartão recusado pela operadora",
|
|
901
|
+
INVALID_CARD: "Dados do cartão inválidos",
|
|
902
|
+
EXPIRED_CARD: "Cartão expirado",
|
|
903
|
+
}
|
|
904
|
+
return map[error] ?? "Erro no pagamento. Tente outro cartão."
|
|
905
|
+
}
|
|
906
|
+
\`\`\`
|
|
907
|
+
|
|
908
|
+
### Toast (Base UI se disponível, senão custom):
|
|
909
|
+
\`\`\`tsx
|
|
910
|
+
// Interface de uso:
|
|
911
|
+
const { toast } = useToast()
|
|
912
|
+
toast({ title: "Produto adicionado!", description: "Veja seu carrinho", type: "success" })
|
|
913
|
+
toast({ title: "Erro ao aplicar cupom", description: friendlyError(error), type: "error" })
|
|
914
|
+
\`\`\`
|
|
915
|
+
|
|
916
|
+
### Skeleton — polimórfico:
|
|
917
|
+
\`\`\`tsx
|
|
918
|
+
// <Skeleton className="h-4 w-32" /> → linha
|
|
919
|
+
// <Skeleton className="aspect-square w-full" /> → imagem
|
|
920
|
+
// ProductCardSkeleton, CartItemSkeleton, etc.
|
|
921
|
+
\`\`\`
|
|
922
|
+
|
|
923
|
+
### app/error.tsx (CRÍTICO — client component):
|
|
924
|
+
\`\`\`tsx
|
|
925
|
+
"use client"
|
|
926
|
+
export default function Error({ error, reset }: { error: Error; reset(): void }) {
|
|
927
|
+
// Não exibir detalhes técnicos ao usuário final
|
|
928
|
+
// Log interno: console.error(error.message) apenas
|
|
929
|
+
// Botão "Tentar novamente" chama reset()
|
|
930
|
+
}
|
|
931
|
+
\`\`\`
|
|
932
|
+
|
|
933
|
+
### Padrões de loading states:
|
|
934
|
+
- Catálogo: grid de 8 ProductCardSkeleton
|
|
935
|
+
- PDP: ImageSkeleton + InfoSkeleton lado a lado
|
|
936
|
+
- Carrinho: lista de CartItemSkeleton
|
|
937
|
+
- Checkout: FormSkeleton por seção
|
|
938
|
+
- Usar Tailwind animate-pulse nos skeletons
|
|
939
|
+
|
|
940
|
+
### Acessibilidade:
|
|
941
|
+
- aria-live="polite" nos toasts
|
|
942
|
+
- aria-busy="true" em botões durante carregamento
|
|
943
|
+
- Focus trap no CartDrawer e dialogs
|
|
944
|
+
- Não remover foco visível (outline)`,
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
id: 'seo',
|
|
948
|
+
label: 'SEO + Infra',
|
|
949
|
+
prompt: `${SHARED}
|
|
950
|
+
|
|
951
|
+
## Tarefa: Agente de SEO e Infraestrutura
|
|
952
|
+
|
|
953
|
+
Implemente SEO, sitemap, robots, metadados e páginas de suporte.
|
|
954
|
+
|
|
955
|
+
### Arquivos a criar:
|
|
956
|
+
- app/sitemap.ts — sitemap dinâmico (produtos + categorias)
|
|
957
|
+
- app/robots.ts — robots.txt
|
|
958
|
+
- lib/metadata.ts — buildMetadata(product) e helpers
|
|
959
|
+
- app/pedido/[referenceId]/page.tsx — confirmação de pedido (noindex)
|
|
960
|
+
- app/conta/layout.tsx — layout da área logada (se não criado pelo auth agent)
|
|
961
|
+
|
|
962
|
+
### lib/metadata.ts:
|
|
963
|
+
\`\`\`ts
|
|
964
|
+
import type { Metadata } from "next"
|
|
965
|
+
import type { CatalogProduct } from "@payflows/unbox-sdk"
|
|
966
|
+
|
|
967
|
+
const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME ?? "Unbox Store"
|
|
968
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://seudominio.com.br"
|
|
969
|
+
|
|
970
|
+
export function buildMetadata(product: CatalogProduct, canonical?: string): Metadata {
|
|
971
|
+
const title = product.pageTitle ?? product.title
|
|
972
|
+
const description = product.metaDescription ?? product.description?.slice(0, 160)
|
|
973
|
+
const image = product.imageUrls?.[0]
|
|
974
|
+
const url = canonical ?? \`\${SITE_URL}/p/\${product.slug}\`
|
|
975
|
+
return {
|
|
976
|
+
title: \`\${title} | \${SITE_NAME}\`,
|
|
977
|
+
description,
|
|
978
|
+
alternates: { canonical: url },
|
|
979
|
+
openGraph: {
|
|
980
|
+
title,
|
|
981
|
+
description,
|
|
982
|
+
url,
|
|
983
|
+
images: image ? [{ url: image, alt: title }] : [],
|
|
984
|
+
type: "website",
|
|
985
|
+
},
|
|
986
|
+
twitter: { card: "summary_large_image", title, description, images: image ? [image] : [] },
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function buildCategoryMetadata(tag: { name: string; description?: string; slug: string }): Metadata {
|
|
991
|
+
return {
|
|
992
|
+
title: \`\${tag.name} | \${SITE_NAME}\`,
|
|
993
|
+
description: tag.description,
|
|
994
|
+
alternates: { canonical: \`\${SITE_URL}/c/\${tag.slug}\` },
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
\`\`\`
|
|
998
|
+
|
|
999
|
+
### app/sitemap.ts:
|
|
1000
|
+
\`\`\`ts
|
|
1001
|
+
import type { MetadataRoute } from "next"
|
|
1002
|
+
import { getUnboxClient } from "@/lib/unbox"
|
|
1003
|
+
|
|
1004
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
1005
|
+
const client = await getUnboxClient()
|
|
1006
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? ""
|
|
1007
|
+
|
|
1008
|
+
// Páginas estáticas
|
|
1009
|
+
const staticRoutes = ["/", "/produtos", "/busca"].map(path => ({
|
|
1010
|
+
url: \`\${SITE_URL}\${path}\`,
|
|
1011
|
+
lastModified: new Date(),
|
|
1012
|
+
changeFrequency: "daily" as const,
|
|
1013
|
+
priority: path === "/" ? 1 : 0.8,
|
|
1014
|
+
}))
|
|
1015
|
+
|
|
1016
|
+
// Categorias
|
|
1017
|
+
const tags = await client.getTags(true)
|
|
1018
|
+
const categoryRoutes = tags.filter(t => t.isVisible).map(tag => ({
|
|
1019
|
+
url: \`\${SITE_URL}/c/\${tag.slug}\`,
|
|
1020
|
+
lastModified: new Date(),
|
|
1021
|
+
changeFrequency: "weekly" as const,
|
|
1022
|
+
priority: 0.7,
|
|
1023
|
+
}))
|
|
1024
|
+
|
|
1025
|
+
// Produtos (paginado para não buscar tudo de uma vez)
|
|
1026
|
+
const productRoutes = []
|
|
1027
|
+
let offset = 0
|
|
1028
|
+
for (;;) {
|
|
1029
|
+
const page = await client.getCatalog({ first: 100, offset })
|
|
1030
|
+
const visible = page.nodes.filter(n => n.product.isVisible)
|
|
1031
|
+
productRoutes.push(...visible.map(n => ({
|
|
1032
|
+
url: \`\${SITE_URL}/p/\${n.product.slug}\`,
|
|
1033
|
+
lastModified: new Date(),
|
|
1034
|
+
changeFrequency: "weekly" as const,
|
|
1035
|
+
priority: 0.6,
|
|
1036
|
+
})))
|
|
1037
|
+
if (!page.pageInfo?.hasNextPage) break
|
|
1038
|
+
offset += 100
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return [...staticRoutes, ...categoryRoutes, ...productRoutes]
|
|
1042
|
+
}
|
|
1043
|
+
\`\`\`
|
|
1044
|
+
|
|
1045
|
+
### app/robots.ts:
|
|
1046
|
+
\`\`\`ts
|
|
1047
|
+
import type { MetadataRoute } from "next"
|
|
1048
|
+
export default function robots(): MetadataRoute.Robots {
|
|
1049
|
+
return {
|
|
1050
|
+
rules: [
|
|
1051
|
+
{ userAgent: "*", allow: "/", disallow: ["/conta/", "/checkout", "/api/"] }
|
|
1052
|
+
],
|
|
1053
|
+
sitemap: \`\${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml\`,
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
\`\`\`
|
|
1057
|
+
|
|
1058
|
+
### Variáveis de ambiente públicas (NEXT_PUBLIC):
|
|
1059
|
+
Apenas NEXT_PUBLIC_SITE_NAME e NEXT_PUBLIC_SITE_URL são seguros como public —
|
|
1060
|
+
NÃO expor shopId, apiKey ou qualquer credencial Unbox.
|
|
1061
|
+
|
|
1062
|
+
### Dados estruturados (JSON-LD) — PDP:
|
|
1063
|
+
\`\`\`ts
|
|
1064
|
+
export function buildProductJsonLd(product: CatalogProduct) {
|
|
1065
|
+
return {
|
|
1066
|
+
"@context": "https://schema.org",
|
|
1067
|
+
"@type": "Product",
|
|
1068
|
+
name: product.title,
|
|
1069
|
+
description: product.description,
|
|
1070
|
+
image: product.imageUrls,
|
|
1071
|
+
sku: product.variants?.[0]?.sku,
|
|
1072
|
+
offers: {
|
|
1073
|
+
"@type": "Offer",
|
|
1074
|
+
availability: product.isSoldOut
|
|
1075
|
+
? "https://schema.org/OutOfStock"
|
|
1076
|
+
: "https://schema.org/InStock",
|
|
1077
|
+
price: product.pricing?.[0]?.price,
|
|
1078
|
+
priceCurrency: "BRL",
|
|
1079
|
+
},
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
\`\`\``,
|
|
1083
|
+
},
|
|
1084
|
+
]
|
|
1085
|
+
|
|
1086
|
+
const sectionResults = await parallel(
|
|
1087
|
+
SECTION_AGENTS.map(section => async () => {
|
|
1088
|
+
const result = await agent(section.prompt, {
|
|
1089
|
+
label: section.label,
|
|
1090
|
+
phase: 'Implement',
|
|
1091
|
+
schema: FILE_SCHEMA,
|
|
1092
|
+
effort: 'high',
|
|
1093
|
+
})
|
|
1094
|
+
await writeSection(result, section.id)
|
|
1095
|
+
return { id: section.id, files: result?.files?.map(f => f.path) ?? [], notes: result?.notes }
|
|
1096
|
+
})
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
const validSections = sectionResults.filter(Boolean)
|
|
1100
|
+
log(`Implement completo: ${validSections.length}/10 seções implementadas`)
|
|
1101
|
+
|
|
1102
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1103
|
+
// FASE 3 — INTEGRATE
|
|
1104
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1105
|
+
phase('Integrate')
|
|
1106
|
+
log('Integrando seções...')
|
|
1107
|
+
|
|
1108
|
+
const sectionSummary = validSections.map(s =>
|
|
1109
|
+
`${s.id}: ${s.files?.slice(0, 5).join(', ')}${s.files?.length > 5 ? '...' : ''}`
|
|
1110
|
+
).join('\n')
|
|
1111
|
+
|
|
1112
|
+
const integration = await agent(
|
|
1113
|
+
`${SHARED}
|
|
1114
|
+
|
|
1115
|
+
## Tarefa: Agente Integrador
|
|
1116
|
+
|
|
1117
|
+
As seções do storefront foram implementadas em paralelo. Agora integre tudo.
|
|
1118
|
+
|
|
1119
|
+
### Seções implementadas:
|
|
1120
|
+
${sectionSummary}
|
|
1121
|
+
|
|
1122
|
+
### O que fazer:
|
|
1123
|
+
|
|
1124
|
+
1. **Leia todos os arquivos em ${TARGET}** e identifique:
|
|
1125
|
+
- Imports quebrados ou inconsistentes
|
|
1126
|
+
- Exports que faltam nos arquivos de índice
|
|
1127
|
+
- Tipos compartilhados que precisam ser centralizados
|
|
1128
|
+
|
|
1129
|
+
2. **Atualize app/layout.tsx** para incluir:
|
|
1130
|
+
- CartProvider wrapping tudo
|
|
1131
|
+
- ToastProvider
|
|
1132
|
+
- Header e Footer reais (não placeholder)
|
|
1133
|
+
- Fonts do Next.js (Geist ou Inter)
|
|
1134
|
+
|
|
1135
|
+
3. **Atualize next.config.ts** com:
|
|
1136
|
+
- domains de imagem (unbox CDN: images.unbox.com.br ou similar)
|
|
1137
|
+
- redirects se necessário
|
|
1138
|
+
|
|
1139
|
+
4. **Crie/atualize index de componentes** (barrel exports) se necessário
|
|
1140
|
+
|
|
1141
|
+
5. **Verifique middleware.ts** — deve proteger /conta/pedidos e /conta/assinaturas
|
|
1142
|
+
|
|
1143
|
+
6. **Resolva conflitos** entre seções (ex: se dois agentes criaram o mesmo componente)
|
|
1144
|
+
|
|
1145
|
+
7. **Crie tsconfig paths** corretos para todos os @/ imports
|
|
1146
|
+
|
|
1147
|
+
8. **Verifique app/globals.css** — deve ter @import "tailwindcss" e variáveis CSS base
|
|
1148
|
+
|
|
1149
|
+
Leia os arquivos existentes antes de editar. Use Edit, não Write, para modificações parciais.`,
|
|
1150
|
+
{ label: 'integrate', effort: 'high' },
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
log(`Integração concluída.`)
|
|
1154
|
+
|
|
1155
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1156
|
+
// FASE 4 — REVIEW
|
|
1157
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1158
|
+
phase('Review')
|
|
1159
|
+
log('Verificando regras de ouro e qualidade...')
|
|
1160
|
+
|
|
1161
|
+
const review = await agent(
|
|
1162
|
+
`${SHARED}
|
|
1163
|
+
|
|
1164
|
+
## Tarefa: Agente Revisor
|
|
1165
|
+
|
|
1166
|
+
Faça uma revisão de segurança e qualidade do storefront gerado em ${TARGET}.
|
|
1167
|
+
|
|
1168
|
+
### Checklist das Regras de Ouro:
|
|
1169
|
+
|
|
1170
|
+
1. **UNBOX_API_KEY**: grep por "UNBOX_API_KEY" em arquivos com "use client" → VIOLAÇÃO se encontrar
|
|
1171
|
+
2. **NEXT_PUBLIC_**: grep por "NEXT_PUBLIC_UNBOX" → VIOLAÇÃO
|
|
1172
|
+
3. **placeOrder idempotência**: verificar se há lock por cartId na API route de checkout
|
|
1173
|
+
4. **customerOTPRequest**: confirmar que só é chamado em Route Handlers (nunca client)
|
|
1174
|
+
5. **Token de cliente**: confirmar que UnboxCustomerClient só é instanciado com customerToken
|
|
1175
|
+
6. **displayStatus**: grep por "displayStatus(" → se encontrar, é VIOLAÇÃO
|
|
1176
|
+
7. **trackingUrl**: grep por "trackingUrl" em contexto cliente → VIOLAÇÃO (use trackingCode)
|
|
1177
|
+
8. **asChild**: grep por 'asChild' → VIOLAÇÃO (shadcn/ui usa Base UI, prop render)
|
|
1178
|
+
9. **totalItemQuantity**: grep → VIOLAÇÃO no contexto customer
|
|
1179
|
+
10. **recurringItemsFrequencyId**: grep → VIOLAÇÃO
|
|
1180
|
+
|
|
1181
|
+
### Checklist de qualidade:
|
|
1182
|
+
- Todo Server Component que busca dados tem \`export const revalidate\` ou cache configurado
|
|
1183
|
+
- Páginas de catálogo/PDP têm generateMetadata
|
|
1184
|
+
- Há loading.tsx para rotas lentas
|
|
1185
|
+
- CartContext não vaza para componentes server
|
|
1186
|
+
- Formulários têm validação de campos obrigatórios do AddressInput
|
|
1187
|
+
- Erros são tratados com try/catch e exibidos via friendlyError()
|
|
1188
|
+
|
|
1189
|
+
### Execute os greps e leia os arquivos críticos, então retorne o resultado estruturado.`,
|
|
1190
|
+
{ label: 'review', schema: REVIEW_SCHEMA, effort: 'high' },
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
if (review?.violations?.length) {
|
|
1194
|
+
log(`⚠️ ${review.violations.length} violação(ões) encontrada(s):`)
|
|
1195
|
+
review.violations.forEach(v => log(` ❌ [${v.rule}] ${v.file}: ${v.fix}`))
|
|
1196
|
+
} else {
|
|
1197
|
+
log('✅ Nenhuma violação encontrada')
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
log(review?.summary ?? '')
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
sections: validSections,
|
|
1204
|
+
approved: review?.approved ?? false,
|
|
1205
|
+
violations: review?.violations ?? [],
|
|
1206
|
+
summary: review?.summary ?? '',
|
|
1207
|
+
}
|