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**: `` โ no size prop
-- **Secondary CTA**: ``
-- **Shadows**: always black `#000000`. use `shadowSize="4"` for action buttons
-- **Border radius**: always `rounded-sm` (not rounded-lg or rounded-md)
-- **Border stroke**: `border border-n-1` (1px black)
-- **Links**: `text-black underline` โ never `text-purple-1` (pink)
-- **Text hierarchy**: `text-n-1` primary, `text-grey-1` secondary
-- **Two Card components**: `0_Bruddle/Card` (standalone containers, named export) vs `Global/Card` (stacked list items, default export)
-- **Backgrounds**: `bg-peanut-repeat-normal` for waving peanut pattern
-- **Messaging**: use "starter balance" for card deposits โ never "card balance" or "Peanut rewards"
-
-### Page Layouts
-
-- **Outer shell**: `flex min-h-[inherit] flex-col gap-8` โ NavHeader is first child
-- **Centered content + CTA** (default): wrap content AND CTA in ``. CTA must be INSIDE this div, never a sibling
-- **Pinned footer CTA**: add `justify-between` to outer div, CTA as last child outside content
-- **Never** use `space-y-*` on the outer flex div (conflicts with centering) โ use `gap-*` instead
-
-## โ
Before Pushing
-
-- **Format**: `pnpm prettier --write
` then verify with `pnpm prettier --check .`
-- **Typecheck**: `npm run typecheck` โ catches type errors that tests miss (tests don't run tsc)
-- **Test**: `npm test` (fast, ~5s) โ all 17 suites must pass
-- **Build** (if touching imports/types): `npm run build` to catch compile errors
-
-## ๐ Commits
-
-- **Be descriptive**
-- **Use emoji prefixes**: โจ features, ๐ fixes, ๐ docs, ๐ infra, โป๏ธ refactor, โก performance
diff --git a/.cursorrules b/.cursorrules
new file mode 120000
index 000000000..eada936c1
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1 @@
+CONTRIBUTING.md
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 8e6080723..f49c3a2b6 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,8 @@ export NODE_ENV="development"
export NEXT_PUBLIC_PEANUT_ACCESS_CODE=""
export NEXT_PUBLIC_NOTIFY_API_SECRET=""
export NEXT_PUBLIC_GA_KEY=""
+export NEXT_PUBLIC_POSTHOG_KEY=""
+export NEXT_PUBLIC_POSTHOG_HOST="https://eu.i.posthog.com"
export NEXT_PUBLIC_WC_PROJECT_ID=""
export NEXT_PUBLIC_SENTRY_DSN=""
export NEXT_PUBLIC_RECAPTCHA_SITE_KEY=""
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 120000
index 000000000..44fcc6343
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1 @@
+../CONTRIBUTING.md
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index afb8d3eaf..ffdc3194f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,3 +77,6 @@ public/swe-worker*
# mobile POC
android/
.claude/
+
+# AI tool worktrees
+.claude/worktrees/
diff --git a/.prettierignore b/.prettierignore
index 40f0fdec7..6a2d8d3cc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,4 @@ node_modules/
pnpm-lock.yaml
**.md
src/assets/
+src/content/
diff --git a/.windsurfrules b/.windsurfrules
new file mode 120000
index 000000000..eada936c1
--- /dev/null
+++ b/.windsurfrules
@@ -0,0 +1 @@
+CONTRIBUTING.md
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 000000000..eada936c1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CONTRIBUTING.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 000000000..eada936c1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+CONTRIBUTING.md
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..efbeec3fd
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,137 @@
+# peanut-ui Development Rules
+
+**Version:** 1.0.0 | **Updated:** 2026-03-10
+
+Single source of truth for developer and AI assistant rules. Tool-specific files (`.cursorrules`, `CLAUDE.md`, `AGENTS.md`, `.windsurfrules`) are symlinks to this file.
+
+---
+
+## ๐ซ Agent Safety
+
+- **Never open SVG files** โ crashes AI agents. Only read jpeg, png, gif, or webp.
+- **Never run `jq`** โ crashes AI agents.
+- **Never run `sleep`** from command line โ hibernates the PC.
+- **Never add AI co-author to commits** โ no "Co-Authored-By" lines for AI assistants.
+- **Do not generate .md files** unless explicitly told to.
+
+## ๐ Git Workflow
+
+- **NEVER commit or push directly to main** โ all changes must go through a pull request. No exceptions.
+- **Always work from a feature branch** โ create a branch, push it, open a PR, wait for CI to pass, then merge.
+- **Use git worktrees** for parallel work (`claude --worktree ` or `git worktree add`).
+- Multiple agents/sessions must use separate worktrees to avoid collisions.
+
+## ๐ป Code Quality
+
+- **Boy scout rule** โ leave code better than you found it.
+- **DRY** โ reuse existing code and shared constants (e.g. `src/constants`). Less code is better code.
+- **Separate business logic from UI** โ important for readability, debugging, and testability.
+- **Use explicit imports** โ no wildcard imports, import from specific files.
+- **Reuse existing components and functions** โ don't hardcode hacky solutions.
+- **Warn about breaking changes** โ if there's risk, explicitly WARN. Flag if frontend changes require backend action (or vice versa).
+- **Mention refactor opportunities** โ if you spot one, mention it. DO NOT make changes unless explicitly told to.
+- **Error messages** โ user-facing errors should be friendly and clear. Console/Sentry errors should be descriptive for debugging.
+
+## ๐ซ Import Rules (critical for build performance)
+
+- **No barrel imports** โ never `import * as X from '@/constants'` or create index.ts barrel files. Import from specific files (e.g. `import { PEANUT_API_URL } from '@/constants/general.consts'`).
+- **No circular dependencies** โ check if target file imports from current file before adding imports. Move shared types to `interfaces.ts` if needed.
+- **No Node.js packages in client components** โ `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, check for TODO comments marking code as legacy/deprecated. Prefer newer implementations.
+
+## ๐ซ Export Rules
+
+- **One component per file** โ never export multiple components from the same file.
+- **Separate types** โ never export types from a component or hook file. Use a separate file if types need to be reused.
+- **Separate utils** โ utility/helper functions go in a separate utils file.
+
+## ๐ 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` 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`).
+- **Multi-step flows** โ URL should always reflect current step and relevant data. Proper back/forward browser button behavior.
+- **Reserve `useState` for ephemeral UI only** โ loading spinners, modal open/close, form validation errors, hover/focus states, temporary animations.
+- **Don't URL-ify everything** โ API responses, auth state, and internal component state generally don't belong in the URL.
+- **Type safety** โ define parsers for query params (`parseAsInteger`, `parseAsStringEnum`).
+
+## ๐จ Design System
+
+- **Live showcase**: visit `/dev/components` to see all components rendered with all variants.
+- **Three layers**: Bruddle primitives (`src/components/0_Bruddle/`), Global shared (`src/components/Global/`), Tailwind custom classes (`tailwind.config.js`).
+
+### 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**: `` โ no size prop.
+- **Secondary CTA**: ``
+- **Shadows**: always black `#000000`. Use `shadowSize="4"` for action buttons.
+- **Border radius**: always `rounded-sm` (not rounded-lg or rounded-md).
+- **Border stroke**: `border border-n-1` (1px black).
+- **Links**: `text-black underline` โ never `text-purple-1` (that's pink).
+- **Text hierarchy**: `text-n-1` primary, `text-grey-1` secondary.
+- **Two Card components**: `0_Bruddle/Card` (standalone containers, named export) vs `Global/Card` (stacked list items, default export).
+- **Backgrounds**: `bg-peanut-repeat-normal` for waving peanut pattern.
+- **Messaging**: use "starter balance" for card deposits โ never "card balance" or "Peanut rewards".
+
+### 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
+
+### Page Layouts
+
+- **Outer shell**: `flex min-h-[inherit] flex-col gap-8` โ NavHeader is first child.
+- **Centered content + CTA** (default): wrap content AND CTA in ``. CTA must be INSIDE this div, never a sibling.
+- **Pinned footer CTA**: add `justify-between` to outer div, CTA as last child outside content.
+- **Never** use `space-y-*` on the outer flex div (conflicts with centering) โ use `gap-*` instead.
+
+## ๐ Performance
+
+- **Cache where possible** โ avoid unnecessary re-renders and data fetching.
+- **Fire simultaneous requests** โ if multiple awaits are independent, fire them in parallel.
+- **Service Worker cache version** โ only bump `NEXT_PUBLIC_API_VERSION` for breaking API changes (see JSDoc in `src/app/sw.ts`).
+- **Gate heavy features in dev** โ prefetching, precompiling, eager loading can add 5-10s to dev cold starts. Wrap with `process.env.NODE_ENV !== 'development'`.
+
+## ๐งช Testing
+
+- **Jest** โ tests placed in `__tests__` directories next to code, named `*.test.ts` or `*.test.tsx`.
+- **Run**: `npm test` (fast, ~5s) โ all suites must pass.
+- **Test new code** where tests make sense, especially with fast unit tests.
+
+## ๐ Documentation
+
+- **All docs go in `docs/`** (except root `README.md` and `CONTRIBUTING.md`).
+- **Keep it concise** โ AI tends to be verbose. No one reads that.
+- **Check existing docs** before creating new ones โ merge instead of duplicate.
+- **Delete or edit outdated docs** instead of creating new ones.
+- **Maintain PR.md for PRs** โ concise `docs/PR.md` with: summary, risks, QA guidelines.
+
+## โ
Before Pushing
+
+1. **Format**: `pnpm prettier --write
` then `pnpm prettier --check .`
+2. **Typecheck**: `npm run typecheck` โ catches type errors tests miss
+3. **Test**: `npm test` (~5s) โ all suites must pass
+4. **Build** (if touching imports/types): `npm run build`
+
+## ๐ Commits
+
+- **Be descriptive** โ focus on the "why", not the "what".
+- follow conventional commits.
diff --git a/README.md b/README.md
index 26415abda..6a38084f9 100644
--- a/README.md
+++ b/README.md
@@ -35,50 +35,9 @@ pnpm dev
Then open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
-## Code Formatting
+## Contributing
-This project uses Prettier for code formatting. To ensure consistent code style:
-
-1. Install the VSCode Prettier extension
-2. Enable "Format on Save" in VSCode
-3. Prettier will automatically format your code when you save
-
-The CI pipeline will check formatting on all PRs. Unformatted code will block merging.
-
-## Testing
-
-This project uses Jest for testing. Tests are located next to their source files in `__tests__` directories.
-
-To run tests:
-
-```bash
-# Run all tests
-pnpm test
-
-# Run tests in watch mode during development
-pnpm test:watch
-
-# Run tests with coverage report
-pnpm test:coverage
-```
-
-### Test Structure
-
-- Tests are placed in `__tests__` directories next to the code they test
-- Test files should be named `*.test.ts` or `*.test.tsx`
-- Use descriptive test names that explain the expected behavior
-
-Example:
-
-```typescript
-describe('Bank Account Formatting', () => {
- it('should format IBAN with spaces every 4 characters', () => {
- // test code
- })
-})
-```
-
-The CI pipeline runs tests on all PRs. Failed tests will block merging.
+See [CONTRIBUTING.md](CONTRIBUTING.md) for coding standards, design system reference, and development rules.
## Archived Branches
diff --git a/SOP.md b/docs/archive/SOP.md
similarity index 100%
rename from SOP.md
rename to docs/archive/SOP.md
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
new file mode 100644
index 000000000..4f3c5e367
--- /dev/null
+++ b/instrumentation-client.ts
@@ -0,0 +1,12 @@
+import posthog from 'posthog-js'
+
+if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
+ api_host: '/ingest',
+ ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
+ person_profiles: 'identified_only',
+ capture_pageview: true,
+ capture_pageleave: true,
+ autocapture: true,
+ })
+}
diff --git a/next.config.js b/next.config.js
index d37743d58..17cbdc77b 100644
--- a/next.config.js
+++ b/next.config.js
@@ -95,6 +95,7 @@ let nextConfig = {
return config
},
reactStrictMode: false,
+ skipTrailingSlashRedirect: true,
async rewrites() {
return {
beforeFiles: [
@@ -111,6 +112,17 @@ let nextConfig = {
destination: '/api/assetLinks',
},
],
+ afterFiles: [
+ // PostHog reverse proxy โ bypasses ad blockers
+ {
+ source: '/ingest/static/:path*',
+ destination: 'https://eu-assets.i.posthog.com/static/:path*',
+ },
+ {
+ source: '/ingest/:path*',
+ destination: 'https://eu.i.posthog.com/:path*',
+ },
+ ],
}
},
async redirects() {
diff --git a/package.json b/package.json
index 4f43b73a7..5dc3fac47 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"next-mdx-remote": "^6.0.0",
"nuqs": "^2.8.6",
"pix-utils": "^2.8.2",
+ "posthog-js": "^1.360.0",
"pulltorefreshjs": "^0.1.22",
"qr-scanner": "^1.4.2",
"react": "^19.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 192bc6dff..671ed5bda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,7 +69,7 @@ importers:
version: 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
'@sentry/nextjs':
specifier: ^8.39.0
- version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)
+ version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)
'@serwist/next':
specifier: ^9.0.10
version: 9.5.0(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)
@@ -148,6 +148,9 @@ importers:
pix-utils:
specifier: ^2.8.2
version: 2.8.2
+ posthog-js:
+ specifier: ^1.360.0
+ version: 1.360.0
pulltorefreshjs:
specifier: ^0.1.22
version: 0.1.22
@@ -1640,6 +1643,10 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@opentelemetry/api-logs@0.208.0':
+ resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
+ engines: {node: '>=8.0.0'}
+
'@opentelemetry/api-logs@0.53.0':
resolution: {integrity: sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==}
engines: {node: '>=14'}
@@ -1668,6 +1675,24 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/core@2.2.0':
+ resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/core@2.6.0':
+ resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0':
+ resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
'@opentelemetry/instrumentation-amqplib@0.46.1':
resolution: {integrity: sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==}
engines: {node: '>=14'}
@@ -1830,6 +1855,18 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
+ '@opentelemetry/otlp-exporter-base@0.208.0':
+ resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-transformer@0.208.0':
+ resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
'@opentelemetry/redis-common@0.36.2':
resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==}
engines: {node: '>=14'}
@@ -1840,12 +1877,42 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/resources@2.2.0':
+ resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/resources@2.6.0':
+ resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-logs@0.208.0':
+ resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+
+ '@opentelemetry/sdk-metrics@2.2.0':
+ resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+
'@opentelemetry/sdk-trace-base@1.30.1':
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/sdk-trace-base@2.2.0':
+ resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
'@opentelemetry/semantic-conventions@1.27.0':
resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==}
engines: {node: '>=14'}
@@ -1983,9 +2050,45 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@posthog/core@1.23.2':
+ resolution: {integrity: sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==}
+
+ '@posthog/types@1.360.0':
+ resolution: {integrity: sha512-roypbiJ49V3jWlV/lzhXGf0cKLLRj69L4H4ZHW6YsITHlnjQ12cgdPhPS88Bb9nW9xZTVSGWWDjfNGsdgAxsNg==}
+
'@prisma/instrumentation@5.22.0':
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.4':
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ '@protobufjs/eventemitter@1.1.0':
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ '@protobufjs/fetch@1.1.0':
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/inquire@1.1.0':
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.0':
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
'@puppeteer/browsers@2.10.10':
resolution: {integrity: sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==}
engines: {node: '>=18'}
@@ -3725,6 +3828,9 @@ packages:
cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
+ core-js@3.48.0:
+ resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -3993,6 +4099,10 @@ packages:
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
+ dompurify@3.3.2:
+ resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
+ engines: {node: '>=20'}
+
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -4292,6 +4402,9 @@ packages:
picomatch:
optional: true
+ fflate@0.4.8:
+ resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -5047,6 +5160,9 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -5741,6 +5857,9 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
+ posthog-js@1.360.0:
+ resolution: {integrity: sha512-jkyO+T97yi6RuiexOaXC7AnEGiC+yIfGU5DIUzI5rqBH6MltmtJw/ve2Oxc4jeua2WDr5sXMzo+SS+acbpueAA==}
+
preact@10.24.2:
resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==}
@@ -5836,6 +5955,10 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ protobufjs@7.5.4:
+ resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
+ engines: {node: '>=12.0.0'}
+
proxy-agent@6.5.0:
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
engines: {node: '>= 14'}
@@ -5886,6 +6009,9 @@ packages:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
+ query-selector-shadow-dom@1.0.1:
+ resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
+
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -6915,6 +7041,9 @@ packages:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
+ web-vitals@5.1.0:
+ resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
+
webdriver-bidi-protocol@0.2.11:
resolution: {integrity: sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==}
@@ -8861,6 +8990,10 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
+ '@opentelemetry/api-logs@0.208.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+
'@opentelemetry/api-logs@0.53.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -8884,6 +9017,25 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+
'@opentelemetry/instrumentation-amqplib@0.46.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9128,6 +9280,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ protobufjs: 7.5.4
+
'@opentelemetry/redis-common@0.36.2': {}
'@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)':
@@ -9136,6 +9305,31 @@ snapshots:
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
'@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9143,6 +9337,13 @@ snapshots:
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
'@opentelemetry/semantic-conventions@1.27.0': {}
'@opentelemetry/semantic-conventions@1.28.0': {}
@@ -9229,6 +9430,12 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@posthog/core@1.23.2':
+ dependencies:
+ cross-spawn: 7.0.6
+
+ '@posthog/types@1.360.0': {}
+
'@prisma/instrumentation@5.22.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9237,6 +9444,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.4': {}
+
+ '@protobufjs/eventemitter@1.1.0': {}
+
+ '@protobufjs/fetch@1.1.0':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/inquire@1.1.0': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.0': {}
+
'@puppeteer/browsers@2.10.10':
dependencies:
debug: 4.4.3
@@ -10310,7 +10540,7 @@ snapshots:
'@sentry/core@8.55.0': {}
- '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)':
+ '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
@@ -10318,7 +10548,7 @@ snapshots:
'@sentry-internal/browser-utils': 8.55.0
'@sentry/core': 8.55.0
'@sentry/node': 8.55.0
- '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
+ '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
'@sentry/react': 8.55.0(react@19.2.4)
'@sentry/vercel-edge': 8.55.0
'@sentry/webpack-plugin': 2.22.7(webpack@5.104.1)
@@ -10387,6 +10617,16 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.39.0
'@sentry/core': 8.55.0
+ '@sentry/opentelemetry@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+ '@sentry/core': 8.55.0
+
'@sentry/react@8.55.0(react@19.2.4)':
dependencies:
'@sentry/browser': 8.55.0
@@ -12173,6 +12413,8 @@ snapshots:
cookie-es@1.2.2: {}
+ core-js@3.48.0: {}
+
core-util-is@1.0.3: {}
cosmiconfig@7.1.0:
@@ -12423,6 +12665,10 @@ snapshots:
dependencies:
webidl-conversions: 7.0.0
+ dompurify@3.3.2:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -12838,6 +13084,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.4.8: {}
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -13871,6 +14119,8 @@ snapshots:
lodash@4.17.23: {}
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -14817,6 +15067,22 @@ snapshots:
dependencies:
xtend: 4.0.2
+ posthog-js@1.360.0:
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+ '@posthog/core': 1.23.2
+ '@posthog/types': 1.360.0
+ core-js: 3.48.0
+ dompurify: 3.3.2
+ fflate: 0.4.8
+ preact: 10.28.2
+ query-selector-shadow-dom: 1.0.1
+ web-vitals: 5.1.0
+
preact@10.24.2: {}
preact@10.28.2: {}
@@ -14860,6 +15126,21 @@ snapshots:
property-information@7.1.0: {}
+ protobufjs@7.5.4:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 20.4.2
+ long: 5.3.2
+
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
@@ -14932,6 +15213,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
+ query-selector-shadow-dom@1.0.1: {}
+
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
@@ -16080,6 +16363,8 @@ snapshots:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
+ web-vitals@5.1.0: {}
+
webdriver-bidi-protocol@0.2.11: {}
webextension-polyfill@0.10.0: {}
diff --git a/public/email/peanut-jail.png b/public/email/peanut-jail.png
new file mode 100644
index 000000000..41a0c37a7
Binary files /dev/null and b/public/email/peanut-jail.png differ
diff --git a/public/email/peanut-wave.png b/public/email/peanut-wave.png
new file mode 100644
index 000000000..a6235cbfa
Binary files /dev/null and b/public/email/peanut-wave.png differ
diff --git a/redirects.json b/redirects.json
index e771b321d..7d110d33c 100644
--- a/redirects.json
+++ b/redirects.json
@@ -19,6 +19,12 @@
"destination": "/en/help",
"permanent": true
},
+ {
+ "source": "/:path*",
+ "has": [{ "type": "host", "value": "docs.peanut.me" }],
+ "destination": "https://peanut.me/en/help",
+ "permanent": true
+ },
{
"source": "/packet",
"destination": "https://github.com/peanutprotocol/peanut-ui/tree/archive/legacy-peanut-to",
diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts
index f7880f371..430868946 100644
--- a/scripts/validate-links.ts
+++ b/scripts/validate-links.ts
@@ -104,9 +104,12 @@ function buildValidPaths(): Set {
}
// Help: /{locale}/help and /{locale}/help/{slug}
+ // Also register without locale prefix since content uses bare /help/... links
paths.add(`/${locale}/help`)
+ paths.add('/help')
for (const slug of helpSlugs) {
paths.add(`/${locale}/help/${slug}`)
+ paths.add(`/help/${slug}`)
}
// Use-cases: /{locale}/use-cases/{slug}
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index c5cb43d2c..ffe661b04 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -30,6 +30,8 @@ import { useExchangeRate } from '@/hooks/useExchangeRate'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// Step type for URL state
type BridgeBankStep = 'inputAmount' | 'showDetails'
@@ -194,6 +196,11 @@ export default function OnrampBankPage() {
return
}
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
+ amount_usd: usdEquivalent,
+ method_type: 'bank',
+ country: selectedCountryPath,
+ })
setShowWarningModal(true)
}
@@ -215,6 +222,11 @@ export default function OnrampBankPage() {
setOnrampData(onrampDataResponse)
if (onrampDataResponse.transferId) {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, {
+ amount_usd: usdEquivalent,
+ method_type: 'bank',
+ country: selectedCountryPath,
+ })
setUrlState({ step: 'showDetails' })
} else {
setError({
@@ -224,6 +236,11 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
+ method_type: 'bank',
+ error_message: errorMessage,
+ })
if (onrampError) {
setError({
showError: true,
diff --git a/src/app/(mobile-ui)/add-money/crypto/page.tsx b/src/app/(mobile-ui)/add-money/crypto/page.tsx
index 8d4481aad..dc868d9de 100644
--- a/src/app/(mobile-ui)/add-money/crypto/page.tsx
+++ b/src/app/(mobile-ui)/add-money/crypto/page.tsx
@@ -8,6 +8,8 @@ import type { RhinoChainType } from '@/services/services.types'
import { useQuery } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const AddMoneyCryptoPage = () => {
const { user } = useAuth()
@@ -25,10 +27,18 @@ const AddMoneyCryptoPage = () => {
staleTime: 1000 * 60 * 60 * 24, // 24 hours
})
- const handleSuccess = useCallback((amount: number) => {
- setDepositedAmount(amount)
- setShowSuccessView(true)
- }, [])
+ const handleSuccess = useCallback(
+ (amount: number) => {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_COMPLETED, {
+ amount,
+ chain_type: chainType,
+ method_type: 'crypto',
+ })
+ setDepositedAmount(amount)
+ setShowSuccessView(true)
+ },
+ [chainType]
+ )
if (showSuccessView) {
return
diff --git a/src/app/(mobile-ui)/dev/full-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx
index 93ec0b65f..ba4d5ff8f 100644
--- a/src/app/(mobile-ui)/dev/full-graph/page.tsx
+++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx
@@ -136,6 +136,8 @@ export default function FullGraphPage() {
externalNodesError,
handleReset,
handleRecalculate,
+ hiddenStatuses,
+ setHiddenStatuses,
}) => (
<>
{/* Controls Panel - Top Right */}
@@ -739,27 +741,69 @@ export default function FullGraphPage() {
- {/* Compact Legend */}
+ {/* Compact Legend โ click to toggle visibility */}
- {/* Nodes */}
+ {/* Nodes โ clickable toggles */}
-
-
- New
-
-
-
- Active
-
-
-
- Inactive
-
-
-
- Jailed
-
+ {(
+ [
+ {
+ key: 'new',
+ label: 'New',
+ color: 'rgba(74, 222, 128, 0.85)',
+ border: false,
+ },
+ {
+ key: 'active',
+ label: 'Active',
+ color: 'rgba(255, 144, 232, 0.85)',
+ border: false,
+ },
+ {
+ key: 'inactive',
+ label: 'Inactive',
+ color: 'rgba(145, 145, 145, 0.7)',
+ border: false,
+ },
+ {
+ key: 'jailed',
+ label: 'Jailed',
+ color: 'rgba(156, 163, 175, 0.85)',
+ border: true,
+ },
+ ] as const
+ ).map(({ key, label, color, border }) => (
+ {
+ const next = new Set(hiddenStatuses)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ setHiddenStatuses(next)
+ }}
+ className="flex cursor-pointer items-center gap-0.5"
+ style={{ opacity: hiddenStatuses.has(key) ? 0.3 : 1 }}
+ title={hiddenStatuses.has(key) ? `Show ${label}` : `Hide ${label}`}
+ >
+
+
+ {label}
+
+
+ ))}
{/* 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 (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {/* 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.
+
-
{
- await requestPermission()
- await afterPermissionAttempt()
+ {
+ setEmailValue(e.target.value)
+ setEmailError('')
}}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit()
+ }}
+ className="h-12"
+ />
+
+ {emailError && }
+
+
- Yes, notify me
+ Continue
+
+
+ {emailError && (
+
+ Skip for now
+
+ )}
+
+ )}
+
+ {/* Step 2: Enable Notifications (skippable) */}
+ {step === 'notifications' && (
+
+
Want instant updates?
+
We'll notify you the moment you get access.
+
+
+ Enable notifications
-
setNotificationSkipped(true)} className="text-sm underline">
+
+ setStep('jail')} className="text-sm underline">
Not now
)}
- {(isPermissionGranted || notificationSkipped) && (
+ {/* Step 3: Jail Screen */}
+ {step === 'jail' && isLoadingWaitlistPosition &&
}
+ {step === 'jail' && !isLoadingWaitlistPosition && (
You're still in Peanut jail
@@ -141,22 +305,19 @@ const JoinWaitlistPage = () => {
{
- handleAcceptInvite()
- }}
- disabled={!isValid || isChanging || isLoading}
+ onClick={handleAcceptInvite}
+ disabled={!isValid || isChanging || isValidating || isAccepting}
>
Next
{!isValid && !isChanging && !!inviteCode && (
-
+
)}
- {/* Show error from the API call */}
{error &&
}
diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx
index adab83c9a..8be747332 100644
--- a/src/components/LandingPage/Footer.tsx
+++ b/src/components/LandingPage/Footer.tsx
@@ -63,12 +63,7 @@ const Footer = ({ showSiteDirectory = true }: { showSiteDirectory?: boolean }) =
Support
-
+
Docs
{mantecaSlot}
+ {yourMoneySlot}
+
{!underMaintenanceConfig.disableCardPioneers && (
<>
@@ -199,8 +202,6 @@ export function LandingPageClient({
{regulatedRailsSlot}
- {yourMoneySlot}
-
{securitySlot}
@@ -212,6 +213,7 @@ export function LandingPageClient({
{footerSlot}
+
>
)
}
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index 8b600b256..c24a54399 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -39,16 +39,14 @@ const Manteca = () => {
- SCAN. PAY. DONE.
+ GET PAID. PAY. DONE.
- PAY INSTANTLY IN ARGENTINA AND BRAZIL .
+ RECEIVE FROM ANYWHERE. NO LOCAL ID NEEDED.
-
- JUST SCAN LOCAL QR CODES. NO BANK DETAILS NEEDED.
-
+
Get best exchange rate.
up to ~15% cheaper than Visa & Mastercard.
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index e1e391d68..711e8ad80 100644
--- a/src/components/LandingPage/RegulatedRails.tsx
+++ b/src/components/LandingPage/RegulatedRails.tsx
@@ -52,39 +52,30 @@ export function RegulatedRails() {
- REGULATED RAILS, SELF-CUSTODY CONTROL
+ YOUR MONEY. YOUR RULES.
- Peanut is a self-custodial wallet that seamlessly connects to banks and payment networks (examples
- below) via multiple third party partners who operate under international licenses and standards to
- keep every transaction secure, private, and under your control.
+ Connect your wallet to your bank and local payment networks like PIX and MercadoPago through
+ licensed partners โ so you can pay like a local without giving up control of your funds.
- Our partners hold{' '}
- MSB
- {' '}
- licenses and are compliant under{' '}
-
- GDPR and CCPA/CPRA
+ Learn more
- frameworks
-
+
+ Works with
+
+
{logos.map((logo) => (
= [
{ slug: 'argentina', name: 'Argentina' },
diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx
new file mode 100644
index 000000000..779d9fdcd
--- /dev/null
+++ b/src/components/LandingPage/StickyMobileCTA.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Button } from '@/components/0_Bruddle/Button'
+
+export function StickyMobileCTA() {
+ const [visible, setVisible] = useState(false)
+ const rafId = useRef(0)
+ const lastVisible = useRef(false)
+
+ useEffect(() => {
+ const check = () => {
+ const atBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight - 100
+ const next = window.scrollY >= 300 && !atBottom
+
+ if (next !== lastVisible.current) {
+ lastVisible.current = next
+ setVisible(next)
+ }
+ }
+
+ const onScroll = () => {
+ cancelAnimationFrame(rafId.current)
+ rafId.current = requestAnimationFrame(check)
+ }
+
+ window.addEventListener('scroll', onScroll, { passive: true })
+ check()
+ return () => {
+ window.removeEventListener('scroll', onScroll)
+ cancelAnimationFrame(rafId.current)
+ }
+ }, [])
+
+ return (
+
+ {visible && (
+
+
+
+ SIGN UP NOW
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index c54f977d0..f563580ef 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -1,10 +1,73 @@
'use client'
-import { ButterySmoothGlobalMoney, PeanutGuyGIF, Star } from '@/assets'
+import { GlobalCashLocalFeel, PeanutGuyGIF, Star } from '@/assets'
import { motion } from 'framer-motion'
+import { useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/0_Bruddle/Button'
import { CloudsCss } from './CloudsCss'
+/**
+ * Peanut mascot that positions itself so only 6% of its height (the feet)
+ * overlaps with the h2 subtitle below. Measures the h2 position on mount
+ * and resize, then sets its own bottom edge to sit 6% into the h2.
+ */
+function PeanutMascot() {
+ const imgRef = useRef
(null)
+
+ const position = useCallback(() => {
+ const img = imgRef.current
+ const hero = document.getElementById('hero')
+ const h2 = hero?.querySelector('h2')
+ if (!img || !hero || !h2) return
+
+ const heroRect = hero.getBoundingClientRect()
+ const h2Rect = h2.getBoundingClientRect()
+ const peanutHeight = img.getBoundingClientRect().height
+
+ if (peanutHeight === 0) return // not rendered yet
+
+ // Position so peanut's feet (bottom 3%) overlap with h2 top
+ const overlap = peanutHeight * 0.06
+ const peanutBottom = h2Rect.top - heroRect.top + overlap
+ const peanutTop = peanutBottom - peanutHeight
+
+ img.style.top = `${peanutTop}px`
+ }, [])
+
+ useEffect(() => {
+ const img = imgRef.current
+ if (!img) return
+
+ // Position once image loads and on resize
+ const onLoad = () => {
+ position()
+ // Re-position after a short delay to account for layout shifts
+ setTimeout(position, 500)
+ }
+
+ if (img.complete) {
+ onLoad()
+ } else {
+ img.addEventListener('load', onLoad)
+ }
+
+ window.addEventListener('resize', position)
+ return () => {
+ img.removeEventListener('load', onLoad)
+ window.removeEventListener('resize', position)
+ }
+ }, [position])
+
+ return (
+
+ )
+}
+
type CTAButton = {
label: string
href: string
@@ -83,9 +146,9 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1
-
+
- TAP. SEND. ANYWHERE
+ TAP. SCAN. ANYWHERE.
- FROM NEW YORK
- TO MADRID
- TO MEXICO CITY
+ Buenos Aires. Sรฃo Paulo. Floripa.
+
+
+ No local ID or bank required.
{primaryCta && renderCTAButton(primaryCta, 'primary')}
{secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
diff --git a/src/components/LandingPage/landingPageData.ts b/src/components/LandingPage/landingPageData.ts
index 4cbdd0326..e258732ae 100644
--- a/src/components/LandingPage/landingPageData.ts
+++ b/src/components/LandingPage/landingPageData.ts
@@ -2,7 +2,7 @@ export const heroConfig = {
primaryCta: {
label: 'SIGN UP',
href: '/setup',
- subtext: 'currently in waitlist',
+ subtext: 'Join +10,000 cool people',
},
}
diff --git a/src/components/Marketing/ComparisonTable.tsx b/src/components/Marketing/ComparisonTable.tsx
deleted file mode 100644
index abe0bca8f..000000000
--- a/src/components/Marketing/ComparisonTable.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Card } from '@/components/0_Bruddle/Card'
-
-interface ComparisonTableProps {
- peanutName?: string
- competitorName: string
- rows: Array<{ feature: string; peanut: string; competitor: string }>
-}
-
-export function ComparisonTable({ peanutName = 'Peanut', competitorName, rows }: ComparisonTableProps) {
- return (
-
-
-
-
- Feature
- {peanutName}
- {competitorName}
-
-
-
- {rows.map((row, i) => (
-
- {row.feature}
- {row.peanut}
- {row.competitor}
-
- ))}
-
-
-
- )
-}
diff --git a/src/components/Marketing/index.ts b/src/components/Marketing/index.ts
index 7d10cac3a..8160e6b57 100644
--- a/src/components/Marketing/index.ts
+++ b/src/components/Marketing/index.ts
@@ -4,7 +4,6 @@ export { MarketingHero } from './MarketingHero'
export { MarketingShell } from './MarketingShell'
export { Section } from './Section'
export { Steps } from './Steps'
-export { ComparisonTable } from './ComparisonTable'
export { FAQSection } from './FAQSection'
export { DestinationGrid } from './DestinationGrid'
export { BlogCard } from './BlogCard'
diff --git a/src/components/Marketing/pages/ReceiveMoneyContent.tsx b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
deleted file mode 100644
index ee37237f0..000000000
--- a/src/components/Marketing/pages/ReceiveMoneyContent.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import Link from 'next/link'
-import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
-import { CORRIDORS, getCountryName, getLocalizedSEO } from '@/data/seo'
-import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
-import type { Locale } from '@/i18n/types'
-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 { RelatedPages } from '@/components/Marketing/RelatedPages'
-import { Card } from '@/components/0_Bruddle/Card'
-
-interface ReceiveMoneyContentProps {
- sourceCountry: string
- locale: Locale
-}
-
-export function ReceiveMoneyContent({ sourceCountry, locale }: ReceiveMoneyContentProps) {
- const i18n = getTranslations(locale)
- const sourceName = getCountryName(sourceCountry, locale)
- const sourceSeo = getLocalizedSEO(sourceCountry, locale)
-
- // Destinations that receive money from this source
- const destinations = CORRIDORS.filter((c) => c.from === sourceCountry).map((c) => c.to)
-
- const sourceMapping = findMappingBySlug(sourceCountry)
-
- const howToSteps = [
- {
- title: t(i18n.stepCreateAccount),
- description: t(i18n.stepCreateAccountDesc),
- },
- {
- title: t(i18n.stepDepositFunds),
- description: t(i18n.stepDepositFundsDesc, { method: sourceSeo?.instantPayment ?? '' }),
- },
- {
- title: i18n.sendMoney,
- description: t(i18n.receiveMoneyFromDesc, { country: sourceName }),
- },
- ]
-
- const baseUrl = 'https://peanut.me'
-
- const breadcrumbSchema = {
- '@context': 'https://schema.org',
- '@type': 'BreadcrumbList',
- itemListElement: [
- { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
- {
- '@type': 'ListItem',
- position: 2,
- name: t(i18n.receiveMoneyFrom, { country: sourceName }),
- item: `${baseUrl}/${locale}/receive-money-from/${sourceCountry}`,
- },
- ],
- }
-
- const faqs = sourceSeo?.faqs ?? []
-
- // Related pages for internal linking
- const relatedPages = [
- {
- title: t(i18n.hubTitle, { country: sourceName }),
- href: localizedBarePath(locale, sourceCountry),
- },
- {
- title: t(i18n.sendMoneyTo, { country: sourceName }),
- href: localizedPath('send-money-to', locale, sourceCountry),
- },
- ]
-
- // Add from-to corridor links for each destination
- for (const dest of destinations.slice(0, 3)) {
- const destName = getCountryName(dest, locale)
- relatedPages.push({
- title: t(i18n.sendMoneyFromTo, { from: sourceName, to: destName }),
- href: localizedPath('send-money-from', locale, `${sourceCountry}/to/${dest}`),
- })
- }
-
- const today = new Date().toISOString().split('T')[0]
-
- return (
- <>
-
-
-
-
-
- {/* Destination countries grid */}
-
-
- {destinations.map((destSlug) => {
- const destName = getCountryName(destSlug, locale)
- const destMapping = findMappingBySlug(destSlug)
- return (
-
-
- {destMapping?.flagCode && (
-
- )}
-
- {sourceName} โ {destName}
-
-
-
- )
- })}
-
-
-
- {/* How it works */}
-
-
- {/* FAQs */}
- {faqs.length > 0 && }
-
- {/* Related pages */}
-
-
- {/* Last updated */}
- {t(i18n.lastUpdated, { date: today })}
-
- >
- )
-}
diff --git a/src/components/Notifications/SetupNotificationsModal.tsx b/src/components/Notifications/SetupNotificationsModal.tsx
index 0e0ee6994..caca4b467 100644
--- a/src/components/Notifications/SetupNotificationsModal.tsx
+++ b/src/components/Notifications/SetupNotificationsModal.tsx
@@ -1,6 +1,8 @@
'use client'
import { useNotifications } from '@/hooks/useNotifications'
import ActionModal from '../Global/ActionModal'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
export default function SetupNotificationsModal() {
const {
@@ -16,6 +18,8 @@ export default function SetupNotificationsModal() {
e?.preventDefault()
e?.stopPropagation()
+ posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.NOTIFICATIONS, cta: 'enable' })
+
try {
// request permission - this shows the native dialog
await requestPermission()
diff --git a/src/components/Payment/Views/Error.validation.view.tsx b/src/components/Payment/Views/Error.validation.view.tsx
index a9d8502fd..ee1a22fe0 100644
--- a/src/components/Payment/Views/Error.validation.view.tsx
+++ b/src/components/Payment/Views/Error.validation.view.tsx
@@ -2,7 +2,6 @@
import PEANUTMAN_CRY from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_05.gif'
import { Button } from '@/components/0_Bruddle/Button'
-import Link from 'next/link'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useModalsContext } from '@/context/ModalsContext'
@@ -48,13 +47,14 @@ function ValidationErrorView({
{message}
{showLearnMore && (
-
Learn how to receive money through Peanut
-
+
)}
{
visible={isInviteFriendsModalOpen}
onClose={() => setIsInviteFriendsModalOpen(false)}
username={user?.user.username ?? ''}
+ source="profile"
/>
)
diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx
index a742bc933..9d2a9b3d6 100644
--- a/src/components/Send/link/views/Initial.link.send.view.tsx
+++ b/src/components/Send/link/views/Initial.link.send.view.tsx
@@ -19,6 +19,8 @@ import { Button } from '@/components/0_Bruddle/Button'
import FileUploadInput from '../../../Global/FileUploadInput'
import AmountInput from '../../../Global/AmountInput'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LinkSendInitialView = () => {
const {
@@ -56,6 +58,13 @@ const LinkSendInitialView = () => {
const { link, pubKey, chainId, contractVersion, depositIdx, txHash, amount, tokenAddress } =
await createLink(parseUnits(tokenValue!, PEANUT_WALLET_TOKEN_DECIMALS))
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_CREATED, {
+ amount: tokenValue,
+ chain_id: chainId,
+ token_address: tokenAddress,
+ has_attachment: !!attachmentOptions?.rawFile,
+ })
+
setLink(link)
setView('SUCCESS')
fetchBalance()
@@ -89,6 +98,10 @@ const LinkSendInitialView = () => {
// handle errors
const errorString = ErrorHandler(error)
setErrorState({ showError: true, errorMessage: errorString })
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_FAILED, {
+ amount: tokenValue,
+ error_message: errorString,
+ })
captureException(error)
} finally {
setLoadingState('Idle')
diff --git a/src/components/Send/link/views/Success.link.send.view.tsx b/src/components/Send/link/views/Success.link.send.view.tsx
index e4003d405..d8b91d751 100644
--- a/src/components/Send/link/views/Success.link.send.view.tsx
+++ b/src/components/Send/link/views/Success.link.send.view.tsx
@@ -17,6 +17,8 @@ import { useEffect, useState } from 'react'
import useClaimLink from '@/components/Claim/useClaimLink'
import { useToast } from '@/components/0_Bruddle/Toast'
import { TRANSACTIONS } from '@/constants/query.consts'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LinkSendSuccessView = () => {
const router = useRouter()
@@ -62,7 +64,15 @@ const LinkSendSuccessView = () => {
{link && (
-
+ {
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_SHARED, {
+ amount: tokenValue,
+ })
+ }}
+ >
Share link
{
// handle click on payment method options
const handleMethodClick = (methodId: string) => {
+ posthog.capture(ANALYTICS_EVENTS.SEND_METHOD_SELECTED, { method: methodId })
switch (methodId) {
case 'peanut-contacts':
// navigate to contacts/user selection page
diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx
index d1496af07..035fdfea5 100644
--- a/src/components/Setup/Views/InstallPWA.tsx
+++ b/src/components/Setup/Views/InstallPWA.tsx
@@ -10,6 +10,8 @@ import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { captureException } from '@sentry/nextjs'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { DeviceType } from '@/hooks/useGetDeviceType'
import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState'
@@ -75,6 +77,7 @@ const InstallPWA = ({
useEffect(() => {
const handleAppInstalled = () => {
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_COMPLETED, { device_type: deviceType })
setTimeout(() => {
setInstallComplete(true)
setIsInstallInProgress(false)
@@ -122,10 +125,12 @@ const InstallPWA = ({
if (!deferredPrompt?.prompt) return
setIsInstallInProgress(true)
setInstallCancelled(false)
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_CLICKED, { device_type: deviceType })
try {
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'dismissed') {
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_DISMISSED, { device_type: deviceType })
setInstallCancelled(true)
setIsInstallInProgress(false)
}
diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx
index d14aec6e2..88ca04257 100644
--- a/src/components/Setup/Views/Landing.tsx
+++ b/src/components/Setup/Views/Landing.tsx
@@ -7,6 +7,8 @@ import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LandingStep = () => {
const { handleNext } = useSetupFlow()
@@ -22,6 +24,7 @@ const LandingStep = () => {
: 'An unexpected error occurred during login.'
toast.error(errorMessage)
Sentry.captureException(error, { extra: { errorCode: error.code } })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: error.code })
}
const onLoginClick = async () => {
@@ -35,7 +38,14 @@ const LandingStep = () => {
return (
- handleNext()}>
+ {
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_CLICKED)
+ handleNext()
+ }}
+ >
Sign up
{
const { username } = useSetupStore()
@@ -39,7 +40,7 @@ const SetupPasskey = () => {
// clear any previous inline errors
setInlineError(null)
setErrorName(null)
- //capturePasskeyDebugInfo('passkey-registration-started')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_STARTED, { device_type: deviceType })
try {
await withWebAuthnRetry(() => handleRegister(username), 'passkey-registration')
@@ -47,6 +48,10 @@ const SetupPasskey = () => {
} catch (error) {
const err = error as Error
console.error('Passkey registration failed:', err)
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_FAILED, {
+ device_type: deviceType,
+ error_name: err.name,
+ })
// capture debug info for all failures
await capturePasskeyDebugInfo('passkey-registration-failed')
@@ -105,9 +110,10 @@ const SetupPasskey = () => {
// once passkey is registered successfully, move to test transaction step
useEffect(() => {
if (address) {
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_SUCCEEDED, { device_type: deviceType })
handleNext()
}
- }, [address, handleNext])
+ }, [address, handleNext, deviceType])
return (
@@ -127,14 +133,14 @@ const SetupPasskey = () => {
-
Learn more about what Passkeys are
- {' '}
+ {' '}
diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx
index ad39241b1..d624539bb 100644
--- a/src/components/Setup/Views/SignTestTransaction.tsx
+++ b/src/components/Setup/Views/SignTestTransaction.tsx
@@ -10,7 +10,8 @@ import { encodeFunctionData, erc20Abi, type Address, type Hex } from 'viem'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
import { capturePasskeyDebugInfo } from '@/utils/passkeyDebug'
import * as Sentry from '@sentry/nextjs'
-import Link from 'next/link'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { twMerge } from 'tailwind-merge'
const SignTestTransaction = () => {
@@ -81,6 +82,7 @@ const SignTestTransaction = () => {
setIsSigning(true)
setError(null)
dispatch(setupActions.setLoading(true))
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_STARTED)
try {
// if test transaction already completed, skip signing and go straight to account creation
@@ -107,6 +109,7 @@ const SignTestTransaction = () => {
console.log('[SignTestTransaction] Transaction signed successfully', {
userOpHash: result.userOpHash,
})
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_SIGNED)
setTestTransactionCompleted(true)
} else {
console.log('[SignTestTransaction] Test transaction already completed, retrying account creation')
@@ -128,6 +131,7 @@ const SignTestTransaction = () => {
// account setup complete - addAccount() already fetched and verified user data
console.log('[SignTestTransaction] Account setup complete, redirecting to the app')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_COMPLETED)
// keep loading state active until redirect completes
} else {
@@ -152,6 +156,7 @@ const SignTestTransaction = () => {
},
})
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_FAILED, { error_name: (e as Error).name })
setError(
"We're having trouble setting up your account. Our team has been notified. Please contact support for help."
)
@@ -188,14 +193,14 @@ const SignTestTransaction = () => {
-
Learn more about what Passkeys are
- {' '}
+ {' '}
@@ -206,14 +211,14 @@ const SignTestTransaction = () => {
export const PasskeyDocsLink = ({ className }: { className?: string }) => {
return (
-
Learn more about what Passkeys are
- {' '}
+ {' '}
)
}
diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx
index d62fe631e..dfd185217 100644
--- a/src/components/Setup/Views/Signup.tsx
+++ b/src/components/Setup/Views/Signup.tsx
@@ -8,6 +8,8 @@ import { setupActions } from '@/redux/slices/setup-slice'
import { fetchWithSentry } from '@/utils/sentry.utils'
import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useState } from 'react'
import { twMerge } from 'tailwind-merge'
@@ -53,13 +55,22 @@ const SignupStep = () => {
switch (res.status) {
case 200:
setError('Username already taken')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, {
+ is_valid: false,
+ error_type: 'taken',
+ })
return false
case 400:
setError('Username is invalid, please use a different one')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, {
+ is_valid: false,
+ error_type: 'invalid',
+ })
return false
case 404:
// handle is available
setError('')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true })
return true
default:
// we dont expect any other status code
diff --git a/src/components/Setup/Views/Welcome.tsx b/src/components/Setup/Views/Welcome.tsx
index 9a69796bb..da4bdc8a7 100644
--- a/src/components/Setup/Views/Welcome.tsx
+++ b/src/components/Setup/Views/Welcome.tsx
@@ -8,6 +8,8 @@ import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useZeroDev } from '@/hooks/useZeroDev'
import { getRedirectUrl, sanitizeRedirectURL, clearRedirectUrl } from '@/utils/general.utils'
import * as Sentry from '@sentry/nextjs'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
@@ -62,7 +64,14 @@ const WelcomeStep = () => {
return (
- handleNext()}>
+ {
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_CREATE_WALLET_CLICKED)
+ handleNext()
+ }}
+ >
Create your wallet
Supported regions`,
+Supported regions `,
CLAIM_RECIPIENT_INFO: `You can claim your funds to:
โข Any Ethereum wallet address
@@ -11,7 +11,7 @@ For US bank accounts, enter just your bank account number, no routing number.
โข EU bank account (IBAN)
โข US bank account
-Learn more about supported regions `,
+Learn more about supported regions `,
CASHOUT_FAQ: `โข What currencies can I cash out?
Most popular tokens and stablecoins are supported.
diff --git a/src/constants/tweets.consts.ts b/src/constants/tweets.consts.ts
index 69a352915..88ce29500 100644
--- a/src/constants/tweets.consts.ts
+++ b/src/constants/tweets.consts.ts
@@ -50,6 +50,7 @@ export interface Tweet {
is_reply?: boolean
reply_to_url?: string | null
media?: TweetMedia[]
+ visible?: boolean
}
interface TweetsData {
@@ -76,7 +77,7 @@ export const TWEETS: Tweet[] = (() => {
const authorCount = new Map()
const filtered: Tweet[] = []
- for (const tweet of ALL_TWEETS) {
+ for (const tweet of ALL_TWEETS.filter((t) => t.visible !== false)) {
const count = authorCount.get(tweet.handle) || 0
if (count >= MAX_PER_AUTHOR) continue
diff --git a/src/constants/tweets.json b/src/constants/tweets.json
index 636786af9..6127059f9 100644
--- a/src/constants/tweets.json
+++ b/src/constants/tweets.json
@@ -1,8 +1,8 @@
{
- "total_positive_mentions": 78,
+ "total_positive_mentions": 17,
"date_range": "2025-10-01 to 2025-11-26",
"scoring": "60% text enthusiasm + 30% user influence + 10% engagement | bots/shills capped | builders/VCs/EF boosted",
- "note": "78 real, deduped, third-party positive mentions. Enriched with timestamps, accurate followers, reply info, and full media arrays. Copy-paste ready for carousel.",
+ "note": "Curated shortlist per LP audit (Mar 2026). Prioritises concrete savings, real-world use cases, and genuine delight.",
"tweets": [
{
"url": "https://x.com/arbitrum/status/1985785756930187576",
@@ -21,7 +21,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G47qQ4mXsAAUdFH.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/arbitrum/status/1992999478295502877",
@@ -35,7 +36,8 @@
"impact_score": 0.99,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/diego_defi/status/1993341996627267698",
@@ -54,7 +56,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1993340416100323328/vid/avc1/352x270/1YCZYGDXTzixcfq3.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/0xPumbi/status/1992624069657649220",
@@ -73,7 +76,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6c5B6FWkAAqost.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/tkstanczak/status/1987523341134016699",
@@ -87,7 +91,8 @@
"impact_score": 0.97,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/coinempress/status/1993349992669434169",
@@ -106,7 +111,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1993349938105790464/vid/avc1/320x692/idZNNMyP4Dz9J5co.mp4"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/on_datawarlock/status/1992974740689158301",
@@ -125,7 +131,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6h3RyeaUAAi8UV.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/cxqmaggie/status/1993393875059441999",
@@ -148,7 +155,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6n1JroWEAAHzss.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/hummusonrails/status/1993351948372754451",
@@ -167,7 +175,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1993351337963048960/vid/avc1/480x270/YkRo7tDTCl54DRE7.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/0xSuperKalo/status/1991674399234175167",
@@ -186,7 +195,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6PYiQ4XkAA-A0U.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/on_datawarlock/status/1989885400387870990",
@@ -205,7 +215,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5197XWbIAAeUN-.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/kushagrasarathe/status/1991642890607685779",
@@ -224,7 +235,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6O8pqsWoAEK6_w.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/on_datawarlock/status/1988300376878293106",
@@ -247,7 +259,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5far_obcAIVrOQ.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/leeederek/status/1986916400653607231",
@@ -274,7 +287,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5LxHmlWsAAK3nU.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/stutireal/status/1993417479448936657",
@@ -288,7 +302,8 @@
"impact_score": 0.89,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": true
},
{
"url": "https://x.com/Emmy_Wilz_01/status/1993198570225238514",
@@ -307,7 +322,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6lDiA1XUAA7qRL.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/Emmy_Wilz_01/status/1988569730118983756",
@@ -326,7 +342,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5jRoD1XwAA0Is-.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/timokonkwo_/status/1993438728761885106",
@@ -340,7 +357,8 @@
"impact_score": 0.87,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": true
},
{
"url": "https://x.com/Emmy_Wilz_01/status/1993233039208395149",
@@ -359,7 +377,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6li4p2WAAAnCdS.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/0xmelch/status/1989765566219821300",
@@ -378,7 +397,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G50ROikWYAAodJi.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/DAppaDanDev/status/1992935274523840902",
@@ -397,7 +417,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6hUEYEWoAALAAz.jpg"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/cyberdrk/status/1992044070802497691",
@@ -411,7 +432,8 @@
"impact_score": 0.86,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": true
},
{
"url": "https://x.com/candicekteo/status/1993420115509956930",
@@ -425,7 +447,8 @@
"impact_score": 0.85,
"is_reply": true,
"reply_to_url": "https://x.com/DAppaDanDev/status/1992935274523840902",
- "media": []
+ "media": [],
+ "visible": true
},
{
"url": "https://x.com/BFreshHB/status/1987912195489878346",
@@ -444,7 +467,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1987910823243595776/vid/avc1/480x270/9ya2Bv7IuwD92lmS.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/cxqmaggie/status/1993394627148497206",
@@ -458,7 +482,8 @@
"impact_score": 0.85,
"is_reply": true,
"reply_to_url": "https://x.com/joinpeanut/status/1993394429261234640",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/yssf_io/status/1993363153611374973",
@@ -477,7 +502,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1993363153611374973/vid/avc1/320x568/1ePUdTUJWR-tXv2k.mp4"
}
- ]
+ ],
+ "visible": true
},
{
"url": "https://x.com/andxqueen/status/1980274681040310657",
@@ -496,7 +522,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G3tZU7FXkAEyn4D.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/0xJim/status/1991138208906703041",
@@ -510,7 +537,8 @@
"impact_score": 0.84,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Estheroche1/status/1993695756273012766",
@@ -529,7 +557,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6sHtqpWMAA2-Nr.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/kalashnikovapv/status/1991179700564341201",
@@ -548,7 +577,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6IXYRPbcAAVO8v.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/korneltrg/status/1990097531859239294",
@@ -567,7 +597,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G54-_00bwAIhiX4.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/zkLumi/status/1988983944335724602",
@@ -586,7 +617,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5l4InfXEAAJqaX.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/korneltrg/status/1989080195522081271",
@@ -605,7 +637,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5qdgM2aAAEU_ZB.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/bonis_crypto/status/1992237891783639051",
@@ -624,7 +657,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1992233946516455424/vid/avc1/320x568/JgI9D9m2jqjm9cBx.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/Churro808/status/1986509791636955382",
@@ -643,7 +677,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5F_PcNXkAAL3FF.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/stoczek_eth/status/1989728703245385791",
@@ -662,7 +697,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5zvtPMXQAAylVB.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/rnaayem/status/1986836999219650810",
@@ -676,7 +712,8 @@
"impact_score": 0.82,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/pedro_breuer/status/1981707623255679118",
@@ -690,7 +727,8 @@
"impact_score": 0.82,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/theZeugh/status/1993446136867393834",
@@ -704,7 +742,8 @@
"impact_score": 0.81,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": true
},
{
"url": "https://x.com/Dablendo01/status/1986750858323783928",
@@ -723,7 +762,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5JbYCRWwAAFkjv.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/yssf_io/status/1991964675307937824",
@@ -737,7 +777,8 @@
"impact_score": 0.81,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/gabipuricelli/status/1981205768595788271",
@@ -751,7 +792,8 @@
"impact_score": 0.68,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/imgacevi/status/1992320489759662200",
@@ -765,7 +807,8 @@
"impact_score": 0.67,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/AnInternetter/status/1993404987452670098",
@@ -779,7 +822,8 @@
"impact_score": 0.67,
"is_reply": true,
"reply_to_url": "https://x.com/AnInternetter/status/1993349829888540978",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/not_qz/status/1993077330895052982",
@@ -793,7 +837,8 @@
"impact_score": 0.67,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/loemrzva/status/1990850591980208148",
@@ -812,7 +857,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1990850284713660417/vid/avc1/320x568/NIFcx2qvtpcYRGjR.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/goyabean/status/1989752309057032505",
@@ -826,7 +872,8 @@
"impact_score": 0.67,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Leoninweb3/status/1993394963615564084",
@@ -840,7 +887,8 @@
"impact_score": 0.66,
"is_reply": true,
"reply_to_url": "https://x.com/cxqmaggie/status/1993393875059441999",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/nick_hollins/status/1991869587504451710",
@@ -859,7 +907,8 @@
"type": "video",
"url": "https://video.twimg.com/amplify_video/1991869426149265408/vid/avc1/320x568/1ePUdTUJWR-tXv2k.mp4"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/fakundirekt/status/1992479033091912117",
@@ -878,7 +927,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6a1HgxWQAAbVEa.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/jnptzl/status/1990821927137452166",
@@ -905,7 +955,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6DR8SHXMAAytOl.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/Ifee_lovee/status/1987260441567846816",
@@ -924,7 +975,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5Qq1u5WwAAD0Ez.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/simoneDotDev/status/1993461126198096216",
@@ -938,7 +990,8 @@
"impact_score": 0.64,
"is_reply": true,
"reply_to_url": "https://x.com/cxqmaggie/status/1993393875059441999",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/piggywallet_app/status/1991877872076877953",
@@ -957,7 +1010,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6SSXLpWoAABovW.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/0xShayan/status/1993381760764117121",
@@ -988,7 +1042,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6nqHncXEAAnyZm.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/mattff3/status/1993027674730852494",
@@ -1007,7 +1062,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6ioGsNX0AAboEK.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/zhiyangxyz/status/1993394901183348853",
@@ -1021,7 +1077,8 @@
"impact_score": 0.62,
"is_reply": true,
"reply_to_url": "https://x.com/AriEiberman/status/1992945656944828667",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/notAlddo/status/1993380368825925809",
@@ -1035,7 +1092,8 @@
"impact_score": 0.62,
"is_reply": true,
"reply_to_url": "https://x.com/AnInternetter/status/1993349829888540978",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/stutireal/status/1993499051070025962",
@@ -1049,7 +1107,8 @@
"impact_score": 0.62,
"is_reply": true,
"reply_to_url": "https://x.com/cxqmaggie/status/1993393875059441999",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Churro808/status/1993493484104564907",
@@ -1063,7 +1122,8 @@
"impact_score": 0.62,
"is_reply": true,
"reply_to_url": "https://x.com/hummusonrails/status/1993351948372754451",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Real_sweetone/status/1989758885301796919",
@@ -1086,7 +1146,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G50LJVRXAAAXxa0.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/hummusonrails/status/1989373688685167011",
@@ -1105,7 +1166,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G5us0jFWAAEasmo.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/lucilajuliana/status/1992487381661110589",
@@ -1124,7 +1186,8 @@
"type": "photo",
"url": "https://pbs.twimg.com/media/G6a8tyLWQAAoO6I.jpg"
}
- ]
+ ],
+ "visible": false
},
{
"url": "https://x.com/Churro808/status/1993491550836535519",
@@ -1138,7 +1201,8 @@
"impact_score": 0.61,
"is_reply": true,
"reply_to_url": "https://x.com/hummusonrails/status/1993351948372754451",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/yotamha1/status/1993368603916485020",
@@ -1152,7 +1216,8 @@
"impact_score": 0.61,
"is_reply": true,
"reply_to_url": "https://x.com/hummusonrails/status/1993351948372754451",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/BFreshHB/status/1993490982307090874",
@@ -1166,7 +1231,8 @@
"impact_score": 0.6,
"is_reply": true,
"reply_to_url": "https://x.com/hummusonrails/status/1993351948372754451",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Player1Taco/status/1988070662179864981",
@@ -1180,7 +1246,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/simoneDotDev/status/1993461948416246275",
@@ -1194,7 +1261,8 @@
"impact_score": 0.6,
"is_reply": true,
"reply_to_url": "https://x.com/cxqmaggie/status/1993393875059441999",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/JuanJoseBTC/status/1992054753778405664",
@@ -1208,7 +1276,8 @@
"impact_score": 0.6,
"is_reply": true,
"reply_to_url": "https://x.com/0xPumbi/status/1992624069657649220",
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/guillermdk/status/1981930130881626610",
@@ -1222,7 +1291,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/linuxcity/status/1989229851622941155",
@@ -1236,7 +1306,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/Yosoyraymon/status/1988225946734694809",
@@ -1250,7 +1321,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/guillermdk/status/1981048731173638203",
@@ -1264,7 +1336,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/paul_ironforce/status/1983907078541373827",
@@ -1278,7 +1351,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/0xMhiskall/status/1989225577233219874",
@@ -1292,7 +1366,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/AufDerSuche23/status/1987524924479590708",
@@ -1306,7 +1381,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
},
{
"url": "https://x.com/ferminrp/status/1981044335459078643",
@@ -1320,7 +1396,8 @@
"impact_score": 0.6,
"is_reply": false,
"reply_to_url": null,
- "media": []
+ "media": [],
+ "visible": false
}
]
}
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index 9b92b434d..bd9e5f600 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -13,6 +13,7 @@ import {
} from '@/utils/general.utils'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { resetCrispProxySessions } from '@/utils/crisp'
+import posthog from 'posthog-js'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react'
@@ -75,6 +76,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('set', { user_id: user.user.userId })
}
+ // PostHog: identify user (stitches anonymous pre-login events to this user)
+ posthog.identify(user.user.userId, {
+ username: user.user.username,
+ })
}
}, [user])
@@ -204,6 +209,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
if (typeof window !== 'undefined') {
resetCrispProxySessions()
}
+
+ // Reset PostHog identity so next user doesn't inherit previous user's session
+ posthog.reset()
}, [dispatch, queryClient, user?.user.userId])
/**
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
index 9486629d6..81a1e7f31 100644
--- a/src/data/seo/exchanges.ts
+++ b/src/data/seo/exchanges.ts
@@ -106,3 +106,23 @@ function estimateProcessingTime(network: string): string {
}
export const EXCHANGES: Record = loadExchanges()
+
+/**
+ * Deposit rails โ payment methods and crypto networks with published MDX content
+ * in content/deposit/{slug}/. These don't have entity data like exchanges do;
+ * they're pure content pages served at /en/deposit/via-{slug}.
+ */
+export const DEPOSIT_RAILS: Record = {
+ // Fiat rails
+ ach: 'ACH Bank Transfer',
+ sepa: 'SEPA Bank Transfer',
+ wire: 'Wire Transfer',
+ // Crypto networks
+ arbitrum: 'Arbitrum',
+ avalanche: 'Avalanche',
+ base: 'Base',
+ ethereum: 'Ethereum',
+ polygon: 'Polygon',
+ solana: 'Solana',
+ tron: 'Tron',
+}
diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts
index e25b4fbe9..ae12a3e5b 100644
--- a/src/data/seo/index.ts
+++ b/src/data/seo/index.ts
@@ -4,7 +4,7 @@ export type { CountrySEO, Corridor } from './corridors'
export { COMPETITORS } from './comparisons'
export type { Competitor } from './comparisons'
-export { EXCHANGES } from './exchanges'
+export { EXCHANGES, DEPOSIT_RAILS } from './exchanges'
export type { Exchange } from './exchanges'
export { PAYMENT_METHODS, PAYMENT_METHOD_SLUGS } from './payment-methods'
diff --git a/src/features/limits/components/LimitsDocsLink.tsx b/src/features/limits/components/LimitsDocsLink.tsx
index 6c65bba40..b7b38c780 100644
--- a/src/features/limits/components/LimitsDocsLink.tsx
+++ b/src/features/limits/components/LimitsDocsLink.tsx
@@ -1,7 +1,7 @@
export default function LimitsDocsLink() {
return (
(null)
usePointsConfetti(points, pointsDivRef)
+ useEffect(() => {
+ if (points) {
+ posthog.capture(ANALYTICS_EVENTS.POINTS_EARNED, {
+ points,
+ flow_type: isWithdrawFlow ? 'withdraw' : type?.toLowerCase(),
+ })
+ }
+ }, [points, isWithdrawFlow, type])
+
useEffect(() => {
// invalidate queries to refetch history
queryClient?.invalidateQueries({ queryKey: [TRANSACTIONS] })
diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts
index d7d151299..fc9653ea1 100644
--- a/src/hooks/useMultiPhaseKycFlow.ts
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -5,6 +5,8 @@ import { useRailStatusTracking } from '@/hooks/useRailStatusTracking'
import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users'
import { type KycModalPhase } from '@/interfaces'
import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const PREPARING_TIMEOUT_MS = 30000
@@ -74,6 +76,10 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
// complete the flow โ close everything, call original onKycSuccess
const completeFlow = useCallback(() => {
+ posthog.capture(
+ regionIntent === 'LATAM' ? ANALYTICS_EVENTS.MANTECA_KYC_COMPLETED : ANALYTICS_EVENTS.KYC_APPROVED,
+ { region_intent: regionIntent }
+ )
isRealtimeFlowRef.current = false
setForceShowModal(false)
setModalPhase('verifying')
@@ -85,7 +91,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
stopTracking()
closeVerificationModalRef.current()
onKycSuccess?.()
- }, [onKycSuccess, clearPreparingTimer, stopTracking])
+ }, [onKycSuccess, clearPreparingTimer, stopTracking, regionIntent])
// called when sumsub status transitions to APPROVED
const handleSumsubApproved = useCallback(async () => {
@@ -146,19 +152,30 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
// so the drawer/status item reads the updated verification record
useEffect(() => {
if (liveKycStatus === 'ACTION_REQUIRED' || liveKycStatus === 'REJECTED') {
+ posthog.capture(ANALYTICS_EVENTS.KYC_REJECTED, {
+ region_intent: regionIntent,
+ status: liveKycStatus,
+ })
fetchUser()
}
- }, [liveKycStatus, fetchUser])
+ }, [liveKycStatus, fetchUser, regionIntent])
// wrap handleSdkComplete to track real-time flow
const handleSdkComplete = useCallback(() => {
+ posthog.capture(ANALYTICS_EVENTS.KYC_SUBMITTED, { region_intent: regionIntent })
isRealtimeFlowRef.current = true
originalHandleSdkComplete()
- }, [originalHandleSdkComplete])
+ }, [originalHandleSdkComplete, regionIntent])
// wrap handleInitiateKyc to reset state for new attempts
const handleInitiateKyc = useCallback(
async (overrideIntent?: KYCRegionIntent, levelName?: string) => {
+ const intent = overrideIntent ?? regionIntent
+ posthog.capture(
+ intent === 'LATAM' ? ANALYTICS_EVENTS.MANTECA_KYC_INITIATED : ANALYTICS_EVENTS.KYC_INITIATED,
+ { region_intent: intent }
+ )
+
setModalPhase('verifying')
setForceShowModal(false)
setPreparingTimedOut(false)
@@ -170,7 +187,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
await originalHandleInitiateKyc(overrideIntent, levelName)
},
- [originalHandleInitiateKyc, clearPreparingTimer]
+ [originalHandleInitiateKyc, clearPreparingTimer, regionIntent]
)
// 30s timeout for preparing phase
@@ -235,6 +252,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
setShowTosIframe(false)
if (source === 'tos_accepted') {
+ posthog.capture(ANALYTICS_EVENTS.KYC_TOS_ACCEPTED)
// show loading state while confirming + polling
setModalPhase('preparing')
await confirmBridgeTosAndAwaitRails(fetchUser)
@@ -252,12 +270,16 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
// handle modal close (Go to Home, etc.)
const handleModalClose = useCallback(() => {
+ posthog.capture(
+ regionIntent === 'LATAM' ? ANALYTICS_EVENTS.MANTECA_KYC_ABANDONED : ANALYTICS_EVENTS.KYC_ABANDONED,
+ { region_intent: regionIntent, phase: modalPhase }
+ )
isRealtimeFlowRef.current = false
setForceShowModal(false)
clearPreparingTimer()
stopTracking()
closeVerificationProgressModal()
- }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal])
+ }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal, regionIntent, modalPhase])
// cleanup on unmount
useEffect(() => {
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 6388138b0..093679a90 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import OneSignal from 'react-onesignal'
import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
import { useUserStore } from '@/redux/hooks'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
export function useNotifications() {
const { user } = useUserStore()
@@ -13,6 +15,7 @@ export function useNotifications() {
const externalIdRef = useRef(null)
const lastLinkedExternalIdRef = useRef(null)
const disableExternalIdLoginRef = useRef(false)
+ const hasTrackedModalShown = useRef(false)
// ui state for permission modal (shown once on login)
const [showPermissionModal, setShowPermissionModal] = useState(false)
@@ -102,6 +105,10 @@ export function useNotifications() {
// show modal only if user hasn't closed it yet
if (!modalClosed) {
setShowPermissionModal(true)
+ if (!hasTrackedModalShown.current) {
+ hasTrackedModalShown.current = true
+ posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.NOTIFICATIONS })
+ }
} else {
setShowPermissionModal(false)
}
@@ -186,6 +193,16 @@ export function useNotifications() {
// update local permission state and immediately re-evaluate ui visibility
refreshPermissionState()
evaluateVisibility()
+
+ // track the resulting permission state
+ if (typeof Notification !== 'undefined') {
+ const perm = Notification.permission
+ if (perm === 'granted') {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_GRANTED)
+ } else if (perm === 'denied') {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_DENIED)
+ }
+ }
})
type PushSubscriptionChangeEvent = { current?: { optedIn?: boolean } | null }
@@ -214,6 +231,7 @@ export function useNotifications() {
// hide modal when user opts in
if (event.current?.optedIn) {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED)
setShowPermissionModal(false)
}
}
@@ -237,6 +255,7 @@ export function useNotifications() {
if (typeof window === 'undefined' || !oneSignalInitialized) return 'default'
setIsRequestingPermission(true)
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_REQUESTED)
try {
// always use the native browser permission dialog, avoid onesignal slidedown ui
@@ -315,6 +334,7 @@ export function useNotifications() {
const closePermissionModal = useCallback(() => {
setShowPermissionModal(false)
updateUserPreferences(user?.user.userId, { notifModalClosed: true })
+ posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.NOTIFICATIONS })
}, [user?.user.userId])
// update permission state after user interacts with permission prompt
diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts
index c8518ecd6..adff7930d 100644
--- a/src/hooks/useZeroDev.ts
+++ b/src/hooks/useZeroDev.ts
@@ -13,6 +13,8 @@ import { useCallback, useContext } from 'react'
import type { TransactionReceipt, Hex, Hash } from 'viem'
import { captureException } from '@sentry/nextjs'
import { invitesApi } from '@/services/invites'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// types
type UserOpEncodedParams = {
@@ -87,7 +89,17 @@ export const useZeroDev = () => {
if (userInviteCode?.trim().length > 0) {
try {
const result = await invitesApi.acceptInvite(userInviteCode, inviteType, campaignTag)
- if (!result.success) {
+ if (result.success) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, {
+ invite_code: userInviteCode,
+ invite_type: inviteType,
+ campaign_tag: campaignTag,
+ })
+ } else {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: userInviteCode,
+ error_message: 'API returned unsuccessful',
+ })
console.error('Error accepting invite', result)
}
if (inviteCodeFromCookie) {
@@ -97,6 +109,10 @@ export const useZeroDev = () => {
removeFromCookie('campaignTag')
}
} catch (e) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: userInviteCode,
+ error_message: String(e),
+ })
console.error('Error accepting invite', e)
}
}
diff --git a/src/services/points.ts b/src/services/points.ts
index c0f1a99eb..6fb25164f 100644
--- a/src/services/points.ts
+++ b/src/services/points.ts
@@ -303,7 +303,7 @@ export const pointsApi = {
getInvitesGraph: async (
apiKey: string,
- options?: { mode?: 'full' | 'payment'; topNodes?: number; password?: string }
+ options?: { mode?: 'full' | 'payment'; topNodes?: number; includeNewDays?: number; password?: string }
): Promise => {
const isPaymentMode = options?.mode === 'payment'
const params = new URLSearchParams()
@@ -313,6 +313,9 @@ export const pointsApi = {
if (options?.topNodes && options.topNodes > 0) {
params.set('topNodes', options.topNodes.toString())
}
+ if (options?.includeNewDays && options.includeNewDays > 0) {
+ params.set('includeNewDays', options.includeNewDays.toString())
+ }
if (options?.password) {
params.set('password', options.password)
}
diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts
index c0f0b2742..968ae9fea 100644
--- a/src/utils/format.utils.ts
+++ b/src/utils/format.utils.ts
@@ -108,3 +108,5 @@ export const formatCurrencyWithIntl = (
return numericValue.toFixed(minDigits)
}
}
+
+export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts
index bbdbc0396..508839158 100644
--- a/src/utils/withdraw.utils.ts
+++ b/src/utils/withdraw.utils.ts
@@ -1,4 +1,5 @@
import { countryData, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
+import { isValidEmail } from '@/utils/format.utils'
/**
* Extracts the country name from an IBAN by parsing the first 2 characters (country code)
@@ -271,8 +272,7 @@ export const validatePixKey = (pixKey: string): { valid: boolean; message?: stri
}
// 4. Email: Standard email format
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
- if (emailRegex.test(trimmed)) {
+ if (isValidEmail(trimmed)) {
if (trimmed.length > 77) {
return { valid: false, message: 'Email is too long (max 77 characters)' }
}