@greatapps/common 1.1.747 → 1.1.749

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 (80) hide show
  1. package/dist/components/layouts/AiCreditsOfferGate.mjs +92 -0
  2. package/dist/components/layouts/AiCreditsOfferGate.mjs.map +1 -0
  3. package/dist/components/modals/AccountFrozenModal.mjs +20 -14
  4. package/dist/components/modals/AccountFrozenModal.mjs.map +1 -1
  5. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs +275 -0
  6. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs.map +1 -0
  7. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs +55 -0
  8. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs.map +1 -0
  9. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs +78 -0
  10. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs.map +1 -0
  11. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs +60 -0
  12. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs.map +1 -0
  13. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs +5 -0
  14. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs.map +1 -0
  15. package/dist/components/navigation/SubscriptionBanner.mjs.map +1 -1
  16. package/dist/handlers.mjs +5 -1
  17. package/dist/handlers.mjs.map +1 -1
  18. package/dist/i18n/messages/en-us.mjs +32 -0
  19. package/dist/i18n/messages/en-us.mjs.map +1 -1
  20. package/dist/i18n/messages/es-es.mjs +32 -0
  21. package/dist/i18n/messages/es-es.mjs.map +1 -1
  22. package/dist/i18n/messages/pt-br.mjs +32 -0
  23. package/dist/i18n/messages/pt-br.mjs.map +1 -1
  24. package/dist/index.mjs +71 -43
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/modules/auth/utils/assert-account-not-frozen.mjs.map +1 -1
  27. package/dist/modules/auth/utils/get-user-context.mjs +2 -1
  28. package/dist/modules/auth/utils/get-user-context.mjs.map +1 -1
  29. package/dist/modules/cards/hooks/cards.hook.mjs +3 -2
  30. package/dist/modules/cards/hooks/cards.hook.mjs.map +1 -1
  31. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs +26 -0
  32. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs.map +1 -0
  33. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs +19 -0
  34. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs.map +1 -0
  35. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs +33 -0
  36. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs.map +1 -0
  37. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs +29 -0
  38. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs.map +1 -0
  39. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs +18 -0
  40. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs.map +1 -0
  41. package/dist/modules/ia-credits/services/ia-credits.service.mjs +67 -0
  42. package/dist/modules/ia-credits/services/ia-credits.service.mjs.map +1 -1
  43. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs +34 -0
  44. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs.map +1 -0
  45. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs +41 -0
  46. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs.map +1 -0
  47. package/dist/modules/subscriptions/constants/subscription.constants.mjs.map +1 -1
  48. package/dist/modules/subscriptions/utils/get-account-freeze-date.mjs.map +1 -1
  49. package/dist/modules/subscriptions/utils/get-account-freeze-state.mjs.map +1 -1
  50. package/dist/store/useAiCreditsOfferModal.mjs +14 -0
  51. package/dist/store/useAiCreditsOfferModal.mjs.map +1 -0
  52. package/package.json +3 -1
  53. package/src/components/layouts/AiCreditsOfferGate.tsx +171 -0
  54. package/src/components/modals/AccountFrozenModal.tsx +26 -37
  55. package/src/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.tsx +400 -0
  56. package/src/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.tsx +97 -0
  57. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.tsx +121 -0
  58. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.tsx +91 -0
  59. package/src/components/modals/promo/AiCreditsOfferModal/index.ts +1 -0
  60. package/src/components/navigation/SubscriptionBanner.tsx +0 -8
  61. package/src/handlers.ts +2 -0
  62. package/src/i18n/messages/en-us.ts +33 -0
  63. package/src/i18n/messages/es-es.ts +33 -0
  64. package/src/i18n/messages/pt-br.ts +33 -0
  65. package/src/index.ts +25 -0
  66. package/src/modules/auth/utils/assert-account-not-frozen.ts +0 -9
  67. package/src/modules/auth/utils/get-user-context.ts +3 -0
  68. package/src/modules/cards/hooks/cards.hook.ts +6 -1
  69. package/src/modules/ia-credits/constants/ai-credits-offer.constants.ts +33 -0
  70. package/src/modules/ia-credits/handlers/get-offer-eligibility.handler.ts +24 -0
  71. package/src/modules/ia-credits/handlers/purchase-ia-credits.handler.ts +42 -0
  72. package/src/modules/ia-credits/hooks/purchase-ia-credits.hook.ts +43 -0
  73. package/src/modules/ia-credits/hooks/use-offer-eligibility.hook.ts +28 -0
  74. package/src/modules/ia-credits/services/ia-credits.service.ts +92 -0
  75. package/src/modules/ia-credits/types/ai-credits-offer.type.ts +87 -0
  76. package/src/modules/ia-credits/utils/offer-stage-cookie.ts +66 -0
  77. package/src/modules/subscriptions/constants/subscription.constants.ts +0 -5
  78. package/src/modules/subscriptions/utils/get-account-freeze-date.ts +0 -13
  79. package/src/modules/subscriptions/utils/get-account-freeze-state.ts +0 -7
  80. package/src/store/useAiCreditsOfferModal.ts +24 -0
