@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
@@ -22,19 +22,28 @@ import { useWhitelabelUrls, useExternalContracting } from '../../providers/white
22
22
  import { useModalManager } from '../../store/useModalManager';
23
23
  import { formatShortDate } from '../../infra/utils/date';
24
24
 
25
- /* Os 9 recursos do card agrupados nos 3 grupos que o usuário reconhece na plataforma.
26
- * Um ícone por grupo; o texto mora no catálogo. */
27
- const RESOURCE_GROUPS: { key: string; icon: ComponentType<IconProps> }[] = [
28
- { key: 'pages', icon: IconFile },
29
- { key: 'projects', icon: IconFolder },
30
- { key: 'domains', icon: IconWorld },
31
- ];
25
+ const RESOURCE_GROUPS = [
26
+ {
27
+ icon: IconFile,
28
+ titleKey: 'common.subscription.frozen.modal.resources.pages.title',
29
+ descriptionKey: 'common.subscription.frozen.modal.resources.pages.description',
30
+ },
31
+ {
32
+ icon: IconFolder,
33
+ titleKey: 'common.subscription.frozen.modal.resources.projects.title',
34
+ descriptionKey: 'common.subscription.frozen.modal.resources.projects.description',
35
+ },
36
+ {
37
+ icon: IconWorld,
38
+ titleKey: 'common.subscription.frozen.modal.resources.domains.title',
39
+ descriptionKey: 'common.subscription.frozen.modal.resources.domains.description',
40
+ },
41
+ ] as const satisfies readonly {
42
+ icon: ComponentType<IconProps>;
43
+ titleKey: string;
44
+ descriptionKey: string;
45
+ }[];
32
46
 
33
- /* `freezeDate` no data = ainda VAI congelar (véspera); sem ele = já congelado.
34
- *
35
- * A LISTA só aparece quando o usuário PEDIU por ela, clicando em "Detalhes" no banner
36
- * (`showResources`). Na modal que abre sozinha ao entrar numa conta bloqueada fica só o
37
- * recado curto e o botão. */
38
47
  interface FrozenModalData {
39
48
  freezeDate?: Date | string | null;
40
49
  showResources?: boolean;
@@ -58,7 +67,6 @@ export default function AccountFrozenModal() {
58
67
  })
59
68
  : translate('common.subscription.frozen.modal.title');
60
69
 
61
- /* Sem a lista o texto tem que fechar sozinho; com a lista ele a INTRODUZ (termina em ":"). */
62
70
  const description = isWarning
