diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 8443da929..000000000 --- a/.cursorrules +++ /dev/null @@ -1,135 +0,0 @@ -# peanut-ui Development Rules - -**Version:** 0.0.2 | **Updated:** December 16, 2025 - -## ๐Ÿšซ Random - -- **Never open SVG files** - it crashes you. Only read jpeg, png, gif, or webp. -- **Never run jq command** - it crashes you. -- **Never run sleep** from command line - it hibernates pc. -- **Do not generate .md files** unless explicity told to do so. -- **Error messages**, any error being shown in the ui should be user friendly and easy to understand, and any error being logged in consoles and sentry should be descriptive for developers to help with debugging -- **Never add AI co-author to commits** - do not add "Co-Authored-By" lines for AI assistants in git commits - -## ๐Ÿ’ป Code Quality - -- **Boy scout rule**: leave code better than you found it. -- **DRY** - do not repeat yourself. Reuse existing code and abstract shared functionality. Less code is better code. -- this also means to use shared consts (e.g. check src/constants) -- **Separate business logic from interface** - this is important for readability, debugging and testability. -- **Reuse existing components and functions** - don't hardcode hacky solutions. -- **Warn about breaking changes** - when making changes, ensure you're not breaking existing functionality, and if there's a risk, explicitly WARN about it. -- **Mention refactor opportunities** - if you notice an opportunity to refactor or improve existing code, mention it. DO NOT make any changes you were not explicitly told to do. Only mention the potential change to the user. -- **Performance is important** - cache where possible, make sure to not make unnecessary re-renders or data fetching. -- **Flag breaking changes** - always flag if changes done in Frontend are breaking and require action on Backend (or viceversa) - -## ๐Ÿ”— URL as State (Critical for UX) - -- **URL is source of truth** - use query parameters for user-facing state that should survive navigation, refresh, or sharing (step indicators, amounts, filters, view modes, selected items) -- **Use nuqs library** - always use `useQueryStates` from [nuqs](https://nuqs.dev) for type-safe URL state management. never manually parse/set query params with router.push or URLSearchParams -- **Enable deep-linking** - users should be able to share or bookmark URLs mid-flow (e.g. `?step=inputAmount&amount=500¤cy=ARS`) -- **Proper navigation** - URL state enables correct back/forward browser button behavior -- **Multi-step flows** - the URL should always reflect current step and relevant data, making the app behave like a proper web app, not a trapped SPA -- **Reserve useState for ephemeral UI** - only use React useState for truly transient state: - - loading spinners and skeleton states - - modal open/close state - - form validation errors (unless they should persist) - - hover/focus states - - temporary UI animations -- **Don't URL-ify everything** - API responses, user authentication state, and internal component state generally shouldn't be in the URL unless they're user-facing and shareable -- **Type safety** - define parsers for query params (e.g. `parseAsInteger`, `parseAsStringEnum`) to ensure type safety and validation - -## ๐Ÿšซ Import Rules (critical for build performance) - -- **No barrel imports** - never use `import * as X from '@/constants'` or create index.ts barrel files. always import from specific files (e.g. `import { PEANUT_API_URL } from '@/constants/general.consts'`). barrel imports slow down builds and cause bundling issues. -- **No circular dependencies** - before adding imports, check if the target file imports from the current file. circular deps cause `Cannot access X before initialization` errors. move shared types to `interfaces.ts` if needed. -- **No node.js packages in client components** - packages like `web-push`, `fs`, `crypto` (node) can't be used in `'use client'` files. use server actions or api routes instead. -- **Check for legacy code** - before importing from a file, check if it has TODO comments marking it as legacy/deprecated. prefer newer implementations. - -## ๐Ÿšซ Export Rules (critical for build performance) - -- **Do not export multiple stuff from same component**: - - never export types or other utility methods from a component or a hook - - for types always use a separate file if they need to be reused - - and for utility/helper functions use a separate utils file to export them and use if they need to be reused - - same for files with multiple components exported, do not export multiple components from same file and if you see this done anywhere in the code, abstract it to other file - -## ๐Ÿงช Testing - -- **Test new code** - where tests make sense, test new code. Especially with fast unit tests. -- **Tests live with code** - tests should live where the code they test is, not in a separate folder -- **Run tests**: `npm test` (fast, ~5s) - -## ๐Ÿ“ Documentation - -- **All docs go in `docs/`** (except root `README.md`) -- **Keep it concise** - docs should be kept quite concise. AI tends to make verbose logs. No one reads that, keep it short and informational. -- **Check existing docs** before creating new ones - merge instead of duplicate -- **Log significant changes** in `docs/CHANGELOG.md` following semantic versioning -- **Maintain PR.md for PRs** - When working on a PR, maintain a very concise `docs/PR.md` with: - 1. Summary of changes - 2. Risks (what might break) - 3. QA guidelines (what to test) - -## ๐Ÿš€ Performance - -- **Cache where possible** - avoid unnecessary re-renders and data fetching -- **Fire simultaneous requests** - if you're doing multiple sequential awaits and they're not interdependent, fire them simultaneously -- **Service Worker cache version** - only bump `NEXT_PUBLIC_API_VERSION` for breaking API changes (see JSDoc in `src/app/sw.ts`). Users auto-migrate. -- **Gate heavy features in dev** - prefetching, precompiling, or eager loading of routes can add 5-10s to dev cold starts. wrap with `process.env.NODE_ENV !== 'development'` (e.g. `` in layout.tsx). - -## ๐ŸŽจ Design System - -- **Live showcase**: visit `/dev/components` to see all components rendered with all variants and copy-paste code -- **Three layers**: Bruddle primitives (`src/components/0_Bruddle/`), Global shared components (`src/components/Global/`), and Tailwind custom classes (`tailwind.config.js`) - -### Bruddle Primitives (`0_Bruddle/`) -- Button, Card (named export), BaseInput, BaseSelect, Checkbox, Divider, Title, Toast, PageContainer, CloudsBackground - -### Global Shared Components (`Global/`) -- **Navigation**: NavHeader (back button + title), TopNavbar, Footer -- **Modals**: Modal (base @headlessui Dialog), ActionModal (with buttons/checkboxes/icons), Drawer (vaul bottom sheet) -- **Loading**: Loading (spinner), PeanutLoading (branded), PeanutFactsLoading (with fun facts) -- **Cards**: Card (with position prop for stacked lists), InfoCard, PeanutActionCard -- **Status**: StatusPill, StatusBadge, ErrorAlert, ProgressBar -- **Icons**: Icon component with 50+ icons โ€” `` -- **Inputs**: AmountInput, ValidatedInput, CopyField, GeneralRecipientInput, TokenSelector -- **Utilities**: CopyToClipboard, AddressLink, ExternalWalletButton, ShareButton, Banner, MarqueeWrapper - -### Color Names (misleading!) -- `purple-1` / `primary-1` = `#FF90E8` (pink, not purple) -- `primary-3` = `#EFE4FF` (lavender) -- `yellow-1` / `secondary-1` = `#FFC900` -- `green-1` = `#98E9AB` - -### Key Rules -- **Button sizing trap**: `size="large"` is `h-10` (40px) โ€” SHORTER than default `h-13` (52px). never use for primary CTAs -- **Primary CTA**: ` + ))} {/* External nodes */} {externalNodesConfig.enabled && ( diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index bdcca9036..d28b43dbb 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -35,6 +35,8 @@ import { updateUserById } from '@/app/actions/users' import { useHaptic } from 'use-haptic' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' import underMaintenanceConfig from '@/config/underMaintenance.config' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' // Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size // Components are only loaded when user triggers them @@ -103,6 +105,7 @@ export default function Home() { e.stopPropagation() setIsBalanceHidden((prev: boolean) => { const newValue = !prev + posthog.capture(ANALYTICS_EVENTS.BALANCE_VISIBILITY_TOGGLED, { is_hidden: newValue }) if (user) { updateUserPreferences(user.user.userId, { balanceHidden: newValue }) } diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 05baf8d35..11b549d56 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -20,6 +20,8 @@ import { pointsApi } from '@/services/points' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { type PointsInvite } from '@/services/services.types' import { useEffect, useRef, useState } from 'react' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import InvitesGraph from '@/components/Global/InvitesGraph' import { CashCard } from '@/components/Points/CashCard' import InviteFriendsModal from '@/components/Global/InviteFriendsModal' @@ -85,6 +87,10 @@ const PointsPage = () => { enabled: !!tierInfo?.data, }) + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.POINTS_PAGE_VIEWED) + }, []) + useEffect(() => { // re-fetch user to get the latest invitees list for showing heart icon fetchUser() @@ -303,6 +309,7 @@ const PointsPage = () => { visible={isInviteModalOpen} onClose={() => setIsInviteModalOpen(false)} username={username ?? ''} + source="points_page" /> diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 83d1e06c4..11248f01c 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -32,6 +32,8 @@ import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' import { useSearchParams } from 'next/navigation' import { parseUnits } from 'viem' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' type View = 'INITIAL' | 'SUCCESS' @@ -153,6 +155,12 @@ export default function WithdrawBankPage() { return } + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { + amount_usd: amountToWithdraw, + method_type: 'bridge', + country, + }) + try { // Step 1: create the transfer to get deposit instructions const destination = destinationDetails(bankAccount) @@ -213,8 +221,17 @@ export default function WithdrawBankPage() { } setView('SUCCESS') + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { + amount_usd: amountToWithdraw, + method_type: 'bridge', + country, + }) } catch (e: any) { const error = ErrorHandler(e) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { + method_type: 'bridge', + error_message: error, + }) if (error.includes('Something failed. Please try again.')) { setError({ showError: true, errorMessage: e.message }) } else { diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index d97db5f86..3a502d9ec 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -31,6 +31,8 @@ import { useRouteCalculation } from '@/features/payments/shared/hooks/useRouteCa import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' import { isTxReverted } from '@/utils/general.utils' import { ErrorHandler } from '@/utils/sdkErrorHandler.utils' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' export default function WithdrawCryptoPage() { const router = useRouter() @@ -254,6 +256,11 @@ export default function WithdrawCryptoPage() { clearErrors() setIsSendingTx(true) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { + amount_usd: usdAmount, + method_type: 'crypto', + }) + try { // send transactions via peanut wallet const txResult = await sendTransactions(transactions, PEANUT_WALLET_CHAIN.id.toString()) @@ -281,9 +288,17 @@ export default function WithdrawCryptoPage() { setPaymentDetails(payment) triggerHaptic() setCurrentView('STATUS') + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { + amount_usd: usdAmount, + method_type: 'crypto', + }) } catch (err) { console.error('Withdrawal execution failed:', err) const errMsg = ErrorHandler(err) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { + method_type: 'crypto', + error_message: errMsg, + }) setError(errMsg) } finally { setIsSendingTx(false) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index baac44283..301438faf 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -54,6 +54,8 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { TRANSACTIONS } from '@/constants/query.consts' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { getLimitsWarningCardProps } from '@/features/limits/utils' @@ -247,6 +249,12 @@ export default function MantecaWithdrawFlow() { const handleWithdraw = async () => { if (!destinationAddress || !usdAmount || !currencyCode || !priceLock) return + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { + amount_usd: usdAmount, + method_type: 'manteca', + country: countryPath, + }) + try { setLoadingState('Preparing transaction') @@ -301,6 +309,11 @@ export default function MantecaWithdrawFlow() { }) if (result.error) { + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { + method_type: 'manteca', + error_message: result.error, + }) + // handle third-party account error with user-friendly message if (result.error === 'TAX_ID_MISMATCH' || result.error === 'CUIT_MISMATCH') { setErrorMessage('You can only withdraw to accounts under your name.') @@ -314,8 +327,17 @@ export default function MantecaWithdrawFlow() { } setStep('success') + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { + amount_usd: usdAmount, + method_type: 'manteca', + country: countryPath, + }) } catch (error) { console.error('Manteca withdraw error:', error) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { + method_type: 'manteca', + error_message: 'Withdraw failed unexpectedly', + }) setErrorMessage('Withdraw failed unexpectedly. If problem persists contact support') setStep('failure') } finally { diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 4f1520b8c..fd9c264a5 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -19,6 +19,8 @@ import { formatUnits } from 'viem' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { getLimitsWarningCardProps } from '@/features/limits/utils' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' type WithdrawStep = 'inputAmount' | 'selectMethod' @@ -252,6 +254,12 @@ export default function WithdrawPage() { setAmountToWithdraw(rawTokenAmount) const usdVal = (selectedTokenData?.price ?? 1) * parseFloat(rawTokenAmount) setUsdAmount(usdVal.toString()) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, { + amount_usd: usdVal, + method_type: selectedMethod.type, + country: selectedMethod.countryPath, + from_send_flow: isFromSendFlow, + }) // Route based on selected method type (check method type first to avoid stale bank account taking priority) // preserve method param if coming from send flow diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index e7ce90791..03b7e2b79 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -8,7 +8,7 @@ import { printableAddress, isStableCoin } from '@/utils/general.utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' import { notFound } from 'next/navigation' -import { RESERVED_ROUTES } from '@/constants/routes' +import { isReservedRoute } from '@/constants/routes' type PageProps = { params: Promise<{ recipient?: string[] }> @@ -19,8 +19,8 @@ export async function generateMetadata({ params, searchParams }: any) { const resolvedParams = await params // Guard: Don't generate metadata for reserved routes (handled by their specific routes) - const firstSegment = resolvedParams.recipient?.[0]?.toLowerCase() - if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) { + const firstSegment = resolvedParams.recipient?.[0] + if (firstSegment && isReservedRoute(`/${firstSegment}`)) { return {} } @@ -191,8 +191,8 @@ export default function Page(props: PageProps) { // Guard: Reserved routes should be handled by their specific route files // If we reach here, it means Next.js routing didn't catch it properly - const firstSegment = recipient[0]?.toLowerCase() - if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) { + const firstSegment = recipient[0] + if (firstSegment && isReservedRoute(`/${firstSegment}`)) { notFound() } diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx index 1870c5124..5121343d9 100644 --- a/src/app/[locale]/(marketing)/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/[country]/page.tsx @@ -58,7 +58,7 @@ export default async function CountryHubPage({ params }: PageProps) { return ( const competitor = COMPETITORS[slug] if (!competitor) return {} - // Try MDX content frontmatter first const mdxContent = readPageContentLocalized('compare', slug, locale) - if (mdxContent && mdxContent.frontmatter.published !== false) { - return { - ...metadataHelper({ - title: mdxContent.frontmatter.title, - description: mdxContent.frontmatter.description, - canonical: `/${locale}/compare/peanut-vs-${slug}`, - dynamicOg: true, - }), - alternates: { - canonical: `/${locale}/compare/peanut-vs-${slug}`, - languages: getAlternates('compare', `peanut-vs-${slug}`), - }, - } - } - - // Fallback: i18n-based metadata - const year = new Date().getFullYear() + if (!mdxContent || mdxContent.frontmatter.published === false) return {} return { ...metadataHelper({ - title: `Peanut vs ${competitor.name} ${year} | Peanut`, - description: `Peanut vs ${competitor.name}: ${competitor.tagline}`, + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, canonical: `/${locale}/compare/peanut-vs-${slug}`, + dynamicOg: true, }), alternates: { canonical: `/${locale}/compare/peanut-vs-${slug}`, @@ -83,90 +59,31 @@ export default async function ComparisonPageLocalized({ params }: PageProps) { const competitor = COMPETITORS[slug] if (!competitor) notFound() - // Try MDX content first const mdxSource = readPageContentLocalized('compare', slug, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const url = `/${locale}/compare/peanut-vs-${slug}` - return ( - - {content} - - ) - } + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() - // Fallback: old React-driven page - const i18n = getTranslations(locale as Locale) - const year = new Date().getFullYear() - - const breadcrumbSchema = { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { '@type': 'ListItem', position: 1, name: i18n.home, item: 'https://peanut.me' }, - { - '@type': 'ListItem', - position: 2, - name: `Peanut vs ${competitor.name}`, - item: `https://peanut.me/${locale}/compare/peanut-vs-${slug}`, - }, - ], - } + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/compare/peanut-vs-${slug}` return ( - <> - - - - - -
- -
- -
-

