diff --git a/e2e/utils/mock-api.ts b/e2e/utils/mock-api.ts index 3c23d45081..75d77c017a 100644 --- a/e2e/utils/mock-api.ts +++ b/e2e/utils/mock-api.ts @@ -20,7 +20,6 @@ const MOCK_TIER_INFO = { transitivePoints: 300, totalPoints: 500, currentTier: 1, - leaderboardRank: 42, nextTierThreshold: 1000, pointsToNextTier: 500, } 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/public/badges/acai_powered.svg b/public/badges/acai_powered.svg new file mode 100644 index 0000000000..e753e0a176 --- /dev/null +++ b/public/badges/acai_powered.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/ens.svg b/public/badges/ens.svg new file mode 100644 index 0000000000..bbfe1d8a57 --- /dev/null +++ b/public/badges/ens.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/splitter.svg b/public/badges/splitter.svg new file mode 100644 index 0000000000..2655cf4631 --- /dev/null +++ b/public/badges/splitter.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/badges/surf_up.svg b/public/badges/surf_up.svg new file mode 100644 index 0000000000..6512b87c17 --- /dev/null +++ b/public/badges/surf_up.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/tron.svg b/public/badges/tron.svg new file mode 100644 index 0000000000..abd02526cf --- /dev/null +++ b/public/badges/tron.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/(mobile-ui)/dev/home-ctas/page.tsx b/src/app/(mobile-ui)/dev/home-ctas/page.tsx index 8e3d5cac18..37987c5b69 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,16 @@ 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: 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.", + }, { id: 'bug-bounty', label: 'Bug bounty', diff --git a/src/app/(mobile-ui)/dev/leaderboard/page.tsx b/src/app/(mobile-ui)/dev/leaderboard/page.tsx deleted file mode 100644 index 79c9ed24bd..0000000000 --- a/src/app/(mobile-ui)/dev/leaderboard/page.tsx +++ /dev/null @@ -1,483 +0,0 @@ -'use client' - -import { useState, useEffect, useCallback, useRef } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Card from '@/components/Global/Card' -import { Button } from '@/components/0_Bruddle/Button' -import { pointsApi } from '@/services/points' -import { Icon } from '@/components/Global/Icons/Icon' -import DevPageShell from '../_components/DevPageShell' - -type TimeFilter = '1h' | '6h' | '24h' | 'custom' - -// Mock data generator - creates realistic time-based data -const generateMockData = (sinceDate: Date) => { - const now = new Date() - const hoursAgo = (now.getTime() - sinceDate.getTime()) / (1000 * 60 * 60) - - // All users with their activity timestamps (hours ago) and base points per hour - const allUsers = [ - { userId: '1', username: 'cryptoqueen', currentTier: 3, hoursAgo: 0.5, pointsPerHour: 280 }, - { userId: '2', username: 'defi_wizard', currentTier: 2, hoursAgo: 1, pointsPerHour: 240 }, - { userId: '3', username: 'hodler_max', currentTier: 3, hoursAgo: 2, pointsPerHour: 220 }, - { userId: '4', username: 'web3_warrior', currentTier: 2, hoursAgo: 3, pointsPerHour: 200 }, - { userId: '5', username: 'nft_collector', currentTier: 1, hoursAgo: 4, pointsPerHour: 180 }, - { userId: '6', username: 'blockchain_bob', currentTier: 2, hoursAgo: 5, pointsPerHour: 160 }, - { userId: '7', username: 'smart_contract', currentTier: 1, hoursAgo: 6, pointsPerHour: 150 }, - { userId: '8', username: 'eth_enthusiast', currentTier: 2, hoursAgo: 8, pointsPerHour: 140 }, - { userId: '9', username: 'token_trader', currentTier: 1, hoursAgo: 10, pointsPerHour: 120 }, - { userId: '10', username: 'meta_master', currentTier: 1, hoursAgo: 12, pointsPerHour: 100 }, - { userId: '11', username: 'crypto_surfer', currentTier: 1, hoursAgo: 14, pointsPerHour: 90 }, - { userId: '12', username: 'defi_degen', currentTier: 0, hoursAgo: 16, pointsPerHour: 80 }, - { userId: '13', username: 'web3_dev', currentTier: 1, hoursAgo: 18, pointsPerHour: 70 }, - { userId: '14', username: 'nft_flipper', currentTier: 0, hoursAgo: 19, pointsPerHour: 65 }, - { userId: '15', username: 'yield_farmer', currentTier: 1, hoursAgo: 20, pointsPerHour: 60 }, - { userId: '16', username: 'staking_pro', currentTier: 0, hoursAgo: 21, pointsPerHour: 55 }, - { userId: '17', username: 'dao_voter', currentTier: 0, hoursAgo: 22, pointsPerHour: 50 }, - { userId: '18', username: 'layer2_fan', currentTier: 0, hoursAgo: 22.5, pointsPerHour: 45 }, - { userId: '19', username: 'gas_optimizer', currentTier: 0, hoursAgo: 23, pointsPerHour: 40 }, - { userId: '20', username: 'wallet_ninja', currentTier: 0, hoursAgo: 23.5, pointsPerHour: 35 }, - ] - - // Filter users who have activity within the time window - const activeUsers = allUsers - .filter((user) => user.hoursAgo <= hoursAgo) - .map((user) => ({ - userId: user.userId, - username: user.username, - currentTier: user.currentTier, - // Calculate points based on how long they've been active - pointsEarned: Math.floor(user.pointsPerHour * Math.min(hoursAgo - user.hoursAgo, 24)), - })) - .filter((user) => user.pointsEarned > 0) - .sort((a, b) => b.pointsEarned - a.pointsEarned) - .slice(0, 20) - .map((user, index) => ({ - ...user, - rank: index + 1, - })) - - return { - leaderboard: activeUsers, - since: sinceDate.toISOString(), - limit: 20, - } -} - -const USE_MOCK_DATA = false // Set to false to use real backend data - -export default function LeaderboardPage() { - const router = useRouter() - const searchParams = useSearchParams() - const debounceTimerRef = useRef(null) - const isManualInputRef = useRef(false) // Track if user is manually typing - - const [timeFilter, setTimeFilter] = useState('24h') - const [customTime, setCustomTime] = useState('') - const [leaderboard, setLeaderboard] = useState< - Array<{ - rank: number - userId: string - username: string - pointsEarned: number - currentTier: number - }> - >([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [sinceDate, setSinceDate] = useState('') - const [lastUpdate, setLastUpdate] = useState(new Date()) - - // Helper: Convert UTC ISO string to local datetime-local format - const utcToLocal = useCallback((utcIso: string): string => { - const date = new Date(utcIso) - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day}T${hours}:${minutes}` - }, []) - - // Helper: Convert local datetime-local format to UTC ISO string - const localToUtc = useCallback((localDateTime: string): string => { - return new Date(localDateTime).toISOString() - }, []) - - const updateURL = useCallback( - (timestamp: string) => { - const params = new URLSearchParams() - params.set('since', timestamp) - router.push(`/dev/leaderboard?${params.toString()}`, { scroll: false }) - }, - [router] - ) - - const fetchLeaderboard = useCallback(async (since: string) => { - setLoading(true) - setError(null) - - // Use mock data if enabled - if (USE_MOCK_DATA) { - setTimeout(() => { - const mockData = generateMockData(new Date(since)) - setLeaderboard(mockData.leaderboard) - setSinceDate(mockData.since) - setLastUpdate(new Date()) - setLoading(false) - }, 300) // Simulate network delay - return - } - - const result = await pointsApi.getTimeLeaderboard({ limit: 20, since }) - - if (result.success && result.data) { - setLeaderboard(result.data.leaderboard) - setSinceDate(result.data.since) - setLastUpdate(new Date()) - } else { - setError('Failed to load leaderboard') - } - setLoading(false) - }, []) - - // Debounced fetch function - const debouncedFetch = useCallback( - (since: string) => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - debounceTimerRef.current = setTimeout(() => { - fetchLeaderboard(since) - }, 500) - }, - [fetchLeaderboard] - ) - - const handleTimeFilterChange = useCallback( - (filter: TimeFilter) => { - isManualInputRef.current = false // Reset flag for preset filters - setTimeFilter(filter) - - if (filter === 'custom') { - return - } - - const now = new Date() - let since: Date - - switch (filter) { - case '1h': - since = new Date(now.getTime() - 60 * 60 * 1000) - break - case '6h': - since = new Date(now.getTime() - 6 * 60 * 60 * 1000) - break - case '24h': - since = new Date(now.getTime() - 24 * 60 * 60 * 1000) - break - } - - const timestamp = since.toISOString() - updateURL(timestamp) - // Fetch immediately for preset filters (no debounce) - fetchLeaderboard(timestamp) - }, - [updateURL, fetchLeaderboard] - ) - - const handleCustomTimeChange = useCallback( - (value: string) => { - // Mark as manual input to prevent sync effect from overwriting - isManualInputRef.current = true - - // Update the input value immediately for responsive typing - setCustomTime(value) - if (!value) return - - setTimeFilter('custom') - try { - const timestamp = localToUtc(value) - updateURL(timestamp) - debouncedFetch(timestamp) - } catch { - // Invalid date format while typing, ignore - } - }, - [localToUtc, updateURL, debouncedFetch] - ) - - const handleCustomTimeSubmit = useCallback(() => { - if (!customTime) return - isManualInputRef.current = false // Reset flag after explicit submit - const timestamp = localToUtc(customTime) - updateURL(timestamp) - // Apply button fetches immediately (user explicitly clicked) - fetchLeaderboard(timestamp) - }, [customTime, localToUtc, updateURL, fetchLeaderboard]) - - const handleSetNow = useCallback(() => { - isManualInputRef.current = false // Not manual typing, it's a button click - const now = new Date() - const localDateTime = utcToLocal(now.toISOString()) - setCustomTime(localDateTime) - setTimeFilter('custom') - - // Also trigger fetch with the current time - const timestamp = now.toISOString() - updateURL(timestamp) - fetchLeaderboard(timestamp) - }, [utcToLocal, updateURL, fetchLeaderboard]) - - // Initialize from URL params - useEffect(() => { - const sinceParam = searchParams.get('since') - if (sinceParam) { - fetchLeaderboard(sinceParam) - } else { - // Load 24h by default - handleTimeFilterChange('24h') - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Sync custom time input with sinceDate (but not during manual input) - useEffect(() => { - if (sinceDate && !isManualInputRef.current) { - // Convert UTC ISO string to local datetime-local format - const localDateTime = utcToLocal(sinceDate) - setCustomTime(localDateTime) - } - // After data loads, reset manual input flag - if (sinceDate && isManualInputRef.current) { - // Give it a moment for the debounced fetch to complete - setTimeout(() => { - isManualInputRef.current = false - }, 1000) - } - }, [sinceDate, utcToLocal]) - - // Auto-refresh every 30 seconds - useEffect(() => { - if (!sinceDate) return - - const interval = setInterval(() => { - fetchLeaderboard(sinceDate) - }, 30000) // Refresh every 30 seconds - - return () => clearInterval(interval) - }, [sinceDate, fetchLeaderboard]) - - // Cleanup debounce timer on unmount - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - }, []) - - const getTierBadgeColor = (tier: number) => { - switch (tier) { - case 0: - return 'bg-gray-100 text-grey-1' - case 1: - return 'bg-blue-100 text-blue-700' - case 2: - return 'bg-purple-100 text-purple-700' - case 3: - return 'bg-yellow-100 text-yellow-700' - default: - return 'bg-gray-100 text-grey-1' - } - } - - const getTrophyColor = (rank: number) => { - if (rank === 1) return 'text-yellow-500' // Gold - if (rank === 2) return 'text-gray-400' // Silver - if (rank === 3) return 'text-orange-600' // Bronze - return 'text-gray-300' - } - - const formatDate = (isoString: string) => { - const date = new Date(isoString) - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - } - - return ( - -
- {/* Header with Prize */} -
-
- Last update: {lastUpdate.toLocaleTimeString()} -
-
-
- 💰 -
-

