@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.
Files changed (358) hide show
  1. package/LICENSE.md +46 -0
  2. package/README.md +175 -0
  3. package/bin/cli.js +439 -0
  4. package/package.json +43 -0
  5. package/src/colors.js +100 -0
  6. package/src/presets.js +241 -0
  7. package/src/theme.js +253 -0
  8. package/template/.claude/agents/assets-marca.md +40 -0
  9. package/template/.claude/agents/avaliador-visual.md +48 -0
  10. package/template/.claude/agents/branding-briefing.md +401 -0
  11. package/template/.claude/agents/conteudo-secao.md +46 -0
  12. package/template/.claude/agents/paginas-legais.md +44 -0
  13. package/template/.claude/settings.json +3 -0
  14. package/template/.claude/skills/frontend-design/ATTRIBUTION.md +3 -0
  15. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  16. package/template/.claude/skills/frontend-design/SKILL.md +55 -0
  17. package/template/.claude/skills/web-design-guidelines/ATTRIBUTION.md +3 -0
  18. package/template/.claude/skills/web-design-guidelines/SKILL.md +50 -0
  19. package/template/.claude/skills/web-design-guidelines/guidelines-snapshot.md +180 -0
  20. package/template/.env.example +158 -0
  21. package/template/.mcp.json.example +14 -0
  22. package/template/.nvmrc +1 -0
  23. package/template/CLAUDE.md +333 -0
  24. package/template/COVERAGE.md +80 -0
  25. package/template/DEPLOY.md +179 -0
  26. package/template/QA.md +108 -0
  27. package/template/README.md +152 -0
  28. package/template/agents/CONSTRUCAO.md +207 -0
  29. package/template/agents/MANAGER.md +192 -0
  30. package/template/agents/PADROES.md +255 -0
  31. package/template/agents/definitions/00-scaffold.md +56 -0
  32. package/template/agents/definitions/01-layout.md +47 -0
  33. package/template/agents/definitions/02-homepage.md +70 -0
  34. package/template/agents/definitions/03-catalog.md +86 -0
  35. package/template/agents/definitions/04-pdp.md +97 -0
  36. package/template/agents/definitions/05-cart.md +116 -0
  37. package/template/agents/definitions/06-checkout.md +146 -0
  38. package/template/agents/definitions/07-auth.md +130 -0
  39. package/template/agents/definitions/08-customer.md +55 -0
  40. package/template/agents/definitions/09-promotions.md +62 -0
  41. package/template/agents/definitions/10-feedback.md +128 -0
  42. package/template/agents/definitions/11-seo-infra.md +159 -0
  43. package/template/agents/definitions/12-deploy.md +173 -0
  44. package/template/agents/definitions/13-cro.md +275 -0
  45. package/template/agents/definitions/14-seo.md +388 -0
  46. package/template/agents/definitions/15-branding.md +26 -0
  47. package/template/agents/definitions/16-qa-visual.md +138 -0
  48. package/template/agents/definitions/17-aeo.md +257 -0
  49. package/template/app/(loja)/busca/page.tsx +72 -0
  50. package/template/app/(loja)/carrinho/oferta/page.tsx +57 -0
  51. package/template/app/(loja)/carrinho/page.tsx +160 -0
  52. package/template/app/(loja)/categoria/[tagSlug]/layout.tsx +31 -0
  53. package/template/app/(loja)/categoria/[tagSlug]/loading.tsx +17 -0
  54. package/template/app/(loja)/categoria/[tagSlug]/page.tsx +49 -0
  55. package/template/app/(loja)/checkout/page.tsx +80 -0
  56. package/template/app/(loja)/checkout/pix/[ref]/page.tsx +133 -0
  57. package/template/app/(loja)/conta/assinaturas/[referenceId]/page.tsx +112 -0
  58. package/template/app/(loja)/conta/assinaturas/page.tsx +51 -0
  59. package/template/app/(loja)/conta/enderecos/page.tsx +20 -0
  60. package/template/app/(loja)/conta/entrar/page.tsx +119 -0
  61. package/template/app/(loja)/conta/page.tsx +45 -0
  62. package/template/app/(loja)/conta/pedidos/[referenceId]/page.tsx +49 -0
  63. package/template/app/(loja)/conta/pedidos/page.tsx +104 -0
  64. package/template/app/(loja)/conta/preferencias/page.tsx +24 -0
  65. package/template/app/(loja)/devolucoes/page.tsx +36 -0
  66. package/template/app/(loja)/layout.tsx +22 -0
  67. package/template/app/(loja)/oferta/page.tsx +57 -0
  68. package/template/app/(loja)/page.tsx +86 -0
  69. package/template/app/(loja)/pedido/[referenceId]/page.tsx +78 -0
  70. package/template/app/(loja)/privacidade/page.tsx +113 -0
  71. package/template/app/(loja)/produto/[productSlug]/layout.tsx +23 -0
  72. package/template/app/(loja)/produto/[productSlug]/loading.tsx +20 -0
  73. package/template/app/(loja)/produto/[productSlug]/page.tsx +372 -0
  74. package/template/app/(loja)/produtos/loading.tsx +16 -0
  75. package/template/app/(loja)/produtos/page.tsx +33 -0
  76. package/template/app/(loja)/termos/page.tsx +104 -0
  77. package/template/app/acesso/page.tsx +129 -0
  78. package/template/app/api/account/addresses/route.ts +39 -0
  79. package/template/app/api/account/exists/route.ts +21 -0
  80. package/template/app/api/account/me/route.ts +25 -0
  81. package/template/app/api/account/otp/route.ts +26 -0
  82. package/template/app/api/account/preferences/route.ts +19 -0
  83. package/template/app/api/account/signin/route.ts +27 -0
  84. package/template/app/api/account/signout/route.ts +9 -0
  85. package/template/app/api/acesso/route.ts +171 -0
  86. package/template/app/api/capi/route.ts +34 -0
  87. package/template/app/api/cart/coupon/route.ts +49 -0
  88. package/template/app/api/cart/items/route.ts +45 -0
  89. package/template/app/api/cart/link/route.ts +69 -0
  90. package/template/app/api/cart/route.ts +113 -0
  91. package/template/app/api/cart/share/route.ts +21 -0
  92. package/template/app/api/cep/[code]/route.ts +18 -0
  93. package/template/app/api/checkout/address/route.ts +24 -0
  94. package/template/app/api/checkout/email/route.ts +41 -0
  95. package/template/app/api/checkout/installments/route.ts +16 -0
  96. package/template/app/api/checkout/route.ts +110 -0
  97. package/template/app/api/checkout/shipping/route.ts +65 -0
  98. package/template/app/api/checkout-destination/route.ts +31 -0
  99. package/template/app/api/order/[ref]/route.ts +17 -0
  100. package/template/app/api/payment-link/route.ts +28 -0
  101. package/template/app/api/revalidate/route.ts +52 -0
  102. package/template/app/api/shipping/quote/route.ts +29 -0
  103. package/template/app/api/subscriptions/[id]/route.ts +52 -0
  104. package/template/app/api/track/route.ts +28 -0
  105. package/template/app/api/unbox/catalogo/route.ts +136 -0
  106. package/template/app/api/unbox/paginas/route.ts +167 -0
  107. package/template/app/api/unbox/vitrine/route.ts +84 -0
  108. package/template/app/api/webhooks/unbox/route.ts +97 -0
  109. package/template/app/apple-icon.svg +5 -0
  110. package/template/app/error.tsx +15 -0
  111. package/template/app/globals.css +457 -0
  112. package/template/app/icon.svg +4 -0
  113. package/template/app/layout.tsx +90 -0
  114. package/template/app/llms.txt/route.ts +43 -0
  115. package/template/app/manifest.ts +21 -0
  116. package/template/app/not-found.tsx +18 -0
  117. package/template/app/opengraph-image.tsx +35 -0
  118. package/template/app/robots.ts +31 -0
  119. package/template/app/sitemap.ts +42 -0
  120. package/template/bootstrap.sh +87 -0
  121. package/template/components/account/account-shell.tsx +47 -0
  122. package/template/components/account/address-book.tsx +147 -0
  123. package/template/components/account/preferences-form.tsx +77 -0
  124. package/template/components/account/reorder-button.tsx +47 -0
  125. package/template/components/account/signout-button.tsx +20 -0
  126. package/template/components/account/subscription-actions.tsx +204 -0
  127. package/template/components/account-nav.tsx +41 -0
  128. package/template/components/address-fields.tsx +102 -0
  129. package/template/components/analytics/data-layer-ready.tsx +16 -0
  130. package/template/components/analytics/purchase-tracker.tsx +32 -0
  131. package/template/components/brand-search.tsx +34 -0
  132. package/template/components/cart/cart-provider.tsx +340 -0
  133. package/template/components/cart/mini-cart.tsx +228 -0
  134. package/template/components/cart-button.tsx +35 -0
  135. package/template/components/catalog/catalog-client.tsx +523 -0
  136. package/template/components/catalog/grid-skeleton.tsx +19 -0
  137. package/template/components/catalog/pager.tsx +66 -0
  138. package/template/components/catalog/product-grid-card.tsx +52 -0
  139. package/template/components/catalog/product-grid.tsx +27 -0
  140. package/template/components/checkout/checkout-client.tsx +1333 -0
  141. package/template/components/chrome/announce-bar.tsx +57 -0
  142. package/template/components/chrome/chrome-recipe.ts +13 -0
  143. package/template/components/chrome/footers/captura-botao.tsx +28 -0
  144. package/template/components/chrome/footers/colunas.tsx +110 -0
  145. package/template/components/chrome/footers/conversao.tsx +90 -0
  146. package/template/components/chrome/footers/editorial.tsx +98 -0
  147. package/template/components/chrome/footers/minimal.tsx +55 -0
  148. package/template/components/chrome/header-bar-mobile.tsx +64 -0
  149. package/template/components/chrome/headers/centralizado.tsx +71 -0
  150. package/template/components/chrome/headers/classico.tsx +64 -0
  151. package/template/components/chrome/headers/compacto.tsx +66 -0
  152. package/template/components/chrome/headers/equilibrado.tsx +73 -0
  153. package/template/components/chrome/headers/imersivo.tsx +62 -0
  154. package/template/components/chrome/registry.ts +70 -0
  155. package/template/components/chrome/solid-on-scroll.tsx +39 -0
  156. package/template/components/empty-state.tsx +22 -0
  157. package/template/components/home/combo-card-compact.tsx +67 -0
  158. package/template/components/home/combos-home.tsx +64 -0
  159. package/template/components/home/combos-section.tsx +177 -0
  160. package/template/components/home/home-recipe.ts +41 -0
  161. package/template/components/home/sections/attributes-marquee.tsx +178 -0
  162. package/template/components/home/sections/benefits.tsx +80 -0
  163. package/template/components/home/sections/bloco-html.tsx +47 -0
  164. package/template/components/home/sections/catalogo.ts +165 -0
  165. package/template/components/home/sections/category-pills.tsx +32 -0
  166. package/template/components/home/sections/combos-carousel.tsx +141 -0
  167. package/template/components/home/sections/comparison.tsx +73 -0
  168. package/template/components/home/sections/founder-story.tsx +40 -0
  169. package/template/components/home/sections/hero.tsx +187 -0
  170. package/template/components/home/sections/kits.tsx +11 -0
  171. package/template/components/home/sections/media-cards.tsx +70 -0
  172. package/template/components/home/sections/newsletter.tsx +62 -0
  173. package/template/components/home/sections/product-showcase.tsx +175 -0
  174. package/template/components/home/sections/purchase-hero.tsx +187 -0
  175. package/template/components/home/sections/quote-banner.tsx +50 -0
  176. package/template/components/home/sections/registry.ts +186 -0
  177. package/template/components/home/sections/reviews-carousel.tsx +66 -0
  178. package/template/components/home/sections/reviews.tsx +68 -0
  179. package/template/components/home/sections/ritual.tsx +96 -0
  180. package/template/components/home/sections/savings.tsx +47 -0
  181. package/template/components/home/sections/social-row.tsx +65 -0
  182. package/template/components/home/sections/spec-table.tsx +117 -0
  183. package/template/components/home/sections/stars.tsx +12 -0
  184. package/template/components/home/sections/stats-grid.tsx +54 -0
  185. package/template/components/home/sections/trust-bar.tsx +56 -0
  186. package/template/components/home/sections/trust-strip.tsx +40 -0
  187. package/template/components/home/sections/video-wall.tsx +59 -0
  188. package/template/components/landing/landing-recipe.ts +20 -0
  189. package/template/components/landing/oferta-sections.tsx +40 -0
  190. package/template/components/landing/product-picker.tsx +257 -0
  191. package/template/components/mobile-nav.tsx +71 -0
  192. package/template/components/order-status.tsx +163 -0
  193. package/template/components/powered-by-unbox.tsx +27 -0
  194. package/template/components/product/pdp/buy-box.tsx +604 -0
  195. package/template/components/product/pdp/catalog-grid.tsx +113 -0
  196. package/template/components/product/pdp/faq-modelo.ts +86 -0
  197. package/template/components/product/pdp/gallery.tsx +87 -0
  198. package/template/components/product/pdp/interactive.tsx +200 -0
  199. package/template/components/product/pdp/newsletter.tsx +64 -0
  200. package/template/components/product/pdp/payment-chips.tsx +22 -0
  201. package/template/components/product/pdp/pdp-view.tsx +227 -0
  202. package/template/components/product/pdp/recommendations.tsx +144 -0
  203. package/template/components/product/pdp/sections.tsx +173 -0
  204. package/template/components/quantity-stepper.tsx +36 -0
  205. package/template/components/search-box.tsx +37 -0
  206. package/template/components/site-footer.tsx +59 -0
  207. package/template/components/site-header.tsx +64 -0
  208. package/template/components/ui/accordion.tsx +72 -0
  209. package/template/components/ui/alert-dialog.tsx +187 -0
  210. package/template/components/ui/alert.tsx +76 -0
  211. package/template/components/ui/aspect-ratio.tsx +22 -0
  212. package/template/components/ui/avatar.tsx +109 -0
  213. package/template/components/ui/badge.tsx +52 -0
  214. package/template/components/ui/breadcrumb.tsx +125 -0
  215. package/template/components/ui/button.tsx +58 -0
  216. package/template/components/ui/card.tsx +103 -0
  217. package/template/components/ui/carousel.tsx +242 -0
  218. package/template/components/ui/command.tsx +196 -0
  219. package/template/components/ui/dialog.tsx +160 -0
  220. package/template/components/ui/dropdown-menu.tsx +268 -0
  221. package/template/components/ui/foto.tsx +54 -0
  222. package/template/components/ui/input-group.tsx +158 -0
  223. package/template/components/ui/input-otp.tsx +87 -0
  224. package/template/components/ui/input.tsx +20 -0
  225. package/template/components/ui/label.tsx +20 -0
  226. package/template/components/ui/navigation-menu.tsx +168 -0
  227. package/template/components/ui/pagination.tsx +132 -0
  228. package/template/components/ui/popover.tsx +90 -0
  229. package/template/components/ui/progress.tsx +83 -0
  230. package/template/components/ui/radio-group.tsx +38 -0
  231. package/template/components/ui/scroll-area.tsx +55 -0
  232. package/template/components/ui/select.tsx +201 -0
  233. package/template/components/ui/separator.tsx +25 -0
  234. package/template/components/ui/sheet.tsx +138 -0
  235. package/template/components/ui/skeleton.tsx +13 -0
  236. package/template/components/ui/sonner.tsx +44 -0
  237. package/template/components/ui/switch.tsx +32 -0
  238. package/template/components/ui/table.tsx +116 -0
  239. package/template/components/ui/tabs.tsx +82 -0
  240. package/template/components/ui/textarea.tsx +18 -0
  241. package/template/components/ui/toggle-group.tsx +89 -0
  242. package/template/components/ui/toggle.tsx +45 -0
  243. package/template/components/ui/tooltip.tsx +66 -0
  244. package/template/components.json +25 -0
  245. package/template/eslint.config.mjs +36 -0
  246. package/template/gitignore +21 -0
  247. package/template/lib/analytics.ts +369 -0
  248. package/template/lib/api.ts +60 -0
  249. package/template/lib/capi.ts +87 -0
  250. package/template/lib/cart-link.ts +51 -0
  251. package/template/lib/cart-normalize.ts +142 -0
  252. package/template/lib/cart-recovery.ts +23 -0
  253. package/template/lib/cart-response.ts +48 -0
  254. package/template/lib/catalog-map.ts +54 -0
  255. package/template/lib/catalog.ts +3 -0
  256. package/template/lib/checkout-lock.ts +21 -0
  257. package/template/lib/checkout-nav.ts +22 -0
  258. package/template/lib/config.ts +51 -0
  259. package/template/lib/crm.ts +40 -0
  260. package/template/lib/customer-session.ts +21 -0
  261. package/template/lib/dataloader.ts +23 -0
  262. package/template/lib/editable/config.ts +11 -0
  263. package/template/lib/editable/document.ts +1762 -0
  264. package/template/lib/editable/index.ts +3 -0
  265. package/template/lib/editable/primitives.tsx +821 -0
  266. package/template/lib/editable/provider.tsx +861 -0
  267. package/template/lib/editable/rastreio-navegacao.tsx +65 -0
  268. package/template/lib/editable/rastreio.tsx +168 -0
  269. package/template/lib/editable/server.ts +189 -0
  270. package/template/lib/editable/tokens.ts +22 -0
  271. package/template/lib/editable/verify.ts +24 -0
  272. package/template/lib/enrichment/combos.ts +184 -0
  273. package/template/lib/enrichment/index.ts +188 -0
  274. package/template/lib/enrichment/products.json +1 -0
  275. package/template/lib/env-check.ts +47 -0
  276. package/template/lib/format.ts +130 -0
  277. package/template/lib/icons.ts +67 -0
  278. package/template/lib/json-ld.ts +9 -0
  279. package/template/lib/llms-txt.ts +75 -0
  280. package/template/lib/mockup.ts +24 -0
  281. package/template/lib/newsletter.ts +12 -0
  282. package/template/lib/orders.ts +160 -0
  283. package/template/lib/queries.ts +66 -0
  284. package/template/lib/ratelimit.ts +47 -0
  285. package/template/lib/rotas-editaveis.ts +192 -0
  286. package/template/lib/sanitize.ts +36 -0
  287. package/template/lib/schemas.ts +78 -0
  288. package/template/lib/session.ts +98 -0
  289. package/template/lib/store-config.ts +67 -0
  290. package/template/lib/unbox/client.ts +829 -0
  291. package/template/lib/unbox/customer.ts +224 -0
  292. package/template/lib/unbox/errors.ts +97 -0
  293. package/template/lib/unbox/index.ts +10 -0
  294. package/template/lib/unbox/store.ts +113 -0
  295. package/template/lib/unbox/types.ts +195 -0
  296. package/template/lib/unbox/webhooks.ts +66 -0
  297. package/template/lib/utils.ts +6 -0
  298. package/template/lib/vitrine.ts +242 -0
  299. package/template/lib/webhook-store.ts +22 -0
  300. package/template/middleware.ts +178 -0
  301. package/template/next.config.ts +63 -0
  302. package/template/package.json +62 -0
  303. package/template/postcss.config.mjs +5 -0
  304. package/template/public/brand/coll/default.png +0 -0
  305. package/template/public/brand/hero-desktop.svg +12 -0
  306. package/template/public/brand/hero-mobile.svg +12 -0
  307. package/template/public/brand/heros/boutique-desktop.svg +11 -0
  308. package/template/public/brand/heros/boutique-mobile.svg +11 -0
  309. package/template/public/brand/heros/editorial-desktop.svg +11 -0
  310. package/template/public/brand/heros/editorial-mobile.svg +11 -0
  311. package/template/public/brand/heros/essencial-desktop.svg +12 -0
  312. package/template/public/brand/heros/essencial-mobile.svg +12 -0
  313. package/template/public/brand/heros/promocional-desktop.svg +12 -0
  314. package/template/public/brand/heros/promocional-mobile.svg +12 -0
  315. package/template/public/brand/logo-chrome.svg +6 -0
  316. package/template/public/brand/logo-white.svg +6 -0
  317. package/template/public/brand/logo.svg +7 -0
  318. package/template/public/brand/pay-amex.webp +0 -0
  319. package/template/public/brand/pay-elo.webp +0 -0
  320. package/template/public/brand/pay-mastercard.webp +0 -0
  321. package/template/public/brand/pay-pix.webp +0 -0
  322. package/template/public/brand/pay-visa.webp +0 -0
  323. package/template/public/brand/ph/avatar-a.svg +5 -0
  324. package/template/public/brand/ph/avatar-b.svg +5 -0
  325. package/template/public/brand/ph/avatar-c.svg +5 -0
  326. package/template/public/brand/ph/avatar-d.svg +5 -0
  327. package/template/public/brand/ph/photo-a.svg +7 -0
  328. package/template/public/brand/ph/photo-b.svg +6 -0
  329. package/template/public/brand/ph/photo-c.svg +6 -0
  330. package/template/public/brand/ph/poster-a.svg +6 -0
  331. package/template/public/brand/ph/poster-b.svg +6 -0
  332. package/template/public/brand/ph/poster-c.svg +6 -0
  333. package/template/public/unbox/powered-by-fundo-preto.png +0 -0
  334. package/template/public/unbox/powered-by-transparente.png +0 -0
  335. package/template/public/unbox/powered-by.png +0 -0
  336. package/template/scripts/abandoned-cart.ts +82 -0
  337. package/template/scripts/check-editable.mjs +467 -0
  338. package/template/scripts/check-honestidade.mjs +111 -0
  339. package/template/scripts/check-placeholder.mjs +200 -0
  340. package/template/scripts/check-recipe.mjs +76 -0
  341. package/template/scripts/check-unbox-brand.mjs +298 -0
  342. package/template/scripts/contraste.js +177 -0
  343. package/template/scripts/dump-catalog.ts +95 -0
  344. package/template/scripts/load-env.ts +13 -0
  345. package/template/scripts/medir-sistema.mjs +56 -0
  346. package/template/scripts/place-order-pix.ts +61 -0
  347. package/template/scripts/qa-screenshots.mjs +150 -0
  348. package/template/scripts/sistema.py +231 -0
  349. package/template/scripts/subscribe-webhook.ts +36 -0
  350. package/template/scripts/test-live.ts +186 -0
  351. package/template/scripts/vocabulario.py +264 -0
  352. package/template/tsconfig.json +27 -0
  353. package/tools/LEIA-ME.md +39 -0
  354. package/tools/check-template-neutro.mjs +111 -0
  355. package/tools/notion-doc.mjs +191 -0
  356. package/tools/tokenize-neutrals.mjs +0 -0
  357. package/tools/tokenize-radius.mjs +52 -0
  358. package/tools/workflow-storefront.legado.js +1207 -0
