@greatapps/common 1.1.748 → 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.
- package/dist/components/layouts/AiCreditsOfferGate.mjs +92 -0
- package/dist/components/layouts/AiCreditsOfferGate.mjs.map +1 -0
- package/dist/components/modals/AccountFrozenModal.mjs.map +1 -1
- package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs +275 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs.map +1 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs +55 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs.map +1 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs +78 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs.map +1 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs +60 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs.map +1 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs +5 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs.map +1 -0
- package/dist/components/navigation/SubscriptionBanner.mjs.map +1 -1
- package/dist/handlers.mjs +5 -1
- package/dist/handlers.mjs.map +1 -1
- package/dist/i18n/messages/en-us.mjs +32 -0
- package/dist/i18n/messages/en-us.mjs.map +1 -1
- package/dist/i18n/messages/es-es.mjs +32 -0
- package/dist/i18n/messages/es-es.mjs.map +1 -1
- package/dist/i18n/messages/pt-br.mjs +32 -0
- package/dist/i18n/messages/pt-br.mjs.map +1 -1
- package/dist/index.mjs +71 -43
- package/dist/index.mjs.map +1 -1
- package/dist/modules/auth/utils/assert-account-not-frozen.mjs.map +1 -1
- package/dist/modules/auth/utils/get-user-context.mjs +2 -1
- package/dist/modules/auth/utils/get-user-context.mjs.map +1 -1
- package/dist/modules/cards/hooks/cards.hook.mjs +3 -2
- package/dist/modules/cards/hooks/cards.hook.mjs.map +1 -1
- package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs +26 -0
- package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs.map +1 -0
- package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs +19 -0
- package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs.map +1 -0
- package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs +33 -0
- package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs.map +1 -0
- package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs +29 -0
- package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs.map +1 -0
- package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs +18 -0
- package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs.map +1 -0
- package/dist/modules/ia-credits/services/ia-credits.service.mjs +67 -0
- package/dist/modules/ia-credits/services/ia-credits.service.mjs.map +1 -1
- package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs +34 -0
- package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs.map +1 -0
- package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs +41 -0
- package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs.map +1 -0
- package/dist/modules/subscriptions/constants/subscription.constants.mjs.map +1 -1
- package/dist/modules/subscriptions/utils/get-account-freeze-date.mjs.map +1 -1
- package/dist/modules/subscriptions/utils/get-account-freeze-state.mjs.map +1 -1
- package/dist/store/useAiCreditsOfferModal.mjs +14 -0
- package/dist/store/useAiCreditsOfferModal.mjs.map +1 -0
- package/package.json +3 -1
- package/src/components/layouts/AiCreditsOfferGate.tsx +171 -0
- package/src/components/modals/AccountFrozenModal.tsx +0 -25
- package/src/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.tsx +400 -0
- package/src/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.tsx +97 -0
- package/src/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.tsx +121 -0
- package/src/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.tsx +91 -0
- package/src/components/modals/promo/AiCreditsOfferModal/index.ts +1 -0
- package/src/components/navigation/SubscriptionBanner.tsx +0 -8
- package/src/handlers.ts +2 -0
- package/src/i18n/messages/en-us.ts +33 -0
- package/src/i18n/messages/es-es.ts +33 -0
- package/src/i18n/messages/pt-br.ts +33 -0
- package/src/index.ts +25 -0
- package/src/modules/auth/utils/assert-account-not-frozen.ts +0 -9
- package/src/modules/auth/utils/get-user-context.ts +3 -0
- package/src/modules/cards/hooks/cards.hook.ts +6 -1
- package/src/modules/ia-credits/constants/ai-credits-offer.constants.ts +33 -0
- package/src/modules/ia-credits/handlers/get-offer-eligibility.handler.ts +24 -0
- package/src/modules/ia-credits/handlers/purchase-ia-credits.handler.ts +42 -0
- package/src/modules/ia-credits/hooks/purchase-ia-credits.hook.ts +43 -0
- package/src/modules/ia-credits/hooks/use-offer-eligibility.hook.ts +28 -0
- package/src/modules/ia-credits/services/ia-credits.service.ts +92 -0
- package/src/modules/ia-credits/types/ai-credits-offer.type.ts +87 -0
- package/src/modules/ia-credits/utils/offer-stage-cookie.ts +66 -0
- package/src/modules/subscriptions/constants/subscription.constants.ts +0 -5
- package/src/modules/subscriptions/utils/get-account-freeze-date.ts +0 -13
- package/src/modules/subscriptions/utils/get-account-freeze-state.ts +0 -7
- package/src/store/useAiCreditsOfferModal.ts +24 -0
|
@@ -22,12 +22,6 @@ 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
|
-
/* Chaves LITERAIS, não `resources.${key}.title`: o catálogo dos apps consumidores tipa
|
|
28
|
-
* translate() com a união de chaves, e a template string não resolve nessa união — o TS cai
|
|
29
|
-
* na sobrecarga de 2-3 argumentos e o build do consumidor quebra (a common sozinha não
|
|
30
|
-
* acusa). */
|
|
31
25
|
const RESOURCE_GROUPS = [
|
|
32
26
|
{
|
|
33
27
|
icon: IconFile,
|
|
@@ -50,11 +44,6 @@ const RESOURCE_GROUPS = [
|
|
|
50
44
|
descriptionKey: string;
|
|
51
45
|
}[];
|
|
52
46
|
|
|
53
|
-
/* `freezeDate` no data = ainda VAI congelar (véspera); sem ele = já congelado.
|
|
54
|
-
*
|
|
55
|
-
* A LISTA só aparece quando o usuário PEDIU por ela, clicando em "Detalhes" no banner
|
|
56
|
-
* (`showResources`). Na modal que abre sozinha ao entrar numa conta bloqueada fica só o
|
|
57
|
-
* recado curto e o botão. */
|
|
58
47
|
interface FrozenModalData {
|
|
59
48
|
freezeDate?: Date | string | null;
|
|
60
49
|
showResources?: boolean;
|
|
@@ -78,7 +67,6 @@ export default function AccountFrozenModal() {
|
|
|
78
67
|
})
|
|
79
68
|
: translate('common.subscription.frozen.modal.title');
|
|
80
69
|
|
|
81
|
-
/* Sem a lista o texto tem que fechar sozinho; com a lista ele a INTRODUZ (termina em ":"). */
|
|
82
70
|
const description = isWarning
|
|
83
71
|
? translate(
|
|
84
72
|
showResources
|
|
@@ -100,9 +88,6 @@ export default function AccountFrozenModal() {
|
|
|
100
88
|
<Dialog open={isOpen} onOpenChange={() => closeModal()}>
|
|
101
89
|
<DialogContent
|
|
102
90
|
showCloseButton
|
|
103
|
-
/* Sem isto o Radix move o foco para o primeiro focável ao abrir — que aqui é o
|
|
104
|
-
* gatilho do tooltip — e o tooltip abre em FOCO, não só em hover: a modal
|
|
105
|
-
* aparecia com um balão já aberto. O foco segue preso no dialog. */
|
|
106
91
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
107
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"
|
|
108
93
|
>
|
|
@@ -122,9 +107,6 @@ export default function AccountFrozenModal() {
|
|
|
122
107
|
|
|
123
108
|
{showResources && (
|
|
124
109
|
<>
|
|
125
|
-
{/* Um bloco só, grupos separados por linha. A descrição de cada grupo
|
|
126
|
-
* vive no tooltip do ícone de info à direita: na modal ela empilhava
|
|
127
|
-
* três parágrafos e virava um paredão. */}
|
|
128
110
|
<div className="bg-white border border-zinc-100 rounded-lg">
|
|
129
111
|
{RESOURCE_GROUPS.map(({ titleKey, descriptionKey, icon: Icon }, index) => (
|
|
130
112
|
<div key={titleKey}>
|
|
@@ -148,11 +130,6 @@ export default function AccountFrozenModal() {
|
|
|
148
130
|
</TooltipTrigger>
|
|
149
131
|
<TooltipContent
|
|
150
132
|
side="top"
|
|
151
|
-
/* Acima do dialog: o globals.css do gapps-app
|
|
152
|
-
* sobe o [data-slot='dialog-content'] para
|
|
153
|
-
* z-index 1011 !important (o componente
|
|
154
|
-
* declara 1001), então o z-50 padrão do
|
|
155
|
-
* tooltip ficava atrás. */
|
|
156
133
|
className="z-[1100] max-w-[240px]"
|
|
157
134
|
>
|
|
158
135
|
{translate(descriptionKey)}
|
|
@@ -163,8 +140,6 @@ export default function AccountFrozenModal() {
|
|
|
163
140
|
))}
|
|
164
141
|
</div>
|
|
165
142
|
|
|
166
|
-
{/* Bloco de informação padrão da plataforma. Ciano fixo: o congelamento
|
|
167
|
-
* é exclusivo da wl 1, então não existe o caso whitelabel/zinc aqui. */}
|
|
168
143
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-cyan-50">
|
|
169
144
|
<IconInfoCircle className="size-4 text-zinc-950 shrink-0" />
|
|
170
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
|
+
}
|