@greatapps/common 1.1.751 → 1.1.752

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 (81) hide show
  1. package/dist/components/layouts/AiCreditsOfferGate.mjs +105 -0
  2. package/dist/components/layouts/AiCreditsOfferGate.mjs.map +1 -0
  3. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs +276 -0
  4. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs.map +1 -0
  5. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs +55 -0
  6. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs.map +1 -0
  7. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs +78 -0
  8. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs.map +1 -0
  9. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs +60 -0
  10. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs.map +1 -0
  11. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs +5 -0
  12. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs.map +1 -0
  13. package/dist/components/ui/form/ComboboxField.mjs +1 -0
  14. package/dist/components/ui/form/ComboboxField.mjs.map +1 -1
  15. package/dist/handlers.mjs +5 -1
  16. package/dist/handlers.mjs.map +1 -1
  17. package/dist/i18n/messages/en-us.mjs +32 -0
  18. package/dist/i18n/messages/en-us.mjs.map +1 -1
  19. package/dist/i18n/messages/es-es.mjs +32 -0
  20. package/dist/i18n/messages/es-es.mjs.map +1 -1
  21. package/dist/i18n/messages/pt-br.mjs +32 -0
  22. package/dist/i18n/messages/pt-br.mjs.map +1 -1
  23. package/dist/index.mjs +73 -43
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/modules/auth/utils/get-user-context.mjs +2 -1
  26. package/dist/modules/auth/utils/get-user-context.mjs.map +1 -1
  27. package/dist/modules/cards/hooks/cards.hook.mjs +3 -2
  28. package/dist/modules/cards/hooks/cards.hook.mjs.map +1 -1
  29. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs +28 -0
  30. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs.map +1 -0
  31. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs +19 -0
  32. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs.map +1 -0
  33. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs +33 -0
  34. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs.map +1 -0
  35. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs +29 -0
  36. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs.map +1 -0
  37. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs +18 -0
  38. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs.map +1 -0
  39. package/dist/modules/ia-credits/services/ia-credits.service.mjs +67 -0
  40. package/dist/modules/ia-credits/services/ia-credits.service.mjs.map +1 -1
  41. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs +34 -0
  42. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs.map +1 -0
  43. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs +43 -0
  44. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs.map +1 -0
  45. package/dist/modules/plans/services/plans.service.mjs +2 -2
  46. package/dist/modules/plans/services/plans.service.mjs.map +1 -1
  47. package/dist/modules/plans/types/plan.type.mjs.map +1 -1
  48. package/dist/modules/plans/utils/map-api-plan-to-ui.mjs +0 -1
  49. package/dist/modules/plans/utils/map-api-plan-to-ui.mjs.map +1 -1
  50. package/dist/modules/subscriptions/types/calculate-subscription.type.mjs +0 -8
  51. package/dist/modules/subscriptions/types/calculate-subscription.type.mjs.map +1 -1
  52. package/dist/store/useAiCreditsOfferModal.mjs +14 -0
  53. package/dist/store/useAiCreditsOfferModal.mjs.map +1 -0
  54. package/package.json +3 -1
  55. package/src/components/layouts/AiCreditsOfferGate.tsx +199 -0
  56. package/src/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.tsx +401 -0
  57. package/src/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.tsx +97 -0
  58. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.tsx +121 -0
  59. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.tsx +91 -0
  60. package/src/components/modals/promo/AiCreditsOfferModal/index.ts +1 -0
  61. package/src/components/ui/form/ComboboxField.tsx +6 -0
  62. package/src/handlers.ts +2 -0
  63. package/src/i18n/messages/en-us.ts +33 -0
  64. package/src/i18n/messages/es-es.ts +33 -0
  65. package/src/i18n/messages/pt-br.ts +33 -0
  66. package/src/index.ts +26 -0
  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 +41 -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 +70 -0
  77. package/src/modules/plans/services/plans.service.ts +3 -3
  78. package/src/modules/plans/types/plan.type.ts +0 -1
  79. package/src/modules/plans/utils/map-api-plan-to-ui.ts +0 -1
  80. package/src/modules/subscriptions/types/calculate-subscription.type.ts +0 -8
  81. package/src/store/useAiCreditsOfferModal.ts +24 -0