{competitor.verdict}

-
- - - - {/* Related comparisons */} - s !== slug) - .slice(0, 5) - .map(([s, c]) => ({ - title: `Peanut vs ${c.name} [${year}]`, - href: localizedPath('compare', locale, `peanut-vs-${s}`), - }))} - /> - - {/* Last updated */} -

- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })} -

-
- + + {content} + ) } diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx index 5c775ba7b..fdf1baba1 100644 --- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx +++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx @@ -1,18 +1,10 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' import { generateMetadata as metadataHelper } from '@/app/metadata' -import { EXCHANGES } from '@/data/seo' -import { MarketingHero } from '@/components/Marketing/MarketingHero' -import { MarketingShell } from '@/components/Marketing/MarketingShell' -import { Section } from '@/components/Marketing/Section' -import { Steps } from '@/components/Marketing/Steps' -import { FAQSection } from '@/components/Marketing/FAQSection' -import { JsonLd } from '@/components/Marketing/JsonLd' -import { Card } from '@/components/0_Bruddle/Card' +import { EXCHANGES, DEPOSIT_RAILS } from '@/data/seo' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' import type { Locale } from '@/i18n/types' -import { getTranslations, t, localizedPath } from '@/i18n' -import { RelatedPages } from '@/components/Marketing/RelatedPages' +import { getTranslations, t } from '@/i18n' import { ContentPage } from '@/components/Marketing/ContentPage' import { readPageContentLocalized, type ContentFrontmatter } from '@/lib/content' import { renderContent } from '@/lib/mdx' @@ -22,181 +14,106 @@ interface PageProps { } export async function generateStaticParams() { - const exchanges = Object.keys(EXCHANGES) - return SUPPORTED_LOCALES.flatMap((locale) => - exchanges.map((exchange) => ({ locale, exchange: `from-${exchange}` })) - ) + const exchangeParams = Object.keys(EXCHANGES).map((e) => `from-${e}`) + const railParams = Object.keys(DEPOSIT_RAILS).map((r) => `via-${r}`) + const allSlugs = [...exchangeParams, ...railParams] + return SUPPORTED_LOCALES.flatMap((locale) => allSlugs.map((exchange) => ({ locale, exchange }))) } export const dynamicParams = false -/** Strip the "from-" URL prefix to get the data key. Returns null if prefix missing. */ -function parseExchange(raw: string): string | null { - if (!raw.startsWith('from-')) return null - return raw.slice('from-'.length) +/** Parse URL slug into { type, key }. Supports "from-binance" (exchange) and "via-sepa" (rail). */ +function parseDepositSlug(raw: string): { type: 'exchange' | 'rail'; key: string } | null { + if (raw.startsWith('from-')) return { type: 'exchange', key: raw.slice(5) } + if (raw.startsWith('via-')) return { type: 'rail', key: raw.slice(4) } + return null +} + +/** Validate slug and return parsed info + display name, or null if invalid. */ +function resolveDeposit(rawSlug: string): { type: 'exchange' | 'rail'; key: string; displayName: string } | null { + const parsed = parseDepositSlug(rawSlug) + if (!parsed) return null + const { type, key } = parsed + if (type === 'exchange') { + const ex = EXCHANGES[key] + return ex ? { type, key, displayName: ex.name } : null + } + const name = DEPOSIT_RAILS[key] + return name ? { type, key, displayName: name } : null } export async function generateMetadata({ params }: PageProps): Promise { - const { locale, exchange: rawExchange } = await params + const { locale, exchange: rawSlug } = await params if (!isValidLocale(locale)) return {} - const exchange = parseExchange(rawExchange) - if (!exchange) return {} - const ex = EXCHANGES[exchange] - if (!ex) return {} + const deposit = resolveDeposit(rawSlug) + if (!deposit) return {} - // Try MDX content frontmatter first - const mdxContent = readPageContentLocalized('deposit', exchange, locale) + const mdxContent = readPageContentLocalized('deposit', deposit.key, locale) if (mdxContent && mdxContent.frontmatter.published !== false) { return { ...metadataHelper({ title: mdxContent.frontmatter.title, description: mdxContent.frontmatter.description, - canonical: `/${locale}/deposit/from-${exchange}`, + canonical: `/${locale}/deposit/${rawSlug}`, dynamicOg: true, }), alternates: { - canonical: `/${locale}/deposit/from-${exchange}`, - languages: getAlternates('deposit', `from-${exchange}`), + canonical: `/${locale}/deposit/${rawSlug}`, + languages: getAlternates('deposit', rawSlug), }, } } - // Fallback: i18n-based metadata + // Fallback: i18n-based metadata (exchanges only โ€” rails must have MDX) + if (deposit.type === 'rail') return {} + const ex = EXCHANGES[deposit.key]! const i18n = getTranslations(locale as Locale) return { ...metadataHelper({ title: `${t(i18n.depositFrom, { exchange: ex.name })} | Peanut`, description: `${t(i18n.depositFrom, { exchange: ex.name })}. ${i18n.recommendedNetwork}: ${ex.recommendedNetwork}.`, - canonical: `/${locale}/deposit/from-${exchange}`, + canonical: `/${locale}/deposit/from-${deposit.key}`, }), alternates: { - canonical: `/${locale}/deposit/from-${exchange}`, - languages: getAlternates('deposit', `from-${exchange}`), + canonical: `/${locale}/deposit/from-${deposit.key}`, + languages: getAlternates('deposit', `from-${deposit.key}`), }, } } export default async function DepositPageLocalized({ params }: PageProps) { - const { locale, exchange: rawExchange } = await params + const { locale, exchange: rawSlug } = await params if (!isValidLocale(locale)) notFound() - const exchange = parseExchange(rawExchange) - if (!exchange) notFound() - const ex = EXCHANGES[exchange] - if (!ex) notFound() - - // Try MDX content first - const mdxSource = readPageContentLocalized('deposit', exchange, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const url = `/${locale}/deposit/from-${exchange}` - return ( - - {content} - - ) - } - - // Fallback: old React-driven page - const i18n = getTranslations(locale as Locale) + const deposit = resolveDeposit(rawSlug) + if (!deposit) notFound() - const steps = ex.steps.map((step, i) => ({ - title: `${i + 1}`, - description: step, - })) + const mdxSource = readPageContentLocalized('deposit', deposit.key, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() - const howToSchema = { - '@context': 'https://schema.org', - '@type': 'HowTo', - name: t(i18n.depositFrom, { exchange: ex.name }), - inLanguage: locale, - step: steps.map((step, i) => ({ - '@type': 'HowToStep', - position: i + 1, - name: step.title, - text: step.description, - })), - } + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/deposit/${rawSlug}` return ( - <> - - - - - -
-
- {[ - { label: i18n.recommendedNetwork, value: ex.recommendedNetwork }, - { label: i18n.withdrawalFee, value: ex.withdrawalFee }, - { label: i18n.processingTime, value: ex.processingTime }, - ].map((item) => ( - - {item.label} - {item.value} - - ))} -
-
- -
- -
- - {ex.troubleshooting.length > 0 && ( -
-
- {ex.troubleshooting.map((item, i) => ( - -

{item.issue}

-

{item.fix}

-
- ))} -
-
- )} - - - - {/* Related deposit guides */} - slug !== exchange) - .slice(0, 5) - .map(([slug, e]) => ({ - title: t(i18n.depositFrom, { exchange: e.name }), - href: localizedPath('deposit', locale, `from-${slug}`), - }))} - /> - - {/* Last updated */} -

- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })} -

-
- + + {content} + ) } diff --git a/src/app/[locale]/(marketing)/help/[slug]/page.tsx b/src/app/[locale]/(marketing)/help/[slug]/page.tsx index 2d389e606..9bb0c680d 100644 --- a/src/app/[locale]/(marketing)/help/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/help/[slug]/page.tsx @@ -64,7 +64,7 @@ export default async function HelpArticlePage({ params }: PageProps) { return ( } } +/** Lightweight skeleton shown while HelpLanding JS hydrates */ +function HelpLandingSkeleton() { + return ( +
+ {/* Search bar placeholder */} +
+ + {/* Category / article rows */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ {[1, 2, 3].map((j) => ( +
+
+
+
+ ))} +
+
+ ))} +
+
+ ) +} + export default async function HelpPage({ params }: PageProps) { const { locale } = await params if (!isValidLocale(locale)) notFound() @@ -84,12 +111,12 @@ export default async function HelpPage({ params }: PageProps) { return ( - + }> if (!isValidLocale(locale)) return {} if (!getReceiveSources().includes(country)) return {} + const mdxContent = readPageContentLocalized('receive-from', country, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + const i18n = getTranslations(locale as Locale) const countryName = getCountryName(country, locale as Locale) @@ -51,24 +53,21 @@ export default async function ReceiveMoneyPage({ params }: PageProps) { if (!isValidLocale(locale)) notFound() if (!getReceiveSources().includes(country)) notFound() - // Try MDX content first (future-proofing โ€” no content files exist yet) const mdxSource = readPageContentLocalized('receive-from', country, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const countryName = getCountryName(country, locale) - return ( - - {content} - - ) - } + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const countryName = getCountryName(country, locale) - // Fallback: old React-driven page - return + return ( + + {content} + + ) } diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx index f37bbcc14..e8ddf1d58 100644 --- a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx +++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx @@ -61,7 +61,7 @@ export default async function FromToCorridorPage({ params }: PageProps) { return ( { }) } - // Deposit pages + // Deposit pages (exchanges + rails) for (const exchange of Object.keys(EXCHANGES)) { pages.push({ path: `/${locale}/deposit/from-${exchange}`, @@ -89,6 +89,13 @@ async function generateSitemap(): Promise { changeFrequency: 'monthly', }) } + for (const rail of Object.keys(DEPOSIT_RAILS)) { + pages.push({ + path: `/${locale}/deposit/via-${rail}`, + priority: 0.7 * basePriority, + changeFrequency: 'monthly', + }) + } // Pay-with pages for (const method of PAYMENT_METHOD_SLUGS) { diff --git a/src/assets/illustrations/global-cash-local-feel.png b/src/assets/illustrations/global-cash-local-feel.png new file mode 100644 index 000000000..848e20b25 Binary files /dev/null and b/src/assets/illustrations/global-cash-local-feel.png differ diff --git a/src/assets/illustrations/index.ts b/src/assets/illustrations/index.ts index 7a8f36ac5..1f2e0598b 100644 --- a/src/assets/illustrations/index.ts +++ b/src/assets/illustrations/index.ts @@ -14,3 +14,4 @@ export { default as Sparkle } from './sparkle.svg' export { default as Star } from './star.svg' export { default as ThinkingPeanut } from './thinking_peanut.gif' export { default as LandingCountries } from './landing-countries.svg' +export { default as GlobalCashLocalFeel } from './global-cash-local-feel.png' diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index eab40ae05..e12390235 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -19,6 +19,8 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { TRANSACTIONS } from '@/constants/query.consts' import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' // Step type for URL state type MantecaStep = 'inputAmount' | 'depositDetails' @@ -148,6 +150,14 @@ const MantecaAddMoney: FC = () => { try { setError(null) setIsCreatingDeposit(true) + + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { + amount_usd: usdAmount, + method_type: 'manteca', + country: selectedCountryPath, + denomination: currentDenomination, + }) + const isUsdDenominated = currentDenomination === 'USD' // Use the displayed amount for the API call const amount = displayedAmount @@ -157,15 +167,30 @@ const MantecaAddMoney: FC = () => { currency: selectedCountry.currency, }) if (depositData.error) { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, { + method_type: 'manteca', + country: selectedCountryPath, + error_message: depositData.error, + }) setError(depositData.error) return } setDepositDetails(depositData.data) + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, { + amount_usd: usdAmount, + method_type: 'manteca', + country: selectedCountryPath, + }) // Update URL state to show deposit details step setUrlState({ step: 'depositDetails' }) } catch (error) { console.log(error) - setError(error instanceof Error ? error.message : String(error)) + const errorMessage = error instanceof Error ? error.message : String(error) + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, { + method_type: 'manteca', + error_message: errorMessage, + }) + setError(errorMessage) } finally { setIsCreatingDeposit(false) } @@ -176,6 +201,8 @@ const MantecaAddMoney: FC = () => { isUserMantecaKycApproved, isCreatingDeposit, setUrlState, + usdAmount, + selectedCountryPath, ]) // Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation) diff --git a/src/components/AddMoney/components/OnrampConfirmationModal.tsx b/src/components/AddMoney/components/OnrampConfirmationModal.tsx index d14b4f724..00c94d0ce 100644 --- a/src/components/AddMoney/components/OnrampConfirmationModal.tsx +++ b/src/components/AddMoney/components/OnrampConfirmationModal.tsx @@ -50,7 +50,7 @@ export const OnrampConfirmationModal = ({ {' '} (the exact amount shown) , - 'Copy the reference code exactly', + 'Copy the one-time reference code exactly', 'Paste it in the description/reference field', ]} /> @@ -60,7 +60,7 @@ export const OnrampConfirmationModal = ({ icon="alert" iconClassName="text-error-5" title="If the amount or reference don't match:" - description="Your deposit will fail and it will take 2 to 10 days to return to your bank and might incur fees." + description="Your deposit will fail and it will take 2 to 10 days to return to your bank and might incur fees. The reference code is single use." />
} diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index 375479a18..34d7c8187 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -21,6 +21,8 @@ import { CountryList } from '../Common/CountryList' import PeanutLoading from '../Global/PeanutLoading' import SavedAccountsView from '../Common/SavedAccountsView' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' interface AddWithdrawRouterViewProps { flow: 'add' | 'withdraw' @@ -126,6 +128,10 @@ export const AddWithdrawRouterView: FC = ({ (method: DepositMethod) => { if (flow === 'add' && user) { saveRecentMethod(user.user.userId, method) + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { + method_type: method.type === 'crypto' ? 'crypto' : 'bank', + country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1), + }) } // Handle "From Bank" specially for add flow @@ -144,6 +150,11 @@ export const AddWithdrawRouterView: FC = ({ const methodType = method.type === 'crypto' ? 'crypto' : isMantecaCountry(method.path) ? 'manteca' : 'bridge' + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: methodType, + country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1), + }) + setSelectedMethod({ type: methodType, countryPath: method.path, @@ -299,6 +310,18 @@ export const AddWithdrawRouterView: FC = ({ inputTitle={mainHeading} viewMode="add-withdraw" onCountryClick={(country) => { + if (flow === 'add') { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { + method_type: 'bank', + country: country.path, + }) + } else { + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: isMantecaCountry(country.path) ? 'manteca' : 'bridge', + country: country.path, + }) + } + // from send flow (bank): set method in context and stay on /withdraw?method=bank if (flow === 'withdraw' && isBankFromSend) { if (isMantecaCountry(country.path)) { @@ -333,8 +356,16 @@ export const AddWithdrawRouterView: FC = ({ }} onCryptoClick={() => { if (flow === 'add') { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { + method_type: 'crypto', + country: 'crypto', + }) setIsSupportedTokensModalOpen(true) } else { + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: 'crypto', + country: 'crypto', + }) // preserve method param if coming from send flow (though crypto shouldn't show this screen) const queryParams = methodParam ? `?method=${methodParam}` : '' const cryptoPath = `${baseRoute}/crypto${queryParams}` diff --git a/src/components/Card/CardPioneerModal.tsx b/src/components/Card/CardPioneerModal.tsx index 9e76f1952..a2c93dd8e 100644 --- a/src/components/Card/CardPioneerModal.tsx +++ b/src/components/Card/CardPioneerModal.tsx @@ -1,6 +1,8 @@ 'use client' import { useEffect, useState } from 'react' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' import { useRouter } from 'next/navigation' import { Button } from '@/components/0_Bruddle/Button' import BaseModal from '@/components/Global/Modal' @@ -44,17 +46,23 @@ const CardPioneerModal = ({ hasPurchased }: CardPioneerModalProps) => { // Show modal with a small delay for better UX const timer = setTimeout(() => { setIsVisible(true) + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.CARD_PIONEER }) }, 1000) return () => clearTimeout(timer) }, [hasPurchased]) const handleDismiss = () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.CARD_PIONEER }) localStorage.setItem(STORAGE_KEY, new Date().toISOString()) setIsVisible(false) } const handleJoinNow = () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { + modal_type: MODAL_TYPES.CARD_PIONEER, + cta: 'get_early_access', + }) setIsVisible(false) router.push('/card') } diff --git a/src/components/Card/CardSuccessScreen.tsx b/src/components/Card/CardSuccessScreen.tsx index bf8c3bd3c..44d35e43a 100644 --- a/src/components/Card/CardSuccessScreen.tsx +++ b/src/components/Card/CardSuccessScreen.tsx @@ -136,6 +136,7 @@ const CardSuccessScreen = ({ onViewBadges }: CardSuccessScreenProps) => { visible={isInviteModalOpen} onClose={() => setIsInviteModalOpen(false)} username={user?.user?.username ?? ''} + source="card_deposit_success" /> ) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index a1b0c0ea7..4a94b1123 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -45,6 +45,8 @@ import { invitesApi } from '@/services/invites' import { EInviteType } from '@/services/services.types' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' export const InitialClaimLinkView = (props: IClaimScreenProps) => { // get campaign tag from claim link url @@ -179,6 +181,18 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { prevUser.current = user }, [user, resetClaimBankFlow]) + const hasTrackedClaimView = useRef(false) + useEffect(() => { + if (claimLinkData && !hasTrackedClaimView.current) { + hasTrackedClaimView.current = true + posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_VIEWED, { + amount: formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals), + token_symbol: claimLinkData.tokenSymbol, + chain_id: claimLinkData.chainId, + }) + } + }, [claimLinkData]) + const resetSelectedToken = useCallback(() => { if (isPeanutWallet) { setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString()) @@ -950,6 +964,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { }) } else { setRecipientType(update.type) + if (update.isValid && !update.isChanging) { + posthog.capture(ANALYTICS_EVENTS.CLAIM_RECIPIENT_SELECTED, { + recipient_type: update.type, + }) + } } setIsValidRecipient(update.isValid) setErrorState({ diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 6475706aa..f71f5d43f 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -19,6 +19,8 @@ import useClaimLink from '../../useClaimLink' import { useAuth } from '@/context/authContext' import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' export const ConfirmClaimLinkView = ({ onNext, @@ -83,6 +85,15 @@ export const ConfirmClaimLinkView = ({ errorMessage: '', }) + const formattedAmount = formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals) + + posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_STARTED, { + amount: formattedAmount, + token_symbol: claimLinkData.tokenSymbol, + chain_id: claimLinkData.chainId, + is_xchain: !!selectedRoute, + }) + try { let claimTxHash: string | undefined = '' if (selectedRoute) { @@ -119,6 +130,12 @@ export const ConfirmClaimLinkView = ({ } } setTransactionHash(claimTxHash) + posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_COMPLETED, { + amount: formattedAmount, + token_symbol: claimLinkData.tokenSymbol, + chain_id: claimLinkData.chainId, + is_xchain: !!selectedRoute, + }) onNext() // Note: Balance/transaction refresh handled by mutation or SUCCESS view } catch (error) { @@ -127,6 +144,11 @@ export const ConfirmClaimLinkView = ({ showError: true, errorMessage: errorString, }) + posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_FAILED, { + amount: formattedAmount, + error_message: errorString, + is_xchain: !!selectedRoute, + }) Sentry.captureException(error) } finally { setLoadingState('Idle') diff --git a/src/components/Global/BackendErrorScreen/index.tsx b/src/components/Global/BackendErrorScreen/index.tsx index 12ad64952..5f6c3f24a 100644 --- a/src/components/Global/BackendErrorScreen/index.tsx +++ b/src/components/Global/BackendErrorScreen/index.tsx @@ -1,7 +1,10 @@ 'use client' +import { useEffect } from 'react' import { useAuth } from '@/context/authContext' import { Button } from '@/components/0_Bruddle/Button' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' // inline peanut icon svg to ensure it works without needing to fetch external assets const PeanutIcon = ({ className }: { className?: string }) => ( @@ -68,11 +71,17 @@ const PeanutIcon = ({ className }: { className?: string }) => ( export default function BackendErrorScreen() { const { logoutUser, isLoggingOut } = useAuth() + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_SHOWN) + }, []) + const handleRetry = () => { + posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_RETRY) window.location.reload() } const handleForceLogout = () => { + posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_LOGOUT) // Use skipBackendCall since backend is likely down (that's why we're on this screen) logoutUser({ skipBackendCall: true }) } diff --git a/src/components/Global/BalanceWarningModal/index.tsx b/src/components/Global/BalanceWarningModal/index.tsx index 1a4456f72..4a56e803b 100644 --- a/src/components/Global/BalanceWarningModal/index.tsx +++ b/src/components/Global/BalanceWarningModal/index.tsx @@ -2,8 +2,10 @@ import { Icon } from '@/components/Global/Icons/Icon' import Modal from '@/components/Global/Modal' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { Slider } from '@/components/Slider' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' enum Platform { IOS = 'ios', @@ -74,6 +76,14 @@ export default function BalanceWarningModal({ visible, onCloseAction }: BalanceW const platform = detectPlatform() return PLATFORM_INFO[platform] }, []) + + const hasTrackedShow = useRef(false) + useEffect(() => { + if (visible && !hasTrackedShow.current) { + hasTrackedShow.current = true + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.BALANCE_WARNING }) + } + }, [visible]) return (
- + { + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { + modal_type: MODAL_TYPES.BALANCE_WARNING, + cta: 'slide_to_continue', + }) + onCloseAction() + }} + title="Slide to Continue" + />
) diff --git a/src/components/Global/CopyToClipboard/index.tsx b/src/components/Global/CopyToClipboard/index.tsx index d5aaa8d8f..c01efb482 100644 --- a/src/components/Global/CopyToClipboard/index.tsx +++ b/src/components/Global/CopyToClipboard/index.tsx @@ -14,18 +14,20 @@ interface Props { iconSize?: '2' | '3' | '4' | '6' | '8' type?: 'button' | 'icon' buttonSize?: ButtonSize + onCopy?: () => void } const CopyToClipboard = forwardRef( - ({ textToCopy, fill = 'black', className, iconSize = '6', type = 'icon', buttonSize }, ref) => { + ({ textToCopy, fill = 'black', className, iconSize = '6', type = 'icon', buttonSize, onCopy }, ref) => { const [copied, setCopied] = useState(false) const copy = useCallback(() => { navigator.clipboard.writeText(textToCopy).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) + onCopy?.() }) - }, [textToCopy]) + }, [textToCopy, onCopy]) useImperativeHandle(ref, () => ({ copy }), [copy]) diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index fd3e7a3cb..d3a235ab9 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -10,6 +10,8 @@ import QRBottomDrawer from '@/components/Global/QRBottomDrawer' import QRScanner from '@/components/Global/QRScanner' import { useAuth } from '@/context/authContext' import { hitUserMetric } from '@/utils/metrics.utils' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import * as Sentry from '@sentry/nextjs' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useMemo, useState, type ChangeEvent } from 'react' @@ -261,6 +263,7 @@ export default function DirectSendQr({ return originalData } hitUserMetric(user!.user.userId, 'scan-qr', { qrType, data: getLogData() }) + posthog.capture(ANALYTICS_EVENTS.QR_SCANNED, { qr_type: qrType }) setQrType(qrType as EQrType) switch (qrType) { case EQrType.PEANUT_URL: diff --git a/src/components/Global/EarlyUserModal/index.tsx b/src/components/Global/EarlyUserModal/index.tsx index 35aa7c126..8daf855f9 100644 --- a/src/components/Global/EarlyUserModal/index.tsx +++ b/src/components/Global/EarlyUserModal/index.tsx @@ -1,23 +1,31 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import ActionModal from '../ActionModal' import ShareButton from '../ShareButton' import { generateInviteCodeLink, generateInvitesShareText } from '@/utils/general.utils' import { useAuth } from '@/context/authContext' import { updateUserById } from '@/app/actions/users' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' const EarlyUserModal = () => { const { user, fetchUser } = useAuth() const inviteLink = generateInviteCodeLink(user?.user.username ?? '').inviteLink const [showModal, setShowModal] = useState(false) + const hasTrackedShow = useRef(false) useEffect(() => { if (user && user.showEarlyUserModal) { setShowModal(true) + if (!hasTrackedShow.current) { + hasTrackedShow.current = true + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.EARLY_USER }) + } } }, [user]) const handleCloseModal = async () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.EARLY_USER }) setShowModal(false) await updateUserById({ userId: user?.user.userId, hasSeenEarlyUserModal: true }) fetchUser() @@ -47,8 +55,9 @@ const EarlyUserModal = () => { Learn more diff --git a/src/components/Global/Footer/consts.ts b/src/components/Global/Footer/consts.ts index bbe2fb269..52fa71bdd 100644 --- a/src/components/Global/Footer/consts.ts +++ b/src/components/Global/Footer/consts.ts @@ -17,8 +17,8 @@ export const SOCIALS = [ logoSrc: icons.DISCORD_ICON.src, }, { - name: 'gitbook', - url: 'https://docs.peanut.me', + name: 'Help', + url: '/en/help', logoSrc: icons.GITBOOK_ICON.src, }, { @@ -31,7 +31,7 @@ export const SOCIALS = [ export const LINKS = [ { name: 'Docs', - url: 'https://docs.peanut.me', + url: '/en/help', }, { name: 'Terms & Privacy', diff --git a/src/components/Global/InviteFriendsModal/index.tsx b/src/components/Global/InviteFriendsModal/index.tsx index 85c45468c..ddd79ad79 100644 --- a/src/components/Global/InviteFriendsModal/index.tsx +++ b/src/components/Global/InviteFriendsModal/index.tsx @@ -5,12 +5,16 @@ import Card from '@/components/Global/Card' import CopyToClipboard from '@/components/Global/CopyToClipboard' import ShareButton from '@/components/Global/ShareButton' import { generateInviteCodeLink, generateInvitesShareText } from '@/utils/general.utils' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import posthog from 'posthog-js' +import { useEffect, useRef } from 'react' import QRCode from 'react-qr-code' interface InviteFriendsModalProps { visible: boolean onClose: () => void username: string + source?: string } /** @@ -19,13 +23,26 @@ interface InviteFriendsModalProps { * * Used in: CardSuccessScreen, Profile, PointsPage */ -export default function InviteFriendsModal({ visible, onClose, username }: InviteFriendsModalProps) { +export default function InviteFriendsModal({ visible, onClose, username, source }: InviteFriendsModalProps) { const { inviteCode, inviteLink } = generateInviteCodeLink(username) + const hasTrackedShow = useRef(false) + useEffect(() => { + if (visible && !hasTrackedShow.current) { + hasTrackedShow.current = true + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.INVITE, source }) + } + }, [visible, source]) + + const handleClose = () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.INVITE, source }) + onClose() + } + return ( {inviteCode}

- + posthog.capture(ANALYTICS_EVENTS.INVITE_LINK_COPIED, { source })} + />
Promise.resolve(generateInvitesShareText(inviteLink))} title="Share your invite link" + onSuccess={() => posthog.capture(ANALYTICS_EVENTS.INVITE_LINK_SHARED, { source })} > Share Invite Link diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index d07a20400..49de10c73 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -140,6 +140,9 @@ interface BaseProps { handleResetView: () => void handleReset: () => void handleRecalculate: () => void + /** Set of activity statuses to hide visually (no re-layout). Values: 'new' | 'active' | 'inactive' | 'jailed' */ + hiddenStatuses: Set + setHiddenStatuses: (v: Set) => void }) => React.ReactNode } @@ -252,6 +255,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { const [showUsernames, setShowUsernames] = useState(initialShowUsernames) // topNodes: limit to top N by points (0 = all). Backend-filtered, triggers refetch. const [topNodes, setTopNodes] = useState(initialTopNodes) + // Hidden activity statuses โ€” purely visual toggle (no re-layout) + const [hiddenStatuses, setHiddenStatuses] = useState>(new Set()) // Particle arrival popups for user mode (+1 pt animations) // Map: linkId โ†’ { timestamp, x, y, nodeId } @@ -841,6 +846,9 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Fetch graph data on mount and when topNodes changes (only in full mode) // Note: topNodes filtering only applies to full mode (payment mode has fixed 5000 limit in backend) + // topNodes is debounced so the slider doesn't trigger a refetch on every tick + const topNodesDebounceRef = useRef | null>(null) + const isInitialFetchRef = useRef(true) useEffect(() => { if (isMinimal) return @@ -852,9 +860,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { const apiMode = mode === 'payment' ? 'payment' : 'full' // Pass topNodes for both modes - payment mode now supports it via Performance button // Pass password for payment mode authentication + // Pass includeNewDays so backend always includes recent signups regardless of topNodes const result = await pointsApi.getInvitesGraph(props.apiKey, { mode: apiMode, topNodes: topNodes > 0 ? topNodes : undefined, + includeNewDays: displaySettingsRef.current.activityFilter.activityDays, password: mode === 'payment' ? props.password : undefined, }) @@ -866,7 +876,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { setLoading(false) } - fetchData() + // First fetch is immediate, subsequent topNodes changes are debounced (500ms) + if (isInitialFetchRef.current) { + isInitialFetchRef.current = false + fetchData() + } else { + if (topNodesDebounceRef.current) clearTimeout(topNodesDebounceRef.current) + topNodesDebounceRef.current = setTimeout(fetchData, 500) + } + + return () => { + if (topNodesDebounceRef.current) clearTimeout(topNodesDebounceRef.current) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMinimal, !isMinimal && props.apiKey, mode, topNodes]) @@ -932,6 +953,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { externalNodesConfig, p2pActiveNodes, inviterNodes, + hiddenStatuses, }) useEffect(() => { displaySettingsRef.current = { @@ -944,6 +966,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { externalNodesConfig, p2pActiveNodes, inviterNodes, + hiddenStatuses, } }, [ showUsernames, @@ -955,6 +978,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { externalNodesConfig, p2pActiveNodes, inviterNodes, + hiddenStatuses, ]) // Helper to determine user activity status @@ -1118,7 +1142,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } else { // Activity filter enabled - three states if (activityStatus === 'new') { - fillColor = 'rgba(144, 168, 237, 0.85)' // secondary-3 #90A8ED for new signups + fillColor = 'rgba(74, 222, 128, 0.85)' // green-400 for new signups } else if (activityStatus === 'active') { fillColor = 'rgba(255, 144, 232, 0.85)' // primary-1 for active } else { @@ -1151,30 +1175,40 @@ export default function InvitesGraph(props: InvitesGraphProps) { } } + // Check if this node's status is hidden via legend toggle + const { hiddenStatuses: hidden } = displaySettingsRef.current + const isJailed = !hasAccess + const isHidden = hidden.size > 0 && (hidden.has(activityStatus) || (isJailed && hidden.has('jailed'))) + if (isHidden) { + ctx.globalAlpha = 0.03 // Nearly invisible but keeps layout stable + } + // Draw fill ctx.beginPath() ctx.arc(node.x, node.y, size, 0, 2 * Math.PI) ctx.fillStyle = fillColor ctx.fill() - // Draw outline based on access/selection - ctx.globalAlpha = 1 - if (isSelected) { - // Selected: golden outline - ctx.strokeStyle = '#FFC900' - ctx.lineWidth = 3 - ctx.stroke() - } else if (!hasAccess) { - // Jailed (no app access): black outline - ctx.strokeStyle = '#000000' - ctx.lineWidth = 2 - ctx.stroke() + // Draw outline based on access/selection (skip if hidden) + if (!isHidden) { + ctx.globalAlpha = 1 + if (isSelected) { + // Selected: golden outline + ctx.strokeStyle = '#FFC900' + ctx.lineWidth = 3 + ctx.stroke() + } else if (!hasAccess) { + // Jailed (no app access): black outline + ctx.strokeStyle = '#000000' + ctx.lineWidth = 2 + ctx.stroke() + } } ctx.globalAlpha = 1 // Reset alpha // In minimal mode, always show labels; otherwise require closer zoom - if (showNames && (minimal || globalScale > 1.2)) { + if (!isHidden && showNames && (minimal || globalScale > 1.2)) { const label = node.username const fontSize = minimal ? 4 : 12 / globalScale const { inviterNodes: inviterNodesSet } = displaySettingsRef.current @@ -2193,6 +2227,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { handleResetView, handleReset, handleRecalculate, + hiddenStatuses, + setHiddenStatuses, })} @@ -2528,6 +2564,8 @@ export default function InvitesGraph(props: InvitesGraphProps) { handleResetView, handleReset, handleRecalculate, + hiddenStatuses, + setHiddenStatuses, })} diff --git a/src/components/Global/NoMoreJailModal/index.tsx b/src/components/Global/NoMoreJailModal/index.tsx index 4fb3c6650..519f34826 100644 --- a/src/components/Global/NoMoreJailModal/index.tsx +++ b/src/components/Global/NoMoreJailModal/index.tsx @@ -1,5 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' import Image from 'next/image' import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' import Modal from '../Modal' @@ -10,6 +12,7 @@ const NoMoreJailModal = () => { const [isOpen, setisOpen] = useState(false) const onClose = () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.POST_SIGNUP, cta: 'start_using' }) setisOpen(false) sessionStorage.removeItem('showNoMoreJailModal') } @@ -18,6 +21,7 @@ const NoMoreJailModal = () => { const showNoMoreJailModal = sessionStorage.getItem('showNoMoreJailModal') if (showNoMoreJailModal === 'true') { setisOpen(true) + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.POST_SIGNUP }) } }, []) diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 0b9fdff39..58181b0e9 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import ActionModal from '@/components/Global/ActionModal' import type { IconName } from '@/components/Global/Icons/Icon' import InfoCard from '@/components/Global/InfoCard' @@ -8,6 +8,8 @@ import { MantecaKycStatus } from '@/interfaces' import { countryData, MantecaSupportedExchanges, type CountryData } from '@/components/AddMoney/consts' import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import { useIdentityVerification } from '@/hooks/useIdentityVerification' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { const { user } = useAuth() @@ -15,6 +17,14 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const { isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent } = useUnifiedKycStatus() + + const hasTrackedShow = useRef(false) + useEffect(() => { + if (isOpen && !hasTrackedShow.current) { + hasTrackedShow.current = true + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.KYC_COMPLETED }) + } + }, [isOpen]) const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { @@ -68,7 +78,13 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = ctas={[ { text: 'Start sending money', - onClick: onClose, + onClick: () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { + modal_type: MODAL_TYPES.KYC_COMPLETED, + cta: 'start_sending', + }) + onClose() + }, variant: 'purple', className: 'w-full', shadowSize: '4', diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 1f9a8bcc1..c3b87bb9d 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import { Suspense, useEffect, useRef, useState } from 'react' +import { Suspense, useEffect, useRef, useState, useCallback } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' @@ -16,6 +16,8 @@ import { EInviteType } from '@/services/services.types' import { saveToCookie } from '@/utils/general.utils' import { useLogin } from '@/hooks/useLogin' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' // mapping of special invite codes to their campaign tags // when these invite codes are used, the corresponding campaign tag is automatically applied @@ -55,6 +57,18 @@ function InvitePageContent() { enabled: !!inviteCode, }) + // track invite page view (ref guard prevents duplicate fires when shouldShowContent toggles) + const hasTrackedPageView = useRef(false) + useEffect(() => { + if (shouldShowContent && inviteCodeData?.success && !hasTrackedPageView.current) { + hasTrackedPageView.current = true + posthog.capture(ANALYTICS_EVENTS.INVITE_PAGE_VIEWED, { + invite_code: inviteCode, + inviter_username: inviteCodeData.username, + }) + } + }, [shouldShowContent, inviteCodeData, inviteCode]) + // determine if we should show content based on user state useEffect(() => { // if still fetching user, don't show content yet @@ -123,6 +137,9 @@ function InvitePageContent() { const handleClaimInvite = async () => { if (inviteCode) { + posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { + invite_code: inviteCode, + }) dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 711d66430..d15d25c01 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -15,56 +15,181 @@ import { useQuery } from '@tanstack/react-query' import PeanutLoading from '../Global/PeanutLoading' import { useSetupStore } from '@/redux/hooks' import { useNotifications } from '@/hooks/useNotifications' +import { updateUserById } from '@/app/actions/users' +import { useQueryState, parseAsStringEnum } from 'nuqs' +import { isValidEmail } from '@/utils/format.utils' +import { BaseInput } from '@/components/0_Bruddle/BaseInput' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +type WaitlistStep = 'email' | 'notifications' | 'jail' + +const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep => + isPermissionGranted ? 'jail' : 'notifications' const JoinWaitlistPage = () => { - const [isValid, setIsValid] = useState(false) - const [isChanging, setIsChanging] = useState(false) - const [isLoading, setisLoading] = useState(false) - const [error, setError] = useState('') const { fetchUser, isFetchingUser, logoutUser, user } = useAuth() - const [isLoggingOut, setisLoggingOut] = useState(false) const router = useRouter() const { inviteType, inviteCode: setupInviteCode } = useSetupStore() - const [inviteCode, setInviteCode] = useState(setupInviteCode) - const { requestPermission, afterPermissionAttempt, isPermissionGranted } = useNotifications() - const [notificationSkipped, setNotificationSkipped] = useState(false) + + // URL-backed step state โ€” survives refresh, enables deep-linking + const [step, setStep] = useQueryState( + 'step', + parseAsStringEnum(['email', 'notifications', 'jail']).withDefault( + (() => { + if (user?.user.email) return nextStepAfterEmail(isPermissionGranted) + return 'email' + })() + ) + ) + + // Step 1: Email state + const [emailValue, setEmailValue] = useState('') + const [emailError, setEmailError] = useState('') + const [isSubmittingEmail, setIsSubmittingEmail] = useState(false) + + // Step 3: Invite code state + const [inviteCode, setInviteCode] = useState(setupInviteCode) + const [isValid, setIsValid] = useState(false) + const [isChanging, setIsChanging] = useState(false) + const [isValidating, setIsValidating] = useState(false) + const [isAccepting, setIsAccepting] = useState(false) + const [error, setError] = useState('') + const [isLoggingOut, setIsLoggingOut] = useState(false) const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ - queryKey: ['waitlist-position'], + queryKey: ['waitlist-position', user?.user.userId], queryFn: () => invitesApi.getWaitlistQueuePosition(), - enabled: !!user?.user.userId, + enabled: !!user?.user.userId && step === 'jail', }) - const validateInviteCode = async (inviteCode: string): Promise => { - setisLoading(true) - const res = await invitesApi.validateInviteCode(inviteCode) - setisLoading(false) - return res.success + // Track whether the email step has been completed or skipped this session, + // so the step invariant useEffect doesn't race with react-query state updates + const [emailStepDone, setEmailStepDone] = useState(!!user?.user.email) + + // Enforce step invariants: prevent URL bypass and fast-forward completed steps + useEffect(() => { + if (isFetchingUser) return + if (step !== 'email' && !user?.user.email && !emailStepDone) { + setStep('email') + } else if (step === 'email' && (user?.user.email || emailStepDone)) { + setStep(nextStepAfterEmail(isPermissionGranted)) + } else if (step === 'notifications' && isPermissionGranted) { + setStep('jail') + } + }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailStepDone]) + + // Sync emailStepDone when user data loads with an existing email + useEffect(() => { + if (user?.user.email) setEmailStepDone(true) + }, [user?.user.email]) + + // Step 1: Submit email via server action + const handleEmailSubmit = async () => { + if (!isValidEmail(emailValue) || isSubmittingEmail) return + + if (!user?.user.userId) { + setEmailError('Account not loaded yet. Please wait a moment and try again.') + return + } + + setIsSubmittingEmail(true) + setEmailError('') + + try { + const result = await updateUserById({ userId: user.user.userId, email: emailValue }) + if (result.error) { + setEmailError(result.error) + return + } + + const refreshedUser = await fetchUser() + if (!refreshedUser?.user.email) { + console.error('[JoinWaitlist] Email update succeeded but fetchUser did not return email') + setEmailError('Email saved, but we had trouble loading your profile. Please try again.') + return + } + + // Mark email step as done BEFORE setStep to prevent the useEffect + // from racing and resetting the step back to 'email' + setEmailStepDone(true) + setStep(nextStepAfterEmail(isPermissionGranted)) + } catch (e) { + console.error('[JoinWaitlist] handleEmailSubmit failed:', e) + setEmailError('Something went wrong. Please try again or skip this step.') + } finally { + setIsSubmittingEmail(false) + } + } + + const handleSkipEmail = () => { + setEmailStepDone(true) + setStep(nextStepAfterEmail(isPermissionGranted)) + } + + // Step 2: Enable notifications (always advances regardless of outcome) + const handleEnableNotifications = async () => { + try { + await requestPermission() + await afterPermissionAttempt() + } catch { + // permission denied or error โ€” that's fine + } + setStep('jail') + } + + // Step 3: Validate and accept invite code (separate loading states to avoid race) + const validateInviteCode = async (code: string): Promise => { + setIsValidating(true) + try { + const res = await invitesApi.validateInviteCode(code) + return res.success + } finally { + setIsValidating(false) + } } const handleAcceptInvite = async () => { - setisLoading(true) + setIsAccepting(true) try { const res = await invitesApi.acceptInvite(inviteCode, inviteType) if (res.success) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + invite_code: inviteCode, + source: 'waitlist_page', + }) sessionStorage.setItem('showNoMoreJailModal', 'true') fetchUser() } else { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: inviteCode, + error_message: 'API returned unsuccessful', + source: 'waitlist_page', + }) setError('Something went wrong. Please try again or contact support.') } } catch { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: inviteCode, + error_message: 'Exception during invite acceptance', + source: 'waitlist_page', + }) setError('Something went wrong. Please try again or contact support.') } finally { - setisLoading(false) + setIsAccepting(false) } } const handleLogout = async () => { - setisLoggingOut(true) - await logoutUser() - router.push('/setup') - setisLoggingOut(false) + setIsLoggingOut(true) + try { + await logoutUser() + router.push('/setup') + } finally { + setIsLoggingOut(false) + setError('') + } } useEffect(() => { @@ -73,40 +198,79 @@ const JoinWaitlistPage = () => { } }, [isFetchingUser, user, router]) - if (isLoadingWaitlistPosition) { - return - } + const stepImage = step === 'jail' ? peanutAnim.src : chillPeanutAnim.src return ( - +
- {!isPermissionGranted && !notificationSkipped && ( + {/* Step 1: Email Collection */} + {step === 'email' && (
-

Enable notifications

-

We'll send you an update as soon as you get access.

+

Stay in the loop

+

+ Enter your email so we can reach you when you get access. +

- + + {emailError && ( + + )} +
+ )} + + {/* Step 2: Enable Notifications (skippable) */} + {step === 'notifications' && ( +
+

Want instant updates?

+

We'll notify you the moment you get access.

+ + -
)} - {(isPermissionGranted || notificationSkipped) && ( + {/* Step 3: Jail Screen */} + {step === 'jail' && isLoadingWaitlistPosition && } + {step === 'jail' && !isLoadingWaitlistPosition && (

You're still in Peanut jail

@@ -141,22 +305,19 @@ const JoinWaitlistPage = () => {
{!isValid && !isChanging && !!inviteCode && ( - + )} - {/* Show error from the API call */} {error && }