@@ -0,0 +1,33 @@
1
+ import "server-only";
2
+ import { ApiError } from "../../../infra/api/types";
3
+ import { PurchaseIaCreditsRequestBodySchema } from "../types/ai-credits-offer.type";
4
+ import { iaCreditsService } from "../services/ia-credits.service";
5
+ function createPurchaseIaCreditsHandler() {
6
+ return async function POST(req, { params }) {
7
+ const { id } = await params;
8
+ const parsedBody = PurchaseIaCreditsRequestBodySchema.safeParse(await req.json().catch(() => null));
9
+ if (!parsedBody.success) {
10
+ return Response.json(
11
+ { status: 0, message: "Erro ao comprar cr\xE9ditos de IA" },
12
+ { status: 400 }
13
+ );
14
+ }
15
+ try {
16
+ const result = await iaCreditsService.purchase(id, {
17
+ quantity: parsedBody.data.quantity,
18
+ paymentMethod: parsedBody.data.payment_method,
19
+ idCard: parsedBody.data.id_card,
20
+ offer: parsedBody.data.offer
21
+ });
22
+ return Response.json(result);
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : "Erro ao comprar cr\xE9ditos de IA";
25
+ const status = error instanceof ApiError ? error.statusCode : 500;
26
+ return Response.json({ status: 0, message }, { status });
27
+ }
28
+ };
29
+ }
30
+ export {
31
+ createPurchaseIaCreditsHandler
32
+ };
33
+ //# sourceMappingURL=purchase-ia-credits.handler.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/handlers/purchase-ia-credits.handler.ts"],"sourcesContent":["import 'server-only';\n\nimport type { NextRequest } from 'next/server';\nimport { ApiError } from '../../../infra/api/types';\nimport { PurchaseIaCreditsRequestBodySchema } from '../types/ai-credits-offer.type';\nimport { iaCreditsService } from '../services/ia-credits.service';\n\n/**\n * `POST /api/subscriptions/:id/credits/purchase` — compra avulsa de créditos de IA, comum ou\n * (com `offer`) a de primeira compra. Desvio deliberado de `safeRouteHandler`: o `BuyOneTimeCreditsModal`\n * do editor e o `AiCreditsOfferModal` desta lib esperam o envelope CRU do gapps-r3-api direto na\n * raiz da resposta (`{ status: 1 | 2 | 0, client_secret?, message?, data? }`), inclusive pra\n * distinguir pago (1), 3DS pendente (2) e recusado (0) — embrulhar em `{ success, data }` quebraria\n * os dois.\n */\nexport function createPurchaseIaCreditsHandler() {\n return async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n const parsedBody = PurchaseIaCreditsRequestBodySchema.safeParse(await req.json().catch(() => null));\n\n if (!parsedBody.success) {\n return Response.json(\n { status: 0, message: 'Erro ao comprar créditos de IA' },\n { status: 400 }\n );\n }\n\n try {\n const result = await iaCreditsService.purchase(id, {\n quantity: parsedBody.data.quantity,\n paymentMethod: parsedBody.data.payment_method,\n idCard: parsedBody.data.id_card,\n offer: parsedBody.data.offer,\n });\n return Response.json(result);\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Erro ao comprar créditos de IA';\n const status = error instanceof ApiError ? error.statusCode : 500;\n return Response.json({ status: 0, message }, { status });\n }\n };\n}\n"],"mappings":"AAAA,OAAO;AAGP,SAAS,gBAAgB;AACzB,SAAS,0CAA0C;AACnD,SAAS,wBAAwB;AAU1B,SAAS,iCAAiC;AAC/C,SAAO,eAAe,KAAK,KAAkB,EAAE,OAAO,GAAwC;AAC5F,UAAM,EAAE,GAAG,IAAI,MAAM;AACrB,UAAM,aAAa,mCAAmC,UAAU,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI,CAAC;AAElG,QAAI,CAAC,WAAW,SAAS;AACvB,aAAO,SAAS;AAAA,QACd,EAAE,QAAQ,GAAG,SAAS,oCAAiC;AAAA,QACvD,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,QAAI;AACF,YAAM,SAAS,MAAM,iBAAiB,SAAS,IAAI;AAAA,QACjD,UAAU,WAAW,KAAK;AAAA,QAC1B,eAAe,WAAW,KAAK;AAAA,QAC/B,QAAQ,WAAW,KAAK;AAAA,QACxB,OAAO,WAAW,KAAK;AAAA,MACzB,CAAC;AACD,aAAO,SAAS,KAAK,MAAM;AAAA,IAC7B,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,YAAM,SAAS,iBAAiB,WAAW,MAAM,aAAa;AAC9D,aAAO,SAAS,KAAK,EAAE,QAAQ,GAAG,QAAQ,GAAG,EAAE,OAAO,CAAC;AAAA,IACzD;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useAuthQueryKey } from "../../../hooks/useAuthQueryKey";
4
+ import { PurchaseIaCreditsResultSchema } from "../types/ai-credits-offer.type";
5
+ function usePurchaseIaCredits() {
6
+ const queryClient = useQueryClient();
7
+ const iaCreditsKey = useAuthQueryKey(["ia-credits"]);
8
+ return useMutation({
9
+ mutationFn: async (input) => {
10
+ const response = await fetch(`/api/subscriptions/${input.subscriptionId}/credits/purchase`, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify({
14
+ ...input.offer ? { offer: input.offer } : { quantity: input.quantity },
15
+ payment_method: input.paymentMethod,
16
+ ...input.idCard ? { id_card: input.idCard } : {}
17
+ })
18
+ });
19
+ return PurchaseIaCreditsResultSchema.parse(await response.json());
20
+ },
21
+ onSettled: () => {
22
+ queryClient.invalidateQueries({ queryKey: iaCreditsKey });
23
+ }
24
+ });
25
+ }
26
+ export {
27
+ usePurchaseIaCredits
28
+ };
29
+ //# sourceMappingURL=purchase-ia-credits.hook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/hooks/purchase-ia-credits.hook.ts"],"sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useAuthQueryKey } from '../../../hooks/useAuthQueryKey';\nimport { PurchaseIaCreditsResultSchema } from '../types/ai-credits-offer.type';\nimport type { PurchaseIaCreditsInput, PurchaseIaCreditsResult } from '../types/ai-credits-offer.type';\n\n/**\n * Compra de créditos de IA — avulsa comum OU a oferta de primeira compra (`offer` no input). POSTa\n * na rota local `/api/subscriptions/:id/credits/purchase` (`createPurchaseIaCreditsHandler`), que\n * repassa CRU pro gapps-r3-api. A resposta:\n * - `{ status: 1, data: [{ credited }] }` — cartão pago, créditos concedidos na hora.\n * - `{ status: 2, client_secret }` — cartão pediu 3DS (confirmar com `confirmPaymentIfRequired`\n * e re-postar o MESMO input: a chave de idempotência do backend devolve a mesma cobrança já paga).\n * - `{ status: 0, message }` — recusado ou inelegível.\n *\n * Um hook só pros dois fluxos (avulsa e oferta): quem recusa PIX na oferta é o backend, o front\n * não precisa saber a regra.\n */\nexport function usePurchaseIaCredits() {\n const queryClient = useQueryClient();\n const iaCreditsKey = useAuthQueryKey(['ia-credits']);\n\n return useMutation({\n mutationFn: async (\n input: PurchaseIaCreditsInput & { subscriptionId: number | string }\n ): Promise<PurchaseIaCreditsResult> => {\n const response = await fetch(`/api/subscriptions/${input.subscriptionId}/credits/purchase`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n ...(input.offer ? { offer: input.offer } : { quantity: input.quantity }),\n payment_method: input.paymentMethod,\n ...(input.idCard ? { id_card: input.idCard } : {}),\n }),\n });\n return PurchaseIaCreditsResultSchema.parse(await response.json());\n },\n onSettled: () => {\n queryClient.invalidateQueries({ queryKey: iaCreditsKey });\n },\n });\n}\n"],"mappings":";AAEA,SAAS,aAAa,sBAAsB;AAC5C,SAAS,uBAAuB;AAChC,SAAS,qCAAqC;AAevC,SAAS,uBAAuB;AACrC,QAAM,cAAc,eAAe;AACnC,QAAM,eAAe,gBAAgB,CAAC,YAAY,CAAC;AAEnD,SAAO,YAAY;AAAA,IACjB,YAAY,OACV,UACqC;AACrC,YAAM,WAAW,MAAM,MAAM,sBAAsB,MAAM,cAAc,qBAAqB;AAAA,QAC1F,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU;AAAA,UACnB,GAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,MAAM,IAAI,EAAE,UAAU,MAAM,SAAS;AAAA,UACtE,gBAAgB,MAAM;AAAA,UACtB,GAAI,MAAM,SAAS,EAAE,SAAS,MAAM,OAAO,IAAI,CAAC;AAAA,QAClD,CAAC;AAAA,MACH,CAAC;AACD,aAAO,8BAA8B,MAAM,MAAM,SAAS,KAAK,CAAC;AAAA,IAClE;AAAA,IACA,WAAW,MAAM;AACf,kBAAY,kBAAkB,EAAE,UAAU,aAAa,CAAC;AAAA,IAC1D;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { apiFetch } from "../../../infra/http/api-fetch";
4
+ import { useAuthQueryKey } from "../../../hooks/useAuthQueryKey";
5
+ import { AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY } from "../constants/ai-credits-offer.constants";
6
+ function useAiCreditsOfferEligibility(options = {}) {
7
+ const queryKey = useAuthQueryKey([...AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY]);
8
+ return useQuery({
9
+ queryKey,
10
+ queryFn: ({ signal }) => apiFetch("/api/ai-credits-offer/eligibility", { signal }),
11
+ enabled: options.enabled ?? true,
12
+ staleTime: 3e4
13
+ });
14
+ }
15
+ export {
16
+ useAiCreditsOfferEligibility
17
+ };
18
+ //# sourceMappingURL=use-offer-eligibility.hook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/hooks/use-offer-eligibility.hook.ts"],"sourcesContent":["'use client';\n\nimport { useQuery } from '@tanstack/react-query';\nimport { apiFetch } from '../../../infra/http/api-fetch';\nimport { useAuthQueryKey } from '../../../hooks/useAuthQueryKey';\nimport { AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY } from '../constants/ai-credits-offer.constants';\nimport type { AiCreditsOfferEligibilityResponse } from '../types/ai-credits-offer.type';\n\nexport interface UseAiCreditsOfferEligibilityOptions {\n enabled?: boolean;\n}\n\n/**\n * Elegibilidade da oferta de primeira compra de créditos de IA, resolvida SEMPRE no servidor\n * (`GET /api/ai-credits-offer/eligibility`) — o `AiCreditsOfferGate` trata erro/timeout como\n * inelegível (fail closed): sem confirmação do backend, a oferta não abre.\n */\nexport function useAiCreditsOfferEligibility(options: UseAiCreditsOfferEligibilityOptions = {}) {\n const queryKey = useAuthQueryKey([...AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY]);\n\n return useQuery({\n queryKey,\n queryFn: ({ signal }) =>\n apiFetch<AiCreditsOfferEligibilityResponse>('/api/ai-credits-offer/eligibility', { signal }),\n enabled: options.enabled ?? true,\n staleTime: 30_000,\n });\n}\n"],"mappings":";AAEA,SAAS,gBAAgB;AACzB,SAAS,gBAAgB;AACzB,SAAS,uBAAuB;AAChC,SAAS,8CAA8C;AAYhD,SAAS,6BAA6B,UAA+C,CAAC,GAAG;AAC9F,QAAM,WAAW,gBAAgB,CAAC,GAAG,sCAAsC,CAAC;AAE5E,SAAO,SAAS;AAAA,IACd;AAAA,IACA,SAAS,CAAC,EAAE,OAAO,MACjB,SAA4C,qCAAqC,EAAE,OAAO,CAAC;AAAA,IAC7F,SAAS,QAAQ,WAAW;AAAA,IAC5B,WAAW;AAAA,EACb,CAAC;AACH;","names":[]}
@@ -2,9 +2,11 @@ import "server-only";
2
2
  import { api } from "../../../infra/api/client";
3
3
  import { ApiError } from "../../../infra/api/types";
4
4
  import { getUserContext } from "../../auth/utils/get-user-context";
5
+ import { subscriptionsService } from "../../subscriptions/services/subscriptions.service";
5
6
  import {
6
7
  IaCreditAddBatchSchema
7
8
  } from "../types";
