@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,829 @@
|
|
|
1
|
+
// UnboxClient — SDK headless da Unbox para Next.js / Vercel (server-side).
|
|
2
|
+
// Zero dependências: usa fetch nativo (Node 18+, Edge Runtime).
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ Este client guarda a API key e o token de loja — use APENAS no servidor
|
|
5
|
+
// (Route Handlers, Server Actions, RSC). Nunca instancie no browser.
|
|
6
|
+
//
|
|
7
|
+
// DOIS ENDPOINTS (transição para a API pública de PARCEIROS):
|
|
8
|
+
// - partners.unbox.com.br/graphql — nova API pública. Uma api key por PARCEIRO (vale para
|
|
9
|
+
// todas as lojas dele); a loja específica autentica por user/senha no signIn. Com
|
|
10
|
+
// `partnerApiKey` configurada, vão para cá: signIn, VITRINE COMPLETA (catalogItems,
|
|
11
|
+
// catalogItemProductBySlug, byId via productIdsOrERPCodes, shopBySlug), tags, cupons
|
|
12
|
+
// (leitura), pedido por referenceId, parcelas, inventário (simpleInventory),
|
|
13
|
+
// subscribeWebhook e createCartByTemplate — tudo validado ao vivo contra o gateway.
|
|
14
|
+
// - core.unbox.com.br/graphql — segue atendendo o que a API de parceiros ainda não expõe:
|
|
15
|
+
// carrinho/checkout/placeOrder, CEP, OTP, área do cliente e availablePaymentMethods.
|
|
16
|
+
// Conforme a Unbox publicar as escritas, cada método ganha o mesmo roteamento condicional.
|
|
17
|
+
// Peculiaridades do gateway (validadas ao vivo): args opcionais não aceitam null (montar
|
|
18
|
+
// query só com args presentes); unions exigem __typename; Authorization aceita token puro
|
|
19
|
+
// E Bearer (a doc pública diz só puro — na prática ambos passam).
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
UnboxConfig, AddressInput, CartItemInput, CartResult,
|
|
23
|
+
PlaceOrderParams, FulfillmentOption, CatalogProduct, Connection,
|
|
24
|
+
PaymentLinkConstraints, DeviceInput, SimpleInventoryInfo,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
apiBaseUrl: "https://api.unbox.com.br",
|
|
29
|
+
gqlUrl: "https://core.unbox.com.br/graphql",
|
|
30
|
+
// API pública de PARCEIROS — gateway na frente do core. Uma api key por parceiro
|
|
31
|
+
// (vale p/ todas as lojas dele); a loja é autenticada pelo user/senha no signIn.
|
|
32
|
+
partnerGqlUrl: "https://partners.unbox.com.br/graphql",
|
|
33
|
+
language: "pt-BR",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Bloco do endereço de entrega dentro de `fulfillmentGroups`. É uma UNIÃO
|
|
37
|
+
* (`OrderFulfillmentGroupData`), e o servidor precisa resolver o tipo concreto em runtime.
|
|
38
|
+
* Medido: o front nativo da Unbox lê `data.shippingAddress` na conta do cliente (funciona lá),
|
|
39
|
+
* e a consulta por `referenceId` falhou em produção com "must resolve to an Object type".
|
|
40
|
+
* Como a falha derruba a consulta INTEIRA e não só o campo, quem consulta pede o endereço e,
|
|
41
|
+
* se o servidor não conseguir resolver, repete sem ele: a loja mostra o endereço onde a API
|
|
42
|
+
* entrega e a página do pedido nunca deixa de abrir. */
|
|
43
|
+
export const BLOCO_ENDERECO_GRUPO =
|
|
44
|
+
"data{ ... on ShippingOrderFulfillmentGroupData { shippingAddress{fullName address1 number neighborhood city region postal} } }";
|
|
45
|
+
|
|
46
|
+
function ehUniaoNaoResolvida(e: unknown): boolean {
|
|
47
|
+
const msgs = e instanceof UnboxError
|
|
48
|
+
? [e.message, ...(e.errors ?? []).map((x: any) => x?.message ?? "")].join(" ")
|
|
49
|
+
: String(e);
|
|
50
|
+
return /must resolve to an Object type|Could not determine the exact type|Can't resolve/i.test(msgs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Roda `exec` com o bloco de endereço; só repete sem ele se o erro for a união não resolvida. */
|
|
54
|
+
export async function comEnderecoDoGrupo<T>(exec: (blocoEndereco: string) => Promise<T>): Promise<T> {
|
|
55
|
+
try {
|
|
56
|
+
return await exec(BLOCO_ENDERECO_GRUPO);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (!ehUniaoNaoResolvida(e)) throw e;
|
|
59
|
+
console.warn("[unbox] fulfillmentGroups.data não resolveu no servidor: repetindo a consulta sem o endereço de entrega.");
|
|
60
|
+
return await exec("");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Código do erro lançado quando a Unbox não respondeu dentro do prazo (ver gql()). */
|
|
65
|
+
export const UNBOX_TIMEOUT = "TIMEOUT";
|
|
66
|
+
export function isUnboxTimeout(e: unknown): boolean {
|
|
67
|
+
return e instanceof UnboxError && e.message === UNBOX_TIMEOUT;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** placeOrder cria pedido + cobra cartão/gera Pix: passa por antifraude e adquirente e pode
|
|
71
|
+
* levar bem mais que os 15 s do timeout padrão. Abortar no cliente NÃO aborta no servidor —
|
|
72
|
+
* o pedido pode nascer depois que a loja já desistiu, e a tela liberava "Pagar" de novo
|
|
73
|
+
* (cobrança dupla, caso real). Prazo próprio, longo, e erro distinguível (UNBOX_TIMEOUT). */
|
|
74
|
+
export const PLACE_ORDER_TIMEOUT_MS = 90_000;
|
|
75
|
+
|
|
76
|
+
export class UnboxError extends Error {
|
|
77
|
+
errors: any[];
|
|
78
|
+
constructor(message: string, errors: any[] = []) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "UnboxError";
|
|
81
|
+
this.errors = errors;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Erro que indica formato de Authorization rejeitado (gateway de parceiros). */
|
|
86
|
+
function isAuthSchemeError(errors: any[]): boolean {
|
|
87
|
+
const msg = errors.map((e: any) => `${e.errorType ?? ""} ${e.message ?? ""}`).join(" ").toUpperCase();
|
|
88
|
+
return /UNAUTHORIZED|NOT AUTHORIZED|ACCESS_DENIED|UNAUTHENTICATED|INVALID TOKEN|401|403/.test(msg);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class UnboxClient {
|
|
92
|
+
apiKey: string;
|
|
93
|
+
shopId: string;
|
|
94
|
+
apiBaseUrl: string;
|
|
95
|
+
gqlUrl: string;
|
|
96
|
+
partnerApiKey: string;
|
|
97
|
+
partnerGqlUrl: string;
|
|
98
|
+
captchaBypass: string;
|
|
99
|
+
language: string;
|
|
100
|
+
timeoutMs: number;
|
|
101
|
+
token: string | null = null;
|
|
102
|
+
/** Formato do Authorization no gateway de parceiros. A doc oficial (docs.unbox.com.br)
|
|
103
|
+
* manda o token PURO, sem "Bearer " ("prefixá-lo quebra a autenticação") — por isso o
|
|
104
|
+
* default é "raw". O fallback pra "bearer" fica só como defesa se o gateway mudar. */
|
|
105
|
+
private partnerAuthScheme: "bearer" | "raw" = "raw";
|
|
106
|
+
|
|
107
|
+
constructor(cfg: UnboxConfig) {
|
|
108
|
+
this.apiKey = cfg.apiKey;
|
|
109
|
+
this.shopId = cfg.shopId;
|
|
110
|
+
this.apiBaseUrl = cfg.apiBaseUrl ?? DEFAULTS.apiBaseUrl;
|
|
111
|
+
this.gqlUrl = cfg.gqlUrl ?? DEFAULTS.gqlUrl;
|
|
112
|
+
this.partnerApiKey = cfg.partnerApiKey ?? "";
|
|
113
|
+
this.partnerGqlUrl = cfg.partnerGqlUrl ?? DEFAULTS.partnerGqlUrl;
|
|
114
|
+
this.captchaBypass = cfg.captchaBypass ?? "";
|
|
115
|
+
this.language = cfg.language ?? DEFAULTS.language;
|
|
116
|
+
this.timeoutMs = cfg.timeoutMs ?? 15000;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** true = api key de PARCEIRO configurada → signIn e leituras com paridade vão pra
|
|
120
|
+
* API de parceiros; carrinho/checkout/cliente seguem no core (escritas ainda não
|
|
121
|
+
* existem lá). Sem a key, comportamento 100% igual ao anterior (só core). */
|
|
122
|
+
get usesPartnerApi(): boolean { return Boolean(this.partnerApiKey); }
|
|
123
|
+
|
|
124
|
+
// -------------------------------------------------------------------- auth
|
|
125
|
+
/**
|
|
126
|
+
* Autentica com user/senha DA LOJA e guarda o access_token (JWT, ~24h).
|
|
127
|
+
* - Modo parceiro: mutation `signIn` na API de parceiros (x-api-key do parceiro +
|
|
128
|
+
* x-captcha-verification opcional). O token retornado é o mesmo JWT do core — o
|
|
129
|
+
* gateway de parceiros é um proxy do core — e vale nos dois endpoints.
|
|
130
|
+
* - Modo antigo: REST /auth/signin com a api key da loja.
|
|
131
|
+
*/
|
|
132
|
+
async signIn(username: string, password: string): Promise<string> {
|
|
133
|
+
if (this.usesPartnerApi) return this.signInPartner(username, password);
|
|
134
|
+
const res = await fetch(`${this.apiBaseUrl}/auth/signin`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/json", "x-api-key": this.apiKey },
|
|
137
|
+
body: JSON.stringify({ username, password }),
|
|
138
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) throw new UnboxError(`signin HTTP ${res.status}: ${await res.text()}`);
|
|
141
|
+
const data = await res.json();
|
|
142
|
+
if (!data.access_token) throw new UnboxError("signin sem access_token");
|
|
143
|
+
this.token = data.access_token;
|
|
144
|
+
return data.access_token as string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** signIn via API de parceiros (mutation GQL). Não exige token prévio.
|
|
148
|
+
* Doc oficial: o signIn EXIGE x-captcha-verification (UNBOX_CAPTCHA_BYPASS). */
|
|
149
|
+
private async signInPartner(username: string, password: string): Promise<string> {
|
|
150
|
+
const headers: Record<string, string> = {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
"x-api-key": this.partnerApiKey,
|
|
153
|
+
};
|
|
154
|
+
if (this.captchaBypass) headers["x-captcha-verification"] = this.captchaBypass;
|
|
155
|
+
const q = `mutation($i:SignInInput!){ signIn(input:$i){ access_token id_token } }`;
|
|
156
|
+
const res = await fetch(this.partnerGqlUrl, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers,
|
|
159
|
+
body: JSON.stringify({ query: q, variables: { i: { username, password } } }),
|
|
160
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
161
|
+
});
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
if (json.errors?.length) {
|
|
164
|
+
const hint = this.captchaBypass ? "" : " (x-captcha-verification ausente — preencha UNBOX_CAPTCHA_BYPASS, obrigatório no signIn de parceiros)";
|
|
165
|
+
throw new UnboxError(`signIn (partner): ${json.errors.map((e: any) => e.message).join(" | ")}${hint}`, json.errors);
|
|
166
|
+
}
|
|
167
|
+
const token = json.data?.signIn?.access_token;
|
|
168
|
+
if (!token) throw new UnboxError("signIn (partner) sem access_token");
|
|
169
|
+
this.token = token;
|
|
170
|
+
return token as string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setToken(token: string) { this.token = token; }
|
|
174
|
+
|
|
175
|
+
// ------------------------------------------------------------- GraphQL (low)
|
|
176
|
+
async gql<T = any>(
|
|
177
|
+
query: string,
|
|
178
|
+
variables: Record<string, any> = {},
|
|
179
|
+
opts: { token?: string; captcha?: boolean; timeoutMs?: number } = {},
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
const token = opts.token ?? this.token;
|
|
182
|
+
if (!token) throw new UnboxError("sem token: chame signIn() ou passe opts.token");
|
|
183
|
+
const headers: Record<string, string> = {
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
};
|
|
187
|
+
// placeOrder, customerOTPRequest, customerPasswordlessSignIn.
|
|
188
|
+
// ⚠️ ORDEM IMPORTA: o header x-captcha-verification foi feito pro UNBOX_CAPTCHA_BYPASS
|
|
189
|
+
// (segredo de 64 chars). Mandar a api key (da2-...) no lugar faz o backend repassá-la ao
|
|
190
|
+
// reCAPTCHA Enterprise, que devolve MALFORMED → o cliente vê CAPTCHA_MALFORMED_ERROR no
|
|
191
|
+
// meio do pagamento (caso real em produção). A key só entra como último recurso se não houver bypass.
|
|
192
|
+
if (opts.captcha) headers["x-captcha-verification"] = this.captchaBypass || this.apiKey;
|
|
193
|
+
let res: Response;
|
|
194
|
+
try {
|
|
195
|
+
res = await fetch(this.gqlUrl, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers,
|
|
198
|
+
body: JSON.stringify({ query, variables }),
|
|
199
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? this.timeoutMs),
|
|
200
|
+
});
|
|
201
|
+
} catch (e: any) {
|
|
202
|
+
// Timeout vira um UnboxError reconhecível (código TIMEOUT). Quem chama decide o que fazer:
|
|
203
|
+
// em leitura, tanto faz; em placeOrder, é a diferença entre "tente de novo" e "NÃO pague de novo".
|
|
204
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new UnboxError(UNBOX_TIMEOUT, [{ message: UNBOX_TIMEOUT }]);
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
const json = await res.json();
|
|
208
|
+
if (json.errors?.length) {
|
|
209
|
+
throw new UnboxError(json.errors.map((e: any) => e.message).join(" | "), json.errors);
|
|
210
|
+
}
|
|
211
|
+
return json.data as T;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ------------------------------------------------------ GraphQL (partner, low)
|
|
215
|
+
/**
|
|
216
|
+
* Chamada à API de PARCEIROS. Headers: x-api-key (key do parceiro) + Authorization
|
|
217
|
+
* (token do signIn — o gateway repassa ao core, que resolve a loja pelo JWT; por isso
|
|
218
|
+
* nenhuma query de parceiro pede shopId). O formato do Authorization não é documentado:
|
|
219
|
+
* tentamos `Bearer <jwt>` e, se vier erro de auth, refazemos UMA vez com o token cru,
|
|
220
|
+
* memorizando o formato que funcionou para as próximas chamadas.
|
|
221
|
+
*/
|
|
222
|
+
async gqlPartner<T = any>(query: string, variables: Record<string, any> = {}): Promise<T> {
|
|
223
|
+
if (!this.token) throw new UnboxError("sem token: chame signIn() ou passe setToken()");
|
|
224
|
+
const attempt = async (scheme: "bearer" | "raw"): Promise<{ json: any }> => {
|
|
225
|
+
const res = await fetch(this.partnerGqlUrl, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
"x-api-key": this.partnerApiKey,
|
|
230
|
+
// Doc oficial: token PURO no Authorization (sem "Bearer ").
|
|
231
|
+
Authorization: scheme === "raw" ? String(this.token) : `Bearer ${this.token}`,
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({ query, variables }),
|
|
234
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
235
|
+
});
|
|
236
|
+
return { json: await res.json() };
|
|
237
|
+
};
|
|
238
|
+
let { json } = await attempt(this.partnerAuthScheme);
|
|
239
|
+
if (json.errors?.length && isAuthSchemeError(json.errors)) {
|
|
240
|
+
const other = this.partnerAuthScheme === "raw" ? "bearer" : "raw";
|
|
241
|
+
const retry = await attempt(other);
|
|
242
|
+
if (!retry.json.errors?.length || !isAuthSchemeError(retry.json.errors)) {
|
|
243
|
+
this.partnerAuthScheme = other; // memoriza o formato aceito
|
|
244
|
+
json = retry.json;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (json.errors?.length) {
|
|
248
|
+
throw new UnboxError(json.errors.map((e: any) => e.message).join(" | "), json.errors);
|
|
249
|
+
}
|
|
250
|
+
return json.data as T;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------- catálogo
|
|
254
|
+
/** Seleção de produto compartilhada entre core e partner (schemas idênticos aqui). */
|
|
255
|
+
private static CATALOG_PRODUCT_FIELDS = `
|
|
256
|
+
_id productId title slug productType isVisible isSoldOut isBackorder isLowQuantity recurrenceAllowed imageUrls
|
|
257
|
+
minOrderQuantity maxOrderQuantity tagIds
|
|
258
|
+
pricing{displayPrice price minPrice maxPrice}
|
|
259
|
+
variants{_id title sku pricing{price displayPrice compareAtPrice{displayAmount}}}`;
|
|
260
|
+
|
|
261
|
+
async getCatalog(opts: {
|
|
262
|
+
first?: number; offset?: number; searchText?: string; tagIds?: string[];
|
|
263
|
+
sortBy?: string; sortOrder?: "asc" | "desc";
|
|
264
|
+
} = {}): Promise<Connection<{ product: CatalogProduct }>> {
|
|
265
|
+
// Partner: sem shopIds (loja vem do JWT). Diferenças do gateway (validadas ao vivo):
|
|
266
|
+
// 1) args opcionais NÃO podem ir nulos (o resolver rejeita sortOrder:null, que o core
|
|
267
|
+
// tolerava) → montamos a query só com os args realmente presentes;
|
|
268
|
+
// 2) o union CatalogItem exige __typename na seleção pra resolver o tipo.
|
|
269
|
+
if (this.usesPartnerApi) {
|
|
270
|
+
const decl = ["$first:Int", "$offset:Int"];
|
|
271
|
+
const args = ["first:$first", "offset:$offset"];
|
|
272
|
+
const vars: Record<string, any> = { first: opts.first ?? 24, offset: opts.offset ?? 0 };
|
|
273
|
+
const opt = (name: string, type: string, value: any) => {
|
|
274
|
+
if (value === undefined || value === null) return;
|
|
275
|
+
decl.push(`$${name}:${type}`); args.push(`${name}:$${name}`); vars[name] = value;
|
|
276
|
+
};
|
|
277
|
+
opt("searchText", "String", opts.searchText);
|
|
278
|
+
opt("tagIds", "[ID]", opts.tagIds);
|
|
279
|
+
opt("sortBy", "CatalogItemSortByField", opts.sortBy);
|
|
280
|
+
opt("sortOrder", "SortOrder", opts.sortOrder);
|
|
281
|
+
const q = `query(${decl.join(",")}){
|
|
282
|
+
catalogItems(${args.join(",")}){
|
|
283
|
+
totalCount pageInfo{hasNextPage endCursor}
|
|
284
|
+
nodes{__typename ... on CatalogItemProduct{ _id shortDescription product{${UnboxClient.CATALOG_PRODUCT_FIELDS}
|
|
285
|
+
}}}
|
|
286
|
+
}}`;
|
|
287
|
+
const d = await this.gqlPartner<{ catalogItems: any }>(q, vars);
|
|
288
|
+
return d.catalogItems;
|
|
289
|
+
}
|
|
290
|
+
const q = `query($s:[ID]!,$first:Int,$offset:Int,$searchText:String,$tagIds:[ID],$sortBy:CatalogItemSortByField,$sortOrder:SortOrder){
|
|
291
|
+
catalogItems(shopIds:$s,first:$first,offset:$offset,searchText:$searchText,tagIds:$tagIds,sortBy:$sortBy,sortOrder:$sortOrder){
|
|
292
|
+
totalCount pageInfo{hasNextPage endCursor}
|
|
293
|
+
nodes{... on CatalogItemProduct{ _id shortDescription product{${UnboxClient.CATALOG_PRODUCT_FIELDS}
|
|
294
|
+
}}}
|
|
295
|
+
}}`;
|
|
296
|
+
const d = await this.gql<{ catalogItems: any }>(q, {
|
|
297
|
+
s: [this.shopId], first: opts.first ?? 24, offset: opts.offset ?? 0,
|
|
298
|
+
searchText: opts.searchText, tagIds: opts.tagIds,
|
|
299
|
+
sortBy: opts.sortBy, sortOrder: opts.sortOrder,
|
|
300
|
+
});
|
|
301
|
+
return d.catalogItems;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private static PDP_PRODUCT_FIELDS = `_id productId title pageTitle slug description additionalInformation productType
|
|
305
|
+
sku isVisible isSoldOut isBackorder isLowQuantity recurrenceAllowed
|
|
306
|
+
minOrderQuantity maxOrderQuantity imageUrls videoUrls tagIds metaDescription
|
|
307
|
+
pricing{displayPrice price minPrice maxPrice}
|
|
308
|
+
variants{_id title sku pricing{price displayPrice compareAtPrice{displayAmount}}}`;
|
|
309
|
+
|
|
310
|
+
async getProductBySlug(productSlug: string): Promise<any> {
|
|
311
|
+
// Partner: mesma query sem shopId (loja vem do JWT). Validado ao vivo no gateway.
|
|
312
|
+
if (this.usesPartnerApi) {
|
|
313
|
+
const q = `query($productSlug:String!){
|
|
314
|
+
catalogItemProductBySlug(productSlug:$productSlug,filterSoldOutVariants:false){
|
|
315
|
+
_id shortDescription cardDescription publishedUrl
|
|
316
|
+
product{ ${UnboxClient.PDP_PRODUCT_FIELDS} }}}`;
|
|
317
|
+
const d = await this.gqlPartner<{ catalogItemProductBySlug: any }>(q, { productSlug });
|
|
318
|
+
return d.catalogItemProductBySlug;
|
|
319
|
+
}
|
|
320
|
+
const q = `query($shopId:ID!,$productSlug:String!){
|
|
321
|
+
catalogItemProductBySlug(shopId:$shopId,productSlug:$productSlug,filterSoldOutVariants:false){
|
|
322
|
+
_id shortDescription cardDescription publishedUrl
|
|
323
|
+
product{ ${UnboxClient.PDP_PRODUCT_FIELDS} }}}`;
|
|
324
|
+
const d = await this.gql<{ catalogItemProductBySlug: any }>(q, { shopId: this.shopId, productSlug });
|
|
325
|
+
return d.catalogItemProductBySlug;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** PDP por productId — fallback/deep link (catalogItemProductById). */
|
|
329
|
+
async getProductById(productId: string): Promise<any> {
|
|
330
|
+
// Partner: catalogItemProductById NÃO existe no gateway; equivalente validado ao vivo:
|
|
331
|
+
// catalogItems(productIdsOrERPCodes:[id], first:1) — devolve o mesmo CatalogItemProduct.
|
|
332
|
+
if (this.usesPartnerApi) {
|
|
333
|
+
const q = `query($ids:[String]){
|
|
334
|
+
catalogItems(productIdsOrERPCodes:$ids,first:1){
|
|
335
|
+
nodes{__typename ... on CatalogItemProduct{ _id shortDescription publishedUrl
|
|
336
|
+
product{ ${UnboxClient.PDP_PRODUCT_FIELDS} }}}}}`;
|
|
337
|
+
const d = await this.gqlPartner<{ catalogItems: any }>(q, { ids: [productId] });
|
|
338
|
+
return d.catalogItems?.nodes?.[0] ?? null;
|
|
339
|
+
}
|
|
340
|
+
const q = `query($shopId:ID!,$productId:ID!){
|
|
341
|
+
catalogItemProductById(shopId:$shopId,productId:$productId,filterSoldOutVariants:false){
|
|
342
|
+
_id shortDescription publishedUrl
|
|
343
|
+
product{ ${UnboxClient.PDP_PRODUCT_FIELDS} }}}`;
|
|
344
|
+
const d = await this.gql<{ catalogItemProductById: any }>(q, { shopId: this.shopId, productId });
|
|
345
|
+
return d.catalogItemProductById;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// isTopLevel omitido = sem filtro (igual à vitrine padrão da Unbox). Forçar `true` esconde
|
|
349
|
+
// categorias reais e visíveis marcadas isTopLevel:false no admin (ex.: "Acessórios") — 404
|
|
350
|
+
// em /categoria/<slug> mesmo com o produto corretamente vinculado à categoria.
|
|
351
|
+
async getTags(isTopLevel?: boolean): Promise<any[]> {
|
|
352
|
+
// Partner: mesma query, sem shopId (a loja vem do JWT). Campos idênticos (validado
|
|
353
|
+
// por introspecção no schema de parceiros).
|
|
354
|
+
if (this.usesPartnerApi) {
|
|
355
|
+
const q = `query($isTopLevel:Boolean){
|
|
356
|
+
tags(isTopLevel:$isTopLevel,shouldIncludeInvisible:false,shouldIncludeDeleted:false,first:100){
|
|
357
|
+
nodes{_id name displayTitle slug description isTopLevel isVisible position subTagIds featuredProductIds}
|
|
358
|
+
}}`;
|
|
359
|
+
const d = await this.gqlPartner<{ tags: any }>(q, { isTopLevel });
|
|
360
|
+
return d.tags.nodes;
|
|
361
|
+
}
|
|
362
|
+
const q = `query($shopId:ID!,$isTopLevel:Boolean){
|
|
363
|
+
tags(shopId:$shopId,isTopLevel:$isTopLevel,shouldIncludeInvisible:false,shouldIncludeDeleted:false,first:100){
|
|
364
|
+
nodes{_id name displayTitle slug description isTopLevel isVisible position subTagIds featuredProductIds}
|
|
365
|
+
}}`;
|
|
366
|
+
const d = await this.gql<{ tags: any }>(q, { shopId: this.shopId, isTopLevel });
|
|
367
|
+
return d.tags.nodes;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ----------------------------------------------------------------------- loja
|
|
371
|
+
/** Dados da loja: promoções (shopSales), política de assinatura, settings, pagamentos. */
|
|
372
|
+
async getShop(slug: string): Promise<any> {
|
|
373
|
+
// Partner: shopBySlug() SEM argumentos (a loja vem do JWT; o `slug` recebido é ignorado).
|
|
374
|
+
// O gateway não expõe allowGuestCheckout nem settings.maxInstallments — omitidos aqui;
|
|
375
|
+
// o app já tem defaults (maxInstallments ?? 12) e allowGuestCheckout não é consumido.
|
|
376
|
+
if (this.usesPartnerApi) {
|
|
377
|
+
const q = `query{ shopBySlug{
|
|
378
|
+
_id name slug acceptsBoleto acceptsCreditCard
|
|
379
|
+
settings{allowAnonymousRecurringOrders allowLegalPersonSales showOutOfStockCatalogs}
|
|
380
|
+
shopSales{_id code label description discountMethod enabled createdAt
|
|
381
|
+
calculation{__typename ... on CalculationFreeItemByTier { tiers { cartSubtotalGTE catalogProductVariant { _id title } } }}}
|
|
382
|
+
recurringOrdersPolicy{_id enabled keepOrderPricingPolicy
|
|
383
|
+
allowedFrequencies{_id title periodicity interval}
|
|
384
|
+
pricingPolicy{type value}
|
|
385
|
+
customerActions{canSkipCycle canPause canChangeFrequency canChangeAddress canAddProducts canRemoveProducts canChangeProductQuantity}}
|
|
386
|
+
}}`;
|
|
387
|
+
const d = await this.gqlPartner<{ shopBySlug: any }>(q);
|
|
388
|
+
return d.shopBySlug;
|
|
389
|
+
}
|
|
390
|
+
const q = `query($slug:String!){ shopBySlug(slug:$slug){
|
|
391
|
+
_id name slug acceptsBoleto acceptsCreditCard allowGuestCheckout
|
|
392
|
+
settings{maxInstallments allowAnonymousRecurringOrders allowLegalPersonSales showOutOfStockCatalogs}
|
|
393
|
+
shopSales{_id code label description discountMethod enabled createdAt
|
|
394
|
+
calculation{__typename ... on CalculationFreeItemByTier { tiers { cartSubtotalGTE catalogProductVariant { _id title } } }}}
|
|
395
|
+
recurringOrdersPolicy{_id enabled keepOrderPricingPolicy
|
|
396
|
+
allowedFrequencies{_id title periodicity interval}
|
|
397
|
+
pricingPolicy{type value}
|
|
398
|
+
customerActions{canSkipCycle canPause canChangeFrequency canChangeAddress canAddProducts canRemoveProducts canChangeProductQuantity}}
|
|
399
|
+
}}`;
|
|
400
|
+
const d = await this.gql<{ shopBySlug: any }>(q, { slug });
|
|
401
|
+
return d.shopBySlug;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async getPaymentMethods(): Promise<any[]> {
|
|
405
|
+
const q = `query($s:ID!){availablePaymentMethods(shopId:$s){name displayName isEnabled canRefund pluginName}}`;
|
|
406
|
+
const d = await this.gql<{ availablePaymentMethods: any[] }>(q, { s: this.shopId });
|
|
407
|
+
return d.availablePaymentMethods;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ------------------------------------------------------------------ promoções
|
|
411
|
+
async listDiscountCodes(first = 50): Promise<Connection<any>> {
|
|
412
|
+
// Partner: discountCodes sem shopId (loja vem do JWT); nodes do tipo Discount, com os
|
|
413
|
+
// mesmos campos que o app consome.
|
|
414
|
+
if (this.usesPartnerApi) {
|
|
415
|
+
const q = `query($first:Int){ discountCodes(first:$first){
|
|
416
|
+
totalCount nodes{_id code label description enabled discountMethod calculation{__typename}} }}`;
|
|
417
|
+
const d = await this.gqlPartner<{ discountCodes: any }>(q, { first });
|
|
418
|
+
return d.discountCodes;
|
|
419
|
+
}
|
|
420
|
+
const q = `query($s:ID!,$first:ConnectionLimitInt){ discountCodes(shopId:$s,first:$first){
|
|
421
|
+
totalCount nodes{_id code label description enabled discountMethod calculation{__typename}} }}`;
|
|
422
|
+
const d = await this.gql<{ discountCodes: any }>(q, { s: this.shopId, first });
|
|
423
|
+
return d.discountCodes;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// -------------------------------------------------------------------- carrinho
|
|
427
|
+
// ⚠️ Schema LIVE: `recurringItemsFrequencyId` NÃO existe em CreateCartInput/AddCartItemsInput
|
|
428
|
+
// (diverge da doc — vale o live). Itens de assinatura marcam-se só com `isRecurring:true`;
|
|
429
|
+
// a FREQUÊNCIA vai no placeOrder (orderRecurrence.recurringItemsFrequencyId).
|
|
430
|
+
async createCart(items: CartItemInput[]): Promise<CartResult> {
|
|
431
|
+
const q = `mutation($i:CreateCartInput!){ createCart(input:$i){
|
|
432
|
+
token
|
|
433
|
+
cart{ _id totalItemQuantity expiresAt
|
|
434
|
+
items{edges{node{_id productConfiguration{productId productVariantId} title variantTitle quantity thumbnail price{amount displayAmount} isRecurring isDiscountedBonusItem}}}
|
|
435
|
+
checkout{fulfillmentGroups{_id} summary{itemTotal{displayAmount} total{amount displayAmount}}}}
|
|
436
|
+
incorrectPriceFailures{productConfiguration{productId} providedPrice{amount} currentPrice{amount}}
|
|
437
|
+
minOrderQuantityFailures{minOrderQuantity quantity}
|
|
438
|
+
maxOrderQuantityFailures{maxOrderQuantity quantity} }}`;
|
|
439
|
+
const d = await this.gql<{ createCart: any }>(q, { i: {
|
|
440
|
+
shopId: this.shopId,
|
|
441
|
+
items: items.map((it) => ({
|
|
442
|
+
price: { amount: it.price, currencyCode: it.currencyCode ?? "BRL" },
|
|
443
|
+
productConfiguration: { productId: it.productId, productVariantId: it.productVariantId },
|
|
444
|
+
quantity: it.quantity,
|
|
445
|
+
isRecurring: it.isRecurring ?? false,
|
|
446
|
+
thumbnail: it.thumbnail ?? "",
|
|
447
|
+
})),
|
|
448
|
+
} });
|
|
449
|
+
const r = d.createCart;
|
|
450
|
+
// Obs.: o servidor SOBRESCREVE o preço com o do catálogo; incorrectPriceFailures vem vazio na
|
|
451
|
+
// prática (ver types.ts). Mantido só como aviso defensivo — quantidade min/max sim pode falhar.
|
|
452
|
+
if (r.incorrectPriceFailures?.length) console.warn("[unbox] incorrectPriceFailures:", r.incorrectPriceFailures);
|
|
453
|
+
return {
|
|
454
|
+
cartId: r.cart._id, cartToken: r.token, cart: r.cart,
|
|
455
|
+
incorrectPriceFailures: r.incorrectPriceFailures,
|
|
456
|
+
minOrderQuantityFailures: r.minOrderQuantityFailures,
|
|
457
|
+
maxOrderQuantityFailures: r.maxOrderQuantityFailures,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Adiciona itens a um carrinho existente (exige cartId + cartToken). isRecurring marca assinatura. */
|
|
462
|
+
async addCartItems(cartId: string, cartToken: string, items: CartItemInput[]): Promise<any> {
|
|
463
|
+
const q = `mutation($i:AddCartItemsInput!){ addCartItems(input:$i){
|
|
464
|
+
cart{ _id totalItemQuantity items{edges{node{_id productConfiguration{productId productVariantId} title variantTitle quantity price{amount displayAmount} isRecurring isDiscountedBonusItem}}} }
|
|
465
|
+
cartEvents{type data}
|
|
466
|
+
minOrderQuantityFailures{minOrderQuantity quantity} maxOrderQuantityFailures{maxOrderQuantity quantity} }}`;
|
|
467
|
+
const d = await this.gql<{ addCartItems: any }>(q, { i: {
|
|
468
|
+
cartId, cartToken,
|
|
469
|
+
items: items.map((it) => ({
|
|
470
|
+
price: { amount: it.price, currencyCode: it.currencyCode ?? "BRL" },
|
|
471
|
+
productConfiguration: { productId: it.productId, productVariantId: it.productVariantId },
|
|
472
|
+
quantity: it.quantity, isRecurring: it.isRecurring ?? false, thumbnail: it.thumbnail ?? "",
|
|
473
|
+
})),
|
|
474
|
+
} });
|
|
475
|
+
return d.addCartItems;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Altera a quantidade de um item do carrinho (cartItemId = node._id do item). */
|
|
479
|
+
async updateItemQuantity(cartId: string, cartToken: string, cartItemId: string, quantity: number): Promise<any> {
|
|
480
|
+
const q = `mutation($i:UpdateCartItemsQuantityInput!){ updateCartItemsQuantity(input:$i){
|
|
481
|
+
cart{ _id totalItemQuantity } cartEvents{type data} }}`;
|
|
482
|
+
const d = await this.gql<{ updateCartItemsQuantity: any }>(q, { i: { cartId, cartToken, items: [{ cartItemId, quantity }] } });
|
|
483
|
+
return d.updateCartItemsQuantity;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Remove itens do carrinho (cartItemIds = node._id dos itens). */
|
|
487
|
+
async removeCartItems(cartId: string, cartToken: string, cartItemIds: string[]): Promise<any> {
|
|
488
|
+
const q = `mutation($i:RemoveCartItemsInput!){ removeCartItems(input:$i){
|
|
489
|
+
cart{ _id totalItemQuantity } cartEvents{type data} }}`;
|
|
490
|
+
const d = await this.gql<{ removeCartItems: any }>(q, { i: { cartId, cartToken, cartItemIds } });
|
|
491
|
+
return d.removeCartItems;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Recarrega um carrinho anônimo (rehidratar a sessão). Retorna o cart completo: itens,
|
|
496
|
+
* brindes, resumo com descontos, e o endereço de entrega já gravado (se houver).
|
|
497
|
+
*/
|
|
498
|
+
async getCart(cartId: string, cartToken: string): Promise<any> {
|
|
499
|
+
const q = `query($cartId:ID!,$cartToken:String!){ anonymousCartByCartId(cartId:$cartId,cartToken:$cartToken){
|
|
500
|
+
_id email expiresAt referenceId recurringItemsFrequencyId totalItemQuantity
|
|
501
|
+
items(first:100){ totalCount edges{node{
|
|
502
|
+
_id productConfiguration{productId productVariantId} title variantTitle quantity addedAt
|
|
503
|
+
thumbnail price{amount displayAmount} isRecurring isDiscountedBonusItem }}}
|
|
504
|
+
checkout{
|
|
505
|
+
fulfillmentGroups{_id selectedFulfillmentOption{fulfillmentMethod{_id displayName name} price{displayAmount}}
|
|
506
|
+
data{shippingAddress{fullName taxPayerId phone address1 address2 number neighborhood city region postal}}}
|
|
507
|
+
summary{itemTotal{displayAmount} discountTotal{displayAmount} fulfillmentTotal{displayAmount} taxTotal{displayAmount} total{amount displayAmount}}} }}`;
|
|
508
|
+
const d = await this.gql<{ anonymousCartByCartId: any }>(q, { cartId, cartToken });
|
|
509
|
+
return d.anonymousCartByCartId;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Grava o e-mail no carrinho anônimo (etapa de contato do checkout, antes do placeOrder).
|
|
514
|
+
* Habilita recuperação de CARRINHO ABANDONADO: o e-mail fica associado ao cart no servidor
|
|
515
|
+
* mesmo se o cliente não finalizar (capturado do storefront oficial — setEmailOnAnonymousCart).
|
|
516
|
+
*/
|
|
517
|
+
async setEmailOnCart(cartId: string, cartToken: string, email: string): Promise<any> {
|
|
518
|
+
const q = `mutation($i:SetEmailOnAnonymousCartInput!){ setEmailOnAnonymousCart(input:$i){
|
|
519
|
+
cart{_id email} }}`;
|
|
520
|
+
const d = await this.gql<{ setEmailOnAnonymousCart: any }>(q, { i: { cartId, cartToken, email } });
|
|
521
|
+
return d.setEmailOnAnonymousCart;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async setShippingAddress(cartId: string, cartToken: string, address: AddressInput): Promise<string> {
|
|
525
|
+
const q = `mutation($i:SetShippingAddressOnCartInput!){ setShippingAddressOnCart(input:$i){
|
|
526
|
+
cart{checkout{fulfillmentGroups{_id}}} }}`;
|
|
527
|
+
const d = await this.gql<{ setShippingAddressOnCart: any }>(q, {
|
|
528
|
+
i: { cartId, cartToken, address: { country: "BR", ...address } },
|
|
529
|
+
});
|
|
530
|
+
return d.setShippingAddressOnCart.cart.checkout.fulfillmentGroups[0]._id;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Retorna TODOS os fulfillmentGroups do carrinho (suporte a N grupos de entrega). */
|
|
534
|
+
async getFulfillmentGroupIds(cartId: string, cartToken: string): Promise<string[]> {
|
|
535
|
+
const q = `query($cartId:ID!,$cartToken:String!){ anonymousCartByCartId(cartId:$cartId,cartToken:$cartToken){
|
|
536
|
+
checkout{fulfillmentGroups{_id}} }}`;
|
|
537
|
+
const d = await this.gql<{ anonymousCartByCartId: any }>(q, { cartId, cartToken });
|
|
538
|
+
return (d.anonymousCartByCartId?.checkout?.fulfillmentGroups ?? []).map((g: any) => g._id);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Cota o frete de UM grupo. OBRIGATÓRIO após setShippingAddress (senão options vem vazio). */
|
|
542
|
+
async quoteShipping(cartId: string, cartToken: string, fulfillmentGroupId: string): Promise<FulfillmentOption[]> {
|
|
543
|
+
const q = `mutation($i:UpdateFulfillmentOptionsForGroupInput!){ updateFulfillmentOptionsForGroup(input:$i){
|
|
544
|
+
cart{checkout{fulfillmentGroups{_id availableFulfillmentOptions{
|
|
545
|
+
price{amount displayAmount} discountPrice{displayAmount}
|
|
546
|
+
fulfillmentMethod{_id name displayName daysToDeliver}}}}} }}`;
|
|
547
|
+
const d = await this.gql<{ updateFulfillmentOptionsForGroup: any }>(q, { i: { cartId, cartToken, fulfillmentGroupId } });
|
|
548
|
+
const groups = d.updateFulfillmentOptionsForGroup.cart.checkout.fulfillmentGroups;
|
|
549
|
+
const fg = groups.find((g: any) => g._id === fulfillmentGroupId) ?? groups[0];
|
|
550
|
+
return fg.availableFulfillmentOptions ?? [];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async applyDiscount(cartId: string, cartToken: string, discountCode: string): Promise<any> {
|
|
554
|
+
const q = `mutation($i:ApplyDiscountCodeToCartInput!){ applyDiscountCodeToCart(input:$i){
|
|
555
|
+
cart{_id checkout{summary{discountTotal{displayAmount} total{amount displayAmount}}}} cartEvents{type data} }}`;
|
|
556
|
+
const d = await this.gql<{ applyDiscountCodeToCart: any }>(q, { i: { cartId, token: cartToken, shopId: this.shopId, discountCode } });
|
|
557
|
+
return d.applyDiscountCodeToCart;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Resolve o discount._id a partir do CÓDIGO do cupom (necessário p/ removeDiscountCodeFromCart,
|
|
562
|
+
* já que o Cart lido não expõe os ids de desconto aplicados). Casa por code (case-insensitive).
|
|
563
|
+
*/
|
|
564
|
+
async findDiscountIdByCode(code: string): Promise<string | null> {
|
|
565
|
+
const list = await this.listDiscountCodes(100);
|
|
566
|
+
const match = (list.nodes as any[]).find((d) => (d.code ?? "").toLowerCase() === code.toLowerCase());
|
|
567
|
+
return match?._id ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Remove um cupom do carrinho. Usa o discountId (= discount._id), NÃO o code. */
|
|
571
|
+
async removeDiscount(cartId: string, cartToken: string, discountId: string): Promise<any> {
|
|
572
|
+
const q = `mutation($i:RemoveDiscountCodeFromCartInput!){ removeDiscountCodeFromCart(input:$i){
|
|
573
|
+
cart{checkout{summary{discountTotal{displayAmount} total{amount displayAmount}}}} cartEvents{type data} }}`;
|
|
574
|
+
const d = await this.gql<{ removeDiscountCodeFromCart: any }>(q, { i: { cartId, token: cartToken, shopId: this.shopId, discountId } });
|
|
575
|
+
return d.removeDiscountCodeFromCart;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Seleciona o frete de UM grupo e devolve o estado final (itens + total) p/ o placeOrder. */
|
|
579
|
+
async selectShipping(cartId: string, cartToken: string, fulfillmentGroupId: string, fulfillmentMethodId: string): Promise<any> {
|
|
580
|
+
const q = `mutation($i:SelectFulfillmentOptionForGroupInput!){ selectFulfillmentOptionForGroup(input:$i){
|
|
581
|
+
cart{
|
|
582
|
+
items{edges{node{productConfiguration{productId productVariantId} title variantTitle price{amount} quantity addedAt thumbnail isDiscountedBonusItem}}}
|
|
583
|
+
checkout{summary{itemTotal{displayAmount} discountTotal{displayAmount} fulfillmentTotal{displayAmount} total{amount displayAmount}}}} }}`;
|
|
584
|
+
const d = await this.gql<{ selectFulfillmentOptionForGroup: any }>(q, { i: { cartId, cartToken, fulfillmentGroupId, fulfillmentMethodId } });
|
|
585
|
+
return d.selectFulfillmentOptionForGroup.cart;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Monta os `items` do placeOrder a partir do carrinho final. INCLUI brindes/itens promocionais:
|
|
590
|
+
* a Unbox exige que os itens do fulfillmentGroup batam EXATAMENTE com os do carrinho no servidor
|
|
591
|
+
* (senão FULFILLMENT_GROUP_AND_CART_ITEMS_DO_NOT_MATCH_ERROR). O valor cobrado é o total
|
|
592
|
+
* autoritativo do servidor (payment.amount = summary.total), então incluir brindes não cobra a mais.
|
|
593
|
+
*/
|
|
594
|
+
buildOrderItems(cart: any): PlaceOrderParams["items"] {
|
|
595
|
+
return cart.items.edges
|
|
596
|
+
.map((e: any) => e.node)
|
|
597
|
+
.map((n: any) => ({
|
|
598
|
+
addedAt: n.addedAt,
|
|
599
|
+
price: n.price.amount,
|
|
600
|
+
productConfiguration: n.productConfiguration,
|
|
601
|
+
quantity: n.quantity,
|
|
602
|
+
thumbnail: n.thumbnail ?? "",
|
|
603
|
+
// ESSENCIAIS p/ casar com o carrinho — sem eles a Unbox lança
|
|
604
|
+
// FULFILLMENT_GROUP_AND_CART_ITEMS_DO_NOT_MATCH (validado contra um pedido real).
|
|
605
|
+
isRecurring: n.isRecurring ?? false,
|
|
606
|
+
isDiscountedBonusItem: n.isDiscountedBonusItem ?? false,
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* "Calcule o frete" para a PDP (fora do checkout). Cria um carrinho efêmero com 1 item,
|
|
612
|
+
* grava o endereço (só CEP basta na prática p/ cotar) e cota o frete. Compõe primitivas
|
|
613
|
+
* já validadas ao vivo — não depende de createShipmentQuote (schema não confirmado).
|
|
614
|
+
*/
|
|
615
|
+
async quoteShippingForProduct(item: CartItemInput, postal: string): Promise<FulfillmentOption[]> {
|
|
616
|
+
const cart = await this.createCart([item]);
|
|
617
|
+
const addr: AddressInput = {
|
|
618
|
+
fullName: "Cotação", taxPayerId: "", postal, address1: "—", number: "0",
|
|
619
|
+
neighborhood: "—", city: "—", region: "SP", phone: "00000000000",
|
|
620
|
+
};
|
|
621
|
+
const fgId = await this.setShippingAddress(cart.cartId, cart.cartToken, addr);
|
|
622
|
+
return this.quoteShipping(cart.cartId, cart.cartToken, fgId);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// -------------------------------------------------------------------- checkout
|
|
626
|
+
/** Opções de parcelamento (sem juros) para um valor — usado no cartão de crédito (não assinatura). */
|
|
627
|
+
async getInstallments(amount: number): Promise<Array<{ installment: number; amount: number }>> {
|
|
628
|
+
// Partner: input sem shopId (loja vem do JWT); resposta com os mesmos campos.
|
|
629
|
+
if (this.usesPartnerApi) {
|
|
630
|
+
const q = `query($amount:Float!){ getInstallments(input:{amount:$amount}){ installments{installment amount} } }`;
|
|
631
|
+
const d = await this.gqlPartner<{ getInstallments: { installments: any[] } }>(q, { amount });
|
|
632
|
+
return (d.getInstallments?.installments ?? []).map((i: any) => ({ installment: i.installment, amount: i.amount }));
|
|
633
|
+
}
|
|
634
|
+
const q = `query($shopId:ID!,$amount:Float!){ getInstallments(input:{shopId:$shopId,amount:$amount}){ installments } }`;
|
|
635
|
+
const d = await this.gql<{ getInstallments: { installments: any[] } }>(q, { shopId: this.shopId, amount });
|
|
636
|
+
return (d.getInstallments?.installments ?? []).map((i: any) => ({ installment: i.installment, amount: i.amount }));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Cria o pedido (REAL). Envia x-captcha-verification automaticamente. */
|
|
640
|
+
async placeOrder(p: PlaceOrderParams): Promise<any> {
|
|
641
|
+
// country é obrigatório (String!) tanto no shippingAddress quanto no billingAddress.
|
|
642
|
+
const address = { country: "BR", ...p.address };
|
|
643
|
+
const payment = p.payment.type === "pix"
|
|
644
|
+
? { amount: p.total, method: "unboxpay_pix", data: { paymentType: "pix" }, billingAddress: address }
|
|
645
|
+
: {
|
|
646
|
+
amount: p.total, method: "unboxpay_credit", billingAddress: address,
|
|
647
|
+
data: {
|
|
648
|
+
cardHolder: p.payment.card.cardHolder, cardNumber: p.payment.card.cardNumber,
|
|
649
|
+
expirationMonth: p.payment.card.expirationMonth, expirationYear: p.payment.card.expirationYear,
|
|
650
|
+
securityCode: p.payment.card.securityCode, installments: p.payment.card.installments ?? 1,
|
|
651
|
+
paymentType: "credit",
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
// device (antifraude/3DS) é OBRIGATÓRIO e vai no NÍVEL RAIZ do PlaceOrderInput
|
|
655
|
+
// (irmão de order/payments). Sem navegador (scripts), o fallback é { type: "API" }.
|
|
656
|
+
const device: DeviceInput = p.device ?? { type: "API" };
|
|
657
|
+
const input: any = {
|
|
658
|
+
order: {
|
|
659
|
+
cartId: p.cartId, currencyCode: "BRL", email: p.email, shopId: this.shopId,
|
|
660
|
+
fulfillmentGroups: [{
|
|
661
|
+
type: "SHIPPING", shopId: this.shopId, totalPrice: p.total,
|
|
662
|
+
selectedFulfillmentMethodId: p.fulfillmentMethodId,
|
|
663
|
+
data: { shippingAddress: address }, items: p.items,
|
|
664
|
+
}],
|
|
665
|
+
},
|
|
666
|
+
payments: [payment],
|
|
667
|
+
device,
|
|
668
|
+
};
|
|
669
|
+
if (p.recurrence) input.orderRecurrence = { createNewRecurringOrder: true, recurringItemsFrequencyId: p.recurrence.recurringItemsFrequencyId };
|
|
670
|
+
|
|
671
|
+
const q = `mutation($i:PlaceOrderInput!){ placeOrder(input:$i){
|
|
672
|
+
token orders{_id referenceId status summary{total{amount displayAmount}}
|
|
673
|
+
recurringOrderId generatedNewRecurringOrder
|
|
674
|
+
payments{method{name} status{status} captureErrorMessage
|
|
675
|
+
data{... on UnboxPayPaymentData{qrCode paymentRecord redirectUrl numberOfInstallments}}}} }}`;
|
|
676
|
+
const d = await this.gql<{ placeOrder: any }>(q, { i: input }, { captcha: true, timeoutMs: PLACE_ORDER_TIMEOUT_MS });
|
|
677
|
+
return d.placeOrder;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Acompanhar pedido pelo referenceId.
|
|
682
|
+
* ⚠️ SEGURANÇA: com o token de LOJA, isto retorna QUALQUER pedido só pelo referenceId (validado ao
|
|
683
|
+
* vivo) — referenceId é curto e adivinhável. NUNCA exponha esta chamada ao browser sem o BFF antes
|
|
684
|
+
* verificar a POSSE do pedido: compare o `token` do placeOrder (guardado em cookie httpOnly) ou use
|
|
685
|
+
* o token do cliente logado (`UnboxCustomerClient.order`). Ver docs 09-seguranca.
|
|
686
|
+
*/
|
|
687
|
+
async getOrder(referenceId: string, token?: string): Promise<any> {
|
|
688
|
+
// Partner: orderByReferenceId(id) NÃO aceita o token de posse — a verificação de posse
|
|
689
|
+
// do pedido é (e sempre foi) responsabilidade do BFF (lib/orders.ts getOwnedOrder, via
|
|
690
|
+
// cookie httpOnly). Diferença de shape: OrderItem tem imageURLs em vez de thumbnail —
|
|
691
|
+
// normalizamos aqui pra manter o contrato do app.
|
|
692
|
+
if (this.usesPartnerApi) {
|
|
693
|
+
const q = (endereco: string) => `query($id:ID!){
|
|
694
|
+
orderByReferenceId(id:$id){
|
|
695
|
+
_id referenceId status email
|
|
696
|
+
summary{total{amount displayAmount}}
|
|
697
|
+
payments{displayName mode processor isCaptured cardBrand captureErrorMessage amount{amount displayAmount}}
|
|
698
|
+
fulfillmentGroups{
|
|
699
|
+
status type trackingCode
|
|
700
|
+
${endereco}
|
|
701
|
+
items{nodes{_id title variantTitle quantity imageURLs{thumbnail small medium large original} productSlug price{amount displayAmount} subtotal{displayAmount} productConfiguration{productId productVariantId}}}
|
|
702
|
+
}
|
|
703
|
+
invoiceIssued dispatched delivered
|
|
704
|
+
recurringOrderId }}`;
|
|
705
|
+
const d = await comEnderecoDoGrupo((endereco) => this.gqlPartner<{ orderByReferenceId: any }>(q(endereco), { id: referenceId }));
|
|
706
|
+
const order = d.orderByReferenceId;
|
|
707
|
+
for (const g of order?.fulfillmentGroups ?? []) {
|
|
708
|
+
for (const n of g?.items?.nodes ?? []) {
|
|
709
|
+
// imageURLs é um OBJETO de tamanhos (ImageSizes), não uma lista: `?.[0]` vinha vazio.
|
|
710
|
+
const img = n?.imageURLs;
|
|
711
|
+
if (n && n.thumbnail === undefined) n.thumbnail = img?.thumbnail ?? img?.small ?? img?.medium ?? img?.original ?? "";
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return order;
|
|
715
|
+
}
|
|
716
|
+
// Seleção conservadora: fora displayStatus e payments.data (resolvedores que quebram a
|
|
717
|
+
// consulta inteira) e trackingUrl (não existe neste contexto: use trackingCode). O endereço
|
|
718
|
+
// do grupo entra pelo comEnderecoDoGrupo, que repete sem ele se a união não resolver.
|
|
719
|
+
const q = (endereco: string) => `query($id:ID!,$shopId:ID,$token:String){
|
|
720
|
+
orderByReferenceId(id:$id,shopId:$shopId,token:$token){
|
|
721
|
+
_id referenceId status email
|
|
722
|
+
summary{total{amount displayAmount}}
|
|
723
|
+
payments{displayName mode processor isCaptured cardBrand captureErrorMessage amount{amount displayAmount}}
|
|
724
|
+
fulfillmentGroups{
|
|
725
|
+
status type trackingCode
|
|
726
|
+
${endereco}
|
|
727
|
+
items{nodes{_id title variantTitle quantity thumbnail productSlug price{amount displayAmount} subtotal{displayAmount} productConfiguration{productId productVariantId}}}
|
|
728
|
+
}
|
|
729
|
+
invoiceIssued dispatched delivered
|
|
730
|
+
recurringOrderId }}`;
|
|
731
|
+
const d = await comEnderecoDoGrupo((endereco) => this.gql<{ orderByReferenceId: any }>(q(endereco), { id: referenceId, shopId: this.shopId, token }));
|
|
732
|
+
return d.orderByReferenceId;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// -------------------------------------------------------- conta do cliente (OTP)
|
|
736
|
+
/** Storefront pede OTP por e-mail (contexto de loja). Exige x-captcha-verification (= UNBOX_CAPTCHA_BYPASS). */
|
|
737
|
+
async requestCustomerOtp(email: string): Promise<boolean> {
|
|
738
|
+
const q = `mutation($i:CustomerOTPRequestInput!){ customerOTPRequest(input:$i){success} }`;
|
|
739
|
+
const d = await this.gql<{ customerOTPRequest: { success: boolean } }>(q, { i: { email, shopId: this.shopId } }, { captcha: true });
|
|
740
|
+
return d.customerOTPRequest.success;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Troca o OTP pelo token DO CLIENTE (use-o no UnboxCustomerClient). */
|
|
744
|
+
async customerSignIn(email: string, otp: string): Promise<{ accessToken: string; firstAccess: boolean }> {
|
|
745
|
+
const q = `mutation($i:CustomerPasswordlessSignInInput){ customerPasswordlessSignIn(input:$i){
|
|
746
|
+
accessToken idToken firstAccess newShopSignIn }}`;
|
|
747
|
+
const d = await this.gql<{ customerPasswordlessSignIn: any }>(q, { i: { email, otp, shopId: this.shopId } }, { captcha: true });
|
|
748
|
+
return { accessToken: d.customerPasswordlessSignIn.accessToken, firstAccess: d.customerPasswordlessSignIn.firstAccess };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async customerAccountExists(email: string): Promise<boolean> {
|
|
752
|
+
const q = `query($i:HasCustomerAccountInput){ hasCustomerAccount(input:$i){result} }`;
|
|
753
|
+
const d = await this.gql<{ hasCustomerAccount: { result: boolean } }>(q, { i: { email, shopId: this.shopId } });
|
|
754
|
+
return d.hasCustomerAccount.result;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async getAddressByPostalCode(postalCode: string): Promise<any> {
|
|
758
|
+
const q = `query($i:getAddressByPostalCodeInput!){ getAddressByPostalCode(input:$i){
|
|
759
|
+
address1 neighborhood city region cityCode }}`;
|
|
760
|
+
const d = await this.gql<{ getAddressByPostalCode: any }>(q, { i: { shopId: this.shopId, postalCode: postalCode.replace(/\D/g, "") } });
|
|
761
|
+
return d.getAddressByPostalCode;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// --------------------------------------------------------------- payment links
|
|
765
|
+
/**
|
|
766
|
+
* Cria um Payment Link hospedado pela Unbox (bom para WhatsApp / compartilhar carrinho).
|
|
767
|
+
* ⚠️ Operação de loja (admin-ish) — derivada da doc 07/10; rode sob demanda. Retorna o
|
|
768
|
+
* PaymentLink; a página pública é montada via getPublicPaymentLink(referenceId).
|
|
769
|
+
*/
|
|
770
|
+
async createPaymentLink(input: {
|
|
771
|
+
items: Array<{ productId: string; productVariantId: string; quantity: number }>;
|
|
772
|
+
constraints?: PaymentLinkConstraints;
|
|
773
|
+
customerData?: Record<string, { value: string; editable: boolean }>;
|
|
774
|
+
}): Promise<any> {
|
|
775
|
+
const q = `mutation($i:CreatePaymentLinkInput!){ createPaymentLink(input:$i){
|
|
776
|
+
_id referenceId status constraints{expirationDate usageLimit} }}`;
|
|
777
|
+
const d = await this.gql<{ createPaymentLink: any }>(q, { i: {
|
|
778
|
+
shopId: this.shopId,
|
|
779
|
+
items: input.items,
|
|
780
|
+
constraints: input.constraints,
|
|
781
|
+
metadata: input.customerData ? { customerData: input.customerData } : undefined,
|
|
782
|
+
} });
|
|
783
|
+
return d.createPaymentLink;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Lê um payment link público (para montar a página de pagamento). discountCode é aplicado aqui. */
|
|
787
|
+
async getPublicPaymentLink(paymentLinkId: string, discountCode?: string): Promise<any> {
|
|
788
|
+
const q = `query($id:ID!,$shopId:ID!,$discountCode:String){ publicPaymentLink(paymentLinkId:$id,shopId:$shopId,discountCode:$discountCode){
|
|
789
|
+
_id referenceId status }}`;
|
|
790
|
+
const d = await this.gql<{ publicPaymentLink: any }>(q, { id: paymentLinkId, shopId: this.shopId, discountCode });
|
|
791
|
+
return d.publicPaymentLink;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Cria um carrinho real a partir de um cart template (campanhas / "compre de novo"). */
|
|
795
|
+
async createCartByTemplate(cartTemplateId: string): Promise<CartResult> {
|
|
796
|
+
const q = `mutation($i:CreateCartByTemplateInput!){ createCartByTemplate(input:$i){
|
|
797
|
+
token cart{ _id totalItemQuantity checkout{summary{total{amount displayAmount}}} } }}`;
|
|
798
|
+
// Escrita já publicada na API de parceiros (CreateCartPayload tem a mesma shape).
|
|
799
|
+
const d = this.usesPartnerApi
|
|
800
|
+
? await this.gqlPartner<{ createCartByTemplate: any }>(q, { i: { shopId: this.shopId, cartTemplateId } })
|
|
801
|
+
: await this.gql<{ createCartByTemplate: any }>(q, { i: { shopId: this.shopId, cartTemplateId } });
|
|
802
|
+
const r = d.createCartByTemplate;
|
|
803
|
+
return { cartId: r.cart._id, cartToken: r.token, cart: r.cart };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// -------------------------------------------------------------------- webhooks
|
|
807
|
+
/** ⚠️ EFEITO REAL: cria uma assinatura de webhook na loja. Rode sob demanda (script). */
|
|
808
|
+
async subscribeWebhook(eventType: string, endpoint: string): Promise<any> {
|
|
809
|
+
const q = `mutation($i:SubscribeToWebhookInput){ subscribeToWebhook(input:$i){
|
|
810
|
+
_id eventType endpoint secret createdAt }}`;
|
|
811
|
+
// Mesma mutation nos dois endpoints — uma das poucas ESCRITAS já publicadas na API de
|
|
812
|
+
// parceiros (junto com createCartByTemplate e os CRUDs de cupom).
|
|
813
|
+
const d = this.usesPartnerApi
|
|
814
|
+
? await this.gqlPartner<{ subscribeToWebhook: any }>(q, { i: { eventType, endpoint } })
|
|
815
|
+
: await this.gql<{ subscribeToWebhook: any }>(q, { i: { eventType, endpoint } });
|
|
816
|
+
return d.subscribeToWebhook;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ------------------------------------------------------------------ inventário
|
|
820
|
+
/** Inventário de uma variante (API de parceiros — simpleInventory). Exige partnerApiKey. */
|
|
821
|
+
async getSimpleInventory(productId: string, productVariantId: string): Promise<SimpleInventoryInfo | null> {
|
|
822
|
+
if (!this.usesPartnerApi) throw new UnboxError("getSimpleInventory exige UNBOX_PARTNER_API_KEY (API de parceiros)");
|
|
823
|
+
const q = `query($pc:ProductConfigurationInput!){ simpleInventory(productConfiguration:$pc){
|
|
824
|
+
_id canBackorder inventoryInStock inventoryReserved isEnabled lowInventoryWarningThreshold
|
|
825
|
+
productConfiguration{productId productVariantId} }}`;
|
|
826
|
+
const d = await this.gqlPartner<{ simpleInventory: SimpleInventoryInfo | null }>(q, { pc: { productId, productVariantId } });
|
|
827
|
+
return d.simpleInventory ?? null;
|
|
828
|
+
}
|
|
829
|
+
}
|