@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.
- package/dist/components/layouts/AiCreditsOfferGate.mjs +105 -0
- package/dist/components/layouts/AiCreditsOfferGate.mjs.map +1 -0
- package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs +276 -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/ui/form/ComboboxField.mjs +1 -0
- package/dist/components/ui/form/ComboboxField.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 +73 -43
- package/dist/index.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 +28 -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 +43 -0
- package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs.map +1 -0
- package/dist/modules/plans/services/plans.service.mjs +2 -2
- package/dist/modules/plans/services/plans.service.mjs.map +1 -1
- package/dist/modules/plans/types/plan.type.mjs.map +1 -1
- package/dist/modules/plans/utils/map-api-plan-to-ui.mjs +0 -1
- package/dist/modules/plans/utils/map-api-plan-to-ui.mjs.map +1 -1
- package/dist/modules/subscriptions/types/calculate-subscription.type.mjs +0 -8
- package/dist/modules/subscriptions/types/calculate-subscription.type.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 +199 -0
- package/src/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.tsx +401 -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/ui/form/ComboboxField.tsx +6 -0
- 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 +26 -0
- 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 +41 -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 +70 -0
- package/src/modules/plans/services/plans.service.ts +3 -3
- package/src/modules/plans/types/plan.type.ts +0 -1
- package/src/modules/plans/utils/map-api-plan-to-ui.ts +0 -1
- package/src/modules/subscriptions/types/calculate-subscription.type.ts +0 -8
- package/src/store/useAiCreditsOfferModal.ts +24 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Painel direito da oferta — implementação do design do Figma (`Testes-UI`, nodes `2569:2938` e
|
|
9
|
+
* `2569:4015`), conferida contra o SVG exportado do bloco inteiro.
|
|
10
|
+
*
|
|
11
|
+
* A geometria vem do arquivo, desenhado num painel de **449×606** — praticamente o tamanho real
|
|
12
|
+
* desta coluna (metade de um modal de 900). Por isso os offsets são os px do próprio design,
|
|
13
|
+
* ancorados ao CENTRO do painel: a janela tem altura derivada da largura, então âncora percentual
|
|
14
|
+
* desmontaria a composição quando o modal mudasse de altura; a partir do centro, a distância entre
|
|
15
|
+
* as peças se mantém.
|
|
16
|
+
*
|
|
17
|
+
* O fundo e a janela são os VETORES do arquivo (`offer-panel-background.svg` e
|
|
18
|
+
* `offer-editor-window.svg`, em `public/promo/` do app consumidor — a lib não publica assets
|
|
19
|
+
* estáticos), fatiados do SVG do bloco; o cartão é DOM, pra o contador animar.
|
|
20
|
+
*
|
|
21
|
+
* Animações em CSS puro (classes `gp-offer-*`, ver o bloco no topo do `AiCreditsOfferModal.tsx`) —
|
|
22
|
+
* respeitam `prefers-reduced-motion` por conta própria, sem depender de JS. Só o CONTADOR do
|
|
23
|
+
* cartão é JS (`requestAnimationFrame`): não dá pra fazer "500" nascer de "0" em CSS puro.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const COUNT_DELAY_MS = 620;
|
|
27
|
+
const COUNT_DURATION_MS = 1150;
|
|
28
|
+
|
|
29
|
+
function easeOutCubic(t: number): number {
|
|
30
|
+
return 1 - (1 - t) ** 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OfferIllustrationProps {
|
|
34
|
+
/** Pacote ofertado — é ele que o contador persegue. */
|
|
35
|
+
credits: number;
|
|
36
|
+
/** Locale de formatação do número (vem do locale da UI). */
|
|
37
|
+
locale: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function OfferIllustration({ credits, locale }: OfferIllustrationProps) {
|
|
41
|
+
const translate = useTranslations();
|
|
42
|
+
const [displayCredits, setDisplayCredits] = useState(0);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (typeof window === 'undefined') return;
|
|
46
|
+
|
|
47
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
48
|
+
setDisplayCredits(credits);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let frameId: number;
|
|
53
|
+
const startedAt = performance.now() + COUNT_DELAY_MS;
|
|
54
|
+
|
|
55
|
+
const tick = (now: number) => {
|
|
56
|
+
const elapsed = now - startedAt;
|
|
57
|
+
if (elapsed < 0) {
|
|
58
|
+
frameId = requestAnimationFrame(tick);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const progress = Math.min(1, elapsed / COUNT_DURATION_MS);
|
|
62
|
+
setDisplayCredits(Math.round(credits * easeOutCubic(progress)));
|
|
63
|
+
if (progress < 1) frameId = requestAnimationFrame(tick);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
frameId = requestAnimationFrame(tick);
|
|
67
|
+
return () => cancelAnimationFrame(frameId);
|
|
68
|
+
}, [credits]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="relative h-full w-full overflow-hidden">
|
|
72
|
+
{/* Fundo: o próprio vetor do arquivo (gradiente + o brilho difuso da quina), extraído do SVG
|
|
73
|
+
do bloco. Vem como imagem, e não como gradiente CSS + `blur`, pra não haver interpretação
|
|
74
|
+
no meio: a cor e o desfoque são os do design, byte a byte. 4,7KB. */}
|
|
75
|
+
<Image
|
|
76
|
+
src="/promo/offer-panel-background.svg"
|
|
77
|
+
alt=""
|
|
78
|
+
fill
|
|
79
|
+
priority
|
|
80
|
+
draggable={false}
|
|
81
|
+
className="object-cover select-none"
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{/* Janela do editor — o vetor exportado do próprio arquivo (chrome, moldura branca, sombra e
|
|
85
|
+
o print do canvas embutido). `gp-offer-window-in` anima `translate`/`scale` (propriedades
|
|
86
|
+
CSS independentes do `transform`), então convive sem conflito com o
|
|
87
|
+
`-translate-x-1/2 -translate-y-1/2` do Tailwind que centraliza o elemento. */}
|
|
88
|
+
<div
|
|
89
|
+
aria-hidden
|
|
90
|
+
className="gp-offer-window-in absolute top-[calc(50%-27px)] left-[calc(50%+98px)] aspect-[557.57/388.22] w-[132%] -translate-x-1/2 -translate-y-1/2 md:w-[124.2%]"
|
|
91
|
+
>
|
|
92
|
+
<Image
|
|
93
|
+
src="/promo/offer-editor-window.svg"
|
|
94
|
+
alt=""
|
|
95
|
+
fill
|
|
96
|
+
sizes="560px"
|
|
97
|
+
priority
|
|
98
|
+
draggable={false}
|
|
99
|
+
className="select-none"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Cartão de créditos (node 2569:4015), solto abaixo da janela, com a folga de gradiente que
|
|
104
|
+
o arquivo tem. Só no desktop: no mobile a faixa é baixa e ele brigaria com a janela. */}
|
|
105
|
+
<div className="gp-offer-card-in absolute top-[calc(50%+146px)] left-[49.7%] hidden h-[66px] w-[193.5px] items-center gap-[11.5px] rounded-[15px] border border-[#8BE4FF]/38 bg-white pl-[11.5px] shadow-[0px_4px_25px_0px_rgba(26,26,26,0.03)] md:flex">
|
|
106
|
+
<span className="gp-offer-icon-pulse flex size-[43.5px] shrink-0 items-center justify-center rounded-[9px] bg-cyan-50">
|
|
107
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- ícone de 39KB, pesado demais pra inlinear no componente da lib. */}
|
|
108
|
+
<img src="/iaicon.svg" alt="" width={24} height={24} className="block" />
|
|
109
|
+
</span>
|
|
110
|
+
<span className="flex items-baseline gap-1 whitespace-nowrap">
|
|
111
|
+
<span className="title-large-semibold text-[25px] leading-[29px] tabular-nums text-zinc-950">
|
|
112
|
+
{displayCredits.toLocaleString(locale)}
|
|
113
|
+
</span>
|
|
114
|
+
<span className="paragraph-small-medium text-[14.5px] text-zinc-500">
|
|
115
|
+
{translate('common.promo.aiCreditsOffer.illustration.credits')}
|
|
116
|
+
</span>
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl';
|
|
4
|
+
import { CardBrandIcon } from '../../../../ui/data-display/CardBrandIcons';
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../../../../ui/overlay/Tooltip';
|
|
6
|
+
import { useCards } from '../../../../../modules/cards/hooks/cards.hook';
|
|
7
|
+
import { useModalManager } from '../../../../../store/useModalManager';
|
|
8
|
+
import { IconPencil, IconPlus } from '@tabler/icons-react';
|
|
9
|
+
|
|
10
|
+
interface OfferPaymentFieldProps {
|
|
11
|
+
/** Cartão em uso (padrão da conta ou o que acabou de ser cadastrado). */
|
|
12
|
+
cardId: string;
|
|
13
|
+
/** Recebe o id do cartão cadastrado no `addCardModal`. */
|
|
14
|
+
onCardAdded: (cardId: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Campo de pagamento da oferta — versão de **um cartão só**.
|
|
19
|
+
*
|
|
20
|
+
* Nasceu do `PaymentInfoCard` (mesma moldura, mesma linha "Pagamento", mesmo `CardBrandIcon`,
|
|
21
|
+
* mesmas chaves de tradução `common.cards.*`), mas sem o seletor e sem o "Nova forma de
|
|
22
|
+
* pagamento": aqui a pessoa está numa compra única de primeira vez, então ou ela ainda não tem
|
|
23
|
+
* cartão (e o campo é só o botão de cadastrar) ou já tem (e o campo é só o cartão). Oferecer uma
|
|
24
|
+
* lista de cartões numa tela cujo objetivo é UM clique era ruído.
|
|
25
|
+
*
|
|
26
|
+
* Com cartão, o lápis fica sempre visível e abre o `addCardModal` — o mesmo fluxo padrão da
|
|
27
|
+
* plataforma, que é por onde se troca a forma de pagamento (não existe edição de cartão: o
|
|
28
|
+
* gateway guarda o dado, então "editar" é cadastrar outro). Nada de formulário próprio.
|
|
29
|
+
*/
|
|
30
|
+
export function OfferPaymentField({ cardId, onCardAdded }: OfferPaymentFieldProps) {
|
|
31
|
+
const translate = useTranslations();
|
|
32
|
+
const { openModal } = useModalManager();
|
|
33
|
+
const { data: cardsData } = useCards();
|
|
34
|
+
|
|
35
|
+
const card = cardsData?.data?.find((item) => item.id.toString() === cardId.toString());
|
|
36
|
+
|
|
37
|
+
const handleOpenCardModal = () => {
|
|
38
|
+
openModal('addCardModal', { onCardAdded });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="overflow-hidden rounded-lg border border-zinc-200">
|
|
43
|
+
<div className="flex h-[60px] items-center gap-3 px-4">
|
|
44
|
+
<span className="paragraph-small-medium w-[100px] text-zinc-500">
|
|
45
|
+
{translate('common.cards.paymentInfo.payment')}
|
|
46
|
+
</span>
|
|
47
|
+
|
|
48
|
+
{card ? (
|
|
49
|
+
<>
|
|
50
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
51
|
+
<span className="paragraph-small-semibold truncate text-zinc-950">{card.name}</span>
|
|
52
|
+
<span className="paragraph-small-medium text-zinc-950">
|
|
53
|
+
•••• {card.number?.replaceAll(/\D/g, '')?.slice(-4)}
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
58
|
+
<CardBrandIcon brand={card.brand} />
|
|
59
|
+
{/* `z-[1002]`: o tooltip nasce num portal e precisa passar por cima do
|
|
60
|
+
`DialogContent` (z-1001), senão fica escondido atrás do modal. */}
|
|
61
|
+
<Tooltip>
|
|
62
|
+
<TooltipTrigger asChild>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={handleOpenCardModal}
|
|
66
|
+
aria-label={translate('common.promo.aiCreditsOffer.editPayment')}
|
|
67
|
+
className="flex size-8 cursor-pointer items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-950"
|
|
68
|
+
>
|
|
69
|
+
<IconPencil size={18} />
|
|
70
|
+
</button>
|
|
71
|
+
</TooltipTrigger>
|
|
72
|
+
<TooltipContent side="top" sideOffset={6} className="z-[1002]">
|
|
73
|
+
{translate('common.promo.aiCreditsOffer.editPayment')}
|
|
74
|
+
</TooltipContent>
|
|
75
|
+
</Tooltip>
|
|
76
|
+
</div>
|
|
77
|
+
</>
|
|
78
|
+
) : (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={handleOpenCardModal}
|
|
82
|
+
className="paragraph-small-semibold flex h-8 w-fit cursor-pointer items-center rounded-md border border-zinc-200 px-2 text-zinc-950 transition-colors hover:border-zinc-400"
|
|
83
|
+
>
|
|
84
|
+
<IconPlus className="size-[18px]" />
|
|
85
|
+
<span className="px-1.5">{translate('common.cards.paymentInfo.addCard')}</span>
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './AiCreditsOfferModal';
|
|
@@ -71,7 +71,13 @@ export function ComboboxField({
|
|
|
71
71
|
</div>
|
|
72
72
|
)}
|
|
73
73
|
|
|
74
|
+
{/* `modal` é obrigatório para o combobox funcionar dentro de Dialog modal
|
|
75
|
+
* (ex.: Preferências): sem ele, o FocusScope do Dialog rouba o foco no
|
|
76
|
+
* pointerdown da opção (o conteúdo do Popover é portalado pra fora do Dialog)
|
|
77
|
+
* e o Popover fecha antes do click — a opção nunca é selecionada. Com `modal`,
|
|
78
|
+
* o Popover entra na pilha de focus scopes do Radix e pausa o do Dialog. */}
|
|
74
79
|
<Popover
|
|
80
|
+
modal
|
|
75
81
|
open={open}
|
|
76
82
|
onOpenChange={(v) => {
|
|
77
83
|
if (!disabled) {
|
package/src/handlers.ts
CHANGED
|
@@ -5,6 +5,8 @@ export { createListChargesHandler } from './modules/charges/handlers/list-charge
|
|
|
5
5
|
export { createFindChargeByIdHandler } from './modules/charges/handlers/find-charge-by-id.handler';
|
|
6
6
|
|
|
7
7
|
export { createListIaCreditsHandler } from './modules/ia-credits/handlers/list-ia-credits.handler';
|
|
8
|
+
export { createPurchaseIaCreditsHandler } from './modules/ia-credits/handlers/purchase-ia-credits.handler';
|
|
9
|
+
export { createGetAiCreditsOfferEligibilityHandler } from './modules/ia-credits/handlers/get-offer-eligibility.handler';
|
|
8
10
|
|
|
9
11
|
export { createCountPagesHandler } from './modules/pages/handlers/count-pages.handler';
|
|
10
12
|
|
|
@@ -612,6 +612,39 @@ const messages = {
|
|
|
612
612
|
'en-us': 'English (US)',
|
|
613
613
|
'es-es': 'Spanish',
|
|
614
614
|
},
|
|
615
|
+
|
|
616
|
+
// Modais promocionais compartilhados entre app/accounts/editor.
|
|
617
|
+
promo: {
|
|
618
|
+
// Componente: AiCreditsOfferModal.
|
|
619
|
+
aiCreditsOffer: {
|
|
620
|
+
eyebrow: 'First purchase pack',
|
|
621
|
+
title: '{credits} AI credits <break></break>at {discount}',
|
|
622
|
+
bullets: {
|
|
623
|
+
first: 'Build a full page from one prompt.',
|
|
624
|
+
second: 'Generate copy and images in the editor.',
|
|
625
|
+
third: 'One-time credits: your plan stays as is.',
|
|
626
|
+
},
|
|
627
|
+
discountBadge: '{percent}% OFF',
|
|
628
|
+
cta: 'Get my credits at {discount}',
|
|
629
|
+
ctaAddCard: 'Add card and lock in the discount',
|
|
630
|
+
later: 'Not now',
|
|
631
|
+
editPayment: 'Edit payment details',
|
|
632
|
+
close: 'Close',
|
|
633
|
+
successToast: 'Done! {credits} AI credits are now in your account.',
|
|
634
|
+
errorToast: 'We could not complete the purchase. Please try again.',
|
|
635
|
+
cardVerification: 'Your card asked for an extra verification. Try another card.',
|
|
636
|
+
illustration: {
|
|
637
|
+
credits: 'credits',
|
|
638
|
+
},
|
|
639
|
+
exit: {
|
|
640
|
+
title: 'Sure you want to lose {discount}?',
|
|
641
|
+
description: 'This screen will not come back. Leave now and the {credits} credits go back to {price}.',
|
|
642
|
+
descriptionNoPrice: 'This screen will not come back. Leave now and the {credits} credits go back to full price.',
|
|
643
|
+
keep: 'Back to the offer',
|
|
644
|
+
lose: 'Lose my offer',
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
},
|
|
615
648
|
} as const;
|
|
616
649
|
|
|
617
650
|
export default messages;
|
|
@@ -613,6 +613,39 @@ const messages = {
|
|
|
613
613
|
'en-us': 'Inglés (EE. UU.)',
|
|
614
614
|
'es-es': 'Español',
|
|
615
615
|
},
|
|
616
|
+
|
|
617
|
+
// Modais promocionais compartilhados entre app/accounts/editor.
|
|
618
|
+
promo: {
|
|
619
|
+
// Componente: AiCreditsOfferModal.
|
|
620
|
+
aiCreditsOffer: {
|
|
621
|
+
eyebrow: 'Paquete primera compra',
|
|
622
|
+
title: '{credits} créditos de IA <break></break>con {discount}',
|
|
623
|
+
bullets: {
|
|
624
|
+
first: 'Crea una página entera con un prompt.',
|
|
625
|
+
second: 'Genera textos e imágenes en el editor.',
|
|
626
|
+
third: 'Créditos sueltos: no tocan tu plan.',
|
|
627
|
+
},
|
|
628
|
+
discountBadge: '{percent}% OFF',
|
|
629
|
+
cta: 'Quiero mis créditos con {discount}',
|
|
630
|
+
ctaAddCard: 'Agregar tarjeta y asegurar el descuento',
|
|
631
|
+
later: 'Ahora no',
|
|
632
|
+
editPayment: 'Editar datos de pago',
|
|
633
|
+
close: 'Cerrar',
|
|
634
|
+
successToast: '¡Listo! {credits} créditos de IA ya están en tu cuenta.',
|
|
635
|
+
errorToast: 'No pudimos completar la compra. Inténtalo de nuevo.',
|
|
636
|
+
cardVerification: 'Tu tarjeta pidió una verificación extra. Prueba con otra tarjeta.',
|
|
637
|
+
illustration: {
|
|
638
|
+
credits: 'créditos',
|
|
639
|
+
},
|
|
640
|
+
exit: {
|
|
641
|
+
title: '¿Seguro que quieres perder {discount}?',
|
|
642
|
+
description: 'Esta pantalla no vuelve. Si sales ahora, los {credits} créditos vuelven a costar {price}.',
|
|
643
|
+
descriptionNoPrice: 'Esta pantalla no vuelve. Si sales ahora, los {credits} créditos vuelven al precio completo.',
|
|
644
|
+
keep: 'Volver a la oferta',
|
|
645
|
+
lose: 'Perder mi oferta',
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
},
|
|
616
649
|
} as const;
|
|
617
650
|
|
|
618
651
|
export default messages;
|
|
@@ -641,6 +641,39 @@ const messages = {
|
|
|
641
641
|
description: 'A página que você está procurando não existe. Verifique se digitou corretamente o link que está procurando.',
|
|
642
642
|
backToHome: 'Voltar para o início',
|
|
643
643
|
},
|
|
644
|
+
|
|
645
|
+
// Modais promocionais compartilhados entre app/accounts/editor.
|
|
646
|
+
promo: {
|
|
647
|
+
// Componente: AiCreditsOfferModal (movido verbatim do gpages — pendência de copy: ver PR).
|
|
648
|
+
aiCreditsOffer: {
|
|
649
|
+
eyebrow: 'Pacote primeira compra',
|
|
650
|
+
title: '{credits} créditos de IA <break></break>com {discount}',
|
|
651
|
+
bullets: {
|
|
652
|
+
first: 'Crie uma página inteira com um prompt.',
|
|
653
|
+
second: 'Gere textos e imagens direto no editor.',
|
|
654
|
+
third: 'Créditos avulsos: não mexem no seu plano.',
|
|
655
|
+
},
|
|
656
|
+
discountBadge: '{percent}% OFF',
|
|
657
|
+
cta: 'Quero meus créditos com {discount}',
|
|
658
|
+
ctaAddCard: 'Cadastrar cartão e garantir o desconto',
|
|
659
|
+
later: 'Agora não',
|
|
660
|
+
editPayment: 'Editar informações de pagamento',
|
|
661
|
+
close: 'Fechar',
|
|
662
|
+
successToast: 'Pronto! {credits} créditos de IA entraram na sua conta.',
|
|
663
|
+
errorToast: 'Não foi possível concluir a compra. Tente novamente.',
|
|
664
|
+
cardVerification: 'Seu cartão pediu uma verificação extra. Tente outro cartão.',
|
|
665
|
+
illustration: {
|
|
666
|
+
credits: 'créditos',
|
|
667
|
+
},
|
|
668
|
+
exit: {
|
|
669
|
+
title: 'Quer mesmo perder {discount}?',
|
|
670
|
+
description: 'Esta tela não volta. Saindo agora, os {credits} créditos voltam a custar {price}.',
|
|
671
|
+
descriptionNoPrice: 'Esta tela não volta. Saindo agora, os {credits} créditos voltam ao preço cheio.',
|
|
672
|
+
keep: 'Voltar para a oferta',
|
|
673
|
+
lose: 'Perder minha oferta',
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
},
|
|
644
677
|
} as const;
|
|
645
678
|
|
|
646
679
|
export default messages;
|
package/src/index.ts
CHANGED
|
@@ -114,6 +114,28 @@ export type {
|
|
|
114
114
|
IaCreditAddBatch,
|
|
115
115
|
IaCreditOperation,
|
|
116
116
|
} from "./modules/ia-credits/types";
|
|
117
|
+
export { usePurchaseIaCredits } from "./modules/ia-credits/hooks/purchase-ia-credits.hook";
|
|
118
|
+
export { useAiCreditsOfferEligibility } from "./modules/ia-credits/hooks/use-offer-eligibility.hook";
|
|
119
|
+
export {
|
|
120
|
+
AI_CREDITS_FIRST_PURCHASE_OFFER,
|
|
121
|
+
AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY,
|
|
122
|
+
AI_CREDITS_OFFER_MAX_REPEATS,
|
|
123
|
+
AiCreditsOfferStage,
|
|
124
|
+
PAYMENT_METHOD_CARD,
|
|
125
|
+
PAYMENT_METHOD_PIX,
|
|
126
|
+
} from "./modules/ia-credits/constants/ai-credits-offer.constants";
|
|
127
|
+
export type {
|
|
128
|
+
AiCreditsOfferEligibility,
|
|
129
|
+
AiCreditsOfferEligibilityResponse,
|
|
130
|
+
AiCreditsOfferIneligibilityReason,
|
|
131
|
+
PurchaseIaCreditsInput,
|
|
132
|
+
PurchaseIaCreditsResult,
|
|
133
|
+
} from "./modules/ia-credits/types/ai-credits-offer.type";
|
|
134
|
+
export {
|
|
135
|
+
readOfferStageCookie,
|
|
136
|
+
writeOfferStageCookie,
|
|
137
|
+
} from "./modules/ia-credits/utils/offer-stage-cookie";
|
|
138
|
+
export type { OfferStageRecord } from "./modules/ia-credits/utils/offer-stage-cookie";
|
|
117
139
|
export type {
|
|
118
140
|
Subscription,
|
|
119
141
|
SubscriptionItem,
|
|
@@ -224,7 +246,11 @@ export { useBuyCreditsModal } from "./store/useBuyCreditsModal";
|
|
|
224
246
|
export { useCreditsDisabledModal } from "./store/useCreditsDisabledModal";
|
|
225
247
|
export { usePaidPlanRequiredModal } from "./store/usePaidPlanRequiredModal";
|
|
226
248
|
export { useAccountDeletionWarningModal } from "./store/useAccountDeletionWarningModal";
|
|
249
|
+
export { useAiCreditsOfferModal } from "./store/useAiCreditsOfferModal";
|
|
227
250
|
export { default as BuyCreditsModal } from "./components/modals/BuyCreditsModal";
|
|
251
|
+
export { default as AiCreditsOfferModal } from "./components/modals/promo/AiCreditsOfferModal";
|
|
252
|
+
export { AiCreditsOfferGate } from "./components/layouts/AiCreditsOfferGate";
|
|
253
|
+
export type { AiCreditsOfferGateProps } from "./components/layouts/AiCreditsOfferGate";
|
|
228
254
|
export { default as CreditsDisabledModal } from "./components/modals/CreditsDisabledModal";
|
|
229
255
|
export { default as PaidPlanRequiredModal } from "./components/modals/PaidPlanRequiredModal";
|
|
230
256
|
export { default as AccountFrozenModal } from "./components/modals/AccountFrozenModal";
|
|
@@ -11,6 +11,8 @@ export async function getUserContext(): Promise<{
|
|
|
11
11
|
id_wl: number;
|
|
12
12
|
id_account: number;
|
|
13
13
|
id_user: number;
|
|
14
|
+
/** `iat` do JWT — carimbo da sessão de login atual (renovado a cada novo login, não a cada request). */
|
|
15
|
+
iat: number;
|
|
14
16
|
}> {
|
|
15
17
|
const cookieStore = await cookies();
|
|
16
18
|
const token = process.env.DUMMY_AUTH_TOKEN || cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
|
@@ -26,6 +28,7 @@ export async function getUserContext(): Promise<{
|
|
|
26
28
|
id_wl: Number(payload.id_wl),
|
|
27
29
|
id_account: payload.id_account,
|
|
28
30
|
id_user: payload.id_user,
|
|
31
|
+
iat: payload.iat,
|
|
29
32
|
};
|
|
30
33
|
} catch {
|
|
31
34
|
throw new ApiError('Token inválido', 'INVALID_TOKEN', 401);
|
|
@@ -8,7 +8,11 @@ import { useAuthQueryKey } from '../../../hooks/useAuthQueryKey';
|
|
|
8
8
|
|
|
9
9
|
export const CARDS_QUERY_KEY = ['cards'] as const;
|
|
10
10
|
|
|
11
|
-
export
|
|
11
|
+
export interface UseCardsOptions {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useCards(options: UseCardsOptions = {}) {
|
|
12
16
|
const queryKey = useAuthQueryKey([...CARDS_QUERY_KEY]);
|
|
13
17
|
|
|
14
18
|
return useQuery({
|
|
@@ -18,5 +22,6 @@ export function useCards() {
|
|
|
18
22
|
...result,
|
|
19
23
|
data: result.data.filter((card) => !card.deleted),
|
|
20
24
|
}),
|
|
25
|
+
enabled: options.enabled ?? true,
|
|
21
26
|
});
|
|
22
27
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oferta de primeira compra de créditos de IA — o slug é o contrato inteiro com o backend
|
|
3
|
+
* (gapps-r3-api): ele resolve pacote/preço/elegibilidade a partir dele, o front nunca manda preço.
|
|
4
|
+
* `credits`/`price` aqui são só o que a UI precisa ANTES do primeiro fetch de elegibilidade (rótulo
|
|
5
|
+
* do CTA, contador da ilustração) — o preço de tabela ("de R$ 19,90") é a `anchorCents` que vem do
|
|
6
|
+
* endpoint de elegibilidade, nunca hardcoded.
|
|
7
|
+
*/
|
|
8
|
+
export const AI_CREDITS_FIRST_PURCHASE_OFFER = {
|
|
9
|
+
slug: 'first-purchase-ia-credits',
|
|
10
|
+
credits: 500,
|
|
11
|
+
price: 4.9,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** 1 = cartão, 3 = pix — mesmos códigos do gapps-r3-api. A oferta só aceita cartão. */
|
|
15
|
+
export const PAYMENT_METHOD_CARD = 1;
|
|
16
|
+
export const PAYMENT_METHOD_PIX = 3;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escada de estágios da oferta, persistida no cookie `ai-credits-offer-stage`. A ordem importa: o
|
|
20
|
+
* gate só abre para um estágio MAIOR que o gravado (ver `AiCreditsOfferGate`) — CONVERTED é
|
|
21
|
+
* terminal, nunca reabre depois dele.
|
|
22
|
+
*/
|
|
23
|
+
export enum AiCreditsOfferStage {
|
|
24
|
+
NONE = 0,
|
|
25
|
+
CAMPAIGN_SHOWN = 1,
|
|
26
|
+
FIRST_USE_SHOWN = 2,
|
|
27
|
+
HALF_SHOWN = 3,
|
|
28
|
+
EXHAUSTED_SHOWN = 4,
|
|
29
|
+
ZERO_SHOWN = 5,
|
|
30
|
+
CONVERTED = 6,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Teto de REPETIÇÕES dos estágios repetíveis (campanha e sem-crédito) — repetições ALÉM da primeira
|
|
35
|
+
* aparição, então `3` permite 4 exibições daquele estágio. A campanha é deliberadamente agressiva
|
|
36
|
+
* (vale inclusive para quem acabou de se cadastrar), mas "agressiva" não é "toda sessão para
|
|
37
|
+
* sempre": esgotado o teto, a oferta só volta se a conta subir de estágio na escada.
|
|
38
|
+
*/
|
|
39
|
+
export const AI_CREDITS_OFFER_MAX_REPEATS = 3;
|
|
40
|
+
|
|
41
|
+
export const AI_CREDITS_OFFER_ELIGIBILITY_QUERY_KEY = ['ai-credits-offer-eligibility'] as const;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { safeRouteHandler } from '../../../infra/route/safe-route-handler';
|
|
4
|
+
import { getUserContext } from '../../auth/utils/get-user-context';
|
|
5
|
+
import { iaCreditsService } from '../services/ia-credits.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `GET /api/ai-credits-offer/eligibility` — proxy da elegibilidade do gapps-r3-api mais o
|
|
9
|
+
* `sessionIssuedAt` (o `iat` do JWT), que o `AiCreditsOfferGate` usa pra distinguir "mesma sessão de
|
|
10
|
+
* login" de "sessão nova" nos gatilhos de campanha/zero-créditos. O `iat` é decodificado aqui (rota,
|
|
11
|
+
* dono do cookie httpOnly) e não no service, que só fala com o backend de créditos.
|
|
12
|
+
*/
|
|
13
|
+
export function createGetAiCreditsOfferEligibilityHandler() {
|
|
14
|
+
return async function GET() {
|
|
15
|
+
return safeRouteHandler(async () => {
|
|
16
|
+
const [{ iat }, eligibility] = await Promise.all([
|
|
17
|
+
getUserContext(),
|
|
18
|
+
iaCreditsService.offerEligibility(),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
return { ...eligibility, sessionIssuedAt: iat };
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import type { NextRequest } from 'next/server';
|
|
4
|
+
import { ApiError } from '../../../infra/api/types';
|
|
5
|
+
import { PurchaseIaCreditsRequestBodySchema } from '../types/ai-credits-offer.type';
|
|
6
|
+
import { iaCreditsService } from '../services/ia-credits.service';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `POST /api/subscriptions/:id/credits/purchase` — compra avulsa de créditos de IA, comum ou
|
|
10
|
+
* (com `offer`) a de primeira compra. Desvio deliberado de `safeRouteHandler`: o `BuyOneTimeCreditsModal`
|
|
11
|
+
* do editor e o `AiCreditsOfferModal` desta lib esperam o envelope CRU do gapps-r3-api direto na
|
|
12
|
+
* raiz da resposta (`{ status: 1 | 2 | 0, client_secret?, message?, data? }`), inclusive pra
|
|
13
|
+
* distinguir pago (1), 3DS pendente (2) e recusado (0) — embrulhar em `{ success, data }` quebraria
|
|
14
|
+
* os dois.
|
|
15
|
+
*/
|
|
16
|
+
export function createPurchaseIaCreditsHandler() {
|
|
17
|
+
return async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
const parsedBody = PurchaseIaCreditsRequestBodySchema.safeParse(await req.json().catch(() => null));
|
|
20
|
+
|
|
21
|
+
if (!parsedBody.success) {
|
|
22
|
+
return Response.json(
|
|
23
|
+
{ status: 0, message: 'Erro ao comprar créditos de IA' },
|
|
24
|
+
{ status: 400 }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await iaCreditsService.purchase(id, {
|
|
30
|
+
quantity: parsedBody.data.quantity,
|
|
31
|
+
paymentMethod: parsedBody.data.payment_method,
|
|
32
|
+
idCard: parsedBody.data.id_card,
|
|
33
|
+
offer: parsedBody.data.offer,
|
|
34
|
+
});
|
|
35
|
+
return Response.json(result);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : 'Erro ao comprar créditos de IA';
|
|
38
|
+
const status = error instanceof ApiError ? error.statusCode : 500;
|
|
39
|
+
return Response.json({ status: 0, message }, { status });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|