@greatapps/common 1.1.751 → 1.1.752

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/components/layouts/AiCreditsOfferGate.mjs +105 -0
  2. package/dist/components/layouts/AiCreditsOfferGate.mjs.map +1 -0
  3. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs +276 -0
  4. package/dist/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.mjs.map +1 -0
  5. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs +55 -0
  6. package/dist/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.mjs.map +1 -0
  7. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs +78 -0
  8. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.mjs.map +1 -0
  9. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs +60 -0
  10. package/dist/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.mjs.map +1 -0
  11. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs +5 -0
  12. package/dist/components/modals/promo/AiCreditsOfferModal/index.mjs.map +1 -0
  13. package/dist/components/ui/form/ComboboxField.mjs +1 -0
  14. package/dist/components/ui/form/ComboboxField.mjs.map +1 -1
  15. package/dist/handlers.mjs +5 -1
  16. package/dist/handlers.mjs.map +1 -1
  17. package/dist/i18n/messages/en-us.mjs +32 -0
  18. package/dist/i18n/messages/en-us.mjs.map +1 -1
  19. package/dist/i18n/messages/es-es.mjs +32 -0
  20. package/dist/i18n/messages/es-es.mjs.map +1 -1
  21. package/dist/i18n/messages/pt-br.mjs +32 -0
  22. package/dist/i18n/messages/pt-br.mjs.map +1 -1
  23. package/dist/index.mjs +73 -43
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/modules/auth/utils/get-user-context.mjs +2 -1
  26. package/dist/modules/auth/utils/get-user-context.mjs.map +1 -1
  27. package/dist/modules/cards/hooks/cards.hook.mjs +3 -2
  28. package/dist/modules/cards/hooks/cards.hook.mjs.map +1 -1
  29. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs +28 -0
  30. package/dist/modules/ia-credits/constants/ai-credits-offer.constants.mjs.map +1 -0
  31. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs +19 -0
  32. package/dist/modules/ia-credits/handlers/get-offer-eligibility.handler.mjs.map +1 -0
  33. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs +33 -0
  34. package/dist/modules/ia-credits/handlers/purchase-ia-credits.handler.mjs.map +1 -0
  35. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs +29 -0
  36. package/dist/modules/ia-credits/hooks/purchase-ia-credits.hook.mjs.map +1 -0
  37. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs +18 -0
  38. package/dist/modules/ia-credits/hooks/use-offer-eligibility.hook.mjs.map +1 -0
  39. package/dist/modules/ia-credits/services/ia-credits.service.mjs +67 -0
  40. package/dist/modules/ia-credits/services/ia-credits.service.mjs.map +1 -1
  41. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs +34 -0
  42. package/dist/modules/ia-credits/types/ai-credits-offer.type.mjs.map +1 -0
  43. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs +43 -0
  44. package/dist/modules/ia-credits/utils/offer-stage-cookie.mjs.map +1 -0
  45. package/dist/modules/plans/services/plans.service.mjs +2 -2
  46. package/dist/modules/plans/services/plans.service.mjs.map +1 -1
  47. package/dist/modules/plans/types/plan.type.mjs.map +1 -1
  48. package/dist/modules/plans/utils/map-api-plan-to-ui.mjs +0 -1
  49. package/dist/modules/plans/utils/map-api-plan-to-ui.mjs.map +1 -1
  50. package/dist/modules/subscriptions/types/calculate-subscription.type.mjs +0 -8
  51. package/dist/modules/subscriptions/types/calculate-subscription.type.mjs.map +1 -1
  52. package/dist/store/useAiCreditsOfferModal.mjs +14 -0
  53. package/dist/store/useAiCreditsOfferModal.mjs.map +1 -0
  54. package/package.json +3 -1
  55. package/src/components/layouts/AiCreditsOfferGate.tsx +199 -0
  56. package/src/components/modals/promo/AiCreditsOfferModal/AiCreditsOfferModal.tsx +401 -0
  57. package/src/components/modals/promo/AiCreditsOfferModal/components/LoseOfferDialog.tsx +97 -0
  58. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferIllustration.tsx +121 -0
  59. package/src/components/modals/promo/AiCreditsOfferModal/components/OfferPaymentField.tsx +91 -0
  60. package/src/components/modals/promo/AiCreditsOfferModal/index.ts +1 -0
  61. package/src/components/ui/form/ComboboxField.tsx +6 -0
  62. package/src/handlers.ts +2 -0
  63. package/src/i18n/messages/en-us.ts +33 -0
  64. package/src/i18n/messages/es-es.ts +33 -0
  65. package/src/i18n/messages/pt-br.ts +33 -0
  66. package/src/index.ts +26 -0
  67. package/src/modules/auth/utils/get-user-context.ts +3 -0
  68. package/src/modules/cards/hooks/cards.hook.ts +6 -1
  69. package/src/modules/ia-credits/constants/ai-credits-offer.constants.ts +41 -0
  70. package/src/modules/ia-credits/handlers/get-offer-eligibility.handler.ts +24 -0
  71. package/src/modules/ia-credits/handlers/purchase-ia-credits.handler.ts +42 -0
  72. package/src/modules/ia-credits/hooks/purchase-ia-credits.hook.ts +43 -0
  73. package/src/modules/ia-credits/hooks/use-offer-eligibility.hook.ts +28 -0
  74. package/src/modules/ia-credits/services/ia-credits.service.ts +92 -0
  75. package/src/modules/ia-credits/types/ai-credits-offer.type.ts +87 -0
  76. package/src/modules/ia-credits/utils/offer-stage-cookie.ts +70 -0
  77. package/src/modules/plans/services/plans.service.ts +3 -3
  78. package/src/modules/plans/types/plan.type.ts +0 -1
  79. package/src/modules/plans/utils/map-api-plan-to-ui.ts +0 -1
  80. package/src/modules/subscriptions/types/calculate-subscription.type.ts +0 -8
  81. package/src/store/useAiCreditsOfferModal.ts +24 -0
@@ -0,0 +1,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 function useCards() {
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
+ }