From 99885a7e66cff1d97b568475f1ed945c5f30c6cd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 9 Aug 2026 19:53:38 +0100 Subject: [PATCH 01/45] fix(badges): make the invite-page campaign warning readable in Sentry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2647 fixed this defect at useZeroDev.ts:222 but missed its sibling here, so half the campaign failures are still undiagnosable in production. Sentry's console integration serializes each console argument. Passing an object holding an array of claims lands in the issue as the literal "[object Object]" — so the two facts the warning exists to carry, which campaign and why, were both unreadable. PEANUT-UI-SJC is a live example from today; the registration-side sibling now reads "utm:pix=unknown" instead. Pre-join the pairs into one string, exactly as useZeroDev.ts does. The message stays constant so Sentry keeps grouping these into a single issue rather than opening one per campaign combination. This matters right now: three live marketing tags (brazil, pix, faster-payments) currently resolve to no campaign, and the invite page was the surface that could not say which. Test pins the payload is a string, names both campaigns and reasons, and pins the message constant. Verified non-vacuous: reverting to the object payload fails it, restoring the join passes. --- src/components/Invites/InvitesPage.test.tsx | 34 +++++++++++++++++++++ src/components/Invites/InvitesPage.tsx | 17 ++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 71feede22d..f0806a9bcd 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -565,6 +565,40 @@ describe('invite and badge campaign routing boundaries', () => { expect(mockPush).not.toHaveBeenCalledWith('/profile/peanut') }) + it('names the campaign and the reason in the unavailable warning, as a string', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockSearch = 'badge_campaign=utm:pix,retired' + mockQueryResult.data = { success: true, attributionResolved: true, onboardingResolved: true, username: '' } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { badgeCampaign: 'utm:pix', outcome: 'unknown' }, + { badgeCampaign: 'retired', outcome: 'expired' }, + ], + }) + + render() + + await waitFor(() => + expect(warn).toHaveBeenCalledWith( + 'Badge campaign unavailable; continuing normally', + 'utm:pix=unknown, retired=expired' + ) + ) + + // The regression this pins. Sentry serializes each console argument, so the + // previous object payload reached production as the literal "[object Object]" + // and the warning named neither the campaign nor the reason (PEANUT-UI-SJC). + const call = warn.mock.calls.find(([message]) => message === 'Badge campaign unavailable; continuing normally') + expect(typeof call?.[1]).toBe('string') + + // The message must stay constant, or Sentry opens a new issue per campaign. + expect(call?.[0]).toBe('Badge campaign unavailable; continuing normally') + + warn.mockRestore() + }) + it('returns existing-user login to code-only badge acquisition before normal fallback', async () => { mockAuth.user = null mockSearch = 'code=offramp' diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index ef3a9d4241..a5a8e2a91e 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -148,12 +148,19 @@ function InvitePageContent() { } } - const unavailable = batch.claims.some(isUnavailableBadgeCampaignClaim) + const unavailable = batch.claims.filter(isUnavailableBadgeCampaignClaim) const retryable = batch.pending.length > 0 - if (unavailable) { - console.warn('Badge campaign unavailable; continuing normally', { - claims: batch.claims, - }) + if (unavailable.length > 0) { + // Pre-joined into ONE string, matching useZeroDev.ts. Sentry's + // console integration serializes each console argument, so an + // object holding an array of claims landed in the issue as the + // literal "[object Object]" — which campaign and why, the only + // two facts worth logging, were both unreadable. Keep the + // message constant so Sentry groups these into one issue. + console.warn( + 'Badge campaign unavailable; continuing normally', + unavailable.map(({ badgeCampaign, outcome }) => `${badgeCampaign}=${outcome}`).join(', ') + ) } if (retryable) { captureException(new Error('invite-page campaign claim retained for retry'), { From 6ebb7e58e76feca84dfd554effef900f83406acd Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 15:21:33 +0200 Subject: [PATCH 02/45] feat(home): flag-gated user-interview invite card in the carousel Ask our most active users for a 15-min founder call (TASK-21189). The cohort lives in the PostHog flag user-interviews-invite (release condition on username), never in code - list edits and the kill switch are one click in PostHog and no usernames ship in the bundle. The card leads the carousel for targeted users, opens the shared cal.com event via the Capacitor-safe helper, and inherits the standard 7-day X-dismissal. Temporary campaign: delete the card, flag, and i18n keys when it ends. --- src/app/(mobile-ui)/dev/home-ctas/page.tsx | 10 ++++++ src/constants/analytics.consts.ts | 3 ++ src/constants/general.consts.ts | 5 +++ src/hooks/useHomeCarouselCTAs.tsx | 36 ++++++++++++++++++++-- src/i18n/app/messages/en.json | 4 +++ src/i18n/app/messages/es-419.json | 4 +++ src/i18n/app/messages/es-AR.json | 4 +++ src/i18n/app/messages/pt-BR.json | 4 +++ 8 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/dev/home-ctas/page.tsx b/src/app/(mobile-ui)/dev/home-ctas/page.tsx index 8e3d5cac18..4287092ac4 100644 --- a/src/app/(mobile-ui)/dev/home-ctas/page.tsx +++ b/src/app/(mobile-ui)/dev/home-ctas/page.tsx @@ -8,6 +8,7 @@ import CarouselCTA from '@/components/Home/HomeCarouselCTA/CarouselCTA' import ActivationCTAs from '@/components/Home/ActivationCTAs' import { type ActivationStep } from '@/hooks/useActivationStatus' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' +import { PeanutWavingHello } from '@/assets/mascot' import DevNoteCard from '../_components/DevNoteCard' import DevPageShell from '../_components/DevPageShell' import DevSectionLabel from '../_components/DevSectionLabel' @@ -120,6 +121,15 @@ const CAROUSEL_PREVIEWS: CarouselPreview[] = [ title: 'Invite friends. Earn rewards', description: 'Earn rewards every time your friends use Peanut.', }, + { + id: 'user-interview', + label: 'User-interview invite (flag-gated campaign, logo variant)', + icon: 'peanut-support', + logo: PeanutWavingHello, + logoSize: 30, + title: 'Help shape Peanut', + description: "You're one of our most active users. Book a 15-min call with the founders.", + }, { id: 'bug-bounty', label: 'Bug bounty', diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index fa2218d851..8f6f078cce 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -132,6 +132,9 @@ export const ANALYTICS_EVENTS = { // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', + // User-interview campaign card (temporary) — clicked = tapped through to cal.com. + // Impressions come free from the flag's $feature_flag_called exposure event. + USER_INTERVIEW_CTA_CLICKED: 'user_interview_cta_clicked', // ── Error ── BACKEND_ERROR_SHOWN: 'backend_error_shown', diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 851a3c7b13..414c14ac15 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -244,3 +244,8 @@ export const ENS_NAME_REGEX = /^(?:[-a-zA-Z0-9]+\.)+[-a-zA-Z0-9]+$/ export const USERNAME_MIN_LENGTH = 4 export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=me.peanut.wallet' + +// User-interview campaign (temporary): one shared cal.com event for the +// flag-gated founder-call card on home. Delete together with the +// `user-interviews-invite` PostHog flag when the campaign ends. +export const USER_INTERVIEW_CAL_URL = 'https://cal.com/aleks-n-hugo/15min' diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 4aca984e92..6e023a9a11 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -12,7 +12,7 @@ import type { StaticImageData } from 'next/image' import { useModalsContext } from '@/context/ModalsContext' import { DeviceType, useDeviceType } from './useGetDeviceType' import { usePWAStatus } from './usePWAStatus' -import { isCapacitor } from '@/utils/capacitor' +import { isCapacitor, openExternalUrl } from '@/utils/capacitor' import { useGeoLocation } from './useGeoLocation' import { useCardInfo } from './useCardInfo' import { useActivationStatus } from './useActivationStatus' @@ -20,10 +20,14 @@ import { useTransactionHistory } from './useTransactionHistory' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useToast } from '@/components/0_Bruddle/Toast' -import { PEANUTMAN_MOBILE } from '@/assets/mascot' +import { PEANUTMAN_MOBILE, PeanutWavingHello } from '@/assets/mascot' import { MIGRATION_SURFACES } from '@/constants/migration.consts' import { useMigrationFlag } from './useMigrationFlag' import { openStore } from '@/utils/migration.utils' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { USER_INTERVIEW_CAL_URL } from '@/constants/general.consts' +import { useFeatureFlags } from './useFeatureFlag' // Days a dismissed CTA stays hidden before reappearing. Set above 1 so dismiss feels // "sticky" but below 14 so we still nudge users about valuable actions they haven't @@ -79,6 +83,12 @@ export const useHomeCarouselCTAs = () => { const t = useTranslations('home.carousel') const tMigration = useTranslations('migration') const migrationOn = useMigrationFlag() + const flagEnabled = useFeatureFlags() + // User-interview campaign gate. nonProdBypass: previews/local always show + // the card for QA; on prod the PostHog `username` release condition decides. + // No mounted guard needed (cf. useMigrationFlag): the value is only read in + // generateCarouselCTAs, which runs after mount in an effect. + const interviewInviteOn = flagEnabled('user-interviews-invite', { nonProdBypass: true }) const [carouselCTAs, setCarouselCTAs] = useState([]) const { user } = useAuth() const dismissedRef = useRef>(new Map()) @@ -138,6 +148,27 @@ export const useHomeCarouselCTAs = () => { const _carouselCTAs: CarouselCTA[] = [] const b = (chunks: React.ReactNode) => {chunks} + // User-interview invite (temporary campaign): hand-picked heavy users + // get asked for a 15-min founder call. The cohort lives in the PostHog + // flag's `username` release condition — never in code. Leads the + // carousel on purpose; it targets a handful of users. X-dismissal uses + // the standard 7-day cooldown (id filter below). Delete this block + + // flag + i18n keys when the campaign ends. + if (interviewInviteOn) { + _carouselCTAs.push({ + id: 'user-interview', + title: t('userInterview.title'), + description: t('userInterview.description'), + icon: 'peanut-support', // required by the type; hidden — logo takes precedence + logo: PeanutWavingHello, + logoSize: 30, + onClick: () => { + posthog.capture(ANALYTICS_EVENTS.USER_INTERVIEW_CTA_CLICKED) + void openExternalUrl(USER_INTERVIEW_CAL_URL) + }, + }) + } + // pwa-sunset notice window: get-the-app nudge leads the carousel and // supersedes the ios-pwa-install CTA below (TASK-20829). Mobile goes // straight to the visitor's store; desktop opens the scan-to-download QR. @@ -355,6 +386,7 @@ export const useHomeCarouselCTAs = () => { dismissCTA, openSupportWithMessage, migrationOn, + interviewInviteOn, tMigration, setIsGetAppModalOpen, ]) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index e41756464d..302578f9ce 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -192,6 +192,10 @@ "title": "Help us improve and get $5!", "description": "Report a bug. Get rewarded! No questions asked." }, + "userInterview": { + "title": "Help shape Peanut", + "description": "You're one of our most active users. Book a 15-min call with the founders." + }, "kyc": { "title": "Unlock QR code payments", "description": "Confirm your ID to pay with Mercado Pago and PIX QR codes" diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 63257e0d34..6daae2ce35 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -192,6 +192,10 @@ "title": "Ayúdanos a mejorar y ¡gana $5!", "description": "Reporta un error. ¡Recibe una recompensa! Sin preguntas." }, + "userInterview": { + "title": "Ayúdanos a construir Peanut", + "description": "Eres uno de nuestros usuarios más activos. Agenda una llamada de 15 min con los fundadores." + }, "kyc": { "title": "Desbloquea pagos con QR", "description": "Confirma tu identidad para pagar códigos QR de Mercado Pago y PIX" diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 19eb364864..33dc4507b1 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -99,6 +99,10 @@ "title": "Ayudanos a mejorar y ¡ganá $5!", "description": "Reportá un error. ¡Recibí una recompensa! Sin preguntas." }, + "userInterview": { + "title": "Ayudanos a construir Peanut", + "description": "Sos uno de nuestros usuarios más activos. Agendá una llamada de 15 min con los fundadores." + }, "kyc": { "title": "Desbloqueá pagos con QR", "description": "Confirmá tu identidad para pagar códigos QR de Mercado Pago y PIX" diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 939fddbdbf..712319156f 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -192,6 +192,10 @@ "title": "Ajude a gente a melhorar e ganhe $5!", "description": "Relate um bug. Ganhe uma recompensa! Sem perguntas." }, + "userInterview": { + "title": "Ajude a construir Peanut", + "description": "Você é um dos nossos usuários mais ativos. Agende um papo de 15 min com os fundadores." + }, "kyc": { "title": "Desbloqueie pagamentos com QR", "description": "Confirme sua identidade para pagar códigos QR do Mercado Pago e do PIX" From bc6af1e84f9a6c73697839dafc433ac44c09591e Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 15:58:32 +0200 Subject: [PATCH 03/45] review: await cal.com open, honest impression-proxy comment, full teardown checklist Applied from the automated review pass: (1) await openExternalUrl so a native Browser.open failure surfaces in CarouselCTA's onClick catch instead of a detached rejection; (2) the analytics comment no longer claims flag exposures equal impressions (they fire per evaluation, prod-only, dismissed users included) and points readers at cal.com bookings + a $host filter; (3) the delete-together checklist now names the dev/home-ctas preview entry so teardown removes all three copies. --- src/constants/analytics.consts.ts | 5 ++++- src/hooks/useHomeCarouselCTAs.tsx | 11 +++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 8f6f078cce..a72771a4ac 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -133,7 +133,10 @@ export const ANALYTICS_EVENTS = { // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', // User-interview campaign card (temporary) — clicked = tapped through to cal.com. - // Impressions come free from the flag's $feature_flag_called exposure event. + // No viewed event: $feature_flag_called is only a rough impression proxy — + // it fires per flag evaluation (prod only, dismissed users included), not + // per card render. Bookings on cal.com are the campaign's real metric. + // Non-prod taps also capture; filter insights by $host = peanut.me. USER_INTERVIEW_CTA_CLICKED: 'user_interview_cta_clicked', // ── Error ── diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 6e023a9a11..53647ff20f 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -152,8 +152,9 @@ export const useHomeCarouselCTAs = () => { // get asked for a 15-min founder call. The cohort lives in the PostHog // flag's `username` release condition — never in code. Leads the // carousel on purpose; it targets a handful of users. X-dismissal uses - // the standard 7-day cooldown (id filter below). Delete this block + - // flag + i18n keys when the campaign ends. + // the standard 7-day cooldown (id filter below). Delete this block, the + // flag, the i18n keys, and the dev/home-ctas preview entry when the + // campaign ends. if (interviewInviteOn) { _carouselCTAs.push({ id: 'user-interview', @@ -162,9 +163,11 @@ export const useHomeCarouselCTAs = () => { icon: 'peanut-support', // required by the type; hidden — logo takes precedence logo: PeanutWavingHello, logoSize: 30, - onClick: () => { + onClick: async () => { posthog.capture(ANALYTICS_EVENTS.USER_INTERVIEW_CTA_CLICKED) - void openExternalUrl(USER_INTERVIEW_CAL_URL) + // Await so a native Browser.open failure surfaces in + // CarouselCTA's onClick try/catch instead of vanishing. + await openExternalUrl(USER_INTERVIEW_CAL_URL) }, }) } From d95c1a5502178289f7ed6eb23c40b2a7bba40a5e Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 16:14:33 +0200 Subject: [PATCH 04/45] review: booking link is the live cal.com dynamic group URL The committed aleks-n-hugo/15min event 404s (CodeRabbit catch, verified). Replaced with the hugo0+abalinda dynamic group link, which resolves and books both hosts' mutual availability. --- src/constants/general.consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 414c14ac15..7ee5139269 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -248,4 +248,4 @@ export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=me. // User-interview campaign (temporary): one shared cal.com event for the // flag-gated founder-call card on home. Delete together with the // `user-interviews-invite` PostHog flag when the campaign ends. -export const USER_INTERVIEW_CAL_URL = 'https://cal.com/aleks-n-hugo/15min' +export const USER_INTERVIEW_CAL_URL = 'https://cal.com/hugo0+abalinda/dynamic' From 9efe3bfb8fb882070d5def54d34aabba2de28242 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 16:35:04 +0200 Subject: [PATCH 05/45] copy: the call is with the team, not the founders Aleks isn't a founder; only Hugo is. All four locales, the dev preview, and the code comments now say team. --- src/app/(mobile-ui)/dev/home-ctas/page.tsx | 2 +- src/constants/general.consts.ts | 2 +- src/hooks/useHomeCarouselCTAs.tsx | 2 +- src/i18n/app/messages/en.json | 2 +- src/i18n/app/messages/es-419.json | 2 +- src/i18n/app/messages/es-AR.json | 2 +- src/i18n/app/messages/pt-BR.json | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/dev/home-ctas/page.tsx b/src/app/(mobile-ui)/dev/home-ctas/page.tsx index 4287092ac4..8ca67d83c2 100644 --- a/src/app/(mobile-ui)/dev/home-ctas/page.tsx +++ b/src/app/(mobile-ui)/dev/home-ctas/page.tsx @@ -128,7 +128,7 @@ const CAROUSEL_PREVIEWS: CarouselPreview[] = [ logo: PeanutWavingHello, logoSize: 30, title: 'Help shape Peanut', - description: "You're one of our most active users. Book a 15-min call with the founders.", + description: "You're one of our most active users. Book a 15-min call with the team.", }, { id: 'bug-bounty', diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 7ee5139269..26c31eee60 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -246,6 +246,6 @@ export const USERNAME_MIN_LENGTH = 4 export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=me.peanut.wallet' // User-interview campaign (temporary): one shared cal.com event for the -// flag-gated founder-call card on home. Delete together with the +// flag-gated team-call card on home. Delete together with the // `user-interviews-invite` PostHog flag when the campaign ends. export const USER_INTERVIEW_CAL_URL = 'https://cal.com/hugo0+abalinda/dynamic' diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 53647ff20f..6003aee2d5 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -149,7 +149,7 @@ export const useHomeCarouselCTAs = () => { const b = (chunks: React.ReactNode) => {chunks} // User-interview invite (temporary campaign): hand-picked heavy users - // get asked for a 15-min founder call. The cohort lives in the PostHog + // get asked for a 15-min call with the team. The cohort lives in the PostHog // flag's `username` release condition — never in code. Leads the // carousel on purpose; it targets a handful of users. X-dismissal uses // the standard 7-day cooldown (id filter below). Delete this block, the diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 302578f9ce..b564cd4863 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -194,7 +194,7 @@ }, "userInterview": { "title": "Help shape Peanut", - "description": "You're one of our most active users. Book a 15-min call with the founders." + "description": "You're one of our most active users. Book a 15-min call with the team." }, "kyc": { "title": "Unlock QR code payments", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 6daae2ce35..26a71bb45f 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -194,7 +194,7 @@ }, "userInterview": { "title": "Ayúdanos a construir Peanut", - "description": "Eres uno de nuestros usuarios más activos. Agenda una llamada de 15 min con los fundadores." + "description": "Eres uno de nuestros usuarios más activos. Agenda una llamada de 15 min con el equipo." }, "kyc": { "title": "Desbloquea pagos con QR", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 33dc4507b1..06f93c9539 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -101,7 +101,7 @@ }, "userInterview": { "title": "Ayudanos a construir Peanut", - "description": "Sos uno de nuestros usuarios más activos. Agendá una llamada de 15 min con los fundadores." + "description": "Sos uno de nuestros usuarios más activos. Agendá una llamada de 15 min con el equipo." }, "kyc": { "title": "Desbloqueá pagos con QR", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 712319156f..d40c75d675 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -194,7 +194,7 @@ }, "userInterview": { "title": "Ajude a construir Peanut", - "description": "Você é um dos nossos usuários mais ativos. Agende um papo de 15 min com os fundadores." + "description": "Você é um dos nossos usuários mais ativos. Agende um papo de 15 min com a equipe." }, "kyc": { "title": "Desbloqueie pagamentos com QR", From 2c83ad7ab99db9eb932f3ca76d746e97774cd8b4 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 16:54:26 +0200 Subject: [PATCH 06/45] ui: render the waving mascot at 44px The shared icon container is size-8, and the Tailwind preflight img max-width clamps any larger logo back to 32px - so the size bump rides the existing iconContainerClassName prop (tailwind-merge lets size-11 override size-8). The asset already animates: it is the animated webp, which Next serves untouched (ANIM chunk verified through /_next/image). --- src/app/(mobile-ui)/dev/home-ctas/page.tsx | 3 ++- src/hooks/useHomeCarouselCTAs.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/dev/home-ctas/page.tsx b/src/app/(mobile-ui)/dev/home-ctas/page.tsx index 8ca67d83c2..37987c5b69 100644 --- a/src/app/(mobile-ui)/dev/home-ctas/page.tsx +++ b/src/app/(mobile-ui)/dev/home-ctas/page.tsx @@ -126,7 +126,8 @@ const CAROUSEL_PREVIEWS: CarouselPreview[] = [ label: 'User-interview invite (flag-gated campaign, logo variant)', icon: 'peanut-support', logo: PeanutWavingHello, - logoSize: 30, + logoSize: 44, + iconContainerClassName: 'size-11', title: 'Help shape Peanut', description: "You're one of our most active users. Book a 15-min call with the team.", }, diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 6003aee2d5..548ac67787 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -162,7 +162,10 @@ export const useHomeCarouselCTAs = () => { description: t('userInterview.description'), icon: 'peanut-support', // required by the type; hidden — logo takes precedence logo: PeanutWavingHello, - logoSize: 30, + logoSize: 44, + // The shared icon container is size-8; without this override the + // Tailwind preflight img max-width clamps the logo back to 32px. + iconContainerClassName: 'size-11', onClick: async () => { posthog.capture(ANALYTICS_EVENTS.USER_INTERVIEW_CTA_CLICKED) // Await so a native Browser.open failure surfaces in From b76908dbebb1315ad4f3cbaf8ad73731fef9f173 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 10 Aug 2026 23:46:18 +0200 Subject: [PATCH 07/45] feat(fx): consume the shared card-markup endpoint and drop both frontend copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card-vs-Peanut markup was modeled twice in the frontend — once as a server action, once copied into the merchant page with a header saying to delete it when #2108 landed. #2108 landed. Both computed their own dolarapi call and their own issuer fee, and neither could agree with the rate on screen. The hook keeps its signature and its never-fails contract: a backend outage falls back to the same static table as before, so a frontend deploy that precedes the backend degrades to today's numbers instead of blanking a surface. --- next.config.js | 1 - .../actions/__tests__/card-comparison.test.ts | 103 --------- src/app/actions/card-comparison.ts | 82 -------- src/app/m/[slug]/MerchantLandingPage.tsx | 38 +--- src/app/m/[slug]/card-comparison.ts | 75 ------- .../Marketing/mdx/CompareSavings.tsx | 152 ++++++++++++++ .../mdx/__tests__/CompareSavings.test.tsx | 64 ++++++ src/components/Marketing/mdx/components.tsx | 3 + src/constants/payment.consts.ts | 22 +- .../__tests__/useCardMarkupRate.test.tsx | 52 +++++ src/hooks/useCardMarkupRate.ts | 41 ++-- src/types/api.generated.ts | 100 +++++++++ src/types/api.openapi.json | 196 ++++++++++++++++++ src/utils/__tests__/fx.utils.test.ts | 84 +++++++- src/utils/fx.utils.ts | 97 +++++++++ 15 files changed, 795 insertions(+), 315 deletions(-) delete mode 100644 src/app/actions/__tests__/card-comparison.test.ts delete mode 100644 src/app/actions/card-comparison.ts delete mode 100644 src/app/m/[slug]/card-comparison.ts create mode 100644 src/components/Marketing/mdx/CompareSavings.tsx create mode 100644 src/components/Marketing/mdx/__tests__/CompareSavings.test.tsx create mode 100644 src/hooks/__tests__/useCardMarkupRate.test.tsx diff --git a/next.config.js b/next.config.js index 4d0b8a1b9b..8c5823a9f1 100644 --- a/next.config.js +++ b/next.config.js @@ -139,7 +139,6 @@ function contentSecurityPolicyReportOnly() { // Token metadata lookup in TransactionDetailsReceipt — a different // CoinGecko host from the two image CDNs above. 'https://api.coingecko.com', - 'https://dolarapi.com', 'https://ipapi.co', 'https://api.justaname.id', 'https://*.crisp.chat', diff --git a/src/app/actions/__tests__/card-comparison.test.ts b/src/app/actions/__tests__/card-comparison.test.ts deleted file mode 100644 index e74f64401c..0000000000 --- a/src/app/actions/__tests__/card-comparison.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getCardMarkupRate } from '@/app/actions/card-comparison' - -jest.mock('@/app/actions/currency', () => ({ - getCurrencyPrice: jest.fn(), -})) - -const { getCurrencyPrice } = jest.requireMock('@/app/actions/currency') - -describe('getCardMarkupRate', () => { - const originalFetch = global.fetch - - beforeEach(() => { - jest.clearAllMocks() - global.fetch = jest.fn() as any - }) - - afterAll(() => { - global.fetch = originalFetch - }) - - it('returns null for currencies outside the comparison set', async () => { - const result = await getCardMarkupRate('EUR', 1.05) - expect(result).toBeNull() - }) - - it('returns null for empty currency code', async () => { - const result = await getCardMarkupRate('', 100) - expect(result).toBeNull() - }) - - it('returns the static BRL rate without hitting the network', async () => { - const result = await getCardMarkupRate('BRL') - expect(result).toEqual({ rate: 0.07, source: 'static' }) - expect(global.fetch).not.toHaveBeenCalled() - expect(getCurrencyPrice).not.toHaveBeenCalled() - }) - - it('returns a live ARS rate when both dolarapi and a Manteca price are available', async () => { - ;(global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ venta: 1000 }), - }) - // mantecaPrice=1100, bcra=1000, issuer=0.03 - // effectiveCardRate = 1000 * 0.97 = 970 - // rate = 1100/970 - 1 ≈ 0.1340 - const result = await getCardMarkupRate('ARS', 1100) - expect(result?.source).toBe('live') - expect(result?.rate).toBeCloseTo(0.134, 3) - }) - - it('fetches Manteca itself when no price is passed (LocalRailNudge call shape)', async () => { - getCurrencyPrice.mockResolvedValueOnce({ sell: 1100, buy: 1080 }) - ;(global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ venta: 1000 }), - }) - const result = await getCardMarkupRate('ARS') - expect(getCurrencyPrice).toHaveBeenCalledWith('ARS') - expect(result?.source).toBe('live') - expect(result?.rate).toBeGreaterThan(0) - }) - - it('falls back to the static ARS rate when dolarapi fails', async () => { - ;(global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 503, - json: () => Promise.resolve({}), - }) - const result = await getCardMarkupRate('ARS', 1100) - expect(result).toEqual({ rate: 0.0913, source: 'static' }) - }) - - it('falls back to the static ARS rate when dolarapi throws', async () => { - ;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('network down')) - const result = await getCardMarkupRate('ARS', 1100) - expect(result).toEqual({ rate: 0.0913, source: 'static' }) - }) - - it('falls back to the static ARS rate when dolarapi returns garbage', async () => { - ;(global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ venta: 'not-a-number' }), - }) - const result = await getCardMarkupRate('ARS', 1100) - expect(result).toEqual({ rate: 0.0913, source: 'static' }) - }) - - it('falls back to static when the live calc produces a non-positive markup (manteca cheaper than BCRA)', async () => { - // Pathological case — BCRA above Manteca should never happen, but we - // guard against showing "-3% savings" if FX weirdness inverts it. - ;(global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ venta: 2000 }), - }) - const result = await getCardMarkupRate('ARS', 1100) - expect(result).toEqual({ rate: 0.0913, source: 'static' }) - }) - - it('normalizes the currency code (lower-case input)', async () => { - const result = await getCardMarkupRate('brl') - expect(result).toEqual({ rate: 0.07, source: 'static' }) - }) -}) diff --git a/src/app/actions/card-comparison.ts b/src/app/actions/card-comparison.ts deleted file mode 100644 index fd32a891fb..0000000000 --- a/src/app/actions/card-comparison.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CARD_FX_MARKUP_BY_CURRENCY } from '@/constants/payment.consts' -import { getCurrencyPrice } from '@/app/actions/currency' - -/** - * Typical foreign-issuer FX markup applied on top of the network rate. Visa/MC - * deliver near mid-market; the issuing bank adds 2–3% as a foreign-transaction - * fee. Used by the ARS dynamic calc on top of the BCRA official rate. - */ -const ISSUER_FX_FEE = 0.03 - -export interface CardMarkup { - /** Savings as a fraction of the Peanut USD amount (i.e. usdAmount × rate = USD saved). */ - rate: number - /** Whether the rate came from a live source or fell back to a static constant. */ - source: 'live' | 'static' -} - -/** - * Single source of truth for "how much extra does a foreign card cost vs - * Peanut's local rail" — shared by every card-comparison surface (the QR pay - * confirm + success screens, and the post-card-spend nudge on transaction - * receipts). Currencies where the comparison is meaningful: ARS, BRL today. - * - * - ARS: live. BCRA official rate via dolarapi.com (what a foreign card user - * actually gets in Argentina post-PAIS-elimination) compared to - * Manteca's MEP/blue-equivalent rate that Peanut delivers. Spread - * fluctuates daily — anywhere from ~5% to ~25% historically — which is - * why this can't be a constant. Note: Manteca's price has its own - * spread baked in, so the returned rate reflects what Peanut actually - * delivers vs what a card delivers, not a pure BCRA-vs-MEP gap. - * - BRL: static. IOF on foreign card purchases (3.5% as of 2025, phasing to 0 - * by 2028) + issuer FX markup ~3%. Statutory + contract — not moving - * on FX news. - * - * Returns null if the currency has no meaningful card-vs-local-rail gap. - * - * @param currencyCode ISO code, case-insensitive - * @param mantecaPriceUsdToLocal Optional. If the caller already has Manteca's - * rate (e.g. from a QR payment lock), pass it to avoid an extra fetch. - * Otherwise the action fetches it itself. Unused for static-fallback - * currencies (BRL). - */ -export async function getCardMarkupRate( - currencyCode: string, - mantecaPriceUsdToLocal?: number | null -): Promise { - const code = currencyCode?.toUpperCase() - if (!code) return null - const staticRate = CARD_FX_MARKUP_BY_CURRENCY[code] - if (staticRate === undefined) return null - - if (code === 'ARS') { - try { - let mantecaPrice = mantecaPriceUsdToLocal - if (!mantecaPrice || mantecaPrice <= 0) { - const { sell } = await getCurrencyPrice('ARS') - mantecaPrice = sell - } - if (mantecaPrice && mantecaPrice > 0) { - const res = await fetch('https://dolarapi.com/v1/dolares/oficial', { - next: { revalidate: 300 }, - }) - if (res.ok) { - const data = (await res.json()) as { venta?: number } - const bcraOfficial = Number(data?.venta) - if (Number.isFinite(bcraOfficial) && bcraOfficial > 0) { - const effectiveCardRate = bcraOfficial * (1 - ISSUER_FX_FEE) - const rate = mantecaPrice / effectiveCardRate - 1 - if (Number.isFinite(rate) && rate > 0) { - return { rate, source: 'live' } - } - } - } - } - } catch { - // Swallow — fall through to static fallback below. Never block - // the payment flow on a third-party FX feed being down. - } - } - - return { rate: staticRate, source: 'static' } -} diff --git a/src/app/m/[slug]/MerchantLandingPage.tsx b/src/app/m/[slug]/MerchantLandingPage.tsx index 9409b0bd13..d666ade637 100644 --- a/src/app/m/[slug]/MerchantLandingPage.tsx +++ b/src/app/m/[slug]/MerchantLandingPage.tsx @@ -4,14 +4,15 @@ import { Fragment, useMemo, useState } from 'react' import Image from 'next/image' import Link from 'next/link' import { motion } from 'framer-motion' -import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/0_Bruddle/Button' import { Marquee } from '@/components/LandingPage' import { FAQsPanel } from '@/components/Global/FAQs' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { useCardMarkupRate } from '@/hooks/useCardMarkupRate' +import type { CardMarkup } from '@/utils/fx.utils' +import { CARD_FX_MARKUP_BY_CURRENCY } from '@/constants/payment.consts' import { Sparkle, Star } from '@/assets/illustrations' import { UTM_MEDIUMS, UTM_SOURCES, withUtm } from '@/utils/utm.utils' -import { getArsCardMarkup, type CardMarkup } from './card-comparison' import type { Merchant, MenuItem } from './merchants' /** Build a `/invite` URL with the merchant's vanity code + the UTM @@ -33,31 +34,6 @@ function buildInviteHref(merchant: Merchant, content: 'hero' | 'end_fold'): stri ) } -/** Documented empirical ARS card-vs-Peanut spread + issuer markup — used - * as the immediate render value and the fallback when the live fetch fails. - * Mirrors `CARD_FX_MARKUP_BY_CURRENCY.ARS` from PR #2108. */ -const ARS_CARD_MARKUP_FALLBACK = 0.0913 - -/** - * Client wrapper around the `getArsCardMarkup` server action — keeps the - * third-party dolarapi.com call off the client and lets Next edge-cache the - * response for 5min. React-query layers an additional 5min client cache and - * re-fetches on focus so the displayed savings stays fresh without us having - * to think about it. Returns the static fallback while loading so the menu - * cards and banner can render unconditionally. - */ -function useCardMarkupArs(criptoUsdToArs: number): CardMarkup { - const { data } = useQuery({ - queryKey: ['merchantLpArsCardMarkup', criptoUsdToArs > 0 ? Math.round(criptoUsdToArs) : 0], - queryFn: () => getArsCardMarkup(criptoUsdToArs), - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - refetchOnWindowFocus: true, - enabled: criptoUsdToArs > 0, - }) - return data ?? { rate: ARS_CARD_MARKUP_FALLBACK, source: 'static' } -} - const ctaButtonClassName = '!w-auto bg-white px-7 py-3 text-base font-extrabold hover:bg-white/90 md:px-9 md:py-8 md:text-xl' @@ -310,7 +286,11 @@ function MenuFold({ fold }: { fold: Extract enabled: fold.showLiveRate, }) const arsPerUnit = currency === 'USD' ? usdArs : eurArs - const cardMarkup = useCardMarkupArs(usdArs) + // No locked price is passed: the backend computes the markup against the + // same market snapshot `usdArs` comes from, so the two agree by + // construction. The static entry only covers a backend outage. + const { data: markup } = useCardMarkupRate('ARS') + const cardMarkup = markup ?? { rate: CARD_FX_MARKUP_BY_CURRENCY.ARS, source: 'static' as const } return (