@@ -0,0 +1,39 @@
1
+ // CRUD de endereços do cliente (address book). Token do cliente do cookie.
2
+ import { requireCustomerClient } from "@/lib/customer-session";
3
+ import { addressSchema } from "@/lib/schemas";
4
+ import { ok, fail, failFrom } from "@/lib/api";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ // POST → upsert (com _id atualiza; sem _id cria)
9
+ export async function POST(req: Request) {
10
+ try {
11
+ const me = await requireCustomerClient();
12
+ const body = await req.json();
13
+ const parsed = addressSchema.safeParse(body.address);
14
+ if (!parsed.success) return fail("Endereço inválido.", 422, { issues: parsed.error.flatten() });
15
+ const r = await me.upsertAddress({
16
+ ...(parsed.data as any),
17
+ _id: body.address?._id,
18
+ alias: body.address?.alias,
19
+ isShippingDefault: body.address?.isShippingDefault,
20
+ isBillingDefault: body.address?.isBillingDefault,
21
+ });
22
+ return ok({ address: r });
23
+ } catch (e) {
24
+ return failFrom(e);
25
+ }
26
+ }
27
+
28
+ // DELETE { ids: string[] }
29
+ export async function DELETE(req: Request) {
30
+ try {
31
+ const me = await requireCustomerClient();
32
+ const { ids } = await req.json();
33
+ if (!Array.isArray(ids) || !ids.length) return fail("Nenhum endereço informado.");
34
+ await me.deleteAddresses(ids);
35
+ return ok({ ok: true });
36
+ } catch (e) {
37
+ return failFrom(e);
38
+ }
39
+ }
@@ -0,0 +1,21 @@
1
+ // Verifica se há conta para o e-mail nesta loja (login vs. primeiro acesso). Rate-limit anti-enumeração.
2
+ import { withStoreClient } from "@/lib/unbox/store";
3
+ import { rateLimit, clientIp, LIMITS } from "@/lib/ratelimit";
4
+ import { emailSchema } from "@/lib/schemas";
5
+ import { ok, fail, failFrom } from "@/lib/api";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export async function POST(req: Request) {
10
+ const rl = rateLimit(`exists:${clientIp(req)}`, LIMITS.exists.limit, LIMITS.exists.windowMs);
11
+ if (!rl.ok) return fail("Muitas tentativas. Aguarde.", 429);
12
+ try {
13
+ const { email } = await req.json();
14
+ const parsed = emailSchema.safeParse(email);
15
+ if (!parsed.success) return fail("E-mail inválido.", 422);
16
+ const exists = await withStoreClient((c) => c.customerAccountExists(parsed.data));
17
+ return ok({ exists });
18
+ } catch (e) {
19
+ return failFrom(e);
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getCustomerClient } from "@/lib/customer-session";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s);
7
+
8
+ /** Primeiro nome do cliente: do endereço salvo, senão da parte local do e-mail. */
9
+ function firstNameFrom(account: any): string | null {
10
+ const full: string =
11
+ account?.addressBooks?.find((a: any) => a.isShippingDefault)?.fullName ||
12
+ account?.addressBooks?.[0]?.fullName ||
13
+ "";
14
+ const fromName = full.trim().split(/\s+/)[0];
15
+ if (fromName) return cap(fromName);
16
+ const local = String(account?.email ?? "").split("@")[0]?.split(/[._-]/)[0];
17
+ return local ? cap(local) : null;
18
+ }
19
+
20
+ export async function GET() {
21
+ const customer = await getCustomerClient();
22
+ if (!customer) return NextResponse.json({ firstName: null });
23
+ const account = await customer.me().catch(() => null);
24
+ return NextResponse.json({ firstName: account ? firstNameFrom(account) : null });
25
+ }
@@ -0,0 +1,26 @@
1
+ // Pede o código OTP por e-mail. ⚠️ EFEITO REAL: dispara e-mail de verdade (customerOTPRequest).
2
+ // Exige x-captcha-verification (o SDK cuida). Rate-limit anti-spam. NÃO retentar automaticamente.
3
+ import { withStoreClient } from "@/lib/unbox/store";
4
+ import { rateLimit, clientIp, LIMITS } from "@/lib/ratelimit";
5
+ import { emailSchema } from "@/lib/schemas";
6
+ import { ok, fail, failFrom } from "@/lib/api";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ export async function POST(req: Request) {
11
+ try {
12
+ const { email } = await req.json();
13
+ const parsed = emailSchema.safeParse(email);
14
+ if (!parsed.success) return fail("E-mail inválido.", 422);
15
+
16
+ // limita por IP e por e-mail
17
+ const ip = rateLimit(`otp:ip:${clientIp(req)}`, LIMITS.otp.limit, LIMITS.otp.windowMs);
18
+ const byEmail = rateLimit(`otp:email:${parsed.data}`, LIMITS.otp.limit, LIMITS.otp.windowMs);
19
+ if (!ip.ok || !byEmail.ok) return fail("Muitas solicitações de código. Aguarde alguns minutos.", 429);
20
+
21
+ await withStoreClient((c) => c.requestCustomerOtp(parsed.data));
22
+ return ok({ ok: true });
23
+ } catch (e) {
24
+ return failFrom(e);
25
+ }
26
+ }
@@ -0,0 +1,19 @@
1
+ // Atualiza preferências da conta (token do cliente do cookie).
2
+ import { requireCustomerClient } from "@/lib/customer-session";
3
+ import { ok, failFrom } from "@/lib/api";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function PATCH(req: Request) {
8
+ try {
9
+ const me = await requireCustomerClient();
10
+ const body = await req.json();
11
+ const input: { receiveNewOrderEmail?: boolean; reuseDataBetweenShops?: boolean } = {};
12
+ if (typeof body.receiveNewOrderEmail === "boolean") input.receiveNewOrderEmail = body.receiveNewOrderEmail;
13
+ if (typeof body.reuseDataBetweenShops === "boolean") input.reuseDataBetweenShops = body.reuseDataBetweenShops;
14
+ const r = await me.updateAccount(input);
15
+ return ok({ account: r });
16
+ } catch (e) {
17
+ return failFrom(e);
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ // Troca o OTP pelo token DO CLIENTE e grava em cookie httpOnly. Rate-limit.
2
+ import { withStoreClient } from "@/lib/unbox/store";
3
+ import { setCustomerToken } from "@/lib/session";
4
+ import { rateLimit, clientIp, LIMITS } from "@/lib/ratelimit";
5
+ import { otpSchema } from "@/lib/schemas";
6
+ import { ok, fail, failFrom } from "@/lib/api";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ export async function POST(req: Request) {
11
+ const rl = rateLimit(`signin:${clientIp(req)}`, LIMITS.signin.limit, LIMITS.signin.windowMs);
12
+ if (!rl.ok) return fail("Muitas tentativas. Aguarde.", 429);
13
+ try {
14
+ const body = await req.json();
15
+ const parsed = otpSchema.safeParse(body);
16
+ if (!parsed.success) return fail("Código inválido.", 422);
17
+
18
+ const { accessToken, firstAccess } = await withStoreClient((c) =>
19
+ c.customerSignIn(parsed.data.email, parsed.data.otp),
20
+ );
21
+ if (!accessToken) return fail("Código ou credenciais inválidos.", 401);
22
+ await setCustomerToken(accessToken);
23
+ return ok({ ok: true, firstAccess });
24
+ } catch (e) {
25
+ return failFrom(e, 401);
26
+ }
27
+ }
@@ -0,0 +1,9 @@
1
+ import { clearCustomerToken } from "@/lib/session";
2
+ import { ok } from "@/lib/api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function POST() {
7
+ await clearCustomerToken();
8
+ return ok({ ok: true });
9
+ }
@@ -0,0 +1,171 @@
1
+ // Registra o lead da porta de preview e assina o cookie que libera a loja.
2
+ // Fica FORA da porta (o middleware deixa /api/acesso passar), senão não haveria como entrar.
3
+ //
4
+ // Os logs da Vercel têm retenção de HORAS — quem PERSISTE o lead é o Pipedrive
5
+ // (e o webhook opcional). Os logs servem pra conferir e contar.
6
+ import { NextResponse } from "next/server";
7
+ import { COOKIE, senhaDoPreview, tokenDaSenha } from "@/middleware";
8
+
9
+ const LOJA = "minhaloja"; // o CLI troca pelo slug — vira o título do negócio: "minhaloja - Nome"
10
+
11
+ export const dynamic = "force-dynamic";
12
+
13
+ /** Cookie que marca "esta pessoa já apareceu antes" — separa gente de recarga. */
14
+ const VISITANTE = `${LOJA}_visitante`;
15
+
16
+ /** "(11) 93619-8174" → "+5511936198174". Null quando não é BR plausível. */
17
+ function toE164(raw: unknown): string | null {
18
+ if (typeof raw !== "string") return null;
19
+ let digits = raw.replace(/\D/g, "");
20
+ if (digits.startsWith("55") && digits.length >= 12) digits = digits.slice(2);
21
+ if (digits.length !== 10 && digits.length !== 11) return null;
22
+ const ddd = Number(digits.slice(0, 2));
23
+ if (ddd < 11 || ddd > 99) return null;
24
+ return `+55${digits}`;
25
+ }
26
+
27
+ /** x-vercel-ip-city vem percent-encoded ("S%C3%A3o%20Paulo") — decodifica antes de
28
+ * qualquer uso humano (log, CRM, webhook), senão o time comercial lê lixo. */
29
+ function cidadeDe(req: Request): string | null {
30
+ const raw = req.headers.get("x-vercel-ip-city");
31
+ if (!raw) return null;
32
+ try { return decodeURIComponent(raw); } catch { return raw; }
33
+ }
34
+
35
+ /**
36
+ * Uma linha estruturada por tentativa. No painel de Logs da Vercel, filtrar por
37
+ * `[preview-acesso]` e `"evento":"entrou"` dá a contagem de quem entrou.
38
+ *
39
+ * ⚠️ NÃO grava o IP: é dado pessoal sob a LGPD, e cidade/país já respondem "quem entrou".
40
+ */
41
+ function registrar(req: Request, evento: "entrou" | "invalido", visitante: string, novo: boolean) {
42
+ const h = req.headers;
43
+ console.log(
44
+ JSON.stringify({
45
+ tag: "[preview-acesso]",
46
+ evento,
47
+ visitante, // id aleatório do cookie: conta PESSOAS, não recargas
48
+ primeiraVez: novo,
49
+ quando: new Date().toISOString(),
50
+ cidade: cidadeDe(req),
51
+ pais: h.get("x-vercel-ip-country") ?? null,
52
+ dispositivo: /mobile|iphone|android/i.test(h.get("user-agent") ?? "") ? "mobile" : "desktop",
53
+ veioDe: h.get("referer") ?? null,
54
+ }),
55
+ );
56
+ }
57
+
58
+ export async function POST(req: Request) {
59
+ // Nome e e-mail obrigatórios; marca e WhatsApp opcionais — barreira alta demais
60
+ // mata a visualização.
61
+ const corpo = (await req.json().catch(() => ({}))) as {
62
+ nome?: string; marca?: string; whatsapp?: string; email?: string;
63
+ };
64
+ const nome = (corpo.nome ?? "").trim();
65
+ const marca = (corpo.marca ?? "").trim();
66
+ const whatsapp = (corpo.whatsapp ?? "").trim();
67
+ const email = (corpo.email ?? "").trim();
68
+
69
+ // Identifica a pessoa entre tentativas, sem saber quem ela é.
70
+ const cookies = req.headers.get("cookie") ?? "";
71
+ const jaTinha = cookies.match(new RegExp(`${VISITANTE}=([^;]+)`))?.[1];
72
+ const visitante = jaTinha ?? crypto.randomUUID().slice(0, 8);
73
+ const marcaVisitante = { sameSite: "lax" as const, path: "/", maxAge: 60 * 60 * 24 * 180 };
74
+
75
+ // Pausa fixa: torna envio em massa chato o bastante pra um link que circula entre
76
+ // poucos, e disfarça a latência do Pipedrive.
77
+ await new Promise((r) => setTimeout(r, 400));
78
+
79
+ if (!nome || nome.length < 2 || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
80
+ registrar(req, "invalido", visitante, !jaTinha);
81
+ const res = NextResponse.json({ ok: false }, { status: 400 });
82
+ if (!jaTinha) res.cookies.set(VISITANTE, visitante, marcaVisitante);
83
+ return res;
84
+ }
85
+
86
+ registrar(req, "entrou", visitante, !jaTinha);
87
+ console.log(
88
+ JSON.stringify({
89
+ tag: "[preview-lead]",
90
+ nome, marca: marca || null, whatsapp: whatsapp || null, email,
91
+ visitante, quando: new Date().toISOString(),
92
+ }),
93
+ );
94
+
95
+ // ── PIPEDRIVE ─────────────────────────────────────────────────────────────
96
+ // AGUARDADO antes da resposta, de propósito: o `void fetch` do webhook abaixo
97
+ // aceita perder a mensagem se a função congelar depois do return; pro CRM não dá.
98
+ // Falha NUNCA bloqueia a entrada: o lead ainda está no log.
99
+ const pdToken = process.env.PIPEDRIVE_API_TOKEN;
100
+ if (pdToken) {
101
+ try {
102
+ const pd = (caminho: string, body?: unknown) =>
103
+ fetch(`https://api.pipedrive.com/v1/${caminho}${caminho.includes("?") ? "&" : "?"}api_token=${pdToken}`, {
104
+ method: body ? "POST" : "GET",
105
+ headers: body ? { "Content-Type": "application/json" } : undefined,
106
+ body: body ? JSON.stringify(body) : undefined,
107
+ }).then((r) => r.json());
108
+
109
+ // Dedup por e-mail: quem se cadastra duas vezes não vira pessoa duplicada.
110
+ const busca = await pd(`persons/search?term=${encodeURIComponent(email)}&fields=email&exact_match=true&limit=1`);
111
+ let pessoaId: number | undefined = busca?.data?.items?.[0]?.item?.id;
112
+ if (!pessoaId) {
113
+ const fone = toE164(whatsapp) ?? (whatsapp || null);
114
+ const criada = await pd("persons", {
115
+ name: nome,
116
+ email: [{ value: email, primary: true }],
117
+ ...(fone ? { phone: [{ value: fone, label: "whatsapp" }] } : {}),
118
+ ...(marca ? { org_name: marca } : {}),
119
+ });
120
+ pessoaId = criada?.data?.id;
121
+ }
122
+ if (pessoaId) {
123
+ // NEGÓCIO no funil, não Lead na caixa de entrada — o time olha o funil.
124
+ // ⚠️ A RESPOSTA é conferida, não presumida: recusa do Pipedrive vem com
125
+ // success:false e status 200 — logar ok sem olhar seria mentira.
126
+ const negocio = await pd("deals", { title: `${LOJA} - ${nome}`, person_id: pessoaId });
127
+ console.log(JSON.stringify({
128
+ tag: "[preview-lead-pipedrive]",
129
+ ok: !!negocio?.success,
130
+ pessoaId,
131
+ dealId: negocio?.data?.id ?? null,
132
+ erro: negocio?.success ? null : (negocio?.error ?? "resposta sem success"),
133
+ email,
134
+ }));
135
+ } else {
136
+ console.log(JSON.stringify({ tag: "[preview-lead-pipedrive]", ok: false, motivo: "pessoa sem id", busca: busca?.success, email }));
137
+ }
138
+ } catch (e) {
139
+ console.log(JSON.stringify({ tag: "[preview-lead-pipedrive]", ok: false, motivo: String(e), email }));
140
+ }
141
+ } else {
142
+ // Pulo VISÍVEL, nunca silencioso: sem esta linha, um deploy sem o token engole
143
+ // leads sem deixar rastro (20 min caçando fantasma numa estreia real).
144
+ console.log(JSON.stringify({ tag: "[preview-lead-pipedrive]", ok: false, motivo: "sem PIPEDRIVE_API_TOKEN no ambiente", email }));
145
+ }
146
+
147
+ const res = NextResponse.json({ ok: true });
148
+ res.cookies.set(COOKIE, await tokenDaSenha(senhaDoPreview()), {
149
+ httpOnly: true, // fora do alcance de JS na página
150
+ sameSite: "lax",
151
+ secure: process.env.NODE_ENV === "production",
152
+ path: "/",
153
+ maxAge: 60 * 60 * 24 * 30, // 30 dias
154
+ });
155
+ if (!jaTinha) res.cookies.set(VISITANTE, visitante, marcaVisitante);
156
+
157
+ // Aviso em tempo real, opcional: aponte PREVIEW_WEBHOOK_URL pra um Slack/Zapier.
158
+ const destino = process.env.PREVIEW_WEBHOOK_URL;
159
+ if (destino) {
160
+ const cidade = cidadeDe(req);
161
+ void fetch(destino, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({
165
+ text: `🔓 Lead na prévia (${LOJA}): ${nome}${marca ? ` (${marca})` : ""} · ${email}${whatsapp ? ` · ${whatsapp}` : ""}${cidade ? ` · ${cidade}` : ""}`,
166
+ }),
167
+ }).catch(() => {});
168
+ }
169
+
170
+ return res;
171
+ }
@@ -0,0 +1,34 @@
1
+ // Meta Conversions API (server-side) — recebe o evento do browser (lib/analytics.ts) com o MESMO
2
+ // event_id do Pixel → o Meta deduplica e conta 1 vez. Enriquece com fbp/fbc (cookies), IP e UA.
3
+ // Todos os campos de cliente (em, ph, fn, ln, zp, external_id) chegam JÁ hasheados do browser;
4
+ // esta rota nunca vê PII crua. O envio em si está em lib/capi.ts (compartilhado com o webhook).
5
+ import { NextResponse } from "next/server";
6
+ import { cookies, headers } from "next/headers";
7
+ import { sendCapi, capiConfigured } from "@/lib/capi";
8
+
9
+ export const runtime = "nodejs";
10
+ export const dynamic = "force-dynamic";
11
+
12
+ export async function POST(req: Request) {
13
+ if (!capiConfigured()) return NextResponse.json({ ok: true, capi: false });
14
+
15
+ let body: any;
16
+ try { body = await req.json(); } catch { return NextResponse.json({ ok: false }, { status: 400 }); }
17
+ const { eventId, eventName, value, currency, contents, sourceUrl, orderId, em, ph, fn, ln, zp, external_id } = body ?? {};
18
+ if (!eventName || !eventId) return NextResponse.json({ ok: false }, { status: 400 });
19
+
20
+ const ck = await cookies();
21
+ const hd = await headers();
22
+ const r = await sendCapi({
23
+ eventId, eventName, value, currency, contents, sourceUrl, orderId,
24
+ actionSource: "website",
25
+ userData: {
26
+ em, ph, fn, ln, zp, external_id,
27
+ fbp: ck.get("_fbp")?.value,
28
+ fbc: ck.get("_fbc")?.value,
29
+ client_ip_address: (hd.get("x-forwarded-for") || "").split(",")[0].trim() || undefined,
30
+ client_user_agent: hd.get("user-agent") || undefined,
31
+ },
32
+ });
33
+ return NextResponse.json(r, { status: r.ok ? 200 : 502 });
34
+ }
@@ -0,0 +1,49 @@
1
+ // Aplicar (POST) e remover (DELETE) cupom de desconto no carrinho.
2
+ // ⚠️ doc 04/05: aplicar o cupom ANTES de selecionar o frete (cupom depois do frete → total divergente).
3
+ import { withStoreClient } from "@/lib/unbox/store";
4
+ import { getCartRef } from "@/lib/session";
5
+ import { cartResponse } from "@/lib/cart-response";
6
+ import { ok, fail, failFrom } from "@/lib/api";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ // POST /api/cart/coupon { code }
11
+ export async function POST(req: Request) {
12
+ try {
13
+ const ref = await getCartRef();
14
+ if (!ref) return fail("Carrinho não encontrado.", 404);
15
+ const { code } = await req.json();
16
+ if (!code) return fail("Informe um cupom.");
17
+
18
+ const trimmed = String(code).trim();
19
+ const { events, discountId } = await withStoreClient(async (c) => {
20
+ const r = await c.applyDiscount(ref.cartId, ref.cartToken, trimmed);
21
+ // resolve o id do desconto p/ permitir remoção (o Cart lido não traz os ids aplicados)
22
+ const id = await c.findDiscountIdByCode(trimmed).catch(() => null);
23
+ return { events: r?.cartEvents ?? [], discountId: id };
24
+ });
25
+ const full = await withStoreClient((c) => c.getCart(ref.cartId, ref.cartToken));
26
+ return ok({ cart: await cartResponse(full, events), coupon: { code: trimmed, discountId } });
27
+ } catch (e) {
28
+ return failFrom(e);
29
+ }
30
+ }
31
+
32
+ // DELETE /api/cart/coupon { discountId }
33
+ export async function DELETE(req: Request) {
34
+ try {
35
+ const ref = await getCartRef();
36
+ if (!ref) return fail("Carrinho não encontrado.", 404);
37
+ const { discountId } = await req.json();
38
+ if (!discountId) return fail("Cupom inválido.");
39
+
40
+ const events = await withStoreClient(async (c) => {
41
+ const r = await c.removeDiscount(ref.cartId, ref.cartToken, discountId);
42
+ return r?.cartEvents ?? [];
43
+ });
44
+ const full = await withStoreClient((c) => c.getCart(ref.cartId, ref.cartToken));
45
+ return ok({ cart: await cartResponse(full, events) });
46
+ } catch (e) {
47
+ return failFrom(e);
48
+ }
49
+ }
@@ -0,0 +1,45 @@
1
+ // Alterar quantidade (PATCH) e remover itens (DELETE) do carrinho.
2
+ import { withStoreClient } from "@/lib/unbox/store";
3
+ import { getCartRef } from "@/lib/session";
4
+ import { cartResponse } from "@/lib/cart-response";
5
+ import { ok, fail, failFrom } from "@/lib/api";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ // PATCH /api/cart/items { cartItemId, quantity }
10
+ export async function PATCH(req: Request) {
11
+ try {
12
+ const ref = await getCartRef();
13
+ if (!ref) return fail("Carrinho não encontrado.", 404);
14
+ const { cartItemId, quantity } = await req.json();
15
+ if (!cartItemId || typeof quantity !== "number") return fail("Parâmetros inválidos.");
16
+
17
+ const events = await withStoreClient(async (c) => {
18
+ const r = await c.updateItemQuantity(ref.cartId, ref.cartToken, cartItemId, quantity);
19
+ return r?.cartEvents ?? [];
20
+ });
21
+ const full = await withStoreClient((c) => c.getCart(ref.cartId, ref.cartToken));
22
+ return ok({ cart: await cartResponse(full, events) });
23
+ } catch (e) {
24
+ return failFrom(e);
25
+ }
26
+ }
27
+
28
+ // DELETE /api/cart/items { cartItemIds: string[] }
29
+ export async function DELETE(req: Request) {
30
+ try {
31
+ const ref = await getCartRef();
32
+ if (!ref) return fail("Carrinho não encontrado.", 404);
33
+ const { cartItemIds } = await req.json();
34
+ if (!Array.isArray(cartItemIds) || !cartItemIds.length) return fail("Nenhum item informado.");
35
+
36
+ const events = await withStoreClient(async (c) => {
37
+ const r = await c.removeCartItems(ref.cartId, ref.cartToken, cartItemIds);
38
+ return r?.cartEvents ?? [];
39
+ });
40
+ const full = await withStoreClient((c) => c.getCart(ref.cartId, ref.cartToken));
41
+ return ok({ cart: await cartResponse(full, events) });
42
+ } catch (e) {
43
+ return failFrom(e);
44
+ }
45
+ }
@@ -0,0 +1,69 @@
1
+ // Link de carrinho: dois modos, pelo mesmo endpoint (URL compartilhável, chamado pelo
2
+ // CartProvider quando os parâmetros aparecem em QUALQUER rota, inclusive /checkout):
3
+ //
4
+ // 1) ?id=<cartId>&token=<cartToken> — RESTAURA um carrinho existente (recuperação de carrinho
5
+ // abandonado / "continuar em outro aparelho"). Valida o par na Unbox antes de gravar o
6
+ // cookie — um link velho/inválido nunca derruba o carrinho atual do visitante.
7
+ // 2) ?produtos=SKU:qtd,...&cupom=CODE — monta um carrinho NOVO a partir de SKUs (products.json).
8
+ //
9
+ // Não redireciona; devolve JSON.
10
+ import { withStoreClient } from "@/lib/unbox/store";
11
+ import { getCatalog } from "@/lib/queries";
12
+ import { getCartRef, setCartRef, setRecurFreq } from "@/lib/session";
13
+ import { parseProdutosParam, resolveSkusToItems } from "@/lib/cart-link";
14
+ import { cartResponse } from "@/lib/cart-response";
15
+ import { ok, fail, failFrom } from "@/lib/api";
16
+
17
+ export const dynamic = "force-dynamic";
18
+
19
+ export async function POST(req: Request) {
20
+ const url = new URL(req.url);
21
+ const id = url.searchParams.get("id");
22
+ const token = url.searchParams.get("token");
23
+ const freq = url.searchParams.get("freq");
24
+ const produtos = url.searchParams.get("produtos");
25
+ const cupom = url.searchParams.get("cupom");
26
+
27
+ // Modo 1: restaurar carrinho existente por id+token — nunca combina com produtos/cupom.
28
+ if (id && token) {
29
+ try {
30
+ const cart = await withStoreClient((c) => c.getCart(id, token));
31
+ if (!cart) return fail("Carrinho expirado ou link inválido.", 404);
32
+ await setCartRef({ cartId: id, cartToken: token });
33
+ // A frequência de assinatura é um campo do pedido (placeOrder), não do carrinho: por
34
+ // isso ela viaja em cookie — sem isso, um carrinho de assinatura restaurado em outra
35
+ // sessão mostra "Assinatura" sem frequência e falha no checkout ("Selecione a frequência").
36
+ if (freq) await setRecurFreq(freq);
37
+ return ok({ ok: true, cart: await cartResponse(cart) });
38
+ } catch {
39
+ // Par inválido/expirado — NÃO grava o cookie, não derruba o carrinho atual do visitante.
40
+ return fail("Carrinho expirado ou link inválido.", 404);
41
+ }
42
+ }
43
+
44
+ try {
45
+ let count = 0;
46
+ // 1) monta um carrinho novo a partir dos SKUs (se houver)
47
+ if (produtos) {
48
+ const skuQtys = parseProdutosParam(produtos);
49
+ const catalog = await getCatalog({ first: 200 }).catch(() => ({ nodes: [] as any[] }));
50
+ const nodes = (catalog.nodes ?? []).map((n: any) => n.product ?? n);
51
+ const items = resolveSkusToItems(skuQtys, nodes);
52
+ if (items.length) {
53
+ const cart = await withStoreClient((c) => c.createCart(items));
54
+ await setCartRef({ cartId: cart.cartId, cartToken: cart.cartToken });
55
+ count = items.reduce((s, i) => s + i.quantity, 0);
56
+ }
57
+ }
58
+
59
+ // 2) aplica o cupom no carrinho atual (recém-criado ou o do cookie)
60
+ if (cupom) {
61
+ const ref = await getCartRef();
62
+ if (ref) await withStoreClient((c) => c.applyDiscount(ref.cartId, ref.cartToken, cupom.trim())).catch(() => {});
63
+ }
64
+
65
+ return ok({ ok: true, count });
66
+ } catch (e) {
67
+ return failFrom(e, 502);
68
+ }
69
+ }
@@ -0,0 +1,113 @@
1
+ // BFF do carrinho. O browser NUNCA fala com a Unbox direto — só com estas rotas same-origin.
2
+ import { withStoreClient } from "@/lib/unbox/store";
3
+ import { UnboxError } from "@/lib/unbox/client";
4
+ import { getCartRef, setCartRef, clearCartRef, setRecurFreq } from "@/lib/session";
5
+ import { cartResponse } from "@/lib/cart-response";
6
+ import { getShopData } from "@/lib/queries";
7
+ import { ok, fail, failFrom } from "@/lib/api";
8
+ import type { CartItemInput } from "@/lib/unbox/types";
9
+
10
+ export const dynamic = "force-dynamic";
11
+
12
+ // GET /api/cart → reidrata o carrinho do cookie ({cartId, cartToken})
13
+ export async function GET() {
14
+ try {
15
+ const ref = await getCartRef();
16
+ if (!ref) return ok({ cart: null });
17
+ const cart = await withStoreClient((c) => c.getCart(ref.cartId, ref.cartToken));
18
+ if (!cart) {
19
+ // A query respondeu OK mas sem carrinho → o cartId/cartToken realmente não existe mais.
20
+ await clearCartRef();
21
+ return ok({ cart: null });
22
+ }
23
+ return ok({ cart: await cartResponse(cart) });
24
+ } catch (e) {
25
+ // Só limpa o cookie quando o erro CONFIRMA que o carrinho/token é inválido — nunca em
26
+ // falha transitória (timeout, rede, 5xx da Unbox). Limpar nesses casos apagava carrinhos
27
+ // válidos por uma simples intermitência de rede, forçando o cliente a montar tudo de novo.
28
+ if (isCartGone(e)) await clearCartRef();
29
+ return ok({ cart: null });
30
+ }
31
+ }
32
+
33
+ // Só considera o carrinho "morto" (cookie deve ser limpo) quando o erro da Unbox indica
34
+ // explicitamente que o cartId/cartToken não é mais válido. Qualquer outra falha (timeout,
35
+ // rede, 5xx, GraphQL genérico) é tratada como transitória — mantém o cookie pra tentar de novo.
36
+ function isCartGone(e: unknown): boolean {
37
+ if (e instanceof UnboxError) {
38
+ const msg = (e.errors?.[0]?.message ?? e.message ?? "").toUpperCase();
39
+ // SÓ códigos do CARRINHO. UNAUTHENTICATED/ACCESS_DENIED são o token da LOJA expirado ou a
40
+ // Unbox Auth instável — transitório. Tratá-los como "carrinho morto" apagava o cookie de
41
+ // TODOS os visitantes numa rotação de senha ou numa oscilação da API.
42
+ return /NOT_FOUND|INVALID_TOKEN|INVALID_CART|CART_EXPIRED/.test(msg);
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // POST /api/cart → adiciona itens (cria o carrinho se ainda não existir)
48
+ //
49
+ // ═══ ASSINATURA (item recorrente) — leia antes de montar o payload ═══
50
+ // `isRecurring: true` no item SOZINHO não assina nada: sem `recurringItemsFrequencyId` o
51
+ // pedido é aceito e registrado como compra avulsa, sem erro. Os dois sempre juntos:
52
+ // 1. item com `isRecurring: true` + 2. `recurringItemsFrequencyId` (id vindo de
53
+ // shop.recurringOrdersPolicy.allowedFrequencies) no body.
54
+ // O desconto de assinante REAL é shop.recurringOrdersPolicy.pricingPolicy
55
+ // ({ type: "PERCENTAGE_OFF", value: N }) — nunca invente o percentual no front.
56
+ // Implementação de referência: components/landing/product-picker.tsx (monta os dois campos).
57
+ export async function POST(req: Request) {
58
+ try {
59
+ const body = await req.json();
60
+ let items: CartItemInput[] = body.items ?? [];
61
+ if (!items.length) return fail("Nenhum item informado.");
62
+ // A Unbox valida `thumbnail` como URI absoluta — path relativo ("/produto.png") derruba o
63
+ // addCartItems inteiro com `"[0].thumbnail" must be a valid uri`. Melhor sem thumbnail
64
+ // (o carrinho renderiza placeholder) do que carrinho quebrado.
65
+ items = items.map(({ thumbnail, ...it }) => /^https?:\/\//.test(thumbnail ?? "") ? { ...it, thumbnail } : it);
66
+ // A frequência NÃO entra no carrinho (input não aceita); guardamos em cookie p/ o placeOrder.
67
+ const recurringItemsFrequencyId: string | undefined = body.recurringItemsFrequencyId;
68
+
69
+ let ref = await getCartRef();
70
+ const result = await withStoreClient(async (c) => {
71
+ if (!ref) {
72
+ const created = await c.createCart(items);
73
+ ref = { cartId: created.cartId, cartToken: created.cartToken };
74
+ return { quantityFailures: failuresOf(created) };
75
+ }
76
+ const added = await c.addCartItems(ref.cartId, ref.cartToken, items);
77
+ return { quantityFailures: failuresOf(added) };
78
+ });
79
+
80
+ if (ref) await setCartRef(ref);
81
+ // Item recorrente SEM frequência vira "Frequência: Não selecionada" no checkout, em vermelho,
82
+ // com o preço já descontado (caso real em produção). As telas que adicionam ao carrinho
83
+ // decidem `isRecurring` e a frequência em expressões separadas, então basta a fonte da
84
+ // frequência estar vazia num instante para sair uma sem a outra, e o sintoma só aparece no
85
+ // fim. Quando dois campos só fazem sentido juntos, quem garante o par é o servidor: ele
86
+ // sempre sabe a política da loja.
87
+ if (items.some((it) => it.isRecurring)) {
88
+ let freq = recurringItemsFrequencyId;
89
+ if (!freq) {
90
+ const shop = await getShopData().catch(() => null);
91
+ freq = shop?.recurringOrdersPolicy?.allowedFrequencies?.[0]?._id;
92
+ }
93
+ if (freq) await setRecurFreq(freq);
94
+ }
95
+ const full = await withStoreClient((c) => c.getCart(ref!.cartId, ref!.cartToken));
96
+ return ok({ cart: await cartResponse(full), warnings: result.quantityFailures });
97
+ } catch (e) {
98
+ return failFrom(e);
99
+ }
100
+ }
101
+
102
+ // DELETE /api/cart → esvazia o carrinho (limpa o cookie)
103
+ export async function DELETE() {
104
+ await clearCartRef();
105
+ return ok({ cart: null });
106
+ }
107
+
108
+ function failuresOf(r: any): string[] {
109
+ const out: string[] = [];
110
+ for (const f of r?.minOrderQuantityFailures ?? []) out.push(`Quantidade mínima: ${f.minOrderQuantity}.`);
111
+ for (const f of r?.maxOrderQuantityFailures ?? []) out.push(`Quantidade máxima: ${f.maxOrderQuantity}.`);
112
+ return out;
113
+ }