@@ -0,0 +1,199 @@
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 { useRequiredBillingData } from '../../modules/accounts/hooks/use-required-billing-data.hook';
11
+ import { useCards } from '../../modules/cards/hooks/cards.hook';
12
+ import { useIaCredits } from '../../modules/ia-credits/hooks/ia-credits.hook';
13
+ import { useAiCreditsOfferEligibility } from '../../modules/ia-credits/hooks/use-offer-eligibility.hook';
14
+ import {
15
+ AI_CREDITS_FIRST_PURCHASE_OFFER,
16
+ AI_CREDITS_OFFER_MAX_REPEATS,
17
+ AiCreditsOfferStage,
18
+ } from '../../modules/ia-credits/constants/ai-credits-offer.constants';
19
+ import {
20
+ readOfferStageCookie,
21
+ writeOfferStageCookie,
22
+ } from '../../modules/ia-credits/utils/offer-stage-cookie';
23
+ import { useAiCreditsOfferModal } from '../../store/useAiCreditsOfferModal';
24
+ import { useModalManager } from '../../store/useModalManager';
25
+ import type { IaCreditsSummary } from '../../modules/ia-credits/types';
26
+
27
+ /** Espera pedida pelo produto: a oferta entra 1s depois de o gatilho decidir, não junto. */
28
+ const OPEN_DELAY_MS = 1000;
29
+
30
+ /**
31
+ * Estágio que os créditos ATUAIS da conta sustentam, na escada do Diogo — sempre o mais alto que os
32
+ * números batem (ordem de checagem: ZERO > EXHAUSTED > HALF > FIRST_USE > CAMPAIGN).
33
+ *
34
+ * `totalCredits === 0` (lote mensal expirado, ainda sem renovar) cai em ZERO, não em divisão por
35
+ * zero — mesma régua do `availableCredits === 0`.
36
+ */
37
+ function resolveCandidateStage(
38
+ summary: Pick<IaCreditsSummary, 'totalCredits' | 'usedCredits' | 'availableCredits'>
39
+ ): AiCreditsOfferStage {
40
+ // Conta que ainda NÃO recebeu lote nenhum (medido em produção: o free só ganha o primeiro lote
41
+ // meses depois do cadastro) não é o mesmo que conta que gastou tudo. Sem esta distinção o
42
+ // recém-cadastrado entrava direto em ZERO_SHOWN, o degrau mais alto, e como a escada só sobe os
43
+ // gatilhos de uso — primeira geração, 50%, 90% — nunca mais disparavam para ele.
44
+ if (summary.totalCredits === 0 && summary.usedCredits === 0) {
45
+ return AiCreditsOfferStage.CAMPAIGN_SHOWN;
46
+ }
47
+ if (summary.availableCredits === 0 || summary.totalCredits === 0) {
48
+ return AiCreditsOfferStage.ZERO_SHOWN;
49
+ }
50
+ const usedRatio = summary.usedCredits / summary.totalCredits;
51
+ if (usedRatio >= 0.9) return AiCreditsOfferStage.EXHAUSTED_SHOWN;
52
+ if (usedRatio >= 0.5) return AiCreditsOfferStage.HALF_SHOWN;
53
+ if (summary.usedCredits >= 1) return AiCreditsOfferStage.FIRST_USE_SHOWN;
54
+ return AiCreditsOfferStage.CAMPAIGN_SHOWN;
55
+ }
56
+
57
+ export interface AiCreditsOfferGateProps {
58
+ /**
59
+ * Overlay local do host, fora do `useModalManager` compartilhado (ex.: geração de IA em
60
+ * progresso, `InsufficientCreditsModal` do editor) — a oferta não empilha em cima nem espera a
61
+ * vez enquanto `true`; ela volta a tentar no próximo render em que a condição cair, porque o
62
+ * estágio só é gravado quando o modal realmente abre.
63
+ */
64
+ suppressWhen?: boolean;
65
+ }
66
+
67
+ /**
68
+ * Quem decide se a oferta de primeira compra de créditos de IA aparece, e em qual estágio (gatilhos
69
+ * do Diogo — ver Asana ID-1217333331953496). Mora no root layout de cada app, ao lado dos outros
70
+ * gates de modal.
71
+ *
72
+ * Precisa passar por, nesta ordem:
73
+ *
74
+ * 1. **Corte barato do cliente.** Whitelabel padrão + conta em BRL + `owner`/`admin` + primeira
75
+ * compra de verdade (sem assinatura paga vigente, sem NENHUMA cobrança no histórico) + menos de
76
+ * 500 créditos já contratados. Evita bater no backend pra quem obviamente não se qualifica.
77
+ * 2. **Elegibilidade do servidor**, com FAIL CLOSED: erro, timeout ou ainda carregando conta como
78
+ * inelegível — sem confirmação do backend não existe oferta (ele é quem sabe se a conta já
79
+ * consumiu a oferta em QUALQUER assinatura, cross-cancelamento incluso).
80
+ * 2.1. **Cartão salvo, também FAIL CLOSED.** O card exige as duas condições juntas — plano
81
+ * elegível E ainda sem cartão. O backend valida isso na elegibilidade, mas o gate confere de
82
+ * novo com `useCards`: sem confirmação de que a lista veio vazia, não abre (evita a corrida em
83
+ * que alguém cadastra o cartão pelo CTA e desiste antes de confirmar, e o cache local de
84
+ * elegibilidade ainda não invalidou).
85
+ * 3. **Estágio candidato**, derivado dos créditos atuais (`resolveCandidateStage`).
86
+ * 4. **Escada monotônica do cookie `ai-credits-offer-stage`**: só abre se o candidato é MAIOR que o
87
+ * estágio gravado, OU se o candidato é CAMPAIGN/ZERO — os dois únicos que reapresentam, e só
88
+ * numa sessão de login nova (`sessionIssuedAt` ≠ `lastSessionIat`). A repetição respeita
89
+ * `AI_CREDITS_OFFER_MAX_REPEATS`: a campanha é agressiva de propósito (vale inclusive para quem
90
+ * acabou de se cadastrar), mas não pode virar o mesmo pop em toda sessão para sempre.
91
+ * 5. **Máx. 1 apresentação por sessão de login.** Sai de graça da regra acima: se o cookie já tem
92
+ * `lastSessionIat` igual ao da sessão atual, NADA abre — nem uma escalada de estágio dentro da
93
+ * mesma sessão (ex.: FIRST_USE → HALF → EXHAUSTED na mesma geração, no Free de 10 créditos).
94
+ */
95
+ export function AiCreditsOfferGate({ suppressWhen = false }: AiCreditsOfferGateProps) {
96
+ const { activeModal } = useModalManager();
97
+ const { openModal } = useAiCreditsOfferModal();
98
+ const { user } = useAuth();
99
+ const isDefaultWl = useIsDefaultWhitelabel();
100
+ const { whitelabel } = useWhitelabel();
101
+ const { data: subscriptionData } = useActiveSubscription();
102
+ const { currency } = useCurrencyFormatter();
103
+ const { summary, isLoading: isCreditsLoading } = useIaCredits();
104
+ const { isRequired: isBillingDataRequired } = useRequiredBillingData();
105
+ const hasArmedRef = useRef(false);
106
+
107
+ /* Modais bloqueantes que NÃO passam pelo `useModalManager` — cada um controla a própria abertura
108
+ * com estado local, então `activeModal` não os enxerga e a oferta subiria por cima:
109
+ * - `RequiredBillingDataModal` (lib), aberto por `useRequiredBillingData().isRequired`;
110
+ * - `CompleteProfileGuard` (app de contas), aberto quando falta nome ou sobrenome.
111
+ * Checar a condição de origem, e não o modal em si, é o que funciona para os três apps sem que
112
+ * cada um precise lembrar de passar `suppressWhen`. */
113
+ const isProfileIncomplete = user != null && (!user.name || !user.last_name);
114
+
115
+ const subscription = subscriptionData?.data?.[0] ?? null;
116
+ const accountId = user?.id_account;
117
+ const canManageBilling = user?.profile === 'owner' || user?.profile === 'admin';
118
+ const currentAiCredits =
119
+ subscription?.items?.find((item) => item.id_addon === ADDON_IDS.AI)?.quantity ?? 0;
120
+ const isFirstPurchase = !hasActivePaidSubscription(subscription) && !subscription?.charged;
121
+
122
+ const passesClientCutoff =
123
+ isDefaultWl &&
124
+ currency === 'brl' &&
125
+ canManageBilling &&
126
+ accountId != null &&
127
+ isFirstPurchase &&
128
+ currentAiCredits < AI_CREDITS_FIRST_PURCHASE_OFFER.credits;
129
+
130
+ // Só consulta o servidor depois do corte barato: quem não passa aqui nunca veria a oferta, e a
131
+ // consulta custa duas chamadas no gapps-r3-api.
132
+ const { data: eligibilityData } = useAiCreditsOfferEligibility({ enabled: passesClientCutoff });
133
+ const cardsQuery = useCards({ enabled: passesClientCutoff });
134
+
135
+ useEffect(() => {
136
+ if (hasArmedRef.current) return;
137
+ if (suppressWhen || activeModal) return;
138
+ if (isBillingDataRequired || isProfileIncomplete) return;
139
+ if (!passesClientCutoff || isCreditsLoading) return;
140
+ // Fail closed: sem resposta do servidor (carregando, erro, ou reason preenchido), não abre.
141
+ if (!eligibilityData || !eligibilityData.eligible || accountId == null) return;
142
+ // Fail closed: sem confirmação de que a lista de cartões carregou vazia, não abre — o backend
143
+ // já valida "sem cartão" na elegibilidade, isto é a segunda camada do lado do cliente.
144
+ if (!cardsQuery.isSuccess || cardsQuery.data.data.length > 0) return;
145
+
146
+ const candidateStage = resolveCandidateStage(summary);
147
+ const cookie = readOfferStageCookie(accountId);
148
+
149
+ const alreadyShownThisSession = cookie.lastSessionIat === eligibilityData.sessionIssuedAt;
150
+ if (alreadyShownThisSession) return;
151
+
152
+ const isRepeatableStage =
153
+ candidateStage === AiCreditsOfferStage.CAMPAIGN_SHOWN ||
154
+ candidateStage === AiCreditsOfferStage.ZERO_SHOWN;
155
+
156
+ const isRepeating = isRepeatableStage && candidateStage === cookie.stage;
157
+
158
+ const shouldOpen = candidateStage > cookie.stage || isRepeating;
159
+ if (!shouldOpen) return;
160
+
161
+ // Subir de estágio zera o contador: o teto limita a insistência DENTRO de um estágio, não a
162
+ // escada inteira. Sem isso, uma conta que já gastou o teto na campanha nunca veria a oferta no
163
+ // momento em que ela mais importa (crédito no fim).
164
+ const repeatCount = isRepeating ? cookie.repeatCount + 1 : 0;
165
+ if (isRepeating && cookie.repeatCount >= AI_CREDITS_OFFER_MAX_REPEATS) return;
166
+
167
+ // Arma uma única vez por montagem: sem isso, qualquer re-render limparia o timer pendente.
168
+ hasArmedRef.current = true;
169
+
170
+ const timer = setTimeout(() => {
171
+ // O estágio é gravado junto com a abertura (e não antes): fechar a aba durante o delay não
172
+ // pode queimar a apresentação.
173
+ writeOfferStageCookie(
174
+ accountId,
175
+ { stage: candidateStage, lastSessionIat: eligibilityData.sessionIssuedAt, repeatCount },
176
+ whitelabel?.domain
177
+ );
178
+ openModal();
179
+ }, OPEN_DELAY_MS);
180
+
181
+ return () => clearTimeout(timer);
182
+ }, [
183
+ accountId,
184
+ activeModal,
185
+ cardsQuery.data,
186
+ cardsQuery.isSuccess,
187
+ eligibilityData,
188
+ isBillingDataRequired,
189
+ isCreditsLoading,
190
+ isProfileIncomplete,
191
+ openModal,
192
+ passesClientCutoff,
193
+ suppressWhen,
194
+ summary,
195
+ whitelabel?.domain,
196
+ ]);
197
+
198
+ return null;
199
+ }
@@ -0,0 +1,401 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+ import { useStripe } from '@stripe/react-stripe-js';
5
+ import { IconCheck, IconX } from '@tabler/icons-react';
6
+ import { useLocale, useTranslations } from 'next-intl';
7
+ import { toast } from 'sonner';
8
+ import { Button } from '../../../ui/buttons/Button';
9
+ import { Dialog, DialogContent, DialogTitle } from '../../../ui/overlay/Dialog';
10
+ import { Toast } from '../../../ui/feedback/Toast';
11
+ import { useAuth } from '../../../../providers/auth.provider';
12
+ import { useWhitelabel, useWhitelabelUrls } from '../../../../providers/whitelabel.provider';
13
+ import { useActiveSubscription } from '../../../../modules/subscriptions/hooks/find-active-subscription.hook';
14
+ import { useCards } from '../../../../modules/cards/hooks/cards.hook';
15
+ import { useCurrencyFormatter } from '../../../../modules/accounts/hooks/use-currency-formatter.hook';
16
+ import { confirmPaymentIfRequired } from '../../../../modules/subscriptions/utils/confirm-payment-if-required';
17
+ import {
18
+ AI_CREDITS_FIRST_PURCHASE_OFFER,
19
+ AiCreditsOfferStage,
20
+ PAYMENT_METHOD_CARD,
21
+ } from '../../../../modules/ia-credits/constants/ai-credits-offer.constants';
22
+ import { useAiCreditsOfferEligibility } from '../../../../modules/ia-credits/hooks/use-offer-eligibility.hook';
23
+ import { usePurchaseIaCredits } from '../../../../modules/ia-credits/hooks/purchase-ia-credits.hook';
24
+ import type { PurchaseIaCreditsResult } from '../../../../modules/ia-credits/types/ai-credits-offer.type';
25
+ import { readOfferStageCookie, writeOfferStageCookie } from '../../../../modules/ia-credits/utils/offer-stage-cookie';
26
+ import { useAiCreditsOfferModal } from '../../../../store/useAiCreditsOfferModal';
27
+ import { useModalManager } from '../../../../store/useModalManager';
28
+ import { LoseOfferDialog } from './components/LoseOfferDialog';
29
+ import { OfferIllustration } from './components/OfferIllustration';
30
+ import { OfferPaymentField } from './components/OfferPaymentField';
31
+
32
+ /**
33
+ * Oferta de primeira compra de créditos de IA — a tela que a base gratuita vê quando o
34
+ * `AiCreditsOfferGate` decide que é a vez dela.
35
+ *
36
+ * Duas colunas, como a referência de produto: à esquerda a oferta e o cartão (via
37
+ * `OfferPaymentField`, que cadastra pelo `addCardModal` padrão da plataforma); à direita o
38
+ * gradiente da tela de login com a ilustração do editor.
39
+ *
40
+ * Fechar não fecha: `X`, `Esc`, clique fora e "Agora não" caem todos no `LoseOfferDialog`, que
41
+ * pergunta se a pessoa quer mesmo perder o desconto.
42
+ *
43
+ * ⚠️ PREÇO: o card manda só o SLUG da oferta (`AI_CREDITS_FIRST_PURCHASE_OFFER.slug`) — quem
44
+ * resolve pacote, preço e elegibilidade é o backend (Asana ID-1217333331953496). O "de/por" desta
45
+ * tela usa `AI_CREDITS_FIRST_PURCHASE_OFFER.price` (fixo, parte do contrato da oferta) contra
46
+ * `anchorCents` do `useAiCreditsOfferEligibility` (derivado do preço real do addon no plano — nunca
47
+ * hardcoded).
48
+ *
49
+ * ANIMAÇÃO: CSS puro, sem `motion/react` (a lib não depende dela). As classes `gp-offer-rise`,
50
+ * `gp-offer-window-in`, `gp-offer-card-in` e `gp-offer-icon-pulse` vêm do `globals.css` de cada app
51
+ * consumidor — bloco documentado abaixo, precisa estar presente nos 3 apps (gpages/gapps/editor):
52
+ *
53
+ * ```css
54
+ * @keyframes gp-offer-rise {
55
+ * from { opacity: 0; translate: 0 10px; }
56
+ * to { opacity: 1; translate: 0 0; }
57
+ * }
58
+ * .gp-offer-rise { animation: gp-offer-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
59
+ *
60
+ * @keyframes gp-offer-window-in {
61
+ * from { opacity: 0; translate: 0 18px; scale: 0.985; }
62
+ * to { opacity: 1; translate: 0 0; scale: 1; }
63
+ * }
64
+ * .gp-offer-window-in { animation: gp-offer-window-in 550ms cubic-bezier(0.22, 1, 0.36, 1) 50ms both; }
65
+ *
66
+ * @keyframes gp-offer-card-in {
67
+ * from { opacity: 0; translate: 0 26px; scale: 0.97; }
68
+ * to { opacity: 1; translate: 0 0; scale: 1; }
69
+ * }
70
+ * .gp-offer-card-in { animation: gp-offer-card-in 520ms cubic-bezier(0.34, 1.56, 0.64, 1) 340ms both; }
71
+ *
72
+ * @keyframes gp-offer-icon-pulse {
73
+ * 0%, 100% { scale: 1; }
74
+ * 50% { scale: 1.06; }
75
+ * }
76
+ * .gp-offer-icon-pulse { animation: gp-offer-icon-pulse 2.6s ease-in-out 1.6s infinite; }
77
+ *
78
+ * @media (prefers-reduced-motion: reduce) {
79
+ * .gp-offer-rise, .gp-offer-window-in, .gp-offer-card-in, .gp-offer-icon-pulse { animation: none; }
80
+ * }
81
+ * ```
82
+ *
83
+ * `translate`/`scale` (propriedades CSS independentes, não `transform`) pra conviver sem conflito
84
+ * com utilitários de posicionamento do Tailwind (mesmo problema que já forçou o `.gp-offer-modal`
85
+ * de entrada/saída a sair de `@layer` — ver `AiCreditsOfferGate`/`globals.css`).
86
+ */
87
+ export default function AiCreditsOfferModal() {
88
+ const translate = useTranslations();
89
+ const locale = useLocale();
90
+ const stripe = useStripe();
91
+ const { open, closeModal } = useAiCreditsOfferModal();
92
+ const { openModal } = useModalManager();
93
+ const { user } = useAuth();
94
+ const { whitelabel } = useWhitelabel();
95
+ const { accountsUrl } = useWhitelabelUrls();
96
+ const { formatCurrencyNumber } = useCurrencyFormatter();
97
+
98
+ const [confirmingExit, setConfirmingExit] = useState(false);
99
+ const [pickedCardId, setPickedCardId] = useState('');
100
+
101
+ const { data: cardsData } = useCards();
102
+ const { data: subscriptionData } = useActiveSubscription();
103
+ const eligibility = useAiCreditsOfferEligibility();
104
+ const purchaseMutation = usePurchaseIaCredits();
105
+
106
+ const subscription = subscriptionData?.data?.[0] ?? null;
107
+ const subscriptionId = subscription?.id;
108
+ const accountId = user?.id_account;
109
+
110
+ const defaultCardId =
111
+ cardsData?.data?.find((card) => card.is_default)?.id?.toString() ??
112
+ cardsData?.data?.[0]?.id?.toString() ??
113
+ '';
114
+
115
+ // Os cartões chegam depois da abertura (query no client). Derivar — em vez de sincronizar num
116
+ // efeito — faz o padrão da conta já entrar selecionado no render em que a lista chega, e a
117
+ // escolha explícita da pessoa continua mandando.
118
+ const pickedCardExists = cardsData?.data?.some((card) => card.id.toString() === pickedCardId);
119
+ const selectedCardId = pickedCardExists ? pickedCardId : defaultCardId;
120
+
121
+ const offerPrice = AI_CREDITS_FIRST_PURCHASE_OFFER.price;
122
+ const fullPrice = eligibility.data?.anchorCents != null ? eligibility.data.anchorCents / 100 : null;
123
+ const discountPercent = fullPrice != null ? Math.round((1 - offerPrice / fullPrice) * 100) : null;
124
+
125
+ const creditsLabel = AI_CREDITS_FIRST_PURCHASE_OFFER.credits.toLocaleString(locale);
126
+ const discountLabel =
127
+ discountPercent != null
128
+ ? translate('common.promo.aiCreditsOffer.discountBadge', { percent: String(discountPercent) })
129
+ : '';
130
+
131
+ const hasCard = Boolean(selectedCardId);
132
+ const isSubmitting = purchaseMutation.isPending;
133
+
134
+ /** Fecha de verdade — só o `LoseOfferDialog` e a compra concluída chegam aqui. */
135
+ const dismiss = useCallback(() => {
136
+ setConfirmingExit(false);
137
+ closeModal();
138
+ }, [closeModal]);
139
+
140
+ /** Toda tentativa de sair passa por aqui — X, Esc, clique fora e "Agora não". */
141
+ const requestClose = useCallback(() => setConfirmingExit(true), []);
142
+
143
+ /**
144
+ * Cobra o cartão informado e credita — sem tocar na recorrência do plano. Chamado tanto pelo CTA
145
+ * com cartão já selecionado quanto (encadeado, sem segundo clique) pelo `onCardAdded` do
146
+ * `addCardModal` quando a pessoa ainda não tinha cartão.
147
+ */
148
+ const submitPurchase = useCallback(
149
+ async (cardId: string) => {
150
+ // Sem assinatura não dá pra cobrar aqui — o CTA vira a porta do checkout do accounts em vez
151
+ // de virar um botão morto.
152
+ if (subscriptionId == null) {
153
+ closeModal();
154
+ window.location.assign(`${accountsUrl}/subscriptions?tab=plans`);
155
+ return;
156
+ }
157
+
158
+ const input = {
159
+ subscriptionId,
160
+ offer: AI_CREDITS_FIRST_PURCHASE_OFFER.slug,
161
+ paymentMethod: PAYMENT_METHOD_CARD,
162
+ idCard: cardId,
163
+ };
164
+
165
+ try {
166
+ let result: PurchaseIaCreditsResult = await purchaseMutation.mutateAsync(input);
167
+
168
+ // 3DS: confirma o desafio e RE-POSTA o MESMO input — a chave de idempotência do backend
169
+ // devolve a mesma cobrança já paga, então o segundo POST fecha com status 1.
170
+ if (result.status === 2) {
171
+ await confirmPaymentIfRequired(stripe, result, translate('common.payment.authFailed'));
172
+ result = await purchaseMutation.mutateAsync(input);
173
+ }
174
+
175
+ // O `message` do status 0 é código de máquina do backend (`offer_consumed`,
176
+ // `offer_card_only`…), não copy — a tela mostra o erro genérico do catálogo.
177
+ if (result.status !== 1) {
178
+ toast.custom((toastId) => (
179
+ <Toast
180
+ variant="error"
181
+ message={translate('common.promo.aiCreditsOffer.errorToast')}
182
+ toastId={toastId}
183
+ />
184
+ ));
185
+ return;
186
+ }
187
+
188
+ if (accountId != null) {
189
+ const cookie = readOfferStageCookie(accountId);
190
+ writeOfferStageCookie(
191
+ accountId,
192
+ {
193
+ stage: AiCreditsOfferStage.CONVERTED,
194
+ lastSessionIat: eligibility.data?.sessionIssuedAt ?? cookie.lastSessionIat,
195
+ repeatCount: cookie.repeatCount,
196
+ },
197
+ whitelabel?.domain
198
+ );
199
+ }
200
+
201
+ toast.custom((toastId) => (
202
+ <Toast
203
+ variant="success"
204
+ message={translate('common.promo.aiCreditsOffer.successToast', { credits: creditsLabel })}
205
+ toastId={toastId}
206
+ />
207
+ ));
208
+ dismiss();
209
+ } catch {
210
+ // Erro técnico (rede, HTTP não-JSON, resposta fora do `PurchaseIaCreditsResultSchema`) —
211
+ // `error.message` aqui é texto de máquina (inclusive o dump do ZodError), nunca copy. Mesma
212
+ // decisão do `status !== 1` acima: a tela mostra a mensagem genérica do catálogo.
213
+ toast.custom((toastId) => (
214
+ <Toast
215
+ variant="error"
216
+ message={translate('common.promo.aiCreditsOffer.errorToast')}
217
+ toastId={toastId}
218
+ />
219
+ ));
220
+ }
221
+ },
222
+ [
223
+ accountId,
224
+ accountsUrl,
225
+ closeModal,
226
+ creditsLabel,
227
+ dismiss,
228
+ eligibility.data?.sessionIssuedAt,
229
+ purchaseMutation,
230
+ stripe,
231
+ subscriptionId,
232
+ translate,
233
+ whitelabel?.domain,
234
+ ]
235
+ );
236
+
237
+ const handleOpenAddCard = useCallback(() => {
238
+ openModal('addCardModal', {
239
+ onCardAdded: (cardId: string) => {
240
+ setPickedCardId(cardId);
241
+ void submitPurchase(cardId);
242
+ },
243
+ });
244
+ }, [openModal, submitPurchase]);
245
+
246
+ const handleConfirm = useCallback(async () => {
247
+ // 1º passo do fluxo: sem cartão, o CTA é o próprio cadastro — a compra encadeia sozinha
248
+ // assim que o cartão é criado (ver `handleOpenAddCard`).
249
+ if (!hasCard) {
250
+ handleOpenAddCard();
251
+ return;
252
+ }
253
+ await submitPurchase(selectedCardId);
254
+ }, [handleOpenAddCard, hasCard, selectedCardId, submitPurchase]);
255
+
256
+ const bullets = [
257
+ translate('common.promo.aiCreditsOffer.bullets.first'),
258
+ translate('common.promo.aiCreditsOffer.bullets.second'),
259
+ translate('common.promo.aiCreditsOffer.bullets.third'),
260
+ ];
261
+
262
+ return (
263
+ <>
264
+ <Dialog open={open} onOpenChange={() => {}}>
265
+ <DialogContent
266
+ showCloseButton={false}
267
+ onOpenAutoFocus={(event) => event.preventDefault()}
268
+ onEscapeKeyDown={(event) => {
269
+ event.preventDefault();
270
+ requestClose();
271
+ }}
272
+ onPointerDownOutside={(event) => {
273
+ event.preventDefault();
274
+ requestClose();
275
+ }}
276
+ onInteractOutside={(event) => event.preventDefault()}
277
+ overlayClassName="duration-300"
278
+ className={[
279
+ // mobile: folha de tela cheia (mesma geometria do BuyCreditsModal)
280
+ 'flex flex-col gap-0 overflow-hidden rounded-t-2xl rounded-b-none border-0 p-0 shadow-elevated',
281
+ 'top-0 right-0 bottom-0 left-0 h-dvh max-w-full translate-x-0 translate-y-0 sm:max-w-full',
282
+ // desktop (≥ md): cartão de duas colunas, centralizado
283
+ 'md:top-[50%] md:right-auto md:bottom-auto md:left-[50%] md:h-auto md:max-h-[92vh] md:w-[900px]! md:max-w-[900px]! md:translate-x-[-50%] md:translate-y-[-50%] md:rounded-xl md:border',
284
+ // entrada de baixo pra cima: a classe (e não utilidade `animate-[...]`) porque o
285
+ // `DialogContent` já traz `animate-in` e o merge do Tailwind não funde as duas — ver
286
+ // `.gp-offer-modal` no globals.css.
287
+ 'gp-offer-modal',
288
+ ].join(' ')}
289
+ >
290
+ <div className="gm-scroll flex h-full flex-col overflow-y-auto md:h-auto md:min-h-[600px] md:flex-row md:items-stretch md:overflow-visible">
291
+ {/* Ilustração: topo no mobile, coluna da direita no desktop (`md:order-2`). */}
292
+ <div className="relative h-[180px] w-full shrink-0 md:order-2 md:h-auto md:w-1/2">
293
+ <OfferIllustration credits={AI_CREDITS_FIRST_PURCHASE_OFFER.credits} locale={locale} />
294
+
295
+ <button
296
+ type="button"
297
+ onClick={requestClose}
298
+ aria-label={translate('common.promo.aiCreditsOffer.close')}
299
+ className="absolute top-4 right-4 z-10 flex size-8 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-950"
300
+ >
301
+ <IconX className="size-[18px]" />
302
+ </button>
303
+ </div>
304
+
305
+ {/* Coluna da oferta */}
306
+ <div className="flex flex-1 flex-col gap-6 p-6 md:order-1 md:w-1/2 md:shrink-0 md:gap-7 md:p-10">
307
+ <span
308
+ className="gp-offer-rise paragraph-xsmall-semibold w-fit rounded-full bg-cyan-50 px-2.5 py-1 text-cyan-700"
309
+ style={{ animationDelay: '160ms' }}
310
+ >
311
+ {translate('common.promo.aiCreditsOffer.eyebrow')}
312
+ </span>
313
+
314
+ {/* 26px (2px acima do `title-large`) e Outfit 700: a oferta bate mais forte que um
315
+ título de modal comum. A quebra vem do catálogo, pela tag `<break>`, então cada
316
+ idioma escolhe onde partir a frase. */}
317
+ <div className="gp-offer-rise" style={{ animationDelay: '220ms' }}>
318
+ <DialogTitle className="title-large-semibold text-[26px] leading-8 font-bold tracking-[-0.4px] text-zinc-950">
319
+ {translate.rich('common.promo.aiCreditsOffer.title', {
320
+ credits: creditsLabel,
321
+ discount: discountLabel,
322
+ break: () => <br />,
323
+ })}
324
+ </DialogTitle>
325
+ </div>
326
+
327
+ <ul className="flex flex-col gap-3.5">
328
+ {bullets.map((bullet, index) => (
329
+ <li
330
+ key={bullet}
331
+ className="gp-offer-rise flex items-start gap-2.5"
332
+ style={{ animationDelay: `${300 + index * 70}ms` }}
333
+ >
334
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-cyan-50">
335
+ <IconCheck size={13} stroke={3} className="text-cyan-700" />
336
+ </span>
337
+ <span className="paragraph-small-medium text-zinc-600">{bullet}</span>
338
+ </li>
339
+ ))}
340
+ </ul>
341
+
342
+ {/* Preço de tabela em cima, pequeno e riscado; o de campanha embaixo, na mesma
343
+ tipografia do título (Outfit 700) pra os dois números não competirem entre si. Sem
344
+ âncora resolvida (elegibilidade ainda carregando) some a linha do preço cheio em
345
+ vez de mostrar um "de" vazio. */}
346
+ <div className="gp-offer-rise flex flex-col gap-0.5" style={{ animationDelay: '520ms' }}>
347
+ {fullPrice != null && (
348
+ <span className="paragraph-small-medium text-zinc-400 line-through">
349
+ {formatCurrencyNumber(fullPrice)}
350
+ </span>
351
+ )}
352
+ <div className="flex items-center gap-2.5">
353
+ <span className="title-large-semibold font-bold tracking-[-0.4px] text-zinc-950">
354
+ {formatCurrencyNumber(offerPrice)}
355
+ </span>
356
+ {discountLabel && (
357
+ <span className="paragraph-xsmall-semibold rounded-md bg-green-50 px-2 py-1 text-green-600">
358
+ {discountLabel}
359
+ </span>
360
+ )}
361
+ </div>
362
+ </div>
363
+
364
+ <div className="gp-offer-rise mt-2" style={{ animationDelay: '580ms' }}>
365
+ <OfferPaymentField cardId={selectedCardId} onCardAdded={setPickedCardId} />
366
+ </div>
367
+
368
+ <div
369
+ className="gp-offer-rise mt-auto flex flex-col gap-2"
370
+ style={{ animationDelay: '640ms' }}
371
+ >
372
+ <Button className="w-full" loading={isSubmitting} onClick={handleConfirm}>
373
+ {hasCard
374
+ ? translate('common.promo.aiCreditsOffer.cta', { discount: discountLabel })
375
+ : translate('common.promo.aiCreditsOffer.ctaAddCard')}
376
+ </Button>
377
+ <Button
378
+ variant="secondary"
379
+ className="h-10! w-full"
380
+ disabled={isSubmitting}
381
+ onClick={requestClose}
382
+ >
383
+ {translate('common.promo.aiCreditsOffer.later')}
384
+ </Button>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ </DialogContent>
389
+ </Dialog>
390
+
391
+ <LoseOfferDialog
392
+ open={confirmingExit}
393
+ discountLabel={discountLabel}
394
+ creditsLabel={creditsLabel}
395
+ fullPriceLabel={fullPrice != null ? formatCurrencyNumber(fullPrice) : ''}
396
+ onKeep={() => setConfirmingExit(false)}
397
+ onLose={dismiss}
398
+ />
399
+ </>
400
+ );
401
+ }