$50 PRIZE

-

for the top scorer!

-
- 🏆 -
-
-
- - {/* Leaderboard */} - {loading ? ( - -
- - Loading leaderboard... -
-
- ) : error ? ( - -

{error}

-
- ) : leaderboard.length === 0 ? ( - -

No points earned in this time period yet.

-
- ) : ( - - {leaderboard.map((entry) => ( -
-
- {/* Rank */} -
- {entry.rank <= 3 ? ( - - ) : ( - #{entry.rank} - )} -
- - {/* User Info */} -
-
- {entry.username} - - Tier {entry.currentTier} - -
- - {entry.pointsEarned.toLocaleString()} points - -
-
- - {/* Rank Badge for Top 3 */} -
- {entry.rank === 1 && ( - - 🥇 1st Place - - )} - {entry.rank === 2 && ( - - 🥈 2nd Place - - )} - {entry.rank === 3 && ( - - 🥉 3rd Place - - )} -
-
- ))} -
- )} -
- - {/* Compact Filter Bar at Bottom */} -
-
-
- {/* Quick Filters */} -
- Time Period: -
- - - -
-
- - {/* Custom Time */} -
- - handleCustomTimeChange(e.target.value)} - className="w-48 rounded-md border border-gray-3 px-2 py-1 text-sm focus:border-primary-1 focus:outline-none focus:ring-1 focus:ring-primary-1" - /> - -
- - {/* Since Info */} - {sinceDate && ( -
- Since: {formatDate(sinceDate)} -
- )} -
-
-
-
- ) -} diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx index 9e0da68f18..482f54d287 100644 --- a/src/app/(mobile-ui)/dev/page.tsx +++ b/src/app/(mobile-ui)/dev/page.tsx @@ -9,12 +9,6 @@ import DevPageShell from './_components/DevPageShell' export default function DevToolsPage() { // static: true → plain (file in public/, not an app route — Next Link can't client-navigate to it) const tools: { name: string; description: string; path: string; icon: IconName; static?: boolean }[] = [ - { - name: 'Points Leaderboard', - description: 'Real-time leaderboard with customizable time filters for event competitions', - path: '/dev/leaderboard', - icon: 'trophy', - }, { name: 'Full Graph', description: diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index f89cf23b93..8817d571ba 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -34,7 +34,7 @@ import { useHaptic } from 'use-haptic' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' -import { isTxReverted, printableAddress } from '@/utils/general.utils' +import { isTxReverted, printableAddress, validateEnsName } from '@/utils/general.utils' import { appBaseUrl } from '@/utils/url.utils' import { useFriendlyError } from '@/hooks/useFriendlyError' import posthog from 'posthog-js' @@ -77,6 +77,7 @@ export default function WithdrawCryptoPage() { paymentDetails, setPaymentDetails, resetWithdrawFlow, + recipient, } = useWithdrawFlow() // hooks for route calculation and payment recording @@ -250,6 +251,7 @@ export default function WithdrawCryptoPage() { throw new Error(t('errors.requestFailed')) } + const recipientEnsName = recipient.name?.trim().toLowerCase() const chargePayload: CreateChargeRequest = { pricing_type: 'fixed_price', local_price: { amount: usdValue.toString(), currency: 'USD' }, @@ -266,6 +268,9 @@ export default function WithdrawCryptoPage() { tokenSymbol: completeWithdrawData.token.symbol, tokenDecimals: Number(completeWithdrawData.token.decimals), recipientAddress: completeWithdrawData.address, + // Withdrawing to a name is still paying at one, and the + // input keeps the name that produced this address. + ...(validateEnsName(recipientEnsName) ? { recipientEnsName } : {}), }, transactionType: 'WITHDRAW', } @@ -295,6 +300,7 @@ export default function WithdrawCryptoPage() { setWithdrawData, setShowCompatibilityModal, setError, + recipient, t, ] ) 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/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx index f98d358d55..a8b9ce5c69 100644 --- a/src/app/crisp-proxy/page.tsx +++ b/src/app/crisp-proxy/page.tsx @@ -1,32 +1,118 @@ 'use client' -import Script from 'next/script' -import { useEffect, Suspense } from 'react' -import { useSearchParams } from 'next/navigation' -import { CRISP_WEBSITE_ID } from '@/constants/crisp' +import { useEffect } from 'react' +import { + CRISP_WEBSITE_ID, + CRISP_PROXY_REQUEST_INIT_MSG, + CRISP_PROXY_INIT_MSG, + type CrispInitPayload, +} from '@/constants/crisp' +import { setCrispUserData } from '@/utils/crisp' /** * Crisp Proxy Page - Same-origin iframe solution for embedded Crisp chat * * This page loads the Crisp widget in full-screen mode and is embedded as an iframe - * from SupportDrawer and SupportPage. By being same-origin, we avoid CORS issues - * and can fully control the Crisp instance via JavaScript. + * from SupportDrawer. By being same-origin, we avoid CORS issues and can fully + * control the Crisp instance via JavaScript. * - * User data flows via URL parameters and is set during Crisp initialization, - * following Crisp's recommended pattern for iframe embedding with JS SDK control. + * User data arrives via a postMessage handshake, never via the URL. This page asks + * the parent for it (CRISP_PROXY_REQUEST_INIT) and boots Crisp only once the reply + * (CRISP_PROXY_INIT) lands. A query string is not a private channel: it rides into + * Vercel logs, browser history, Referer headers, and the $current_url of every + * analytics event fired from this document (2026-08-10 postmortem F5). The pull + * model also solves the timing problem the old URL transport was built to avoid — + * the iframe initiates, so the parent can never post before this page listens. */ -function CrispProxyContent() { - const searchParams = useSearchParams() +/** + * Push identity/metadata to the widget — used at boot and on later payload updates. + * `withPrefill` guards message:text: re-pushing an unchanged prefill on a routine + * metadata refresh would overwrite whatever the user is typing in the composer, + * so only boot and a genuinely new prefill may set it. + */ +function applyUserData(payload: CrispInitPayload | null, withPrefill: boolean) { + if (!window.$crisp) return + const prefill = withPrefill ? payload?.prefilledMessage : undefined + // skip the all-empty push for anonymous visitors — nothing to show agents + if (payload?.userData && Object.values(payload.userData).some(Boolean)) { + setCrispUserData(window.$crisp, payload.userData, prefill) + } else if (prefill) { + window.$crisp.push(['set', 'message:text', [prefill]]) + } +} - useEffect(() => { - if (typeof window === 'undefined') return +function bootCrisp(payload: CrispInitPayload | null, onSessionLoaded: () => void) { + // Everything must be queued on the $crisp stub BEFORE l.js is injected, so the + // widget initializes with the token, locale and identity in one shot. + window.$crisp = [] + window.CRISP_WEBSITE_ID = CRISP_WEBSITE_ID + window.CRISP_RUNTIME_CONFIG = { + lock_maximized: true, + lock_full_view: true, + cross_origin_cookies: true, + ...(payload?.locale ? { locale: payload.locale } : {}), + } + if (payload?.tokenId) { + window.CRISP_TOKEN_ID = payload.tokenId + } + window.$crisp.push(['safe', true]) + + // Reset the Crisp session whenever the identity changes, so Crisp binds + // the new token to a clean session. Two independent triggers: + // 1. explicit logout flag (sessionStorage) — set at logout, but per-tab + // and wiped on app restart, so it is routinely missed on multi-account + // devices. + // 2. token mismatch vs the last identity we loaded (localStorage) — + // survives restarts. Crisp silently refuses to bind a new token over a + // persisted session without a reset first, which is what leaves the + // chatbox blank for users who have hosted more than one account. + let needsReset = false + let lastTokenId = '' + try { + needsReset = sessionStorage.getItem('crisp_needs_reset') === 'true' + lastTokenId = localStorage.getItem('crisp_last_token_id') ?? '' + } catch { + // storage blocked (private mode / partitioned iframe) — fall back to no + // stored identity; the token-change check below still triggers a reset, + // and identity/session setup below proceeds instead of aborting. + } + if (needsReset || lastTokenId !== (payload?.tokenId ?? '')) { + window.$crisp.push(['do', 'session:reset']) + try { + sessionStorage.removeItem('crisp_needs_reset') + } catch { + // storage blocked — the flag couldn't have been read as true anyway. + } + } + // NB: crisp_last_token_id is persisted once Crisp confirms the session actually + // loaded (notifyParentReady) — not here, so a failed load still resets on Retry. + + applyUserData(payload, true) + + // Wait for Crisp to be fully ready (session loaded and UI rendered) + window.$crisp.push(['on', 'session:loaded', onSessionLoaded]) + + const script = document.createElement('script') + script.src = 'https://client.crisp.chat/l.js' + script.async = true + script.onerror = () => { + window.__crispLoadFailed = true + } + document.head.appendChild(script) +} - const email = searchParams.get('user_email') - const nickname = searchParams.get('user_nickname') - const avatar = searchParams.get('user_avatar') - const sessionDataJson = searchParams.get('session_data') - const prefilledMessage = searchParams.get('prefilled_message') - const currentTokenId = searchParams.get('crisp_token_id') +export default function CrispProxyPage() { + useEffect(() => { + // booted guards double-boot within this effect run; the window flag guards + // React strict-mode re-running the effect (booting twice would clobber the + // $crisp queue and inject l.js twice). A retry remounts the iframe (fresh + // window), so per-window scope is right. + let booted = false + let bootedTokenId = '' + // last prefill actually applied — lets updates distinguish "new prefill from a + // new support entry point" (apply) from "same prefill riding along on a + // metadata refresh" (never re-apply, it would clobber the user's typing) + let appliedPrefill: string | undefined // Report readiness to the parent (SupportDrawer). A READY may supersede an earlier // FAILED: on a slow connection the 8s watchdog can post FAILED before the chatbox @@ -34,9 +120,9 @@ function CrispProxyContent() { // fallback. Once READY has been sent, FAILED never fires. Each is sent at most once. let readyNotified = false let failedNotified = false - const postToParent = (type: 'CRISP_READY' | 'CRISP_FAILED') => { + const postToParent = (message: { type: string }) => { if (window.parent !== window) { - window.parent.postMessage({ type }, window.location.origin) + window.parent.postMessage(message, window.location.origin) } } const notifyParentReady = () => { @@ -45,176 +131,99 @@ function CrispProxyContent() { // Record the identity as loaded only now that Crisp has confirmed ready — // writing it optimistically would let a failed load skip the reset on Retry. try { - localStorage.setItem('crisp_last_token_id', currentTokenId ?? '') + localStorage.setItem('crisp_last_token_id', bootedTokenId) } catch { // storage blocked (private mode / partitioned iframe) — the token-change // reset simply re-fires on the next load, which is harmless. } - postToParent('CRISP_READY') + postToParent({ type: 'CRISP_READY' }) } const notifyParentFailed = () => { if (failedNotified || readyNotified) return failedNotified = true - postToParent('CRISP_FAILED') + postToParent({ type: 'CRISP_FAILED' }) } // Crisp upgrades the $crisp array in place once l.js loads, adding methods. const crispScriptLoaded = () => typeof window.$crisp?.is === 'function' - const setAllData = () => { - if (!window.$crisp) return false - - // Reset the Crisp session whenever the identity changes, so Crisp binds - // the new token to a clean session. Two independent triggers: - // 1. explicit logout flag (sessionStorage) — set at logout, but per-tab - // and wiped on app restart, so it is routinely missed on multi-account - // devices. - // 2. token mismatch vs the last identity we loaded (localStorage) — - // survives restarts. Crisp silently refuses to bind a new token over a - // persisted session without a reset first, which is what leaves the - // chatbox blank for users who have hosted more than one account. - let needsReset = false - let lastTokenId = '' - try { - needsReset = sessionStorage.getItem('crisp_needs_reset') === 'true' - lastTokenId = localStorage.getItem('crisp_last_token_id') ?? '' - } catch { - // storage blocked (private mode / partitioned iframe) — fall back to no - // stored identity; the token-change check below still triggers a reset, - // and identity/session setup below proceeds instead of aborting. - } - const tokenChanged = lastTokenId !== (currentTokenId ?? '') - if (needsReset || tokenChanged) { - window.$crisp.push(['do', 'session:reset']) - try { - sessionStorage.removeItem('crisp_needs_reset') - } catch { - // storage blocked — the flag couldn't have been read as true anyway. - } - } - // NB: crisp_last_token_id is persisted in notifyParentReady, once Crisp confirms - // the session actually loaded — not here, so a failed load still resets on Retry. + const boot = (payload: CrispInitPayload | null) => { + if (booted) return + booted = true + bootedTokenId = payload?.tokenId ?? '' + appliedPrefill = payload?.prefilledMessage + // Already booted by a previous effect run (React strict mode re-runs the + // effect): adopt the state instead of clobbering the $crisp queue and + // injecting l.js twice. The watchdog below then judges the live widget. + if (window.__crispProxyBooted) return + window.__crispProxyBooted = true + bootCrisp(payload, notifyParentReady) + } - // Set user identification - if (email) { - window.$crisp.push(['set', 'user:email', [email]]) - } - if (nickname) { - window.$crisp.push(['set', 'user:nickname', [nickname]]) - } - if (avatar) { - window.$crisp.push(['set', 'user:avatar', [avatar]]) - } + // Handshake: ask the parent for the init payload, re-asking until it answers. + // The parent registers its listener long before this iframe mounts, so the + // first request normally lands; the interval covers a dropped message. If no + // reply ever comes, the readiness watchdog below reports CRISP_FAILED. + const requestTimer = setInterval(() => { + if (!booted) postToParent({ type: CRISP_PROXY_REQUEST_INIT_MSG }) + else clearInterval(requestTimer) + }, 250) - // Set session metadata for support agents - if (sessionDataJson) { - try { - const data = JSON.parse(sessionDataJson) - const sessionDataArray = [ - [ - ['username', data.username || ''], - ['user_id', data.user_id || ''], - ['full_name', data.full_name || ''], - ['wallet_address', data.wallet_address || ''], - ['bridge_user_id', data.bridge_user_id || ''], - ['manteca_user_id', data.manteca_user_id || ''], - ['posthog_person', data.posthog_person || ''], - ], - ] - window.$crisp.push(['set', 'session:data', sessionDataArray]) - } catch (e) { - console.error('[Crisp] Failed to parse session_data:', e) - } - } + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return - if (prefilledMessage) { - window.$crisp.push(['set', 'message:text', [prefilledMessage]]) + if (event.data?.type === CRISP_PROXY_INIT_MSG) { + clearInterval(requestTimer) + const payload = (event.data.payload as CrispInitPayload | undefined) ?? null + if (!booted) { + boot(payload) + } else { + // the parent re-sends the payload when it changes (new email/name + // during onboarding, fresh prefill) — apply it live instead of + // remounting the whole embedded app. Token/locale changes remount + // via the iframe key, so those never take this path. + const prefillChanged = payload?.prefilledMessage !== appliedPrefill + applyUserData(payload, prefillChanged) + if (prefillChanged) appliedPrefill = payload?.prefilledMessage + } + } else if (event.data?.type === 'CRISP_RESET_SESSION' && window.$crisp) { + window.CRISP_TOKEN_ID = null + window.$crisp.push(['do', 'session:reset']) } - - // Wait for Crisp to be fully ready (session loaded and UI rendered) - window.$crisp.push(['on', 'session:loaded', notifyParentReady]) - - return true } + window.addEventListener('message', handleMessage) - // Initialize data once Crisp loads - if (window.$crisp) { - setAllData() + if (window.parent === window) { + // Direct /crisp-proxy visit — no parent to ask; boot an anonymous session. + clearInterval(requestTimer) + boot(null) } else { - const checkCrisp = setInterval(() => { - if (window.$crisp) { - setAllData() - clearInterval(checkCrisp) - } - }, 100) - - setTimeout(() => clearInterval(checkCrisp), 5000) + postToParent({ type: CRISP_PROXY_REQUEST_INIT_MSG }) } // Readiness watchdog. session:loaded is the real "chatbox is up" signal, but it // doesn't always fire. After 8s, if we haven't already reported ready, decide: - // - the Crisp bundle errored or never upgraded the $crisp stub → report FAILED - // (parent shows a fallback so the user isn't stuck on a blank panel). - // - the bundle loaded but session:loaded didn't fire → report READY + // - no init payload arrived, the Crisp bundle errored, or it never upgraded + // the $crisp stub → report FAILED (parent shows a fallback so the user + // isn't stuck on a blank panel). + // - the bundle loaded but session:loaded didn't fire → report READY // (assume the chatbox rendered — preserves the prior fallback behaviour). const readinessTimer = setTimeout(() => { - if (window.__crispLoadFailed || !crispScriptLoaded()) { + if (!booted || window.__crispLoadFailed || !crispScriptLoaded()) { + // stop the request loop too — the handshake is declared dead + clearInterval(requestTimer) notifyParentFailed() } else { notifyParentReady() } }, 8000) - // Listen for reset messages from parent window - const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - - if (event.data.type === 'CRISP_RESET_SESSION' && window.$crisp) { - window.CRISP_TOKEN_ID = null - window.$crisp.push(['do', 'session:reset']) - } - } - - window.addEventListener('message', handleMessage) return () => { + clearInterval(requestTimer) clearTimeout(readinessTimer) window.removeEventListener('message', handleMessage) } - }, [searchParams]) - - return ( -
- -
- ) -} + }, []) -export default function CrispProxyPage() { - return ( - }> - - - ) + return
} diff --git a/src/app/m/[slug]/MerchantLandingPage.tsx b/src/app/m/[slug]/MerchantLandingPage.tsx index 9409b0bd13..9b93c2ec65 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,17 @@ 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') + // `undefined` is still loading — show the documented assumption so the + // cards render. `null` is the backend stating it has no comparison to + // publish, and a rate of 0 is how every consumer here renders nothing. + const cardMarkup = + markup === undefined + ? { rate: CARD_FX_MARKUP_BY_CURRENCY.ARS, source: 'static' as const } + : (markup ?? { rate: 0, source: 'static' as const }) return (