9
+ import { AI_CREDITS_FIRST_PURCHASE_OFFER } from "../constants/ai-credits-offer.constants";
8
10
  class IaCreditsService {
9
11
  async getSummaryData(subscriptionId) {
10
12
  const { id_account } = await getUserContext();
@@ -45,6 +47,71 @@ class IaCreditsService {
45
47
  const raw = response.data ?? [];
46
48
  return raw.map((item) => IaCreditAddBatchSchema.parse(item));
47
49
  }
50
+ /**
51
+ * Elegibilidade da oferta de primeira compra, pra assinatura ATIVA da conta — resolvida aqui (e
52
+ * não recebida por parâmetro) porque o consumidor (`createGetAiCreditsOfferEligibilityHandler`)
53
+ * não tem id de assinatura: a rota é `/api/ai-credits-offer/eligibility`, sem `:id`.
54
+ *
55
+ * Sem assinatura ativa a resposta já sai inelegível — nem vale bater no backend, que exige
56
+ * `id_subscription` na URL.
57
+ */
58
+ async offerEligibility() {
59
+ const { id_account } = await getUserContext();
60
+ const { data: subscriptions } = await subscriptionsService.listSubscriptions({ limit: 1 });
61
+ const subscription = subscriptions[0];
62
+ if (!subscription) {
63
+ return {
64
+ eligible: false,
65
+ reason: "no_active_subscription",
66
+ credits: AI_CREDITS_FIRST_PURCHASE_OFFER.credits,
67
+ priceCents: null,
68
+ anchorCents: null
69
+ };
70
+ }
71
+ const endpoint = `/accounts/${id_account}/subscriptions/${subscription.id}/items/balance/purchase-offer/${AI_CREDITS_FIRST_PURCHASE_OFFER.slug}`;
72
+ const response = await api.apps.get(
73
+ endpoint
74
+ );
75
+ if (response.status === 0) {
76
+ throw new ApiError(
77
+ response.message || "Erro ao consultar elegibilidade da oferta de cr\xE9ditos de IA",
78
+ "GET_OFFER_ELIGIBILITY_FAILED",
79
+ 400
80
+ );
81
+ }
82
+ const raw = response.data[0];
83
+ if (!raw) {
84
+ throw new ApiError(
85
+ "Resposta vazia ao consultar elegibilidade da oferta de cr\xE9ditos de IA",
86
+ "GET_OFFER_ELIGIBILITY_EMPTY",
87
+ 502
88
+ );
89
+ }
90
+ return {
91
+ eligible: raw.eligible,
92
+ reason: raw.reason,
93
+ credits: raw.credits,
94
+ priceCents: raw.price_cents,
95
+ anchorCents: raw.anchor_cents
96
+ };
97
+ }
98
+ /**
99
+ * Compra de créditos de IA — avulsa comum (`offer` ausente) OU a oferta de primeira compra
100
+ * (`offer: AI_CREDITS_FIRST_PURCHASE_OFFER.slug`). Devolve o envelope CRU do gapps-r3-api
101
+ * (`status` 1/2/0), sem reembrulhar em `SuccessResult`: quem consome é sempre o hook de compra,
102
+ * que já sabe interpretar esses três status (ver `usePurchaseIaCredits`).
103
+ */
104
+ async purchase(subscriptionId, input) {
105
+ const { id_account } = await getUserContext();
106
+ return api.apps.post(
107
+ `/accounts/${id_account}/subscriptions/${subscriptionId}/items/balance/purchase`,
108
+ {
109
+ ...input.offer ? { offer: input.offer } : { quantity: input.quantity },
110
+ payment_method: input.paymentMethod,
111
+ ...input.idCard ? { id_card: input.idCard } : {}
112
+ }
113
+ );
114
+ }
48
115
  }
49
116
  const iaCreditsService = new IaCreditsService();
50
117
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/ia-credits/services/ia-credits.service.ts"],"sourcesContent":["import 'server-only';\n\nimport { api } from '../../../infra/api/client';\nimport { ApiError, ApiPaginatedActionResult, SuccessResult } from '../../../infra/api/types';\nimport { getUserContext } from '../../auth/utils/get-user-context';\nimport {\n IaCreditAddBatch,\n IaCreditAddBatchSchema,\n IaCreditOperation,\n IaCreditsSummaryData,\n} from '../types';\n\nclass IaCreditsService {\n async getSummaryData(\n subscriptionId: number | string\n ): Promise<SuccessResult<IaCreditsSummaryData>> {\n const { id_account } = await getUserContext();\n\n const [availableCredits, addBatches] = await Promise.all([\n this.fetchCurrentBalance(id_account, subscriptionId),\n this.fetchAddBatches(id_account, subscriptionId),\n ]);\n\n return { success: true, data: { availableCredits, addBatches } };\n }\n\n // Saldo atual = balance_after_operation da última operação do extrato\n // (sort id:DESC, limit=1). Já é o saldo do usuário, não precisa recalcular.\n private async fetchCurrentBalance(\n idAccount: number | string,\n subscriptionId: number | string\n ): Promise<number> {\n const endpoint = `/accounts/${idAccount}/subscriptions/${subscriptionId}/items/balance/list?page=1&limit=1&sort=id:DESC`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<IaCreditOperation>>(endpoint);\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao consultar saldo de créditos de IA',\n 'GET_IA_CREDITS_BALANCE_FAILED',\n 400\n );\n }\n\n const balance = (response.data ?? [])[0]?.balance_after_operation;\n return balance == null ? 0 : Number(balance);\n }\n\n // Lotes de adição com added/consumed/remaining já agregados pelo backend.\n // sort id:DESC garante que os lotes mais recentes (não expirados) entram\n // primeiro no limite; lotes expirados são filtrados no hook.\n private async fetchAddBatches(\n idAccount: number | string,\n subscriptionId: number | string\n ): Promise<IaCreditAddBatch[]> {\n const endpoint = `/accounts/${idAccount}/subscriptions/${subscriptionId}/items/balance/list/add?page=1&limit=100&sort=id:DESC`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<IaCreditAddBatch>>(endpoint);\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao listar lotes de créditos de IA',\n 'LIST_IA_CREDITS_ADDS_FAILED',\n 400\n );\n }\n\n const raw = response.data ?? [];\n return raw.map((item) => IaCreditAddBatchSchema.parse(item));\n }\n}\n\nexport const iaCreditsService = new IaCreditsService();\n"],"mappings":"AAAA,OAAO;AAEP,SAAS,WAAW;AACpB,SAAS,gBAAyD;AAClE,SAAS,sBAAsB;AAC/B;AAAA,EAEE;AAAA,OAGK;AAEP,MAAM,iBAAiB;AAAA,EACrB,MAAM,eACJ,gBAC8C;AAC9C,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAE5C,UAAM,CAAC,kBAAkB,UAAU,IAAI,MAAM,QAAQ,IAAI;AAAA,MACvD,KAAK,oBAAoB,YAAY,cAAc;AAAA,MACnD,KAAK,gBAAgB,YAAY,cAAc;AAAA,IACjD,CAAC;AAED,WAAO,EAAE,SAAS,MAAM,MAAM,EAAE,kBAAkB,WAAW,EAAE;AAAA,EACjE;AAAA;AAAA;AAAA,EAIA,MAAc,oBACZ,WACA,gBACiB;AACjB,UAAM,WAAW,aAAa,SAAS,kBAAkB,cAAc;AAEvE,UAAM,WAAW,MAAM,IAAI,KAAK,IAAiD,QAAQ;AAEzF,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,WAAW,SAAS,QAAQ,CAAC,GAAG,CAAC,GAAG;AAC1C,WAAO,WAAW,OAAO,IAAI,OAAO,OAAO;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,gBACZ,WACA,gBAC6B;AAC7B,UAAM,WAAW,aAAa,SAAS,kBAAkB,cAAc;AAEvE,UAAM,WAAW,MAAM,IAAI,KAAK,IAAgD,QAAQ;AAExF,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,MAAM,SAAS,QAAQ,CAAC;AAC9B,WAAO,IAAI,IAAI,CAAC,SAAS,uBAAuB,MAAM,IAAI,CAAC;AAAA,EAC7D;AACF;AAEO,MAAM,mBAAmB,IAAI,iBAAiB;","names":[]}
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/services/ia-credits.service.ts"],"sourcesContent":["import 'server-only';\n\nimport { api } from '../../../infra/api/client';\nimport { ApiError, ApiPaginatedActionResult, SuccessResult } from '../../../infra/api/types';\nimport { getUserContext } from '../../auth/utils/get-user-context';\nimport { subscriptionsService } from '../../subscriptions/services/subscriptions.service';\nimport {\n IaCreditAddBatch,\n IaCreditAddBatchSchema,\n IaCreditOperation,\n IaCreditsSummaryData,\n} from '../types';\nimport { AI_CREDITS_FIRST_PURCHASE_OFFER } from '../constants/ai-credits-offer.constants';\nimport type {\n AiCreditsOfferEligibility,\n PurchaseIaCreditsInput,\n PurchaseIaCreditsResult,\n} from '../types/ai-credits-offer.type';\n\n/** Shape da API pra elegibilidade — `price_cents`/`anchor_cents` viram camelCase ao sair do service. */\ninterface AiCreditsOfferEligibilityApiItem {\n eligible: boolean;\n reason: AiCreditsOfferEligibility['reason'];\n credits: number;\n price_cents: number | null;\n anchor_cents: number | null;\n}\n\nclass IaCreditsService {\n async getSummaryData(\n subscriptionId: number | string\n ): Promise<SuccessResult<IaCreditsSummaryData>> {\n const { id_account } = await getUserContext();\n\n const [availableCredits, addBatches] = await Promise.all([\n this.fetchCurrentBalance(id_account, subscriptionId),\n this.fetchAddBatches(id_account, subscriptionId),\n ]);\n\n return { success: true, data: { availableCredits, addBatches } };\n }\n\n // Saldo atual = balance_after_operation da última operação do extrato\n // (sort id:DESC, limit=1). Já é o saldo do usuário, não precisa recalcular.\n private async fetchCurrentBalance(\n idAccount: number | string,\n subscriptionId: number | string\n ): Promise<number> {\n const endpoint = `/accounts/${idAccount}/subscriptions/${subscriptionId}/items/balance/list?page=1&limit=1&sort=id:DESC`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<IaCreditOperation>>(endpoint);\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao consultar saldo de créditos de IA',\n 'GET_IA_CREDITS_BALANCE_FAILED',\n 400\n );\n }\n\n const balance = (response.data ?? [])[0]?.balance_after_operation;\n return balance == null ? 0 : Number(balance);\n }\n\n // Lotes de adição com added/consumed/remaining já agregados pelo backend.\n // sort id:DESC garante que os lotes mais recentes (não expirados) entram\n // primeiro no limite; lotes expirados são filtrados no hook.\n private async fetchAddBatches(\n idAccount: number | string,\n subscriptionId: number | string\n ): Promise<IaCreditAddBatch[]> {\n const endpoint = `/accounts/${idAccount}/subscriptions/${subscriptionId}/items/balance/list/add?page=1&limit=100&sort=id:DESC`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<IaCreditAddBatch>>(endpoint);\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao listar lotes de créditos de IA',\n 'LIST_IA_CREDITS_ADDS_FAILED',\n 400\n );\n }\n\n const raw = response.data ?? [];\n return raw.map((item) => IaCreditAddBatchSchema.parse(item));\n }\n\n /**\n * Elegibilidade da oferta de primeira compra, pra assinatura ATIVA da conta — resolvida aqui (e\n * não recebida por parâmetro) porque o consumidor (`createGetAiCreditsOfferEligibilityHandler`)\n * não tem id de assinatura: a rota é `/api/ai-credits-offer/eligibility`, sem `:id`.\n *\n * Sem assinatura ativa a resposta já sai inelegível — nem vale bater no backend, que exige\n * `id_subscription` na URL.\n */\n async offerEligibility(): Promise<AiCreditsOfferEligibility> {\n const { id_account } = await getUserContext();\n const { data: subscriptions } = await subscriptionsService.listSubscriptions({ limit: 1 });\n const subscription = subscriptions[0];\n\n if (!subscription) {\n return {\n eligible: false,\n reason: 'no_active_subscription',\n credits: AI_CREDITS_FIRST_PURCHASE_OFFER.credits,\n priceCents: null,\n anchorCents: null,\n };\n }\n\n const endpoint = `/accounts/${id_account}/subscriptions/${subscription.id}/items/balance/purchase-offer/${AI_CREDITS_FIRST_PURCHASE_OFFER.slug}`;\n const response = await api.apps.get<ApiPaginatedActionResult<AiCreditsOfferEligibilityApiItem>>(\n endpoint\n );\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao consultar elegibilidade da oferta de créditos de IA',\n 'GET_OFFER_ELIGIBILITY_FAILED',\n 400\n );\n }\n\n const raw = response.data[0];\n if (!raw) {\n throw new ApiError(\n 'Resposta vazia ao consultar elegibilidade da oferta de créditos de IA',\n 'GET_OFFER_ELIGIBILITY_EMPTY',\n 502\n );\n }\n\n return {\n eligible: raw.eligible,\n reason: raw.reason,\n credits: raw.credits,\n priceCents: raw.price_cents,\n anchorCents: raw.anchor_cents,\n };\n }\n\n /**\n * Compra de créditos de IA — avulsa comum (`offer` ausente) OU a oferta de primeira compra\n * (`offer: AI_CREDITS_FIRST_PURCHASE_OFFER.slug`). Devolve o envelope CRU do gapps-r3-api\n * (`status` 1/2/0), sem reembrulhar em `SuccessResult`: quem consome é sempre o hook de compra,\n * que já sabe interpretar esses três status (ver `usePurchaseIaCredits`).\n */\n async purchase(\n subscriptionId: number | string,\n input: PurchaseIaCreditsInput\n ): Promise<PurchaseIaCreditsResult> {\n const { id_account } = await getUserContext();\n\n return api.apps.post<PurchaseIaCreditsResult>(\n `/accounts/${id_account}/subscriptions/${subscriptionId}/items/balance/purchase`,\n {\n ...(input.offer ? { offer: input.offer } : { quantity: input.quantity }),\n payment_method: input.paymentMethod,\n ...(input.idCard ? { id_card: input.idCard } : {}),\n }\n );\n }\n}\n\nexport const iaCreditsService = new IaCreditsService();\n"],"mappings":"AAAA,OAAO;AAEP,SAAS,WAAW;AACpB,SAAS,gBAAyD;AAClE,SAAS,sBAAsB;AAC/B,SAAS,4BAA4B;AACrC;AAAA,EAEE;AAAA,OAGK;AACP,SAAS,uCAAuC;AAgBhD,MAAM,iBAAiB;AAAA,EACrB,MAAM,eACJ,gBAC8C;AAC9C,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAE5C,UAAM,CAAC,kBAAkB,UAAU,IAAI,MAAM,QAAQ,IAAI;AAAA,MACvD,KAAK,oBAAoB,YAAY,cAAc;AAAA,MACnD,KAAK,gBAAgB,YAAY,cAAc;AAAA,IACjD,CAAC;AAED,WAAO,EAAE,SAAS,MAAM,MAAM,EAAE,kBAAkB,WAAW,EAAE;AAAA,EACjE;AAAA;AAAA;AAAA,EAIA,MAAc,oBACZ,WACA,gBACiB;AACjB,UAAM,WAAW,aAAa,SAAS,kBAAkB,cAAc;AAEvE,UAAM,WAAW,MAAM,IAAI,KAAK,IAAiD,QAAQ;AAEzF,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,WAAW,SAAS,QAAQ,CAAC,GAAG,CAAC,GAAG;AAC1C,WAAO,WAAW,OAAO,IAAI,OAAO,OAAO;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,gBACZ,WACA,gBAC6B;AAC7B,UAAM,WAAW,aAAa,SAAS,kBAAkB,cAAc;AAEvE,UAAM,WAAW,MAAM,IAAI,KAAK,IAAgD,QAAQ;AAExF,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,MAAM,SAAS,QAAQ,CAAC;AAC9B,WAAO,IAAI,IAAI,CAAC,SAAS,uBAAuB,MAAM,IAAI,CAAC;AAAA,EAC7D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,mBAAuD;AAC3D,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAC5C,UAAM,EAAE,MAAM,cAAc,IAAI,MAAM,qBAAqB,kBAAkB,EAAE,OAAO,EAAE,CAAC;AACzF,UAAM,eAAe,cAAc,CAAC;AAEpC,QAAI,CAAC,cAAc;AACjB,aAAO;AAAA,QACL,UAAU;AAAA,QACV,QAAQ;AAAA,QACR,SAAS,gCAAgC;AAAA,QACzC,YAAY;AAAA,QACZ,aAAa;AAAA,MACf;AAAA,IACF;AAEA,UAAM,WAAW,aAAa,UAAU,kBAAkB,aAAa,EAAE,iCAAiC,gCAAgC,IAAI;AAC9I,UAAM,WAAW,MAAM,IAAI,KAAK;AAAA,MAC9B;AAAA,IACF;AAEA,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,MAAM,SAAS,KAAK,CAAC;AAC3B,QAAI,CAAC,KAAK;AACR,YAAM,IAAI;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,MACL,UAAU,IAAI;AAAA,MACd,QAAQ,IAAI;AAAA,MACZ,SAAS,IAAI;AAAA,MACb,YAAY,IAAI;AAAA,MAChB,aAAa,IAAI;AAAA,IACnB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SACJ,gBACA,OACkC;AAClC,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAE5C,WAAO,IAAI,KAAK;AAAA,MACd,aAAa,UAAU,kBAAkB,cAAc;AAAA,MACvD;AAAA,QACE,GAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,MAAM,IAAI,EAAE,UAAU,MAAM,SAAS;AAAA,QACtE,gBAAgB,MAAM;AAAA,QACtB,GAAI,MAAM,SAAS,EAAE,SAAS,MAAM,OAAO,IAAI,CAAC;AAAA,MAClD;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,mBAAmB,IAAI,iBAAiB;","names":[]}
@@ -0,0 +1,34 @@
1
+ import z from "zod";
2
+ import { PAYMENT_METHOD_CARD, PAYMENT_METHOD_PIX } from "../constants/ai-credits-offer.constants";
3
+ const PurchaseIaCreditsRequestBodySchema = z.object({
4
+ quantity: z.number().optional(),
5
+ payment_method: z.union([z.literal(PAYMENT_METHOD_CARD), z.literal(PAYMENT_METHOD_PIX)]),
6
+ id_card: z.string().optional(),
7
+ offer: z.string().optional()
8
+ });
9
+ const PurchaseIaCreditsPixDataSchema = z.object({
10
+ correlation_id: z.string().optional(),
11
+ qr_code: z.string().optional(),
12
+ qr_code_image: z.string().optional(),
13
+ payment_link: z.string().optional(),
14
+ expires_at: z.string().optional()
15
+ });
16
+ const PurchaseIaCreditsResultSchema = z.object({
17
+ status: z.number(),
18
+ message: z.string().optional(),
19
+ client_secret: z.string().optional(),
20
+ data: z.array(
21
+ z.object({
22
+ credited: z.number().optional(),
23
+ invoice_id: z.string().optional(),
24
+ pending: z.number().optional(),
25
+ payment_method: z.number().optional(),
26
+ pix: PurchaseIaCreditsPixDataSchema.optional()
27
+ })
28
+ ).optional()
29
+ });
30
+ export {
31
+ PurchaseIaCreditsRequestBodySchema,
32
+ PurchaseIaCreditsResultSchema
33
+ };
34
+ //# sourceMappingURL=ai-credits-offer.type.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/types/ai-credits-offer.type.ts"],"sourcesContent":["import z from 'zod';\nimport { PAYMENT_METHOD_CARD, PAYMENT_METHOD_PIX } from '../constants/ai-credits-offer.constants';\n\n/**\n * `reason` é sempre um código de máquina do backend — nunca renderizar como texto: o modal usa uma\n * mensagem de erro genérica já existente no catálogo (ver `AiCreditsOfferModal`).\n */\nexport type AiCreditsOfferIneligibilityReason =\n | 'not_default_wl'\n | 'gateway_not_supported'\n | 'no_active_subscription'\n | 'already_charged'\n | 'has_paid_plan'\n | 'has_card'\n | 'offer_consumed'\n | 'offer_card_only'\n | null;\n\nexport interface AiCreditsOfferEligibility {\n eligible: boolean;\n reason: AiCreditsOfferIneligibilityReason;\n credits: number;\n /** Preço promocional em centavos. `null` quando `eligible` é `false` sem cálculo de preço. */\n priceCents: number | null;\n /** Preço de tabela em centavos, derivado de `getAddonUnitPrice` no backend — nunca hardcoded. */\n anchorCents: number | null;\n}\n\n/** Resposta do route handler local — a elegibilidade do backend + o carimbo da sessão de login. */\nexport interface AiCreditsOfferEligibilityResponse extends AiCreditsOfferEligibility {\n /** `iat` do JWT no momento da consulta — usado pelo gate para saber se é uma sessão nova. */\n sessionIssuedAt: number;\n}\n\nexport interface PurchaseIaCreditsInput {\n /** Ignorado pelo backend quando `offer` é enviado (a oferta força a quantidade do pacote). */\n quantity?: number;\n paymentMethod: number;\n idCard?: string;\n /** Slug da oferta (`AI_CREDITS_FIRST_PURCHASE_OFFER.slug`). Ausente = compra avulsa comum. */\n offer?: string;\n}\n\n/**\n * Corpo aceito pela rota local de compra (`createPurchaseIaCreditsHandler`) — validado no handler\n * porque o JSON do request não é confiável: `payment_method` fora do union (1 = cartão, 3 = pix,\n * únicos métodos que o gapps-r3-api reconhece) nem chega a virar chamada de backend.\n */\nexport const PurchaseIaCreditsRequestBodySchema = z.object({\n quantity: z.number().optional(),\n payment_method: z.union([z.literal(PAYMENT_METHOD_CARD), z.literal(PAYMENT_METHOD_PIX)]),\n id_card: z.string().optional(),\n offer: z.string().optional(),\n});\nexport type PurchaseIaCreditsRequestBody = z.infer<typeof PurchaseIaCreditsRequestBodySchema>;\n\n/** Envelope PIX dentro de `data`, presente só quando a compra foi feita com `payment_method: 3`. */\nconst PurchaseIaCreditsPixDataSchema = z.object({\n correlation_id: z.string().optional(),\n qr_code: z.string().optional(),\n qr_code_image: z.string().optional(),\n payment_link: z.string().optional(),\n expires_at: z.string().optional(),\n});\n\n/**\n * Envelope CRU do gapps-r3-api — status 1 (pago), 2 (3DS pendente) ou 0 (recusado). Devolvido\n * sem reembrulho pelo `createPurchaseIaCreditsHandler` (ver o comentário no handler); o hook valida\n * a resposta contra este schema em vez de confiar cegamente no `fetch`.\n */\nexport const PurchaseIaCreditsResultSchema = z.object({\n status: z.number(),\n message: z.string().optional(),\n client_secret: z.string().optional(),\n data: z\n .array(\n z.object({\n credited: z.number().optional(),\n invoice_id: z.string().optional(),\n pending: z.number().optional(),\n payment_method: z.number().optional(),\n pix: PurchaseIaCreditsPixDataSchema.optional(),\n })\n )\n .optional(),\n});\nexport type PurchaseIaCreditsResult = z.infer<typeof PurchaseIaCreditsResultSchema>;\n"],"mappings":"AAAA,OAAO,OAAO;AACd,SAAS,qBAAqB,0BAA0B;AA+CjD,MAAM,qCAAqC,EAAE,OAAO;AAAA,EACzD,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,EAC9B,gBAAgB,EAAE,MAAM,CAAC,EAAE,QAAQ,mBAAmB,GAAG,EAAE,QAAQ,kBAAkB,CAAC,CAAC;AAAA,EACvF,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAID,MAAM,iCAAiC,EAAE,OAAO;AAAA,EAC9C,gBAAgB,EAAE,OAAO,EAAE,SAAS;AAAA,EACpC,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,eAAe,EAAE,OAAO,EAAE,SAAS;AAAA,EACnC,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA,EAClC,YAAY,EAAE,OAAO,EAAE,SAAS;AAClC,CAAC;AAOM,MAAM,gCAAgC,EAAE,OAAO;AAAA,EACpD,QAAQ,EAAE,OAAO;AAAA,EACjB,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,EAC7B,eAAe,EAAE,OAAO,EAAE,SAAS;AAAA,EACnC,MAAM,EACH;AAAA,IACC,EAAE,OAAO;AAAA,MACP,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,MAC9B,YAAY,EAAE,OAAO,EAAE,SAAS;AAAA,MAChC,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,MAC7B,gBAAgB,EAAE,OAAO,EAAE,SAAS;AAAA,MACpC,KAAK,+BAA+B,SAAS;AAAA,IAC/C,CAAC;AAAA,EACH,EACC,SAAS;AACd,CAAC;","names":[]}
@@ -0,0 +1,41 @@
1
+ import { cookieDomainFromDomain } from "../../../utils/intl/cookie-domain";
2
+ import { AiCreditsOfferStage } from "../constants/ai-credits-offer.constants";
3
+ const COOKIE_NAME = "ai-credits-offer-stage";
4
+ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
5
+ const EMPTY_RECORD = {
6
+ stage: AiCreditsOfferStage.NONE,
7
+ lastSessionIat: 0
8
+ };
9
+ function readOfferStageCookie(idAccount) {
10
+ if (typeof document === "undefined") return EMPTY_RECORD;
11
+ const match = new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`).exec(document.cookie);
12
+ if (!match) return EMPTY_RECORD;
13
+ const raw = decodeURIComponent(match[1]);
14
+ const [rawAccount, rawStage, rawIat] = raw.split(":");
15
+ if (Number(rawAccount) !== idAccount) return EMPTY_RECORD;
16
+ const stage = Number(rawStage);
17
+ if (!(stage in AiCreditsOfferStage)) return EMPTY_RECORD;
18
+ return {
19
+ stage,
20
+ lastSessionIat: Number(rawIat) || 0
21
+ };
22
+ }
23
+ function writeOfferStageCookie(idAccount, record, whitelabelDomain) {
24
+ if (typeof document === "undefined") return;
25
+ const value = `${idAccount}:${record.stage}:${record.lastSessionIat}`;
26
+ const domain = cookieDomainFromDomain(whitelabelDomain);
27
+ const parts = [
28
+ `${COOKIE_NAME}=${encodeURIComponent(value)}`,
29
+ "path=/",
30
+ `max-age=${ONE_YEAR_SECONDS}`,
31
+ "samesite=lax",
32
+ domain ? `domain=${domain}` : "",
33
+ typeof window !== "undefined" && window.location.protocol === "https:" ? "secure" : ""
34
+ ].filter(Boolean);
35
+ document.cookie = parts.join("; ");
36
+ }
37
+ export {
38
+ readOfferStageCookie,
39
+ writeOfferStageCookie
40
+ };
41
+ //# sourceMappingURL=offer-stage-cookie.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/ia-credits/utils/offer-stage-cookie.ts"],"sourcesContent":["import { cookieDomainFromDomain } from '../../../utils/intl/cookie-domain';\nimport { AiCreditsOfferStage } from '../constants/ai-credits-offer.constants';\n\nconst COOKIE_NAME = 'ai-credits-offer-stage';\nconst ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nexport interface OfferStageRecord {\n stage: AiCreditsOfferStage;\n /** `iat` do JWT na sessão em que este estágio foi gravado — é o que diferencia \"sessão nova\". */\n lastSessionIat: number;\n}\n\nconst EMPTY_RECORD: OfferStageRecord = {\n stage: AiCreditsOfferStage.NONE,\n lastSessionIat: 0,\n};\n\n/**\n * Cookie `ai-credits-offer-stage`, no domínio pai da whitelabel (`cookieDomainFromDomain` — mesmo\n * mecanismo do cookie de locale, que por sua vez espelha o cookie de auth `greatapps`): sustenta a\n * escada de estágios entre app/accounts/editor, 1 ano de validade.\n *\n * Valor: `${idAccount}:${stage}:${lastSessionIat}`. Registro de OUTRA conta\n * (id diferente do gravado) lê como estágio zerado — last-writer-wins, aceito porque o cookie é por\n * navegador e trocar de conta paga no mesmo device durante a janela da oferta é evento raro.\n */\nexport function readOfferStageCookie(idAccount: number): OfferStageRecord {\n if (typeof document === 'undefined') return EMPTY_RECORD;\n\n const match = new RegExp(`(?:^|;\\\\s*)${COOKIE_NAME}=([^;]+)`).exec(document.cookie);\n if (!match) return EMPTY_RECORD;\n\n const raw = decodeURIComponent(match[1]);\n const [rawAccount, rawStage, rawIat] = raw.split(':');\n\n if (Number(rawAccount) !== idAccount) return EMPTY_RECORD;\n\n const stage = Number(rawStage);\n if (!(stage in AiCreditsOfferStage)) return EMPTY_RECORD;\n\n return {\n stage,\n lastSessionIat: Number(rawIat) || 0,\n };\n}\n\nexport function writeOfferStageCookie(\n idAccount: number,\n record: OfferStageRecord,\n whitelabelDomain?: string | null\n): void {\n if (typeof document === 'undefined') return;\n\n const value = `${idAccount}:${record.stage}:${record.lastSessionIat}`;\n const domain = cookieDomainFromDomain(whitelabelDomain);\n const parts = [\n `${COOKIE_NAME}=${encodeURIComponent(value)}`,\n 'path=/',\n `max-age=${ONE_YEAR_SECONDS}`,\n 'samesite=lax',\n domain ? `domain=${domain}` : '',\n typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'secure' : '',\n ].filter(Boolean);\n\n document.cookie = parts.join('; ');\n}\n"],"mappings":"AAAA,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AAEpC,MAAM,cAAc;AACpB,MAAM,mBAAmB,KAAK,KAAK,KAAK;AAQxC,MAAM,eAAiC;AAAA,EACrC,OAAO,oBAAoB;AAAA,EAC3B,gBAAgB;AAClB;AAWO,SAAS,qBAAqB,WAAqC;AACxE,MAAI,OAAO,aAAa,YAAa,QAAO;AAE5C,QAAM,QAAQ,IAAI,OAAO,cAAc,WAAW,UAAU,EAAE,KAAK,SAAS,MAAM;AAClF,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,MAAM,mBAAmB,MAAM,CAAC,CAAC;AACvC,QAAM,CAAC,YAAY,UAAU,MAAM,IAAI,IAAI,MAAM,GAAG;AAEpD,MAAI,OAAO,UAAU,MAAM,UAAW,QAAO;AAE7C,QAAM,QAAQ,OAAO,QAAQ;AAC7B,MAAI,EAAE,SAAS,qBAAsB,QAAO;AAE5C,SAAO;AAAA,IACL;AAAA,IACA,gBAAgB,OAAO,MAAM,KAAK;AAAA,EACpC;AACF;AAEO,SAAS,sBACd,WACA,QACA,kBACM;AACN,MAAI,OAAO,aAAa,YAAa;AAErC,QAAM,QAAQ,GAAG,SAAS,IAAI,OAAO,KAAK,IAAI,OAAO,cAAc;AACnE,QAAM,SAAS,uBAAuB,gBAAgB;AACtD,QAAM,QAAQ;AAAA,IACZ,GAAG,WAAW,IAAI,mBAAmB,KAAK,CAAC;AAAA,IAC3C;AAAA,IACA,WAAW,gBAAgB;AAAA,IAC3B;AAAA,IACA,SAAS,UAAU,MAAM,KAAK;AAAA,IAC9B,OAAO,WAAW,eAAe,OAAO,SAAS,aAAa,WAAW,WAAW;AAAA,EACtF,EAAE,OAAO,OAAO;AAEhB,WAAS,SAAS,MAAM,KAAK,IAAI;AACnC;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/subscriptions/constants/subscription.constants.ts"],"sourcesContent":["export const SUBSCRIPTION_GRACE_PERIOD_DAYS = 15;\n\nexport const ACCOUNT_FREEZE_AFTER_DAYS = 7;\nexport const FREEZE_WARNING_DAYS_BEFORE = 1;\n\n/* Congelamento só vale para a wl 1: o aviso de véspera e o e-mail de bloqueio saem do\n * cron `dailyNotifications` do gapps-r3-api, que só roda para ela. Congelar as demais\n * seria bloquear sem avisar. Espelhado nos gates dos dois backends. */\nexport const FREEZE_ELIGIBLE_WHITELABEL_ID = 1;\n\n/* Stripe Smart Retries (dunning): até 8 tentativas de cobrança no cartão antes\n * do cancelamento automático. `tentativas restantes = MAX - attempt_count`. */\nexport const MAX_PAYMENT_ATTEMPTS = 8;\n"],"mappings":"AAAO,MAAM,iCAAiC;AAEvC,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAKnC,MAAM,gCAAgC;AAItC,MAAM,uBAAuB;","names":[]}
1
+ {"version":3,"sources":["../../../../src/modules/subscriptions/constants/subscription.constants.ts"],"sourcesContent":["export const SUBSCRIPTION_GRACE_PERIOD_DAYS = 15;\n\nexport const ACCOUNT_FREEZE_AFTER_DAYS = 7;\nexport const FREEZE_WARNING_DAYS_BEFORE = 1;\n\nexport const FREEZE_ELIGIBLE_WHITELABEL_ID = 1;\n\nexport const MAX_PAYMENT_ATTEMPTS = 8;\n"],"mappings":"AAAO,MAAM,iCAAiC;AAEvC,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAEnC,MAAM,gCAAgC;AAEtC,MAAM,uBAAuB;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/subscriptions/utils/get-account-freeze-date.ts"],"sourcesContent":["import {\n ACCOUNT_FREEZE_AFTER_DAYS,\n FREEZE_ELIGIBLE_WHITELABEL_ID,\n} from \"../constants/subscription.constants\";\nimport { toCalendarDate } from \"../../../infra/utils/date\";\nimport type { Subscription } from \"../types/subscription.type\";\n\n/**\n * Data em que a conta é congelada por falta de pagamento: date_due + ACCOUNT_FREEZE_AFTER_DAYS.\n *\n * Só congela assinatura `client` da whitelabel 1 — MESMA régua do\n * `isSubscriptionFrozen` (gapps-r3-api) e do `isAccountFrozen` (gpages-r3-api).\n * Se as três divergirem, o usuário é bloqueado por uma camada e liberado por outra.\n *\n * - `type !== \"client\"` cobre free, trial, partner e whitelabel de uma vez (a coluna\n * aceita mais valores do que a lista que o SubscriptionBanner enumera).\n * - `id_wl !== 1`: o pipeline de aviso (cron `dailyNotifications` do gapps-r3-api) só\n * existe para a wl 1. Congelar conta de whitelabel seria bloquear sem nunca avisar —\n * o defeito do ID-5029 que este card existe para não repetir.\n */\nexport function getAccountFreezeDate(\n subscription:\n | Pick<Subscription, \"type\" | \"date_due\" | \"id_wl\">\n | undefined\n | null,\n): Date | null {\n if (!subscription) return null;\n\n if (subscription.type !== \"client\") return null;\n\n if (Number(subscription.id_wl) !== FREEZE_ELIGIBLE_WHITELABEL_ID) return null;\n\n const dueDate = toCalendarDate(subscription.date_due);\n\n if (!dueDate) return null;\n\n const result = new Date(dueDate);\n result.setDate(result.getDate() + ACCOUNT_FREEZE_AFTER_DAYS);\n return result;\n}\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAgBxB,SAAS,qBACd,cAIa;AACb,MAAI,CAAC,aAAc,QAAO;AAE1B,MAAI,aAAa,SAAS,SAAU,QAAO;AAE3C,MAAI,OAAO,aAAa,KAAK,MAAM,8BAA+B,QAAO;AAEzE,QAAM,UAAU,eAAe,aAAa,QAAQ;AAEpD,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,SAAS,IAAI,KAAK,OAAO;AAC/B,SAAO,QAAQ,OAAO,QAAQ,IAAI,yBAAyB;AAC3D,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../../src/modules/subscriptions/utils/get-account-freeze-date.ts"],"sourcesContent":["import {\n ACCOUNT_FREEZE_AFTER_DAYS,\n FREEZE_ELIGIBLE_WHITELABEL_ID,\n} from \"../constants/subscription.constants\";\nimport { toCalendarDate } from \"../../../infra/utils/date\";\nimport type { Subscription } from \"../types/subscription.type\";\n\nexport function getAccountFreezeDate(\n subscription:\n | Pick<Subscription, \"type\" | \"date_due\" | \"id_wl\">\n | undefined\n | null,\n): Date | null {\n if (!subscription) return null;\n\n if (subscription.type !== \"client\") return null;\n\n if (Number(subscription.id_wl) !== FREEZE_ELIGIBLE_WHITELABEL_ID) return null;\n\n const dueDate = toCalendarDate(subscription.date_due);\n\n if (!dueDate) return null;\n\n const result = new Date(dueDate);\n result.setDate(result.getDate() + ACCOUNT_FREEZE_AFTER_DAYS);\n return result;\n}\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAGxB,SAAS,qBACd,cAIa;AACb,MAAI,CAAC,aAAc,QAAO;AAE1B,MAAI,aAAa,SAAS,SAAU,QAAO;AAE3C,MAAI,OAAO,aAAa,KAAK,MAAM,8BAA+B,QAAO;AAEzE,QAAM,UAAU,eAAe,aAAa,QAAQ;AAEpD,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,SAAS,IAAI,KAAK,OAAO;AAC/B,SAAO,QAAQ,OAAO,QAAQ,IAAI,yBAAyB;AAC3D,SAAO;AACT;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/subscriptions/utils/get-account-freeze-state.ts"],"sourcesContent":["import { FREEZE_WARNING_DAYS_BEFORE } from \"../constants/subscription.constants\";\nimport { toCalendarDate } from \"../../../infra/utils/date\";\nimport { getAccountFreezeDate } from \"./get-account-freeze-date\";\nimport { getSubscriptionCancellationState } from \"./get-subscription-cancellation-state\";\nimport type { Subscription } from \"../types/subscription.type\";\n\nexport type AccountFreezeState = \"none\" | \"warning\" | \"frozen\";\n\n/**\n * Estado de congelamento por falta de pagamento (date_due + ACCOUNT_FREEZE_AFTER_DAYS).\n *\n * Conta cancelada/pendente de cancelamento é tratada pelos ramos de cancelamento\n * (mesma ordem do SubscriptionBanner) — aqui sempre \"none\" nesse caso, pra não\n * disputar mensagem com o CancelledSubscriptionBanner/PendingCancellationBanner.\n */\nexport function getAccountFreezeState(\n subscription:\n | Pick<\n Subscription,\n \"type\" | \"active\" | \"date_due\" | \"date_cancellation\" | \"id_wl\"\n >\n | undefined\n | null,\n): AccountFreezeState {\n if (!subscription) return \"none\";\n\n if (getSubscriptionCancellationState(subscription) !== \"none\") return \"none\";\n\n const freezeDate = getAccountFreezeDate(subscription);\n if (!freezeDate) return \"none\";\n\n const today = toCalendarDate(new Date());\n if (!today) return \"none\";\n\n if (today.getTime() >= freezeDate.getTime()) return \"frozen\";\n\n const warningDate = new Date(freezeDate);\n warningDate.setDate(warningDate.getDate() - FREEZE_WARNING_DAYS_BEFORE);\n\n if (today.getTime() >= warningDate.getTime()) return \"warning\";\n\n return \"none\";\n}\n"],"mappings":"AAAA,SAAS,kCAAkC;AAC3C,SAAS,sBAAsB;AAC/B,SAAS,4BAA4B;AACrC,SAAS,wCAAwC;AAY1C,SAAS,sBACd,cAOoB;AACpB,MAAI,CAAC,aAAc,QAAO;AAE1B,MAAI,iCAAiC,YAAY,MAAM,OAAQ,QAAO;AAEtE,QAAM,aAAa,qBAAqB,YAAY;AACpD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,QAAQ,eAAe,oBAAI,KAAK,CAAC;AACvC,MAAI,CAAC,MAAO,QAAO;AAEnB,MAAI,MAAM,QAAQ,KAAK,WAAW,QAAQ,EAAG,QAAO;AAEpD,QAAM,cAAc,IAAI,KAAK,UAAU;AACvC,cAAY,QAAQ,YAAY,QAAQ,IAAI,0BAA0B;AAEtE,MAAI,MAAM,QAAQ,KAAK,YAAY,QAAQ,EAAG,QAAO;AAErD,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../../src/modules/subscriptions/utils/get-account-freeze-state.ts"],"sourcesContent":["import { FREEZE_WARNING_DAYS_BEFORE } from \"../constants/subscription.constants\";\nimport { toCalendarDate } from \"../../../infra/utils/date\";\nimport { getAccountFreezeDate } from \"./get-account-freeze-date\";\nimport { getSubscriptionCancellationState } from \"./get-subscription-cancellation-state\";\nimport type { Subscription } from \"../types/subscription.type\";\n\nexport type AccountFreezeState = \"none\" | \"warning\" | \"frozen\";\n\nexport function getAccountFreezeState(\n subscription:\n | Pick<\n Subscription,\n \"type\" | \"active\" | \"date_due\" | \"date_cancellation\" | \"id_wl\"\n >\n | undefined\n | null,\n): AccountFreezeState {\n if (!subscription) return \"none\";\n\n if (getSubscriptionCancellationState(subscription) !== \"none\") return \"none\";\n\n const freezeDate = getAccountFreezeDate(subscription);\n if (!freezeDate) return \"none\";\n\n const today = toCalendarDate(new Date());\n if (!today) return \"none\";\n\n if (today.getTime() >= freezeDate.getTime()) return \"frozen\";\n\n const warningDate = new Date(freezeDate);\n warningDate.setDate(warningDate.getDate() - FREEZE_WARNING_DAYS_BEFORE);\n\n if (today.getTime() >= warningDate.getTime()) return \"warning\";\n\n return \"none\";\n}\n"],"mappings":"AAAA,SAAS,kCAAkC;AAC3C,SAAS,sBAAsB;AAC/B,SAAS,4BAA4B;AACrC,SAAS,wCAAwC;AAK1C,SAAS,sBACd,cAOoB;AACpB,MAAI,CAAC,aAAc,QAAO;AAE1B,MAAI,iCAAiC,YAAY,MAAM,OAAQ,QAAO;AAEtE,QAAM,aAAa,qBAAqB,YAAY;AACpD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,QAAQ,eAAe,oBAAI,KAAK,CAAC;AACvC,MAAI,CAAC,MAAO,QAAO;AAEnB,MAAI,MAAM,QAAQ,KAAK,WAAW,QAAQ,EAAG,QAAO;AAEpD,QAAM,cAAc,IAAI,KAAK,UAAU;AACvC,cAAY,QAAQ,YAAY,QAAQ,IAAI,0BAA0B;AAEtE,MAAI,MAAM,QAAQ,KAAK,YAAY,QAAQ,EAAG,QAAO;AAErD,SAAO;AACT;","names":[]}
@@ -0,0 +1,14 @@
1
+ import { useCallback } from "react";
2
+ import { useModalManager } from "./useModalManager";
3
+ const useAiCreditsOfferModal = () => {
4
+ const { activeModal, openModal, closeModalByKey } = useModalManager();
5
+ return {
6
+ open: activeModal === "aiCreditsOfferModal",
7
+ openModal: useCallback(() => openModal("aiCreditsOfferModal"), [openModal]),
8
+ closeModal: useCallback(() => closeModalByKey("aiCreditsOfferModal"), [closeModalByKey])
9
+ };
10
+ };
11
+ export {
12
+ useAiCreditsOfferModal
13
+ };
14
+ //# sourceMappingURL=useAiCreditsOfferModal.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/store/useAiCreditsOfferModal.ts"],"sourcesContent":["import { useCallback } from 'react';\nimport { useModalManager } from './useModalManager';\n\n/**\n * Oferta de primeira compra de créditos de IA. Quem abre é o `AiCreditsOfferGate` (1s depois do\n * gatilho decidir que é a vez dela) — daí não existir payload: a oferta é sempre a mesma.\n *\n * Os callbacks vão em `useCallback` (as ações do zustand já são estáveis): o gate arma um\n * `setTimeout` num efeito que tem `openModal` nas dependências, e uma identidade nova a cada render\n * limparia o timer antes de ele disparar — a oferta nunca abriria.\n *\n * O fechamento usa `closeModalByKey` e não `closeModal`: o modal abre o `addCardModal` por cima (a\n * pilha do `useModalManager` empilha), e um `closeModal` disparado com o cartão em cima derrubaria\n * o topo da pilha, não a oferta.\n */\nexport const useAiCreditsOfferModal = () => {\n const { activeModal, openModal, closeModalByKey } = useModalManager();\n\n return {\n open: activeModal === 'aiCreditsOfferModal',\n openModal: useCallback(() => openModal('aiCreditsOfferModal'), [openModal]),\n closeModal: useCallback(() => closeModalByKey('aiCreditsOfferModal'), [closeModalByKey]),\n };\n};\n"],"mappings":"AAAA,SAAS,mBAAmB;AAC5B,SAAS,uBAAuB;AAczB,MAAM,yBAAyB,MAAM;AAC1C,QAAM,EAAE,aAAa,WAAW,gBAAgB,IAAI,gBAAgB;AAEpE,SAAO;AAAA,IACL,MAAM,gBAAgB;AAAA,IACtB,WAAW,YAAY,MAAM,UAAU,qBAAqB,GAAG,CAAC,SAAS,CAAC;AAAA,IAC1E,YAAY,YAAY,MAAM,gBAAgB,qBAAqB,GAAG,CAAC,eAAe,CAAC;AAAA,EACzF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/common",
3
- "version": "1.1.747",
3
+ "version": "1.1.749",
4
4
  "description": "Shared library for GreatApps frontend applications",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./src/index.ts",
@@ -61,12 +61,14 @@
61
61
  "@greatapps/jwt": "1.0.12",
62
62
  "@radix-ui/react-accordion": "1.2.12",
63
63
  "@radix-ui/react-checkbox": "1.3.3",
64
+ "@radix-ui/react-dialog": "1.1.23",
64
65
  "@radix-ui/react-popover": "1.1.15",
65
66
  "@radix-ui/react-progress": "1.1.8",
66
67
  "@radix-ui/react-radio-group": "1.3.8",
67
68
  "@radix-ui/react-scroll-area": "1.2.10",
68
69
  "@radix-ui/react-select": "2.2.6",
69
70
  "@radix-ui/react-separator": "1.1.8",
71
+ "@radix-ui/react-slot": "1.3.3",
70
72
  "@radix-ui/react-switch": "1.2.6",
71
73
  "@radix-ui/react-tabs": "1.1.13",
72
74
  "@radix-ui/react-tooltip": "1.2.8",
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { useAuth } from '../../providers/auth.provider';
5
+ import { useIsDefaultWhitelabel, useWhitelabel } from '../../providers/whitelabel.provider';
6
+ import { useActiveSubscription } from '../../modules/subscriptions/hooks/find-active-subscription.hook';
7
+ import { hasActivePaidSubscription } from '../../modules/subscriptions/utils/has-active-paid-subscription';
8
+ import { ADDON_IDS } from '../../modules/subscriptions/constants/addons.constants';
9
+ import { useCurrencyFormatter } from '../../modules/accounts/hooks/use-currency-formatter.hook';
10
+ import { useCards } from '../../modules/cards/hooks/cards.hook';
11
+ import { useIaCredits } from '../../modules/ia-credits/hooks/ia-credits.hook';
12
+ import { useAiCreditsOfferEligibility } from '../../modules/ia-credits/hooks/use-offer-eligibility.hook';
13
+ import {
14
+ AI_CREDITS_FIRST_PURCHASE_OFFER,
15
+ AiCreditsOfferStage,
16
+ } from '../../modules/ia-credits/constants/ai-credits-offer.constants';
17
+ import {
18
+ readOfferStageCookie,
19
+ writeOfferStageCookie,
20
+ } from '../../modules/ia-credits/utils/offer-stage-cookie';
21
+ import { useAiCreditsOfferModal } from '../../store/useAiCreditsOfferModal';
22
+ import { useModalManager } from '../../store/useModalManager';
23
+ import type { IaCreditsSummary } from '../../modules/ia-credits/types';
24
+
25
+ /** Espera pedida pelo produto: a oferta entra 1s depois de o gatilho decidir, não junto. */
26
+ const OPEN_DELAY_MS = 1000;
27
+
28
+ /**
29
+ * Estágio que os créditos ATUAIS da conta sustentam, na escada do Diogo — sempre o mais alto que os
30
+ * números batem (ordem de checagem: ZERO > EXHAUSTED > HALF > FIRST_USE > CAMPAIGN).
31
+ *
32
+ * `totalCredits === 0` (lote mensal expirado, ainda sem renovar) cai em ZERO, não em divisão por
33
+ * zero — mesma régua do `availableCredits === 0`.
34
+ */
35
+ function resolveCandidateStage(
36
+ summary: Pick<IaCreditsSummary, 'totalCredits' | 'usedCredits' | 'availableCredits'>
37
+ ): AiCreditsOfferStage {
38
+ if (summary.availableCredits === 0 || summary.totalCredits === 0) {
39
+ return AiCreditsOfferStage.ZERO_SHOWN;
40
+ }
41
+ const usedRatio = summary.usedCredits / summary.totalCredits;
42
+ if (usedRatio >= 0.9) return AiCreditsOfferStage.EXHAUSTED_SHOWN;
43
+ if (usedRatio >= 0.5) return AiCreditsOfferStage.HALF_SHOWN;
44
+ if (summary.usedCredits >= 1) return AiCreditsOfferStage.FIRST_USE_SHOWN;
45
+ return AiCreditsOfferStage.CAMPAIGN_SHOWN;
46
+ }
47
+
48
+ export interface AiCreditsOfferGateProps {
49
+ /**
50
+ * Overlay local do host, fora do `useModalManager` compartilhado (ex.: geração de IA em
51
+ * progresso, `InsufficientCreditsModal` do editor) — a oferta não empilha em cima nem espera a
52
+ * vez enquanto `true`; ela volta a tentar no próximo render em que a condição cair, porque o
53
+ * estágio só é gravado quando o modal realmente abre.
54
+ */
55
+ suppressWhen?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Quem decide se a oferta de primeira compra de créditos de IA aparece, e em qual estágio (gatilhos
60
+ * do Diogo — ver Asana ID-1217333331953496). Mora no root layout de cada app, ao lado dos outros
61
+ * gates de modal.
62
+ *
63
+ * Precisa passar por, nesta ordem:
64
+ *
65
+ * 1. **Corte barato do cliente.** Whitelabel padrão + conta em BRL + `owner`/`admin` + primeira
66
+ * compra de verdade (sem assinatura paga vigente, sem NENHUMA cobrança no histórico) + menos de
67
+ * 500 créditos já contratados. Evita bater no backend pra quem obviamente não se qualifica.
68
+ * 2. **Elegibilidade do servidor**, com FAIL CLOSED: erro, timeout ou ainda carregando conta como
69
+ * inelegível — sem confirmação do backend não existe oferta (ele é quem sabe se a conta já
70
+ * consumiu a oferta em QUALQUER assinatura, cross-cancelamento incluso).
71
+ * 2.1. **Cartão salvo, também FAIL CLOSED.** O card exige as duas condições juntas — plano
72
+ * elegível E ainda sem cartão. O backend valida isso na elegibilidade, mas o gate confere de
73
+ * novo com `useCards`: sem confirmação de que a lista veio vazia, não abre (evita a corrida em
74
+ * que alguém cadastra o cartão pelo CTA e desiste antes de confirmar, e o cache local de
75
+ * elegibilidade ainda não invalidou).
76
+ * 3. **Estágio candidato**, derivado dos créditos atuais (`resolveCandidateStage`).
77
+ * 4. **Escada monotônica do cookie `ai-credits-offer-stage`**: só abre se o candidato é MAIOR que o
78
+ * estágio gravado, OU se o candidato é CAMPAIGN/ZERO — os dois únicos que reapresentam, e só
79
+ * numa sessão de login nova (`sessionIssuedAt` ≠ `lastSessionIat`). Nenhum dos dois tem teto de
80
+ * repetições: o Diogo pediu literalmente "mostrar sempre que iniciar sessão nova" para a
81
+ * campanha e "pode ficar mostrando" para quem está sem crédito (Asana ID-1217333331953496).
82
+ * 5. **Máx. 1 apresentação por sessão de login.** Sai de graça da regra acima: se o cookie já tem
83
+ * `lastSessionIat` igual ao da sessão atual, NADA abre — nem uma escalada de estágio dentro da
84
+ * mesma sessão (ex.: FIRST_USE → HALF → EXHAUSTED na mesma geração, no Free de 10 créditos).
85
+ */
86
+ export function AiCreditsOfferGate({ suppressWhen = false }: AiCreditsOfferGateProps) {
87
+ const { activeModal } = useModalManager();
88
+ const { openModal } = useAiCreditsOfferModal();
89
+ const { user } = useAuth();
90
+ const isDefaultWl = useIsDefaultWhitelabel();
91
+ const { whitelabel } = useWhitelabel();
92
+ const { data: subscriptionData } = useActiveSubscription();
93
+ const { currency } = useCurrencyFormatter();
94
+ const { summary, isLoading: isCreditsLoading } = useIaCredits();
95
+ const hasArmedRef = useRef(false);
96
+
97
+ const subscription = subscriptionData?.data?.[0] ?? null;
98
+ const accountId = user?.id_account;
99
+ const canManageBilling = user?.profile === 'owner' || user?.profile === 'admin';
100
+ const currentAiCredits =
101
+ subscription?.items?.find((item) => item.id_addon === ADDON_IDS.AI)?.quantity ?? 0;
102
+ const isFirstPurchase = !hasActivePaidSubscription(subscription) && !subscription?.charged;
103
+
104
+ const passesClientCutoff =
105
+ isDefaultWl &&
106
+ currency === 'brl' &&
107
+ canManageBilling &&
108
+ accountId != null &&
109
+ isFirstPurchase &&
110
+ currentAiCredits < AI_CREDITS_FIRST_PURCHASE_OFFER.credits;
111
+
112
+ // Só consulta o servidor depois do corte barato: quem não passa aqui nunca veria a oferta, e a
113
+ // consulta custa duas chamadas no gapps-r3-api.
114
+ const { data: eligibilityData } = useAiCreditsOfferEligibility({ enabled: passesClientCutoff });
115
+ const cardsQuery = useCards({ enabled: passesClientCutoff });
116
+
117
+ useEffect(() => {
118
+ if (hasArmedRef.current) return;
119
+ if (suppressWhen || activeModal) return;
120
+ if (!passesClientCutoff || isCreditsLoading) return;
121
+ // Fail closed: sem resposta do servidor (carregando, erro, ou reason preenchido), não abre.
122
+ if (!eligibilityData || !eligibilityData.eligible || accountId == null) return;
123
+ // Fail closed: sem confirmação de que a lista de cartões carregou vazia, não abre — o backend
124
+ // já valida "sem cartão" na elegibilidade, isto é a segunda camada do lado do cliente.
125
+ if (!cardsQuery.isSuccess || cardsQuery.data.data.length > 0) return;
126
+
127
+ const candidateStage = resolveCandidateStage(summary);
128
+ const cookie = readOfferStageCookie(accountId);
129
+
130
+ const alreadyShownThisSession = cookie.lastSessionIat === eligibilityData.sessionIssuedAt;
131
+ if (alreadyShownThisSession) return;
132
+
133
+ const isRepeatableStage =
134
+ candidateStage === AiCreditsOfferStage.CAMPAIGN_SHOWN ||
135
+ candidateStage === AiCreditsOfferStage.ZERO_SHOWN;
136
+
137
+ const shouldOpen =
138
+ candidateStage > cookie.stage || (isRepeatableStage && candidateStage === cookie.stage);
139
+ if (!shouldOpen) return;
140
+
141
+ // Arma uma única vez por montagem: sem isso, qualquer re-render limparia o timer pendente.
142
+ hasArmedRef.current = true;
143
+
144
+ const timer = setTimeout(() => {
145
+ // O estágio é gravado junto com a abertura (e não antes): fechar a aba durante o delay não
146
+ // pode queimar a apresentação.
147
+ writeOfferStageCookie(
148
+ accountId,
149
+ { stage: candidateStage, lastSessionIat: eligibilityData.sessionIssuedAt },
150
+ whitelabel?.domain
151
+ );
152
+ openModal();
153
+ }, OPEN_DELAY_MS);
154
+
155
+ return () => clearTimeout(timer);
156
+ }, [
157
+ accountId,
158
+ activeModal,
159
+ cardsQuery.data,
160
+ cardsQuery.isSuccess,
161
+ eligibilityData,
162
+ isCreditsLoading,
163
+ openModal,
164
+ passesClientCutoff,
165
+ suppressWhen,
166
+ summary,
167
+ whitelabel?.domain,
168
+ ]);
169
+
170
+ return null;
171
+ }