63
71
  ? translate(
64
72
  showResources
@@ -80,9 +88,6 @@ export default function AccountFrozenModal() {
80
88
  <Dialog open={isOpen} onOpenChange={() => closeModal()}>
81
89
  <DialogContent
82
90
  showCloseButton
83
- /* Sem isto o Radix move o foco para o primeiro focável ao abrir — que aqui é o
84
- * gatilho do tooltip — e o tooltip abre em FOCO, não só em hover: a modal
85
- * aparecia com um balão já aberto. O foco segue preso no dialog. */
86
91
  onOpenAutoFocus={(event) => event.preventDefault()}
87
92
  className="flex flex-col p-0! gap-0! max-w-[calc(100%-2rem)]! w-full sm:max-w-[400px]! lg:max-w-[420px]! rounded-lg border"
88
93
  >
@@ -102,29 +107,22 @@ export default function AccountFrozenModal() {
102
107
 
103
108
  {showResources && (
104
109
  <>
105
- {/* Um bloco só, grupos separados por linha. A descrição de cada grupo
106
- * vive no tooltip do ícone de info à direita: na modal ela empilhava
107
- * três parágrafos e virava um paredão. */}
108
110
  <div className="bg-white border border-zinc-100 rounded-lg">
109
- {RESOURCE_GROUPS.map(({ key, icon: Icon }, index) => (
110
- <div key={key}>
111
+ {RESOURCE_GROUPS.map(({ titleKey, descriptionKey, icon: Icon }, index) => (
112
+ <div key={titleKey}>
111
113
  {index > 0 && <div className="h-px bg-zinc-100" />}
112
114
  <div className="flex items-center justify-between gap-3 p-4">
113
115
  <div className="flex items-center gap-3">
114
116
  <Icon size={20} className="text-zinc-950 shrink-0" />
115
117
  <span className="paragraph-small-semibold text-zinc-950">
116
- {translate(
117
- `common.subscription.frozen.modal.resources.${key}.title`,
118
- )}
118
+ {translate(titleKey)}
119
119
  </span>
120
120
  </div>
121
121
  <Tooltip>
122
122
  <TooltipTrigger asChild>
123
123
  <button
124
124
  type="button"
125
- aria-label={translate(
126
- `common.subscription.frozen.modal.resources.${key}.title`,
127
- )}
125
+ aria-label={translate(titleKey)}
128
126
  className="flex items-center justify-center shrink-0 cursor-pointer"
129
127
  >
130
128
  <IconInfoCircle className="size-4 text-zinc-400" />
@@ -132,16 +130,9 @@ export default function AccountFrozenModal() {
132
130
  </TooltipTrigger>
133
131
  <TooltipContent
134
132
  side="top"
135
- /* Acima do dialog: o globals.css do gapps-app
136
- * sobe o [data-slot='dialog-content'] para
137
- * z-index 1011 !important (o componente
138
- * declara 1001), então o z-50 padrão do
139
- * tooltip ficava atrás. */
140
133
  className="z-[1100] max-w-[240px]"
141
134
  >
142
- {translate(
143
- `common.subscription.frozen.modal.resources.${key}.description`,
144
- )}
135
+ {translate(descriptionKey)}
145
136
  </TooltipContent>
146
137
  </Tooltip>
147
138
  </div>
@@ -149,8 +140,6 @@ export default function AccountFrozenModal() {
149
140
  ))}
150
141
  </div>
151
142
 
152
- {/* Bloco de informação padrão da plataforma. Ciano fixo: o congelamento
153
- * é exclusivo da wl 1, então não existe o caso whitelabel/zinc aqui. */}
154
143
  <div className="flex items-center gap-3 p-4 rounded-lg bg-cyan-50">
155
144
  <IconInfoCircle className="size-4 text-zinc-950 shrink-0" />
156
145
  <span className="paragraph-small-regular text-zinc-950">
@@ -0,0 +1,400 @@
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
+ },
196
+ whitelabel?.domain
197
+ );
198
+ }
199
+
200
+ toast.custom((toastId) => (
201
+ <Toast
202
+ variant="success"
203
+ message={translate('common.promo.aiCreditsOffer.successToast', { credits: creditsLabel })}
204
+ toastId={toastId}
205
+ />
206
+ ));
207
+ dismiss();
208
+ } catch {
209
+ // Erro técnico (rede, HTTP não-JSON, resposta fora do `PurchaseIaCreditsResultSchema`) —
210
+ // `error.message` aqui é texto de máquina (inclusive o dump do ZodError), nunca copy. Mesma
211
+ // decisão do `status !== 1` acima: a tela mostra a mensagem genérica do catálogo.
212
+ toast.custom((toastId) => (
213
+ <Toast
214
+ variant="error"
215
+ message={translate('common.promo.aiCreditsOffer.errorToast')}
216
+ toastId={toastId}
217
+ />
218
+ ));
219
+ }
220
+ },
221
+ [
222
+ accountId,
223
+ accountsUrl,
224
+ closeModal,
225
+ creditsLabel,
226
+ dismiss,
227
+ eligibility.data?.sessionIssuedAt,
228
+ purchaseMutation,
229
+ stripe,
230
+ subscriptionId,
231
+ translate,
232
+ whitelabel?.domain,
233
+ ]
234
+ );
235
+
236
+ const handleOpenAddCard = useCallback(() => {
237
+ openModal('addCardModal', {
238
+ onCardAdded: (cardId: string) => {
239
+ setPickedCardId(cardId);
240
+ void submitPurchase(cardId);
241
+ },
242
+ });
243
+ }, [openModal, submitPurchase]);
244
+
245
+ const handleConfirm = useCallback(async () => {
246
+ // 1º passo do fluxo: sem cartão, o CTA é o próprio cadastro — a compra encadeia sozinha
247
+ // assim que o cartão é criado (ver `handleOpenAddCard`).
248
+ if (!hasCard) {
249
+ handleOpenAddCard();
250
+ return;
251
+ }
252
+ await submitPurchase(selectedCardId);
253
+ }, [handleOpenAddCard, hasCard, selectedCardId, submitPurchase]);
254
+
255
+ const bullets = [
256
+ translate('common.promo.aiCreditsOffer.bullets.first'),
257
+ translate('common.promo.aiCreditsOffer.bullets.second'),
258
+ translate('common.promo.aiCreditsOffer.bullets.third'),
259
+ ];
260
+
261
+ return (
262
+ <>
263
+ <Dialog open={open} onOpenChange={() => {}}>
264
+ <DialogContent
265
+ showCloseButton={false}
266
+ onOpenAutoFocus={(event) => event.preventDefault()}
267
+ onEscapeKeyDown={(event) => {
268
+ event.preventDefault();
269
+ requestClose();
270
+ }}
271
+ onPointerDownOutside={(event) => {
272
+ event.preventDefault();
273
+ requestClose();
274
+ }}
275
+ onInteractOutside={(event) => event.preventDefault()}
276
+ overlayClassName="duration-300"
277
+ className={[
278
+ // mobile: folha de tela cheia (mesma geometria do BuyCreditsModal)
279
+ 'flex flex-col gap-0 overflow-hidden rounded-t-2xl rounded-b-none border-0 p-0 shadow-elevated',
280
+ 'top-0 right-0 bottom-0 left-0 h-dvh max-w-full translate-x-0 translate-y-0 sm:max-w-full',
281
+ // desktop (≥ md): cartão de duas colunas, centralizado
282
+ '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',
283
+ // entrada de baixo pra cima: a classe (e não utilidade `animate-[...]`) porque o
284
+ // `DialogContent` já traz `animate-in` e o merge do Tailwind não funde as duas — ver
285
+ // `.gp-offer-modal` no globals.css.
286
+ 'gp-offer-modal',
287
+ ].join(' ')}
288
+ >
289
+ <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">
290
+ {/* Ilustração: topo no mobile, coluna da direita no desktop (`md:order-2`). */}
291
+ <div className="relative h-[180px] w-full shrink-0 md:order-2 md:h-auto md:w-1/2">
292
+ <OfferIllustration credits={AI_CREDITS_FIRST_PURCHASE_OFFER.credits} locale={locale} />
293
+
294
+ <button
295
+ type="button"
296
+ onClick={requestClose}
297
+ aria-label={translate('common.promo.aiCreditsOffer.close')}
298
+ 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"
299
+ >
300
+ <IconX className="size-[18px]" />
301
+ </button>
302
+ </div>
303
+
304
+ {/* Coluna da oferta */}
305
+ <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">
306
+ <span
307
+ className="gp-offer-rise paragraph-xsmall-semibold w-fit rounded-full bg-cyan-50 px-2.5 py-1 text-cyan-700"
308
+ style={{ animationDelay: '160ms' }}
309
+ >
310
+ {translate('common.promo.aiCreditsOffer.eyebrow')}
311
+ </span>
312
+
313
+ {/* 26px (2px acima do `title-large`) e Outfit 700: a oferta bate mais forte que um
314
+ título de modal comum. A quebra vem do catálogo, pela tag `<break>`, então cada
315
+ idioma escolhe onde partir a frase. */}
316
+ <div className="gp-offer-rise" style={{ animationDelay: '220ms' }}>
317
+ <DialogTitle className="title-large-semibold text-[26px] leading-8 font-bold tracking-[-0.4px] text-zinc-950">
318
+ {translate.rich('common.promo.aiCreditsOffer.title', {
319
+ credits: creditsLabel,
320
+ discount: discountLabel,
321
+ break: () => <br />,
322
+ })}
323
+ </DialogTitle>
324
+ </div>
325
+
326
+ <ul className="flex flex-col gap-3.5">
327
+ {bullets.map((bullet, index) => (
328
+ <li
329
+ key={bullet}
330
+ className="gp-offer-rise flex items-start gap-2.5"
331
+ style={{ animationDelay: `${300 + index * 70}ms` }}
332
+ >
333
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-cyan-50">
334
+ <IconCheck size={13} stroke={3} className="text-cyan-700" />
335
+ </span>
336
+ <span className="paragraph-small-medium text-zinc-600">{bullet}</span>
337
+ </li>
338
+ ))}
339
+ </ul>
340
+
341
+ {/* Preço de tabela em cima, pequeno e riscado; o de campanha embaixo, na mesma
342
+ tipografia do título (Outfit 700) pra os dois números não competirem entre si. Sem
343
+ âncora resolvida (elegibilidade ainda carregando) some a linha do preço cheio em
344
+ vez de mostrar um "de" vazio. */}
345
+ <div className="gp-offer-rise flex flex-col gap-0.5" style={{ animationDelay: '520ms' }}>
346
+ {fullPrice != null && (
347
+ <span className="paragraph-small-medium text-zinc-400 line-through">
348
+ {formatCurrencyNumber(fullPrice)}
349
+ </span>
350
+ )}
351
+ <div className="flex items-center gap-2.5">
352
+ <span className="title-large-semibold font-bold tracking-[-0.4px] text-zinc-950">
353
+ {formatCurrencyNumber(offerPrice)}
354
+ </span>
355
+ {discountLabel && (
356
+ <span className="paragraph-xsmall-semibold rounded-md bg-green-50 px-2 py-1 text-green-600">
357
+ {discountLabel}
358
+ </span>
359
+ )}
360
+ </div>
361
+ </div>
362
+
363
+ <div className="gp-offer-rise mt-2" style={{ animationDelay: '580ms' }}>
364
+ <OfferPaymentField cardId={selectedCardId} onCardAdded={setPickedCardId} />
365
+ </div>
366
+
367
+ <div
368
+ className="gp-offer-rise mt-auto flex flex-col gap-2"
369
+ style={{ animationDelay: '640ms' }}
370
+ >
371
+ <Button className="w-full" loading={isSubmitting} onClick={handleConfirm}>
372
+ {hasCard
373
+ ? translate('common.promo.aiCreditsOffer.cta', { discount: discountLabel })
374
+ : translate('common.promo.aiCreditsOffer.ctaAddCard')}
375
+ </Button>
376
+ <Button
377
+ variant="secondary"
378
+ className="h-10! w-full"
379
+ disabled={isSubmitting}
380
+ onClick={requestClose}
381
+ >
382
+ {translate('common.promo.aiCreditsOffer.later')}
383
+ </Button>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ </DialogContent>
388
+ </Dialog>
389
+
390
+ <LoseOfferDialog
391
+ open={confirmingExit}
392
+ discountLabel={discountLabel}
393
+ creditsLabel={creditsLabel}
394
+ fullPriceLabel={fullPrice != null ? formatCurrencyNumber(fullPrice) : ''}
395
+ onKeep={() => setConfirmingExit(false)}
396
+ onLose={dismiss}
397
+ />
398
+ </>
399
+ );
400
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+ import { Button } from '../../../../ui/buttons/Button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '../../../../ui/overlay/Dialog';
12
+ import { Separator } from '../../../../ui/data-display/Separator';
13
+ import { IconAlertTriangle } from '@tabler/icons-react';
14
+
15
+ interface LoseOfferDialogProps {
16
+ open: boolean;
17
+ /** Selo do desconto ("50% OFF") — repetido aqui porque é ele que dói. */
18
+ discountLabel: string;
19
+ /** Pacote ofertado, já formatado ("1.000"). */
20
+ creditsLabel: string;
21
+ /** Preço cheio formatado: o número que volta a valer. Vazio enquanto a âncora carrega. */
22
+ fullPriceLabel: string;
23
+ /** Volta pra oferta (fecha só esta confirmação). */
24
+ onKeep: () => void;
25
+ /** Abre mão da oferta e fecha tudo. */
26
+ onLose: () => void;
27
+ }
28
+
29
+ /**
30
+ * Confirmação de saída ("quer mesmo perder essa oferta?").
31
+ *
32
+ * É um Dialog SEPARADO, empilhado por cima da oferta com os mesmos z-index do
33
+ * `InsufficientIaCreditsModal` (overlay 1002 / conteúdo 1003) — assim a oferta continua **visível
34
+ * atrás do véu**: a pessoa decide olhando exatamente para o que está prestes a perder, que é o
35
+ * ponto inteiro desta tela.
36
+ *
37
+ * O principal é VOLTAR para a oferta; perder fica como secundário, a um clique de distância. A
38
+ * saída não está escondida — o peso da decisão está na frase, não no botão.
39
+ */
40
+ export function LoseOfferDialog({
41
+ open,
42
+ discountLabel,
43
+ creditsLabel,
44
+ fullPriceLabel,
45
+ onKeep,
46
+ onLose,
47
+ }: LoseOfferDialogProps) {
48
+ const translate = useTranslations();
49
+
50
+ return (
51
+ <Dialog open={open} onOpenChange={(next) => !next && onKeep()}>
52
+ <DialogContent
53
+ showCloseButton={false}
54
+ onOpenAutoFocus={(event) => event.preventDefault()}
55
+ overlayClassName="z-[1002] bg-black/40"
56
+ className="z-[1003] flex flex-col gap-0 p-0 max-[440px]:w-[calc(100%-32px)] md:w-[420px]! lg:w-[420px]! max-w-[420px]! data-[state=open]:animate-[gp-offer-confirm-in_260ms_cubic-bezier(0.16,1,0.3,1)]"
57
+ >
58
+ <DialogHeader className="gap-3 p-5 text-left">
59
+ <div className="flex w-fit items-center justify-center rounded-lg bg-red-50 p-2.5">
60
+ <IconAlertTriangle size={20} className="text-red-500" />
61
+ </div>
62
+ <div className="flex flex-col gap-2">
63
+ <DialogTitle className="paragraph-medium-semibold text-zinc-950">
64
+ {translate('common.promo.aiCreditsOffer.exit.title', { discount: discountLabel })}
65
+ </DialogTitle>
66
+ <DialogDescription className="paragraph-small-regular text-zinc-600">
67
+ {/* Sem preço resolvido (elegibilidade ainda carregando) a frase cai na versão sem
68
+ número — prometer "volta a custar" sem dizer quanto é pior do que não dizer. */}
69
+ {fullPriceLabel
70
+ ? translate('common.promo.aiCreditsOffer.exit.description', {
71
+ credits: creditsLabel,
72
+ price: fullPriceLabel,
73
+ })
74
+ : translate('common.promo.aiCreditsOffer.exit.descriptionNoPrice', {
75
+ credits: creditsLabel,
76
+ })}
77
+ </DialogDescription>
78
+ </div>
79
+ </DialogHeader>
80
+
81
+ <Separator />
82
+
83
+ {/* Rodapé com os dois botões juntos à esquerda (`gap-2`, sem `justify-between`), o
84
+ principal primeiro. Ficar é o caminho principal; perder a oferta continua a um clique,
85
+ mas como ação secundária. */}
86
+ <div className="flex items-center gap-2 p-5">
87
+ <Button className="h-10! transition-colors" onClick={onKeep}>
88
+ {translate('common.promo.aiCreditsOffer.exit.keep')}
89
+ </Button>
90
+ <Button variant="secondary" className="h-10! transition-colors" onClick={onLose}>
91
+ {translate('common.promo.aiCreditsOffer.exit.lose')}
92
+ </Button>
93
+ </div>
94
+ </DialogContent>
95
+ </Dialog>
96
+ );
97
+ }