diff --git a/.cursorrules b/.cursorrules index 59bcc3cf4..6ecc152d0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -42,6 +42,7 @@ - **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. ## 📝 Commits diff --git a/docs/CRISP_IOS_FIX.md b/docs/CRISP_IOS_FIX.md deleted file mode 100644 index 1c9bab333..000000000 --- a/docs/CRISP_IOS_FIX.md +++ /dev/null @@ -1,75 +0,0 @@ -# Crisp iOS Fix - -**Problem:** Support drawer blank forever on iOS -**Fix:** Removed Suspense + manual URL params + device-specific timeouts -**Status:** Ready to test on iOS device - -## What Was Wrong - -iOS Safari + Suspense + useSearchParams = streaming deadlock → blank screen forever - -## Changes (44 net lines) - -1. **Removed Suspense wrapper** - Causes Safari buffering issue -2. **Manual URLSearchParams** - Replaced useSearchParams() hook (iOS hydration bug) -3. **Device timeouts** - 3s desktop, 8s iOS (DRY hook: `useCrispIframeReady`) -4. **iOS height styles** - Added `-webkit-fill-available` fallback - -## Files - -``` -src/app/crisp-proxy/page.tsx | ±40 lines -src/hooks/useCrispIframeReady.ts | +55 (NEW) -src/components/Global/SupportDrawer/index.tsx | -19 lines -src/app/(mobile-ui)/support/page.tsx | -11 lines -``` - -## How Timeouts Work - -**NOT forced delays** - Shows content immediately when ready: - -```typescript -// Listens for CRISP_READY postMessage -// Shows iframe AS SOON as message received (typically 1-2s) -// Timeout = MAX wait to prevent infinite loading -``` - -**Timeline:** - -- Good connection: Crisp loads in 1-2s → shows immediately ✅ -- Slow connection: Still waits for CRISP_READY, up to timeout -- Crisp fails: Timeout fires → shows friendly error with retry button - -**Why device-specific?** - -- Desktop/Android: 3s max (fast execution) -- iOS: 8s max (stricter security, slower in iframes) - -## Fallback Behavior - -If Crisp never loads (timeout or failure): - -1. Loading spinner hides after timeout -2. Shows friendly error message: - - "Having trouble loading support chat" - - "Check your internet connection and try again" - - Retry button to try loading again - - Fallback email: support@peanut.me -3. User can retry, contact via email, or close drawer - -## Known Limitations - -1. **iOS PWA cookie blocking** - Cannot be fixed (iOS WKWebView limitation) -2. **Very slow networks** - Timeout may fire before Crisp loads on 2G (but user can retry) - -## Testing - -**iOS Safari:** - -- [ ] Visit `/support` → loads quickly -- [ ] Open drawer → loads quickly -- [ ] Fast WiFi: < 3s, Slow 3G: < 8s - -**Desktop/Android (no regression):** - -- [ ] Loads in < 3s as before diff --git a/docs/INVITE_GRAPH_PR.md b/docs/INVITE_GRAPH_PR.md deleted file mode 100644 index 18fe03a29..000000000 --- a/docs/INVITE_GRAPH_PR.md +++ /dev/null @@ -1,178 +0,0 @@ -# PR: Interactive Invite Graph Visualization - -## Overview - -Adds an interactive 2D force-directed graph visualization of all Peanut invites to the dev tools section. - -## Changes - -### Backend API (`peanut-api-ts/src/routes/invite.ts`) - -**New Endpoint**: `GET /invites/graph` (admin-only) -- Returns all invites in graph format (nodes + edges) -- Protected with `requireApiKey()` decorator -- Single optimized query with joins (no N+1) -- DRY refactor: Extracted node building logic to `addUserNode()` helper -- Proper error handling and logging - -**Query Performance**: -- Fetches all invites with `include` for inviter/invitee (efficient single query) -- Builds deduplicated node map in-memory -- Returns ~2.5MB of data for 5000 invites (acceptable for admin tool) - -### Frontend (`peanut-ui`) - -**New Page**: `/dev/invite-graph` -- Interactive force-directed graph using `react-force-graph-2d` -- Toggle username display -- Toggle points display (direct + transitive) -- Click user to filter to their tree (ancestors + descendants) -- Orphan nodes (no app access) displayed separately -- Performant for 3000+ nodes with WebGL acceleration - -**Dependencies**: -- Added `react-force-graph-2d` (2D-only version, no VR dependencies) -- Added `d3-force` for custom force configuration - -**Service Layer**: `pointsApi.getInvitesGraph(apiKey)` -- 30-second timeout with AbortController -- Proper error handling for timeout/network errors - -## Production Readiness - -### ✅ **APPROVED FOR PRODUCTION** - -**Security**: -- ✅ API key authentication required (admin-only) -- ✅ No PII exposure (only usernames, public points) -- ✅ API key not persisted (component state only) - -**Performance**: -- ✅ Single optimized DB query (no N+1) -- ✅ Acceptable memory footprint (~5MB client-side) -- ✅ WebGL canvas acceleration -- ✅ Request timeout (30s frontend) - -**Code Quality**: -- ✅ DRY refactoring applied (backend node building) -- ✅ Proper error handling -- ✅ TypeScript type safety -- ✅ Loading/error states -- ✅ Memory leak prevention (cleanup useEffect) - -**Testing**: -- Manual testing with admin API key required -- Verify graph renders correctly with real data -- Test tree filtering by clicking nodes -- Test zoom/pan controls -- Verify node clustering and spacing - -## Implementation Details - -### Force Simulation - -**Configuration** (applied once on initial mount): -- **Charge**: `-300` strength, `500px` distance max → even distribution -- **Link**: `80px` distance, `0.5` strength → spread out connections -- **Collision**: Dynamic radius based on node size → prevent overlap -- **Center**: Gentle gravity → keep graph compact -- **Particles**: Speed `0.0012` → smooth, visible flow from leaves to roots - -**Result**: Evenly distributed, spacious layout from the start - -### Data Structure - -```typescript -{ - nodes: Array<{ - id: string // userId - username: string - hasAppAccess: boolean - directPoints: number - transitivePoints: number - totalPoints: number - }>; - edges: Array<{ - id: string // invite ID - source: string // inviterId (reversed in frontend for particle flow) - target: string // inviteeId - type: 'DIRECT' | 'PAYMENT_LINK' - createdAt: string - }>; - stats: { - totalNodes: number - totalEdges: number - usersWithAccess: number - orphans: number - } -} -``` - -### Node Styling - -- **Size**: Based on total points (`baseSize + sqrt(points)/30`) -- **Color**: - - Purple (`#8b5cf6`) for users with app access - - Gray (`#9ca3af`) for orphans - - Yellow (`#fbbf24`) for selected node -- **Labels**: Username + points (shown at sufficient zoom level) - -### Edge Styling - -- **Direction**: Invitee → Inviter (reversed for particle flow) -- **Colors**: - - Purple (`rgba(139, 92, 246, 0.4)`) for DIRECT invites - - Pink (`rgba(236, 72, 153, 0.4)`) for PAYMENT_LINK invites -- **Width**: 1.5px for DIRECT, 1px for PAYMENT_LINK -- **Particles**: 2 particles flowing from leaves to roots (like fees/energy) - -### UI Features - -**Full-screen layout**: No header/sidebar, maximizes graph space -**Top control bar**: Title, stats, toggle buttons -**Legend**: Bottom-left, explains node/edge types -**Mobile controls**: Floating buttons for touch devices -**Selected user banner**: Shows filtering info when node is clicked -**Hover tooltips**: Clean, minimal design with user info - -## Files Changed - -``` -peanut-api-ts/src/routes/invite.ts | +63 (new endpoint + DRY) -peanut-ui/src/services/points.ts | +33 (new API function + timeout) -peanut-ui/src/app/(mobile-ui)/dev/invite-graph/page.tsx | +548 (NEW) -peanut-ui/src/app/(mobile-ui)/dev/page.tsx | +7 (add to tools list) -peanut-ui/src/types/react-force-graph.d.ts | +13 (d3-force types) -peanut-ui/src/constants/routes.ts | +1 (public route regex) -peanut-ui/src/context/authContext.tsx | +3 (skip user fetch) -peanut-ui/src/app/(mobile-ui)/layout.tsx | +5 (public route handling) -peanut-ui/package.json | +2 (dependencies) -peanut-ui/docs/INVITE_GRAPH_PRODUCTION_REVIEW.md | +145 (NEW) -``` - -## Usage - -1. Navigate to `/dev` in the app -2. Click on "Invite Graph" tool -3. Enter admin API key -4. Explore the graph: - - Zoom/pan to navigate - - Click nodes to filter to their tree - - Toggle Names/Points for different views - - Hover over nodes for detailed info - -## Future Optimizations (if needed) - -- Add Redis caching (5min TTL) if endpoint is hit frequently -- Add pagination if user count exceeds 10,000 -- Add data export button (CSV/JSON) for analysis -- Consider WebWorker for force simulation (if graph grows to 10k+ nodes) -- Add response compression middleware (gzip, ~70% reduction) - -## Notes - -- This is an admin-only tool, not user-facing -- Low traffic expected (internal use only) -- API key required for all operations -- Public route (no JWT auth) for easy admin access - diff --git a/docs/INVITE_GRAPH_PRODUCTION_REVIEW.md b/docs/INVITE_GRAPH_PRODUCTION_REVIEW.md deleted file mode 100644 index 18231c6a8..000000000 --- a/docs/INVITE_GRAPH_PRODUCTION_REVIEW.md +++ /dev/null @@ -1,132 +0,0 @@ -# Invite Graph Production Review - -## Security Issues - -### ✅ SAFE -1. **API Key Auth**: Backend endpoint properly requires `requireApiKey()` ✓ -2. **No PII exposure**: Only exposes usernames, public points data ✓ -3. **Admin-only**: Route is under `/dev` which is public but requires API key to fetch data ✓ - -### ⚠️ RECOMMENDATIONS -1. **API Key Storage**: Currently in component state - acceptable for admin tool but should NEVER be cached/stored -2. **Rate Limiting**: Consider adding rate limiting to `/invites/graph` endpoint (currently unlimited) - -## Performance Issues - -### Backend (`/invites/graph`) - -#### ❌ CRITICAL - Memory & Query Performance -```typescript -// Current: Fetches ALL invites with nested includes - O(n) query + O(n) memory -const invites = await prisma.invites.findMany({ - include: { inviter: { ... }, invitee: { ... } } -}) -``` - -**Problem**: For 3000+ users with 5000+ invites, this could be: -- 5000 rows × ~500 bytes = 2.5MB of data transferred -- No query optimization -- No caching -- Single query is good (no N+1), but result set is large - -**Solutions**: -1. ✅ **Keep as-is** - Query is actually efficient (single DB roundtrip with joins) -2. Add response compression (gzip) - reduces payload by ~70% -3. Add Redis caching with 5min TTL - most admin views don't need real-time data -4. Add pagination (if graph becomes too large in future) - -**Verdict**: ✅ **ACCEPTABLE for production** - Single optimized query, acceptable for admin tool - -### Frontend - -#### ❌ MODERATE - Large DOM/Canvas Elements -- Rendering 3000+ nodes on canvas is heavy but acceptable -- WebGL acceleration helps -- Force simulation runs in background thread - -**Memory profile**: -- 3000 nodes × 200 bytes ≈ 600KB in memory -- Canvas rendering: ~2-3MB GPU memory -- Force simulation: ~1MB working memory -- **Total: ~5MB - acceptable** - -#### ⚠️ Minor Improvements -1. Add cleanup in useEffect for graph ref -2. Memoize more callbacks to prevent re-renders -3. Consider debouncing toggle buttons (not critical - ref fixes this) - -## Code Quality (DRY) - -### Backend - Duplication Found - -#### ❌ Code Smell: Repeated Node Building Logic -```typescript -// Lines 392-403 and 405-415 - same logic repeated -if (!nodeMap.has(invite.inviter.userId)) { - nodeMap.set(invite.inviter.userId, { - id: invite.inviter.userId, - username: invite.inviter.username, - // ... repeated 6 times - }) -} -``` - -**Fix**: Extract to helper function - -### Frontend - Good Structure -- ✅ Proper component separation -- ✅ Custom hooks for adjacency logic -- ✅ Memoization with useMemo/useCallback -- ✅ Ref usage to prevent zoom resets - -## Production Readiness - -### Backend -- ✅ Error handling with try/catch -- ✅ Proper logging with `logger.error()` -- ✅ Transaction safety (not needed here, read-only) -- ✅ Type safety with Prisma types -- ⚠️ No request timeout (Fastify default: 30s - acceptable) -- ⚠️ No response size limit (Fastify default: 1MB - might need increase) - -### Frontend -- ✅ Loading states -- ✅ Error boundaries (global) -- ✅ SSR disabled (dynamic import) -- ✅ Type safety -- ⚠️ No request timeout on frontend fetch (browser default: varies) -- ⚠️ No retry logic on API failure - -## Recommendations for Production - -### HIGH PRIORITY -None - code is production-ready as-is for admin tool - -### MEDIUM PRIORITY -1. **Add response compression** (backend middleware) -2. **Extract DRY violation** (node building logic) -3. **Add request timeout** to frontend fetch (30s) - -### LOW PRIORITY (Future Optimization) -1. Add Redis caching (5min TTL) if endpoint is hit frequently -2. Add pagination if user count exceeds 10,000 -3. Add data export button (CSV/JSON) for analysis -4. Consider WebWorker for force simulation (if graph grows to 10k+ nodes) - -## Final Verdict - -### ✅ APPROVED FOR PRODUCTION - -**Reasoning**: -- No security vulnerabilities -- Single optimized DB query (no N+1) -- Acceptable memory footprint (~5MB) -- Proper error handling -- Admin-only tool (not user-facing, low traffic) - -**Action Items**: -- [x] Review complete -- [ ] Implement DRY refactor (optional) -- [ ] Add response compression (recommended) -- [ ] Test with production data size - diff --git a/next.config.js b/next.config.js index 424711a5e..b3e0d3715 100644 --- a/next.config.js +++ b/next.config.js @@ -141,7 +141,7 @@ let nextConfig = { headers: [ { key: 'Permissions-Policy', - value: 'camera=*, microphone=*, clipboard-read=(self), clipboard-write=(self)', + value: 'camera=(self "*"), microphone=(self "*"), clipboard-read=(self), clipboard-write=(self)', }, ], }, @@ -205,6 +205,8 @@ if (process.env.NODE_ENV !== 'development') { const withSerwist = (await import('@serwist/next')).default({ swSrc: './src/app/sw.ts', swDest: 'public/sw.js', + // explicitly include offline screen assets in precache + additionalPrecacheEntries: ['/icons/peanut-icon.svg'], }) return withSerwist(nextConfig) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e625f25e8..6bc175144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 2.0.4 '@daimo/pay': specifier: ^1.16.5 - version: 1.16.5(aab9c1b916d14222248b36333fb0cd5f) + version: 1.16.5(0133d12034a313dd0ab10c88bd8ab5d6) '@dicebear/collection': specifier: ^9.2.2 version: 9.2.4(@dicebear/core@9.2.4) @@ -8849,11 +8849,11 @@ snapshots: - typescript - utf-8-validate - '@daimo/pay@1.16.5(aab9c1b916d14222248b36333fb0cd5f)': + '@daimo/pay@1.16.5(0133d12034a313dd0ab10c88bd8ab5d6)': dependencies: '@daimo/pay-common': 1.16.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0)(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0)(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10) '@tanstack/react-query': 5.8.4(react-dom@19.1.1(react@19.1.1))(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@trpc/client': 11.5.0(@trpc/server@11.5.0(typescript@5.9.2))(typescript@5.9.2) @@ -12020,11 +12020,11 @@ snapshots: '@wallet-standard/features': 1.1.0 eventemitter3: 5.0.1 - '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0)(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0)(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: '@solana-mobile/wallet-adapter-mobile': 2.2.3(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(react-native@0.81.0(@babel/core@7.28.3)(@react-native/metro-config@0.81.0(@babel/core@7.28.3))(@types/react@18.3.23)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)) - '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1) + '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0)(react@19.1.1) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10) react: 19.1.1 transitivePeerDependencies: @@ -12065,6 +12065,19 @@ snapshots: '@wallet-standard/wallet': 1.1.0 bs58: 5.0.0 + '@solana/wallet-standard-wallet-adapter-base@1.1.4(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0)': + dependencies: + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)) + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@solana/wallet-standard-util': 1.1.2 + '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10) + '@wallet-standard/app': 1.1.0 + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + '@wallet-standard/wallet': 1.1.0 + bs58: 6.0.0 + '@solana/wallet-standard-wallet-adapter-react@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)) @@ -12076,6 +12089,17 @@ snapshots: - '@solana/web3.js' - bs58 + '@solana/wallet-standard-wallet-adapter-react@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0)(react@19.1.1)': + dependencies: + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)) + '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@6.0.0) + '@wallet-standard/app': 1.1.0 + '@wallet-standard/base': 1.1.0 + react: 19.1.1 + transitivePeerDependencies: + - '@solana/web3.js' + - bs58 + '@solana/wallet-standard-wallet-adapter@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1)': dependencies: '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10))(bs58@5.0.0) diff --git a/public/badges/arbiverse_devconnect.svg b/public/badges/arbiverse_devconnect.svg new file mode 100644 index 000000000..94bdc8b18 --- /dev/null +++ b/public/badges/arbiverse_devconnect.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons/apple-touch-icon-152x152-beta.png b/public/icons/apple-touch-icon-152x152-beta.png new file mode 100644 index 000000000..a7e5ddd99 Binary files /dev/null and b/public/icons/apple-touch-icon-152x152-beta.png differ diff --git a/public/icons/apple-touch-icon-152x152.png b/public/icons/apple-touch-icon-152x152.png deleted file mode 100644 index 4722fb99b..000000000 Binary files a/public/icons/apple-touch-icon-152x152.png and /dev/null differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon-beta.png similarity index 100% rename from public/icons/apple-touch-icon.png rename to public/icons/apple-touch-icon-beta.png diff --git a/public/icons/icon-192x192-beta.png b/public/icons/icon-192x192-beta.png new file mode 100644 index 000000000..a7e5ddd99 Binary files /dev/null and b/public/icons/icon-192x192-beta.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png deleted file mode 100644 index b7acba229..000000000 Binary files a/public/icons/icon-192x192.png and /dev/null differ diff --git a/public/icons/icon-512x512-beta.png b/public/icons/icon-512x512-beta.png new file mode 100644 index 000000000..375cdc578 Binary files /dev/null and b/public/icons/icon-512x512-beta.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png deleted file mode 100644 index 56403670d..000000000 Binary files a/public/icons/icon-512x512.png and /dev/null differ diff --git a/public/icons/peanut-icon.svg b/public/icons/peanut-icon.svg index 4de20c1b0..bb11db4d8 100644 --- a/public/icons/peanut-icon.svg +++ b/public/icons/peanut-icon.svg @@ -1,18 +1,33 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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 a54a02b5c..242ddd2cc 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -25,6 +25,8 @@ import { getCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/ import { OnrampConfirmationModal } from '@/components/AddMoney/components/OnrampConfirmationModal' import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' import InfoCard from '@/components/Global/InfoCard' +import { usePaymentStore } from '@/redux/hooks' +import { saveDevConnectIntent } from '@/utils' type AddStep = 'inputAmount' | 'kyc' | 'loading' | 'collectUserDetails' | 'showDetails' @@ -47,6 +49,7 @@ export default function OnrampBankPage() { const { balance } = useWallet() const { user, fetchUser } = useAuth() const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp() + const { parsedPaymentData } = usePaymentStore() const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { @@ -173,6 +176,14 @@ export default function OnrampBankPage() { setOnrampData(onrampDataResponse) if (onrampDataResponse.transferId) { + // @dev: save devconnect intent if this is a devconnect flow - to be deleted post devconnect + saveDevConnectIntent( + user?.user?.userId, + parsedPaymentData, + cleanedAmount, + onrampDataResponse.transferId + ) + setStep('showDetails') } else { setError({ @@ -344,7 +355,7 @@ export default function OnrampBankPage() { /> diff --git a/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx b/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx index e31365526..bbee3d991 100644 --- a/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx +++ b/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx @@ -13,6 +13,9 @@ import { useState } from 'react' import { trackDaimoDepositTransactionHash } from '@/app/actions/users' import InfoCard from '@/components/Global/InfoCard' import Link from 'next/link' +import ActionModal from '@/components/Global/ActionModal' +import { Button } from '@/components/0_Bruddle' +import { Slider } from '@/components/Slider' export default function AddMoneyCryptoDirectPage() { const router = useRouter() @@ -21,6 +24,24 @@ export default function AddMoneyCryptoDirectPage() { const [isPaymentSuccess, setisPaymentSuccess] = useState(false) const [isUpdatingDepositStatus, setIsUpdatingDepositStatus] = useState(false) const [error, setError] = useState(null) + const [showModal, setShowModal] = useState(false) + + const validateAmount = () => { + const formattedAmount = parseFloat(inputTokenAmount.replace(/,/g, '')) + + if (formattedAmount < 0.1) { + setError('Minimum deposit using crypto is $0.1.') + return false + } + + if (formattedAmount > 30_000) { + setError('Maximum deposit using crypto is $30,000.') + return false + } + + setError(null) + return true + } const onPaymentCompleted = async (e: any) => { setIsUpdatingDepositStatus(true) @@ -87,47 +108,98 @@ export default function AddMoneyCryptoDirectPage() { hideBalance /> - - - - This deposit is processed by Daimo, a third-party provider.{' '} - - Click here to deposit with Arbitrum USDC - {' '} - for a simple and free deposit. -

- } - /> - {address && ( - - Add Money - - )} +
{error && }
+ + {address && ( + setShowModal(false)} + > + {({ onClick, disabled, loading }) => ( + { + setShowModal(false) + }} + title="IMPORTANT!" + titleClassName="text-lg font-bold text-black" + icon="alert" + iconContainerClassName="bg-secondary-1" + content={ +
+

You MUST:

+ + + + + + This deposit is processed by Daimo, a third-party provider.{' '} + + Deposit with Arbitrum USDC + {' '} + for a simple and free deposit. +

+ } + /> + + { + setShowModal(false) + // Small delay to allow modal close animation, then trigger Daimo + setTimeout(() => { + onClick() + }, 300) + }} + /> +
+ } + /> + )} +
+ )} ) } diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 08db86699..36de49bba 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -15,9 +15,10 @@ import * as Sentry from '@sentry/nextjs' import { isKycStatusItem } from '@/hooks/useBridgeKycFlow' import { useAuth } from '@/context/authContext' import { BadgeStatusItem, isBadgeHistoryItem } from '@/components/Badges/BadgeStatusItem' -import React, { useEffect, useMemo, useRef } from 'react' +import React, { useMemo } from 'react' import { useQueryClient, type InfiniteData } from '@tanstack/react-query' import { useWebSocket } from '@/hooks/useWebSocket' +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { TRANSACTIONS } from '@/constants/query.consts' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import type { HistoryResponse } from '@/hooks/useTransactionHistory' @@ -29,7 +30,6 @@ import { formatUnits } from 'viem' * displays the user's transaction history with infinite scrolling and date grouping. */ const HistoryPage = () => { - const loaderRef = useRef(null) const { user } = useUserStore() const queryClient = useQueryClient() const { fetchUser } = useAuth() @@ -47,6 +47,13 @@ const HistoryPage = () => { limit: 20, }) + // infinite scroll hook + const { loaderRef } = useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + }) + // Real-time updates via WebSocket useWebSocket({ username: user?.user.username ?? undefined, @@ -134,29 +141,6 @@ const HistoryPage = () => { }, }) - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - const target = entries[0] - if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage() - } - }, - { - threshold: 0.1, - } - ) - const currentLoaderRef = loaderRef.current - if (currentLoaderRef) { - observer.observe(currentLoaderRef) - } - return () => { - if (currentLoaderRef) { - observer.unobserve(currentLoaderRef) - } - } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]) - const allEntries = useMemo(() => historyData?.pages.flatMap((page) => page.entries) ?? [], [historyData]) const combinedAndSortedEntries = useMemo(() => { diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index d9f59b13e..25f4addd1 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -3,7 +3,6 @@ import { Button, type ButtonSize, type ButtonVariant } from '@/components/0_Bruddle' import PageContainer from '@/components/0_Bruddle/PageContainer' import { Icon } from '@/components/Global/Icons/Icon' -import IOSInstallPWAModal from '@/components/Global/IOSInstallPWAModal' import Loading from '@/components/Global/Loading' import PeanutLoading from '@/components/Global/PeanutLoading' //import RewardsModal from '@/components/Global/RewardsModal' @@ -16,10 +15,9 @@ import { useUserStore } from '@/redux/hooks' import { formatExtendedNumber, getUserPreferences, printableUsdc, updateUserPreferences, getRedirectUrl } from '@/utils' import { useDisconnect } from '@reown/appkit/react' import Link from 'next/link' -import { useEffect, useMemo, useState, useCallback } from 'react' +import { useEffect, useMemo, useState, useCallback, lazy, Suspense } from 'react' import { twMerge } from 'tailwind-merge' import { useAccount } from 'wagmi' -import BalanceWarningModal from '@/components/Global/BalanceWarningModal' // import ReferralCampaignModal from '@/components/Home/ReferralCampaignModal' // import FloatingReferralButton from '@/components/Home/FloatingReferralButton' import { AccountType } from '@/interfaces' @@ -29,18 +27,25 @@ import { PostSignupActionManager } from '@/components/Global/PostSignupActionMan import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType' -import SetupNotificationsModal from '@/components/Notifications/SetupNotificationsModal' import { useNotifications } from '@/hooks/useNotifications' import useKycStatus from '@/hooks/useKycStatus' import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA' -import NoMoreJailModal from '@/components/Global/NoMoreJailModal' -import EarlyUserModal from '@/components/Global/EarlyUserModal' import InvitesIcon from '@/components/Home/InvitesIcon' import NavigationArrow from '@/components/Global/NavigationArrow' -import KycCompletedModal from '@/components/Home/KycCompletedModal' import { updateUserById } from '@/app/actions/users' import { useHaptic } from 'use-haptic' +// Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size +// Components are only loaded when user triggers them +// Wrapped in error boundaries to gracefully handle chunk load failures +const IOSInstallPWAModal = lazy(() => import('@/components/Global/IOSInstallPWAModal')) +const BalanceWarningModal = lazy(() => import('@/components/Global/BalanceWarningModal')) +const SetupNotificationsModal = lazy(() => import('@/components/Notifications/SetupNotificationsModal')) +const NoMoreJailModal = lazy(() => import('@/components/Global/NoMoreJailModal')) +const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal')) +const KycCompletedModal = lazy(() => import('@/components/Home/KycCompletedModal')) +import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' + const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds @@ -218,7 +223,13 @@ export default function Home() { - {showPermissionModal && } + {showPermissionModal && ( + + + + + + )} {/* Render the new Rewards Modal @@ -229,43 +240,69 @@ export default function Home() { */} {/* iOS PWA Install Modal */} - setShowIOSPWAInstallModal(false)} /> + + + setShowIOSPWAInstallModal(false)} + /> + + {/* Add Money Prompt Modal */} {/* TODO @dev Disabling this, re-enable after properly fixing */} {/* setShowAddMoneyPromptModal(false)} /> */} - - - - - { - // close the modal immediately for better ux - setShowKycModal(false) - // update the database and refetch user to ensure sync - if (user?.user.userId) { - await updateUserById({ - userId: user.user.userId, - showKycCompletedModal: false, - }) - // refetch user to ensure the modal doesn't reappear - await fetchUser() - } - }} - /> + + + + + + + + + + + + + + + { + // close the modal immediately for better ux + setShowKycModal(false) + // update the database and refetch user to ensure sync + if (user?.user.userId) { + await updateUserById({ + userId: user.user.userId, + showKycCompletedModal: false, + }) + // refetch user to ensure the modal doesn't reappear + await fetchUser() + } + }} + /> + + {/* Balance Warning Modal */} - { - setShowBalanceWarningModal(false) - updateUserPreferences(user!.user.userId, { - hasSeenBalanceWarning: { value: true, expiry: Date.now() + BALANCE_WARNING_EXPIRY * 1000 }, - }) - }} - /> + + + { + setShowBalanceWarningModal(false) + updateUserPreferences(user!.user.userId, { + hasSeenBalanceWarning: { + value: true, + expiry: Date.now() + BALANCE_WARNING_EXPIRY * 1000, + }, + }) + }} + /> + + {/* Referral Campaign Modal - DISABLED FOR NOW */} {/* { const pathName = usePathname() @@ -37,9 +38,14 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const isSupport = pathName === '/support' const alignStart = isHome || isHistory || isSupport const router = useRouter() - const { deviceType: detectedDeviceType } = useDeviceType() const { showIosPwaInstallScreen } = useSetupStore() + // detect online/offline status for full-page offline screen + const { isOnline, isInitialized } = useNetworkStatus() + + // cache the scrollable content element to avoid DOM queries on every scroll event + const scrollableContentRef = useRef(null) + useEffect(() => { // check for JWT token setHasToken(hasValidJwtToken()) @@ -47,44 +53,40 @@ const Layout = ({ children }: { children: React.ReactNode }) => { setIsReady(true) }, []) - // Pull-to-refresh is only enabled on iOS devices since Android has native pull-to-refresh - // docs here: https://github.com/BoxFactura/pulltorefresh.js - useEffect(() => { - if (typeof window === 'undefined') return - - // Only initialize pull-to-refresh on iOS devices - if (detectedDeviceType !== DeviceType.IOS) return - - PullToRefresh.init({ - mainElement: 'body', - onRefresh: () => { - window.location.reload() - }, - instructionsPullToRefresh: 'Pull down to refresh', - instructionsReleaseToRefresh: 'Release to refresh', - instructionsRefreshing: 'Refreshing...', - shouldPullToRefresh: () => { - const el = document.querySelector('body') - if (!el) return false - - return el.scrollTop === 0 && window.scrollY === 0 - }, - distThreshold: 70, - distMax: 120, - distReload: 80, - }) - - return () => { - PullToRefresh.destroyAll() + // memoizing shouldPullToRefresh callback to prevent re-initialization on every render + // lazy-load element ref to ensure DOM is ready + const shouldPullToRefresh = useCallback(() => { + // lazy-load the element reference if not cached yet + if (!scrollableContentRef.current) { + scrollableContentRef.current = document.querySelector('#scrollable-content') + } + + const scrollableContent = scrollableContentRef.current + if (!scrollableContent) { + // fallback to window scroll check if element not found + return window.scrollY === 0 } + + // only allow pull-to-refresh when at the very top + return scrollableContent.scrollTop === 0 }, []) + // enable pull-to-refresh for both ios and android + usePullToRefresh({ shouldPullToRefresh }) + useEffect(() => { if (!isPublicPath && isReady && !isFetchingUser && !user) { router.push('/setup') } }, [user, isFetchingUser, isReady, isPublicPath, router]) + // show full-page offline screen when user is offline + // only show after initialization to prevent flash on initial load + // when connection is restored, page auto-reloads (no "back online" screen) + if (isInitialized && !isOnline) { + return + } + // For public paths, skip user loading and just show content when ready if (isPublicPath) { if (!isReady) { diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 18f94df9a..02d8d2b9b 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -16,12 +16,14 @@ import Image from 'next/image' import PeanutLoading from '@/components/Global/PeanutLoading' import TokenAmountInput from '@/components/Global/TokenAmountInput' import { useWallet } from '@/hooks/wallet/useWallet' +import { useSignUserOp } from '@/hooks/wallet/useSignUserOp' import { clearRedirectUrl, getRedirectUrl, isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { calculateSavingsInCents, isArgentinaMantecaQrPayment, getSavingsMessage } from '@/utils/qr-payment.utils' import ErrorAlert from '@/components/Global/ErrorAlert' import { PEANUT_WALLET_TOKEN_DECIMALS, TRANSACTIONS, PERK_HOLD_DURATION_MS } from '@/constants' import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import { MIN_MANTECA_QR_PAYMENT_AMOUNT } from '@/constants/payment.consts' import { formatUnits, parseUnits } from 'viem' import type { TransactionReceipt, Hash } from 'viem' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' @@ -38,9 +40,9 @@ import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate' import ActionModal from '@/components/Global/ActionModal' import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' import { SoundPlayer } from '@/components/Global/SoundPlayer' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, useQuery } from '@tanstack/react-query' import { shootDoubleStarConfetti } from '@/utils/confetti' -import { STAR_STRAIGHT_ICON } from '@/assets' +import { PeanutGuyGIF, STAR_STRAIGHT_ICON } from '@/assets' import { useAuth } from '@/context/authContext' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' @@ -49,10 +51,11 @@ import { useWebSocket } from '@/hooks/useWebSocket' import type { HistoryEntry } from '@/hooks/useTransactionHistory' import { completeHistoryEntry } from '@/utils/history.utils' import { useSupportModalContext } from '@/context/SupportModalContext' -import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif' import maintenanceConfig from '@/config/underMaintenance.config' +import PointsCard from '@/components/Common/PointsCard' const MAX_QR_PAYMENT_AMOUNT = '2000' +const MIN_QR_PAYMENT_AMOUNT = '0.1' type PaymentProcessor = 'MANTECA' | 'SIMPLEFI' @@ -63,6 +66,7 @@ export default function QRPayPage() { const timestamp = searchParams.get('t') const qrType = searchParams.get('type') const { balance, sendMoney } = useWallet() + const { signTransferUserOp } = useSignUserOp() const [isSuccess, setIsSuccess] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) @@ -246,6 +250,12 @@ export default function QRPayPage() { [pendingSimpleFiPaymentId, simpleFiPayment, setLoadingState] ) + useEffect(() => { + if (isSuccess) { + setLoadingState('Idle') + } + }, [isSuccess]) + // First fetch for qrcode info — only after KYC gating allows proceeding useEffect(() => { resetState() @@ -413,7 +423,7 @@ export default function QRPayPage() { } setSimpleFiPayment(response) - setAmount(response.currencyAmount) + setAmount(response.usdAmount) setCurrencyAmount(response.currencyAmount) setCurrency({ code: 'ARS', @@ -442,35 +452,92 @@ export default function QRPayPage() { // 2. The actual payment action is blocked by shouldBlockPay (line 713 & 1109) // 3. KYC modals are shown if needed before user can pay // This reduces latency from 4-5s to <1s for KYC'd users + // + // NETWORK RESILIENCE: Retry network/timeout errors with exponential backoff + // - Max 3 attempts: immediate, +1s delay, +2s delay + // - Provider-specific errors (e.g., "can't decode") are NOT retried + // - Prevents state updates on unmounted component + // Fetch Manteca payment lock with TanStack Query - handles retries, caching, and loading states + const { + data: fetchedPaymentLock, + isLoading: isLoadingPaymentLock, + error: paymentLockError, + failureCount, + } = useQuery({ + queryKey: ['manteca-payment-lock', qrCode, timestamp], + queryFn: async ({ queryKey }) => { + if (paymentProcessor !== 'MANTECA' || !qrCode || !isPaymentProcessorQR(qrCode)) { + return null + } + return mantecaApi.initiateQrPayment({ qrCode }) + }, + enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock, + retry: (failureCount, error: any) => { + // Don't retry provider-specific errors + if (error?.message?.includes("provider can't decode it")) { + return false + } + // Retry network/timeout errors up to 2 times (3 total attempts) + return failureCount < 2 + }, + retryDelay: (attemptIndex) => { + const delayMs = Math.min(1000 * 2 ** attemptIndex, 2000) // 1s, 2s exponential backoff + const MAX_RETRIES = 2 + const attemptNumber = attemptIndex + 1 // attemptIndex is 0-based, display as 1-based + console.log( + `Payment lock fetch failed, retrying in ${delayMs}ms... (attempt ${attemptNumber}/${MAX_RETRIES})` + ) + return delayMs + }, + staleTime: 0, // Always fetch fresh data + gcTime: 0, // Don't cache for garbage collection + }) + + // Handle payment lock fetch results useEffect(() => { if (paymentProcessor !== 'MANTECA') return - if (!qrCode || !isPaymentProcessorQR(qrCode)) return - if (!!paymentLock || !shouldRetry) return - setShouldRetry(false) - setLoadingState('Fetching details') - mantecaApi - .initiateQrPayment({ qrCode }) - .then((pl) => { - setWaitingForMerchantAmount(false) - setPaymentLock(pl) - }) - .catch((error) => { - if (error.message.includes("provider can't decode it")) { - if (EQrType.PIX === qrType) { - setErrorInitiatingPayment( - 'We are currently experiencing issues with PIX payments due to an external provider. We are working to fix it as soon as possible' - ) - } else { - setWaitingForMerchantAmount(true) - } + if (isLoadingPaymentLock) { + setLoadingState('Fetching details') + return + } + + if (fetchedPaymentLock && !paymentLock) { + setPaymentLock(fetchedPaymentLock) + setWaitingForMerchantAmount(false) + setLoadingState('Idle') + } + + if (paymentLockError) { + const error = paymentLockError as Error + setLoadingState('Idle') + + // Provider-specific errors: show appropriate message + if (error.message.includes("provider can't decode it")) { + if (EQrType.PIX === qrType) { + setErrorInitiatingPayment( + 'We are currently experiencing issues with PIX payments due to an external provider. We are working to fix it as soon as possible' + ) } else { - setErrorInitiatingPayment(error.message) - setWaitingForMerchantAmount(false) + setWaitingForMerchantAmount(true) } - }) - .finally(() => setLoadingState('Idle')) - }, [paymentLock, qrCode, setLoadingState, paymentProcessor, shouldRetry, qrType]) + } else { + // Network/timeout errors after all retries exhausted + setErrorInitiatingPayment( + error.message || 'Failed to load payment details. Please check your connection and try again.' + ) + setWaitingForMerchantAmount(false) + } + } + }, [ + fetchedPaymentLock, + isLoadingPaymentLock, + paymentLockError, + paymentLock, + qrType, + paymentProcessor, + setLoadingState, + ]) const merchantName = useMemo(() => { if (paymentProcessor === 'SIMPLEFI') { @@ -569,13 +636,11 @@ export default function QRPayPage() { setLoadingState('Idle') return } + setLoadingState('Preparing transaction') - let userOpHash: Hash - let receipt: TransactionReceipt | null + let signedUserOpData try { - const result = await sendMoney(MANTECA_DEPOSIT_ADDRESS, finalPaymentLock.paymentAgainstAmount) - userOpHash = result.userOpHash - receipt = result.receipt + signedUserOpData = await signTransferUserOp(MANTECA_DEPOSIT_ADDRESS, finalPaymentLock.paymentAgainstAmount) } catch (error) { if ((error as Error).toString().includes('not allowed')) { setErrorMessage('Please confirm the transaction.') @@ -587,26 +652,56 @@ export default function QRPayPage() { setLoadingState('Idle') return } - if (receipt !== null && isTxReverted(receipt)) { - setErrorMessage('Transaction reverted by the network.') - setLoadingState('Idle') - setIsSuccess(false) - return - } - const txHash = receipt?.transactionHash ?? userOpHash - setLoadingState('Paying') + + // Send signed UserOp to backend for coordinated execution + // Backend will: 1) Complete Manteca payment, 2) Broadcast UserOp only if Manteca succeeds + setTimeout(() => setLoadingState('Paying'), 3000) try { - const qrPayment = await mantecaApi.completeQrPayment({ paymentLockCode: finalPaymentLock.code, txHash }) + const signedUserOp = { + sender: signedUserOpData.signedUserOp.sender, + nonce: signedUserOpData.signedUserOp.nonce, + callData: signedUserOpData.signedUserOp.callData, + signature: signedUserOpData.signedUserOp.signature, + callGasLimit: signedUserOpData.signedUserOp.callGasLimit, + verificationGasLimit: signedUserOpData.signedUserOp.verificationGasLimit, + preVerificationGas: signedUserOpData.signedUserOp.preVerificationGas, + initCode: signedUserOpData.signedUserOp.initCode, + maxFeePerGas: signedUserOpData.signedUserOp.maxFeePerGas, + maxPriorityFeePerGas: signedUserOpData.signedUserOp.maxPriorityFeePerGas, + paymaster: signedUserOpData.signedUserOp.paymaster, + paymasterData: signedUserOpData.signedUserOp.paymasterData, + paymasterVerificationGasLimit: signedUserOpData.signedUserOp.paymasterVerificationGasLimit, + paymasterPostOpGasLimit: signedUserOpData.signedUserOp.paymasterPostOpGasLimit, + } + const qrPayment = await mantecaApi.completeQrPaymentWithSignedTx({ + paymentLockCode: finalPaymentLock.code, + signedUserOp, + chainId: signedUserOpData.chainId, + entryPointAddress: signedUserOpData.entryPointAddress, + }) setQrPayment(qrPayment) setIsSuccess(true) } catch (error) { captureException(error) - setErrorMessage('Could not complete payment due to unexpected error. Please contact support') + const errorMsg = (error as Error).message || 'Could not complete payment' + + // Handle specific error cases + if (errorMsg.toLowerCase().includes('nonce')) { + setErrorMessage( + 'Transaction failed due to account state change. Please try again. If the problem persists, contact support.' + ) + } else if (errorMsg.toLowerCase().includes('expired') || errorMsg.toLowerCase().includes('stale')) { + setErrorMessage('Payment session expired. Please scan the QR code again.') + } else { + setErrorMessage( + 'Could not complete payment. Please scan the QR code again. If problem persists contact support' + ) + } setIsSuccess(false) } finally { setLoadingState('Idle') } - }, [paymentLock?.code, sendMoney, qrCode, currencyAmount, setLoadingState]) + }, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState]) const payQR = useCallback(async () => { if (paymentProcessor === 'SIMPLEFI') { @@ -776,7 +871,7 @@ export default function QRPayPage() { holdTimerRef.current = timer }, [claimPerk]) - // Check user balance + // Check user balance and payment limits useEffect(() => { // Skip balance check on success screen (balance may not have updated yet) if (isSuccess) { @@ -785,7 +880,8 @@ export default function QRPayPage() { } // Skip balance check if transaction is being processed - if (hasPendingTransactions || isWaitingForWebSocket) { + // isLoading covers the gap between sendMoney completing and completeQrPayment finishing + if (hasPendingTransactions || isWaitingForWebSocket || isLoading) { return } @@ -794,14 +890,26 @@ export default function QRPayPage() { return } const paymentAmount = parseUnits(usdAmount.replace(/,/g, ''), PEANUT_WALLET_TOKEN_DECIMALS) + + // Manteca-specific validation (PIX, MercadoPago, QR3) + if (paymentProcessor === 'MANTECA') { + if (paymentAmount < parseUnits(MIN_MANTECA_QR_PAYMENT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { + setBalanceErrorMessage(`Payment amount must be at least $${MIN_MANTECA_QR_PAYMENT_AMOUNT}`) + return + } + } + + // Common validations for all payment processors if (paymentAmount > parseUnits(MAX_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) + } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { + setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount > balance) { setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket, isSuccess]) + }, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket, isSuccess, isLoading, paymentProcessor]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) @@ -1013,25 +1121,7 @@ export default function QRPayPage() { } if (waitingForMerchantAmount) { - return ( -
-
- Peanut Mascot - -
- -
-

Waiting for the merchant to set the amount

-
-
-
- ) + return } if (showOrderNotReadyModal) { @@ -1068,14 +1158,18 @@ export default function QRPayPage() { } // show loading spinner if we're still loading payment data OR KYC state - if (isLoadingPaymentData || isLoadingKycState) { - return + if (isLoadingPaymentData || isLoadingKycState || loadingState.toLowerCase() === 'paying') { + return ( + + ) } //Success if (isSuccess && paymentProcessor === 'MANTECA' && !qrPayment) { return null - } else if (isSuccess && paymentProcessor === 'MANTECA' && qrPayment) { + } else if (isSuccess && paymentProcessor === 'MANTECA') { // Calculate savings for Argentina Manteca QR payments only const savingsInCents = calculateSavingsInCents(usdAmount) const showSavingsMessage = savingsInCents > 0 && isArgentinaMantecaQrPayment(qrType, paymentProcessor) @@ -1101,11 +1195,14 @@ export default function QRPayPage() {

- You paid {qrPayment!.details.merchant.name} + You paid {qrPayment?.details.merchant.name ?? paymentLock?.paymentRecipientName}

{currency.symbol}{' '} - {formatNumberForDisplay(qrPayment!.details.paymentAssetAmount, { maxDecimals: 2 })} + {formatNumberForDisplay( + qrPayment?.details.paymentAssetAmount ?? paymentLock?.paymentAssetAmount, + { maxDecimals: 2 } + )}
≈ {formatNumberForDisplay(usdAmount ?? undefined, { maxDecimals: 2 })} USD @@ -1120,7 +1217,7 @@ export default function QRPayPage() { {/* Perk Eligibility Card - Show before claiming */} {qrPayment?.perk?.eligible && !perkClaimed && !qrPayment.perk.claimed && ( - +
star
@@ -1167,14 +1264,8 @@ export default function QRPayPage() { )} {/* Points Display - ref used for confetti origin point */} - {pointsData?.estimatedPoints && ( -
- star -

- You've earned {pointsData.estimatedPoints}{' '} - {pointsData.estimatedPoints === 1 ? 'point' : 'points'}! -

-
+ {!qrPayment?.perk?.eligible && pointsData?.estimatedPoints && ( + )}
@@ -1454,3 +1545,26 @@ export default function QRPayPage() { ) } + +export const QrPayPageLoading = ({ message }: { message: string }) => { + return ( +
+
+ Peanut Man + + +
+ +
+

{message}

+
+
+
+ ) +} diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 9cfac792a..fdd9ab6db 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -230,7 +230,8 @@ export default function WithdrawBankPage() { // Balance validation useEffect(() => { // Skip balance check if transaction is pending - if (hasPendingTransactions) { + // isLoading covers the gap between sendMoney completing and confirmOfframp completing + if (hasPendingTransactions || isLoading) { return } @@ -245,7 +246,7 @@ export default function WithdrawBankPage() { } else { setBalanceErrorMessage(null) } - }, [amountToWithdraw, balance, hasPendingTransactions]) + }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index ac78f33d5..05c811a31 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useWallet } from '@/hooks/wallet/useWallet' -import { useState, useMemo, useContext, useEffect, useCallback, useRef, useId } from 'react' +import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' @@ -19,7 +19,6 @@ import { formatAmount, formatNumberForDisplay } from '@/utils' import { validateCbuCvuAlias, validatePixKey, normalizePixPhoneNumber, isPixPhoneNumber } from '@/utils/withdraw.utils' import ValidatedInput from '@/components/Global/ValidatedInput' import TokenAmountInput from '@/components/Global/TokenAmountInput' -import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { formatUnits, parseUnits } from 'viem' import type { TransactionReceipt, Hash } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' @@ -34,6 +33,7 @@ import { type MantecaBankCode, MANTECA_DEPOSIT_ADDRESS, TRANSACTIONS, + PEANUT_WALLET_TOKEN_DECIMALS, } from '@/constants' import Select from '@/components/Global/Select' import { SoundPlayer } from '@/components/Global/SoundPlayer' @@ -75,7 +75,7 @@ export default function MantecaWithdrawFlow() { const queryClient = useQueryClient() const { isUserBridgeKycApproved } = useKycStatus() const { hasPendingTransactions } = usePendingTransactions() - const swapCurrency = searchParams.get('swap-currency') ?? 'false' + const swapCurrency = searchParams.get('swap-currency') // Get method and country from URL parameters const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc. const countryFromUrl = searchParams.get('country') // argentina, brazil, etc. @@ -88,6 +88,10 @@ export default function MantecaWithdrawFlow() { return countryData.find((country) => country.type === 'country' && country.path === countryPath) }, [countryPath]) + const isMantecaCountry = useMemo(() => { + return selectedCountry?.id && selectedCountry.id in MANTECA_COUNTRIES_CONFIG + }, [selectedCountry]) + const countryConfig = useMemo(() => { if (!selectedCountry) return undefined return MANTECA_COUNTRIES_CONFIG[selectedCountry.id] @@ -100,6 +104,25 @@ export default function MantecaWithdrawFlow() { isLoading: isCurrencyLoading, } = useCurrency(selectedCountry?.currency!) + // determine if initial input should be in usd or local currency + // for manteca countries, default to local currency unless explicitly overridden + const isInitialInputUsd = useMemo(() => { + // if swap-currency param is explicitly set to 'true' (user toggled to local currency) + // then show local currency first + if (swapCurrency === 'true') { + return false + } + + // if it's a manteca country, default to local currency (not usd) + // ignore swap-currency=false for manteca countries to ensure local currency default + if (isMantecaCountry) { + return false + } + + // otherwise default to usd (for non-manteca countries) + return true + }, [swapCurrency, isMantecaCountry]) + // Initialize KYC flow hook const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry }) @@ -282,7 +305,8 @@ export default function MantecaWithdrawFlow() { useEffect(() => { // Skip balance check if transaction is being processed // Use hasPendingTransactions to prevent race condition with optimistic updates - if (hasPendingTransactions) { + // isLoading covers the gap between sendMoney completing and API withdraw completing + if (hasPendingTransactions || isLoading) { return } @@ -300,7 +324,7 @@ export default function MantecaWithdrawFlow() { } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasPendingTransactions]) + }, [usdAmount, balance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -431,7 +455,7 @@ export default function MantecaWithdrawFlow() { walletBalance={ balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined } - isInitialInputUsd={swapCurrency !== 'true'} + isInitialInputUsd={isInitialInputUsd} />
= ({ source }) => { const params = useParams() const router = useRouter() + const searchParams = useSearchParams() const [step, setStep] = useState('inputAmount') const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [tokenAmount, setTokenAmount] = useState('') @@ -37,6 +38,7 @@ const MantecaAddMoney: FC = ({ source }) => { const [depositDetails, setDepositDetails] = useState() const [isKycModalOpen, setIsKycModalOpen] = useState(false) const queryClient = useQueryClient() + const { parsedPaymentData } = usePaymentStore() const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { @@ -65,10 +67,10 @@ const MantecaAddMoney: FC = ({ source }) => { return } const paymentAmount = parseUnits(usdAmount.replace(/,/g, ''), PEANUT_WALLET_TOKEN_DECIMALS) - if (paymentAmount < parseUnits(MIN_DEPOSIT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { - setError(`Deposit amount must be at least $${MIN_DEPOSIT_AMOUNT}`) - } else if (paymentAmount > parseUnits(MAX_DEPOSIT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { - setError(`Deposit amount exceeds maximum limit of $${MAX_DEPOSIT_AMOUNT}`) + if (paymentAmount < parseUnits(MIN_MANTECA_DEPOSIT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { + setError(`Deposit amount must be at least $${MIN_MANTECA_DEPOSIT_AMOUNT}`) + } else if (paymentAmount > parseUnits(MAX_MANTECA_DEPOSIT_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { + setError(`Deposit amount exceeds maximum limit of $${MAX_MANTECA_DEPOSIT_AMOUNT}`) } else { setError(null) } @@ -114,6 +116,10 @@ const MantecaAddMoney: FC = ({ source }) => { return } setDepositDetails(depositData.data) + + // @dev: save devconnect intent if this is a devconnect flow - to be deleted post devconnect + saveDevConnectIntent(user?.user?.userId, parsedPaymentData, usdAmount, depositData.data?.externalId) + setStep('depositDetails') } catch (error) { console.log(error) diff --git a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx index 12fe80c3c..da429fdcf 100644 --- a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx +++ b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx @@ -2,7 +2,7 @@ import NavHeader from '@/components/Global/NavHeader' import { useParams, useRouter } from 'next/navigation' -import React, { useMemo } from 'react' +import { useMemo } from 'react' import { countryData } from '@/components/AddMoney/consts' import ShareButton from '@/components/Global/ShareButton' import { type MantecaDepositResponseData } from '@/types/manteca.types' @@ -21,10 +21,12 @@ const MantecaDepositShareDetails = ({ depositDetails, source, currencyAmount, + onBack, }: { depositDetails: MantecaDepositResponseData source: 'bank' | 'regionalMethod' currencyAmount?: string | undefined + onBack?: () => void }) => { const router = useRouter() const params = useParams() @@ -84,7 +86,7 @@ const MantecaDepositShareDetails = ({ return (
- router.back()} /> + router.back())} />
{/* Amount Display Card */} diff --git a/src/components/AddMoney/components/OnrampConfirmationModal.tsx b/src/components/AddMoney/components/OnrampConfirmationModal.tsx index bef577fbd..d14b4f724 100644 --- a/src/components/AddMoney/components/OnrampConfirmationModal.tsx +++ b/src/components/AddMoney/components/OnrampConfirmationModal.tsx @@ -34,6 +34,7 @@ export const OnrampConfirmationModal = ({ } content={
+

In the next step you'll see:

You MUST:

= { MOST_INVITES: '/badges/most_invites.svg', BIGGEST_REQUEST_POT: '/badges/biggest_request_pot.svg', SEEDLING_DEVCONNECT_BA_2025: '/badges/seedlings_devconnect.svg', + ARBIVERSE_DEVCONNECT_BA_2025: '/badges/arbiverse_devconnect.svg', } // public-facing descriptions for badges (third-person perspective) @@ -25,7 +26,8 @@ const PUBLIC_DESCRIPTIONS: Record = { MOST_PAYMENTS_DEVCON: `Money Machine - They move money like it's light work. Most payments made!`, MOST_INVITES: 'Onboarded more users than Coinbase ads!', BIGGEST_REQUEST_POT: 'High Roller or Master Beggar? They created the pot with the highest number of contributors.', - SEEDLING_DEVCONNECT_BA_2025: 'Peanut Ambassador. You spread the word and brought others into the ecosystem.', + SEEDLING_DEVCONNECT_BA_2025: 'Peanut Ambassador. They spread the word and brought others into the ecosystem.', + ARBIVERSE_DEVCONNECT_BA_2025: 'Peanut 🤝 Arbiverse. They joined us at the amazing Arbiverse booth.', } export function getBadgeIcon(code?: string) { diff --git a/src/components/Claim/useClaimLink.tsx b/src/components/Claim/useClaimLink.tsx index f0074684e..5e60aeb86 100644 --- a/src/components/Claim/useClaimLink.tsx +++ b/src/components/Claim/useClaimLink.tsx @@ -267,6 +267,10 @@ const useClaimLink = () => { */ const claimLinkMutation = useMutation({ mutationKey: [CLAIM_LINK], + // Disable retry for financial transactions to prevent duplicate claims + // Link claims transfer funds and are not idempotent at the mutation level + // If a claim succeeds but times out, retrying would create a duplicate claim + retry: false, mutationFn: async ({ address, link, @@ -312,6 +316,10 @@ const useClaimLink = () => { */ const claimLinkXChainMutation = useMutation({ mutationKey: [CLAIM_LINK_XCHAIN], + // Disable retry for financial transactions to prevent duplicate claims + // X-chain claims transfer funds and are not idempotent at the mutation level + // If a claim succeeds but times out, retrying would create a duplicate claim + retry: false, mutationFn: async ({ address, link, diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx index f5d902f49..c1de9f64a 100644 --- a/src/components/Common/ActionList.tsx +++ b/src/components/Common/ActionList.tsx @@ -5,7 +5,7 @@ import IconStack from '../Global/IconStack' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { type ClaimLinkData } from '@/services/sendLinks' import { formatUnits } from 'viem' -import { useContext, useCallback, useMemo, useState } from 'react' +import { useContext, useMemo, useState, useRef } from 'react' import ActionModal from '@/components/Global/ActionModal' import Divider from '../0_Bruddle/Divider' import { Button } from '../0_Bruddle' @@ -15,7 +15,7 @@ import { useRouter } from 'next/navigation' import { PEANUTMAN_LOGO } from '@/assets/peanut' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' -import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' +import { useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { type ParsedURL } from '@/lib/url-parser/types/payment' import { useAppDispatch, usePaymentStore } from '@/redux/hooks' import { BankRequestType, useDetermineBankRequestType } from '@/hooks/useDetermineBankRequestType' @@ -36,6 +36,8 @@ import { tokenSelectorContext } from '@/context' import SupportCTA from '../Global/SupportCTA' import { DEVCONNECT_LOGO } from '@/assets' import useKycStatus from '@/hooks/useKycStatus' +import { usePaymentInitiator } from '@/hooks/usePaymentInitiator' +import { MIN_BANK_TRANSFER_AMOUNT, validateMinimumAmount } from '@/constants' const SHOW_INVITE_MODAL_FOR_DEVCONNECT = false @@ -47,6 +49,7 @@ interface IActionListProps { isInviteLink?: boolean showDevconnectMethod?: boolean setExternalWalletRecipient?: (recipient: { name: string | undefined; address: string }) => void + usdAmount?: string } /** @@ -65,6 +68,7 @@ export default function ActionList({ isInviteLink = false, showDevconnectMethod, setExternalWalletRecipient, + usdAmount: usdAmountValue, }: IActionListProps) { const router = useRouter() const { @@ -78,15 +82,13 @@ export default function ActionList({ const { balance } = useWallet() const [showMinAmountError, setShowMinAmountError] = useState(false) const { claimType } = useDetermineBankClaimType(claimLinkData?.sender?.userId ?? '') - const { chargeDetails } = usePaymentStore() + const { chargeDetails, usdAmount, parsedPaymentData } = usePaymentStore() const requesterUserId = chargeDetails?.requestLink?.recipientAccount?.userId ?? '' const { requestType } = useDetermineBankRequestType(requesterUserId) const savedAccounts = useSavedAccounts() - const { usdAmount } = usePaymentStore() const { addParamStep } = useClaimLink() const { setShowRequestFulfilmentBankFlowManager, - setShowExternalWalletFulfillMethods, setFlowStep: setRequestFulfilmentBankFlowStep, setFulfillUsingManteca, setRegionalMethodType: setRequestFulfillmentRegionalMethodType, @@ -106,7 +108,11 @@ export default function ActionList({ const [isUsePeanutBalanceModalShown, setIsUsePeanutBalanceModalShown] = useState(false) const [showUsePeanutBalanceModal, setShowUsePeanutBalanceModal] = useState(false) const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null) + const { initiatePayment, loadingStep } = usePaymentInitiator() const { isUserMantecaKycApproved } = useKycStatus() + const isPaymentInProgress = loadingStep !== 'Idle' && loadingStep !== 'Error' && loadingStep !== 'Success' + // ref to store daimo button click handler for triggering from balance modal + const daimoButtonClickRef = useRef<(() => void) | null>(null) const dispatch = useAppDispatch() @@ -120,18 +126,15 @@ export default function ActionList({ return false }, [claimType, requestType, flow]) - // Memoize the callback to prevent unnecessary re-sorts - const isMethodUnavailable = useCallback( - (method: PaymentMethod) => method.soon || (method.id === 'bank' && requiresVerification), - [requiresVerification] - ) - // use the hook to filter and sort payment methods based on geolocation const { filteredMethods: sortedActionMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({ sortUnavailable: true, - isMethodUnavailable: (method) => method.soon || (method.id === 'bank' && requiresVerification), + isMethodUnavailable: (method) => + method.soon || + (method.id === 'bank' && requiresVerification) || + (['mercadopago', 'pix'].includes(method.id) && !isUserMantecaKycApproved), methods: showDevconnectMethod - ? DEVCONNECT_CLAIM_METHODS.filter((method) => method.id !== 'devconnect') //TODO @dev remove this after devconnect app testing phase + ? DEVCONNECT_CLAIM_METHODS.filter((method) => method.id !== 'devconnect') : undefined, }) @@ -139,7 +142,24 @@ export default function ActionList({ const amountInUsd = usdAmount ? parseFloat(usdAmount) : 0 const hasSufficientPeanutBalance = user && balance && Number(balance) >= amountInUsd + // check if amount is valid for request flow + const currentRequestAmount = usdAmountValue ?? usdAmount + const requestAmountValue = currentRequestAmount ? parseFloat(currentRequestAmount) : 0 + const isAmountEntered = flow === 'request' ? !!currentRequestAmount && requestAmountValue > 0 : true + const handleMethodClick = async (method: PaymentMethod, bypassBalanceModal = false) => { + // validate minimum amount for bank/mercado pago/pix in request flow + if (flow === 'request' && requestLinkData) { + // check minimum amount for bank/mercado pago/pix + if ( + ['bank', 'mercadopago', 'pix'].includes(method.id) && + !validateMinimumAmount(requestAmountValue, method.id) + ) { + setShowMinAmountError(true) + return + } + } + // For request flow: Check if user has sufficient Peanut balance and hasn't dismissed the modal if (flow === 'request' && requestLinkData && !bypassBalanceModal) { if (!isUsePeanutBalanceModalShown && hasSufficientPeanutBalance) { @@ -151,7 +171,7 @@ export default function ActionList({ if (flow === 'claim' && claimLinkData) { const amountInUsd = parseFloat(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) - if (method.id === 'bank' && amountInUsd < 5) { + if (method.id === 'bank' && !validateMinimumAmount(amountInUsd, method.id)) { setShowMinAmountError(true) return } @@ -196,40 +216,21 @@ export default function ActionList({ break } } else if (flow === 'request' && requestLinkData) { - // @dev TODO: Fix req fulfillment with bank properly post devconnect - if (method.id === 'bank') { - if (user?.user) { - router.push('/add-money') - } else { - const redirectUri = encodeURIComponent('/add-money') - router.push(`/setup?redirect_uri=${redirectUri}`) - } - return - } - + // for bank/mercadopago/pix in request flow, redirect to add-money flow switch (method.id) { case 'bank': - if (requestType === BankRequestType.GuestKycNeeded) { - addParamStep('bank') - setIsGuestVerificationModalOpen(true) - } else { - setShowRequestFulfilmentBankFlowManager(true) - setRequestFulfilmentBankFlowStep(RequestFulfillmentBankFlowStep.BankCountryList) - } - break case 'mercadopago': case 'pix': - if (!user) { - addParamStep('regional-req-fulfill') - setIsGuestVerificationModalOpen(true) - return + if (user?.user) { + // user is logged in, redirect to add-money + router.push('/add-money') + } else { + // user is not logged in, save redirect url and go to setup + const redirectUri = encodeURIComponent('/add-money') + router.push(`/setup?redirect_uri=${redirectUri}`) } - setRequestFulfillmentRegionalMethodType(method.id) - setFulfillUsingManteca(true) - break - case 'exchange-or-wallet': - setShowExternalWalletFulfillMethods(true) break + // 'exchange-or-wallet' case removed - handled by ActionListDaimoPayButton } } } @@ -333,6 +334,8 @@ export default function ActionList({ } return true // Proceed with Daimo }} + isDisabled={!isAmountEntered} + clickHandlerRef={daimoButtonClickRef} />
) @@ -340,7 +343,7 @@ export default function ActionList({ let methodRequiresVerification = method.id === 'bank' && requiresVerification - if ((!isUserMantecaKycApproved && method.id == 'mercadopago') || method.id == 'pix') { + if (!isUserMantecaKycApproved && ['mercadopago', 'pix'].includes(method.id)) { methodRequiresVerification = true } @@ -356,7 +359,11 @@ export default function ActionList({ }} key={method.id} method={method} - requiresVerification={methodRequiresVerification} + requiresVerification={ + methodRequiresVerification || + (['mercadopago', 'pix'].includes(method.id) && !isUserMantecaKycApproved) + } + isDisabled={!isAmountEntered} /> ) })} @@ -365,14 +372,15 @@ export default function ActionList({ setShowMinAmountError(false)} - title="Minimum Amount " - description={'The minimum amount for a bank transaction is $5. Please try a different method.'} + title="Minimum Amount" + description={`The minimum amount for this payment method is $${MIN_BANK_TRANSFER_AMOUNT}. Please enter a higher amount or try a different method.`} icon="alert" ctas={[{ text: 'Close', shadowSize: '4', onClick: () => setShowMinAmountError(false) }]} iconContainerClassName="bg-yellow-400" preventClose={false} modalPanelClassName="max-w-md mx-8" /> + { + daimoButtonClickRef.current?.() + }, 0) + } else { + // for other methods, use handleMethodClick + handleMethodClick(selectedPaymentMethod, true) // true = bypass modal check + } } setSelectedPaymentMethod(null) }, @@ -450,10 +467,12 @@ export const MethodCard = ({ method, onClick, requiresVerification, + isDisabled, }: { method: PaymentMethod onClick: () => void requiresVerification?: boolean + isDisabled?: boolean }) => { return ( } onClick={onClick} - isDisabled={method.soon} + isDisabled={method.soon || isDisabled} rightContent={} /> ) diff --git a/src/components/Common/ActionListDaimoPayButton.tsx b/src/components/Common/ActionListDaimoPayButton.tsx index 97d6a7d94..609978550 100644 --- a/src/components/Common/ActionListDaimoPayButton.tsx +++ b/src/components/Common/ActionListDaimoPayButton.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useRef, useEffect } from 'react' +import { useCallback, useState, useRef } from 'react' import IconStack from '../Global/IconStack' import { useAppDispatch, usePaymentStore } from '@/redux/hooks' import { paymentActions } from '@/redux/slices/payment-slice' @@ -16,12 +16,16 @@ interface ActionListDaimoPayButtonProps { handleContinueWithPeanut: () => void showConfirmModal: boolean onBeforeShow?: () => boolean | Promise + isDisabled?: boolean + clickHandlerRef?: React.MutableRefObject<(() => void) | null> } const ActionListDaimoPayButton = ({ handleContinueWithPeanut, showConfirmModal, onBeforeShow, + isDisabled, + clickHandlerRef, }: ActionListDaimoPayButtonProps) => { const dispatch = useAppDispatch() const searchParams = useSearchParams() @@ -110,10 +114,18 @@ const ActionListDaimoPayButton = ({ if (chargeDetails) { dispatch(paymentActions.setIsDaimoPaymentProcessing(true)) try { + // validate and parse destination chain id with proper fallback + // use chargeDetails chainId if it's a valid non-negative integer, otherwise use daimo response + const parsedChainId = Number(chargeDetails.chainId) + const destinationChainId = + Number.isInteger(parsedChainId) && parsedChainId >= 0 + ? parsedChainId + : Number(daimoPaymentResponse.payment.destination.chainId) + const result = await completeDaimoPayment({ chargeDetails: chargeDetails, txHash: daimoPaymentResponse.txHash as string, - destinationchainId: daimoPaymentResponse.payment.destination.chainId, + destinationchainId: destinationChainId, payerAddress: peanutWalletAddress ?? daimoPaymentResponse.payment.source.payerAddress, sourceChainId: daimoPaymentResponse.payment.source.chainId, sourceTokenAddress: daimoPaymentResponse.payment.source.tokenAddress, @@ -146,6 +158,8 @@ const ActionListDaimoPayButton = ({ { // First check if parent wants to intercept (e.g. show balance modal) @@ -176,10 +190,14 @@ const ActionListDaimoPayButton = ({ {({ onClick, loading }) => { // Store the onClick function so we can trigger it from elsewhere daimoPayButtonClickRef.current = onClick + // also store in parent ref if provided (for balance modal in ActionList) + if (clickHandlerRef) { + clickHandlerRef.current = onClick + } return ( }) => { + return ( + + +

+ You've earned {points} {points === 1 ? 'point' : 'points'}! +

+
+ ) +} + +export default PointsCard diff --git a/src/components/Global/Card/index.tsx b/src/components/Global/Card/index.tsx index f369e1c18..869cd6ee4 100644 --- a/src/components/Global/Card/index.tsx +++ b/src/components/Global/Card/index.tsx @@ -9,6 +9,7 @@ interface CardProps { className?: string onClick?: () => void border?: boolean + ref?: React.Ref } export function getCardPosition(index: number, totalItems: number): CardPosition { @@ -18,7 +19,7 @@ export function getCardPosition(index: number, totalItems: number): CardPosition return 'middle' } -const Card: React.FC = ({ children, position = 'single', className = '', onClick, border = true }) => { +const Card: React.FC = ({ children, position = 'single', className = '', onClick, border = true, ref }) => { const getBorderRadius = () => { switch (position) { case 'single': @@ -53,6 +54,7 @@ const Card: React.FC = ({ children, position = 'single', className = return (
diff --git a/src/components/Global/DaimoPayButton/index.tsx b/src/components/Global/DaimoPayButton/index.tsx index 2702efa0c..cdb6b6732 100644 --- a/src/components/Global/DaimoPayButton/index.tsx +++ b/src/components/Global/DaimoPayButton/index.tsx @@ -13,6 +13,10 @@ export interface DaimoPayButtonProps { amount: string /** The recipient address */ toAddress: string + /** Target chain ID (defaults to Arbitrum if not specified) */ + toChainId?: number + /** Target token address (defaults to USDC on Arbitrum if not specified) */ + toTokenAddress?: string /** * Render function that receives click handler and other props * OR React node for backwards compatibility @@ -51,6 +55,8 @@ export interface DaimoPayButtonProps { export const DaimoPayButton = ({ amount, toAddress, + toChainId, + toTokenAddress, children, variant = 'purple', icon, @@ -139,10 +145,10 @@ export const DaimoPayButton = ({ resetOnSuccess // resets the daimo payment state after payment is successfully completed appId={daimoAppId} intent="Deposit" - toChain={arbitrum.id} + toChain={toChainId ?? arbitrum.id} // use provided chain or default to arbitrum toUnits={amount.replace(/,/g, '')} toAddress={getAddress(toAddress)} - toToken={getAddress(PEANUT_WALLET_TOKEN)} // USDC on arbitrum + toToken={getAddress(toTokenAddress ?? PEANUT_WALLET_TOKEN)} // use provided token or default to usdc on arbitrum onPaymentCompleted={onPaymentCompleted} closeOnSuccess onClose={onClose} diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index 609b25141..cfc853ad8 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -5,6 +5,9 @@ import Checkbox from '@/components/0_Bruddle/Checkbox' import { useToast } from '@/components/0_Bruddle/Toast' import Modal from '@/components/Global/Modal' import QRBottomDrawer from '@/components/Global/QRBottomDrawer' +import PeanutLoading from '@/components/Global/PeanutLoading' +// QRScanner is NOT lazy-loaded - critical path for payments, needs instant response +// 50KB bundle cost is worth it for better UX on primary flow import QRScanner from '@/components/Global/QRScanner' import { useAuth } from '@/context/authContext' import { usePush } from '@/context/pushProvider' @@ -438,7 +441,7 @@ export default function DirectSendQr({ shadowSize="4" shadowType="primary" className={twMerge( - 'mx-auto h-20 w-20 cursor-pointer justify-center rounded-full p-4 hover:bg-primary-1/100', + 'mx-auto h-20 w-20 cursor-pointer justify-center rounded-full p-4.5 hover:bg-primary-1/100', className )} disabled={disabled} @@ -499,12 +502,15 @@ export default function DirectSendQr({ {isQRScannerOpen && ( <> setIsQRScannerOpen(false)} isOpen={true} /> + {/* Render QRBottomDrawer with z-[60] to ensure it appears above QRScanner (z-50) + This allows "scan OR be scanned" dual functionality */} )} diff --git a/src/components/Global/EmptyStates/EmptyState.tsx b/src/components/Global/EmptyStates/EmptyState.tsx index 633d5dbd6..5a5b41e49 100644 --- a/src/components/Global/EmptyStates/EmptyState.tsx +++ b/src/components/Global/EmptyStates/EmptyState.tsx @@ -1,19 +1,21 @@ import React from 'react' import Card from '../Card' import { Icon, type IconName } from '../Icons/Icon' +import { twMerge } from 'tailwind-merge' interface EmptyStateProps { icon: IconName title: string | React.ReactNode description?: string cta?: React.ReactNode + containerClassName?: HTMLDivElement['className'] } // EmptyState component - Used for dispalying when there's no data in a certain scneario and we want to inform users with a cta (optional) -export default function EmptyState({ title, description, icon, cta }: EmptyStateProps) { +export default function EmptyState({ title, description, icon, cta, containerClassName }: EmptyStateProps) { return ( - -
+ +
diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 66ff79396..048dd5b94 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -65,6 +65,7 @@ import { InviteHeartIcon } from './invite-heart' import { LockIcon } from './lock' import { SplitIcon } from './split' import { GlobeLockIcon } from './globe-lock' +import { BulbIcon } from './bulb' // available icon names export type IconName = @@ -134,6 +135,7 @@ export type IconName = | 'lock' | 'split' | 'globe-lock' + | 'bulb' export interface IconProps extends SVGProps { name: IconName size?: number | string @@ -207,6 +209,7 @@ const iconComponents: Record>> = lock: LockIcon, split: SplitIcon, 'globe-lock': GlobeLockIcon, + bulb: BulbIcon, } export const Icon: FC = ({ name, size = 24, width, height, ...props }) => { diff --git a/src/components/Global/Icons/bulb.tsx b/src/components/Global/Icons/bulb.tsx new file mode 100644 index 000000000..92de9e363 --- /dev/null +++ b/src/components/Global/Icons/bulb.tsx @@ -0,0 +1,11 @@ +import { type FC, type SVGProps } from 'react' + +export const BulbIcon: FC> = (props) => ( + + + +) diff --git a/src/components/Global/InfoCard/index.tsx b/src/components/Global/InfoCard/index.tsx index 18ad57733..38fcb1eb2 100644 --- a/src/components/Global/InfoCard/index.tsx +++ b/src/components/Global/InfoCard/index.tsx @@ -18,6 +18,7 @@ interface InfoCardProps { itemIcon?: IconProps['name'] itemIconSize?: number itemIconClassName?: string + containerClassName?: string } const VARIANT_CLASSES = { @@ -44,14 +45,22 @@ const InfoCard = ({ itemIcon, itemIconSize = 16, itemIconClassName, + containerClassName, }: InfoCardProps) => { const variantClasses = VARIANT_CLASSES[variant] const hasContent = title || description || items return ( -
- {icon && } +
+ {icon && ( + + )}
{title && {title}} {description && ( diff --git a/src/components/Global/LazyLoadErrorBoundary/index.tsx b/src/components/Global/LazyLoadErrorBoundary/index.tsx new file mode 100644 index 000000000..5f98fe41e --- /dev/null +++ b/src/components/Global/LazyLoadErrorBoundary/index.tsx @@ -0,0 +1,45 @@ +'use client' +import React from 'react' + +interface LazyLoadErrorBoundaryProps { + children: React.ReactNode + fallback?: React.ReactNode + onError?: (error: Error) => void +} + +interface LazyLoadErrorBoundaryState { + hasError: boolean + error: Error | null +} + +/** + * Error Boundary for lazy-loaded components + * Catches chunk loading failures (network errors, 404s, timeouts) and provides + * graceful fallback instead of crashing the entire app + */ +class LazyLoadErrorBoundary extends React.Component { + constructor(props: LazyLoadErrorBoundaryProps) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): LazyLoadErrorBoundaryState { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('LazyLoad Error Boundary caught error:', error, errorInfo) + this.props.onError?.(error) + } + + render() { + if (this.state.hasError) { + // Use custom fallback if provided, otherwise render null (graceful degradation) + return this.props.fallback ?? null + } + + return this.props.children + } +} + +export default LazyLoadErrorBoundary diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index af01e93b3..01026f708 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -13,6 +13,7 @@ interface NavHeaderProps { hideLabel?: boolean icon?: IconName showLogoutBtn?: boolean + titleClassName?: string } const NavHeader = ({ @@ -23,6 +24,7 @@ const NavHeader = ({ onPrev, disableBackBtn, showLogoutBtn = false, + titleClassName, }: NavHeaderProps) => { const { logoutUser, isLoggingOut } = useAuth() @@ -48,7 +50,12 @@ const NavHeader = ({ )} {!hideLabel && ( -
+
{title}
)} diff --git a/src/components/Global/OfflineScreen/index.tsx b/src/components/Global/OfflineScreen/index.tsx new file mode 100644 index 000000000..6efbc5a10 --- /dev/null +++ b/src/components/Global/OfflineScreen/index.tsx @@ -0,0 +1,161 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' + +// inline peanut icon svg to ensure it works offline without needing to fetch external assets +const PeanutIcon = ({ className }: { className?: string }) => ( + + {/* */} + + + {/* */} + + + {/* */} + + + {/* */} + + + {/* */} + + + {/* */} + + + {/* */} + + + {/* */} + + + + + + + + + + {/* */} + + + + {/* */} + + + + + +) + +/** + * full-page offline screen shown when user loses internet connection + * displays peanut logo and helpful message about pwa cached content + * when connection is restored, page automatically reloads + */ +export default function OfflineScreen() { + // check if connection is restored before reloading + const handleRetryConnection = () => { + if (navigator.onLine) { + // connection restored, reload the page + window.location.reload() + } else { + // still offline, automatic polling will detect when back online + console.log('Still offline, waiting for connection...') + } + } + + return ( +
+
+ +
+
+

You're Offline

+

+ No internet connection detected. Please check your network settings and try again. +

+
+ +
+ ) +} diff --git a/src/components/Global/PeanutFactsLoading/index.tsx b/src/components/Global/PeanutFactsLoading/index.tsx new file mode 100644 index 000000000..70fc7c894 --- /dev/null +++ b/src/components/Global/PeanutFactsLoading/index.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import { PeanutGuyGIF } from '@/assets/illustrations' +import Card from '../Card' +import PeanutLoading from '../PeanutLoading' + +const PEANUT_FACTS = [ + "Peanuts aren't nuts—they're legumes! They're basically fancy beans pretending to be nuts.", + 'It takes about 540 peanuts to make a 12-ounce jar of peanut butter.', + 'Peanuts grow underground, making them the introverts of the nut world.', + "Astronauts eat peanuts in space because they're the perfect protein-packed snack.", + "The world's largest peanut was 4 inches long. An absolute unit.", + 'Peanuts are one of the ingredients in dynamite (through the oil—glycerol).', + 'A peanut plant produces about 40 peanuts per plant. Efficient little guys.', + 'Arachibutyrophobia is the fear of peanut butter sticking to the roof of your mouth. Yes, really.', + 'Peanuts can be used to make everything from soap to shaving cream. Versatile kings.', + "China and India produce over 60% of the world's peanuts. Peanut powerhouses!", + 'Peanut butter was invented as a protein substitute for people with bad teeth.', + 'The peanut shell is actually a pod, just like peas. Legume logic.', + 'Two-thirds of all peanuts grown are used to make peanut butter and peanut snacks.', + 'Peanuts are a great source of protein—about 7 grams per ounce.', + 'Wild peanuts still grow in South America, where they originally came from.', + 'Boiled peanuts are a popular snack in many countries. Soft, salty, and delicious.', + 'Peanuts can improve soil quality by adding nitrogen. Environmental heroes!', +] + +interface PeanutFactsLoadingProps { + message?: string +} + +export default function PeanutFactsLoading({ message = 'Processing...' }: PeanutFactsLoadingProps) { + // pick a random fact once when component mounts + const [currentFactIndex] = useState(() => Math.floor(Math.random() * PEANUT_FACTS.length)) + + return ( +
+
+ +

Did you know?

+

+ {PEANUT_FACTS[currentFactIndex]} +

+
+ + {/* Peanutman with beer character at the top */} +
+
+ Peanut Man +
+
+ +
+
+ +
+

{message}

+
+
+
+ ) +} diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx index 12da1fc98..6d4a2d02f 100644 --- a/src/components/Global/PeanutLoading/index.tsx +++ b/src/components/Global/PeanutLoading/index.tsx @@ -1,19 +1,28 @@ import { PEANUTMAN_LOGO } from '@/assets' import { twMerge } from 'tailwind-merge' -export default function PeanutLoading({ coverFullScreen = false }: { coverFullScreen?: boolean }) { +export default function PeanutLoading({ + coverFullScreen = false, + message, +}: { + coverFullScreen?: boolean + message?: string +}) { return ( -
-
- logo - Loading... +
+
+
+ logo + {message ?? 'Loading...'} +
+
{message}
) } diff --git a/src/components/Global/QRBottomDrawer/index.tsx b/src/components/Global/QRBottomDrawer/index.tsx index bc386e540..40a10d288 100644 --- a/src/components/Global/QRBottomDrawer/index.tsx +++ b/src/components/Global/QRBottomDrawer/index.tsx @@ -10,9 +10,10 @@ interface QRBottomDrawerProps { expandedTitle: string text: string buttonText: string + className?: string } -const QRBottomDrawer = ({ url, collapsedTitle, expandedTitle, text, buttonText }: QRBottomDrawerProps) => { +const QRBottomDrawer = ({ url, collapsedTitle, expandedTitle, text, buttonText, className }: QRBottomDrawerProps) => { const contentRef = useRef(null) const snapPoints = [0.75, 1] // 75%, 100% of screen height @@ -31,7 +32,7 @@ const QRBottomDrawer = ({ url, collapsedTitle, expandedTitle, text, buttonText } setActiveSnapPoint={handleSnapPointChange} modal={false} > - +

{activeSnapPoint === snapPoints[0] ? collapsedTitle : expandedTitle} diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 341de9c62..314de37e3 100644 --- a/src/components/Global/TokenAmountInput/index.tsx +++ b/src/components/Global/TokenAmountInput/index.tsx @@ -300,7 +300,7 @@ const TokenAmountInput = ({
{ let value = e.target.value @@ -346,7 +346,9 @@ const TokenAmountInput = ({ ≈{' '} {displayMode === 'TOKEN' ? alternativeDisplayValue - : formatCurrency(alternativeDisplayValue.replace(',', ''))}{' '} + : alternativeDisplayValue + ? formatCurrency(alternativeDisplayValue.replace(',', '')) + : '0.00'}{' '} {alternativeDisplaySymbol} )} diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index 0e4555897..a509bb0f6 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -1,3 +1,4 @@ +'use client' import { PEANUT_LOGO } from '@/assets' import DirectSendQr from '@/components/Global/DirectSendQR' import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/Icon' @@ -7,7 +8,7 @@ import { useUserStore } from '@/redux/hooks' import classNames from 'classnames' import Image from 'next/image' import Link from 'next/link' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { useHaptic } from 'use-haptic' type NavPathProps = { @@ -33,32 +34,35 @@ type NavSectionProps = { pathName: string } -const NavSection: React.FC = ({ paths, pathName }) => ( - <> - {paths.map(({ name, href, icon, size }, index) => ( -
- { - if (pathName === href) { - window.location.reload() - } - }} - > - - {name} - - {index === 4 &&
} -
- ))} - -) +const NavSection: React.FC = ({ paths, pathName }) => { + const router = useRouter() + return ( + <> + {paths.map(({ name, href, icon, size }, index) => ( +
+ { + if (pathName === href) { + router.refresh() + } + }} + > + + {name} + + {index === 4 &&
} +
+ ))} + + ) +} type MobileNavProps = { pathName: string diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index f58454251..08f56ff26 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { Suspense, useEffect } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' @@ -17,16 +17,30 @@ import { saveToCookie } from '@/utils' import { useLogin } from '@/hooks/useLogin' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' +// mapping of special invite codes to their campaign tags +// when these invite codes are used, the corresponding campaign tag is automatically applied +const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { + arbiverseinvitesyou: 'ARBIVERSE_DEVCONNECT_BA_2025', +} + function InvitePageContent() { const searchParams = useSearchParams() - const inviteCode = searchParams.get('code') + const inviteCode = searchParams.get('code')?.toLowerCase() const redirectUri = searchParams.get('redirect_uri') - const campaign = searchParams.get('campaign') - const { user, isFetchingUser } = useAuth() + const campaignParam = searchParams.get('campaign') + const { user, isFetchingUser, fetchUser } = useAuth() + + // determine campaign tag: use query param if provided, otherwise check invite code mapping + const campaign = campaignParam || (inviteCode ? INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode] : undefined) const dispatch = useAppDispatch() const router = useRouter() const { handleLoginClick, isLoggingIn } = useLogin() + const [isAwardingBadge, setIsAwardingBadge] = useState(false) + const hasStartedAwardingRef = useRef(false) + + // Track if we should show content (prevents flash) + const [shouldShowContent, setShouldShowContent] = useState(false) const { data: inviteCodeData, @@ -38,26 +52,71 @@ function InvitePageContent() { enabled: !!inviteCode, }) - // Redirect logged-in users who already have app access to the inviter's profile - // Users without app access should stay on this page to claim the invite and get access + // determine if we should show content based on user state + useEffect(() => { + // if still fetching user, don't show content yet + if (isFetchingUser) { + setShouldShowContent(false) + return + } + + // if invite validation is still loading, don't show content yet + if (isLoading) { + setShouldShowContent(false) + return + } + + // if user has app access AND invite is valid, they'll be redirected + // don't show content in this case (show loading instead) + if (!redirectUri && user?.user?.hasAppAccess && inviteCodeData?.success) { + setShouldShowContent(false) + return + } + + // otherwise, safe to show content (either error view or invite screen) + setShouldShowContent(true) + }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading]) + + // redirect logged-in users who already have app access + // users without app access should stay on this page to claim the invite and get access useEffect(() => { - // Wait for both user and invite data to be loaded + // wait for both user and invite data to be loaded if (!user?.user || !inviteCodeData || isLoading || isFetchingUser) { return } - // If user has app access and invite is valid, redirect to inviter's profile, if a campaign is provided, award the badge and redirect to the home page + // prevent running the effect multiple times (ref doesn't trigger re-renders) + if (hasStartedAwardingRef.current) { + return + } + + // if user has app access and invite is valid, handle redirect if (!redirectUri && user.user.hasAppAccess && inviteCodeData.success && inviteCodeData.username) { - // If the potential ambassador is already a peanut user, simply award the badge and redirect to the home page + // if campaign is present, award the badge and redirect to home if (campaign) { - invitesApi.awardBadge(campaign).then(() => { - router.push('/home') - }) + hasStartedAwardingRef.current = true + setIsAwardingBadge(true) + invitesApi + .awardBadge(campaign) + .then(async () => { + // refetch user data to get the newly awarded badge + await fetchUser() + router.push('/home') + }) + .catch(async () => { + // if badge awarding fails, still refetch and redirect + await fetchUser() + router.push('/home') + }) + .finally(() => { + setIsAwardingBadge(false) + }) } else { + // no campaign, just redirect to inviter's profile router.push(`/${inviteCodeData.username}`) } } - }, [user, inviteCodeData, isLoading, isFetchingUser, router, campaign, redirectUri]) + }, [user, inviteCodeData, isLoading, isFetchingUser, router, campaign, redirectUri, fetchUser]) const handleClaimInvite = async () => { if (inviteCode) { @@ -76,7 +135,10 @@ function InvitePageContent() { } } - if (isLoading || isFetchingUser) { + // show loading if: + // 1. badge is being awarded + // 2. we determined content shouldn't be shown yet (covers user fetching + invite validation) + if (isAwardingBadge || !shouldShowContent) { return } diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index c9f3c7180..460bd0b49 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -10,8 +10,9 @@ import FileUploadInput from '@/components/Global/FileUploadInput' import { type IconName } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import TokenAmountInput from '@/components/Global/TokenAmountInput' +import TokenSelector from '@/components/Global/TokenSelector/TokenSelector' import UserCard from '@/components/User/UserCard' -import { PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' +import { PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_CHAIN } from '@/constants' import { tokenSelectorContext } from '@/context' import { useAuth } from '@/context/authContext' import { useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' @@ -35,7 +36,6 @@ import { useAccount } from 'wagmi' import { useUserInteractions } from '@/hooks/useUserInteractions' import { useUserByUsername } from '@/hooks/useUserByUsername' import { type PaymentFlow } from '@/app/[...recipient]/client' -import MantecaFulfillment from '../Views/MantecaFulfillment.view' import { invitesApi } from '@/services/invites' import { EInviteType } from '@/services/services.types' import ContributorCard from '@/components/Global/Contributors/ContributorCard' @@ -85,15 +85,9 @@ export const PaymentForm = ({ error: paymentStoreError, attachmentOptions, currentView, + parsedPaymentData, } = usePaymentStore() - const { - setShowExternalWalletFulfillMethods, - setExternalWalletFulfillMethod, - fulfillUsingManteca, - setFulfillUsingManteca, - triggerPayWithPeanut, - setTriggerPayWithPeanut, - } = useRequestFulfillmentFlow() + const { triggerPayWithPeanut, setTriggerPayWithPeanut } = useRequestFulfillmentFlow() const recipientUsername = !chargeDetails && recipient?.recipientType === 'USERNAME' ? recipient.identifier : null const { user: recipientUser } = useUserByUsername(recipientUsername) @@ -182,6 +176,9 @@ export const PaymentForm = ({ setInputTokenAmount(amount) } + // for ADDRESS/ENS recipients, initialize token/chain from URL or defaults + const isExternalRecipient = recipient?.recipientType === 'ADDRESS' || recipient?.recipientType === 'ENS' + if (chain) { setSelectedChainID((chain.chainId || requestDetails?.chainId) ?? '') if (!token && !requestDetails?.tokenAddress) { @@ -191,15 +188,37 @@ export const PaymentForm = ({ // Note: decimals automatically derived by useTokenPrice hook } } + } else if (isExternalRecipient && !selectedChainID) { + // default to arbitrum for external recipients if no chain specified + setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString()) } if (token) { setSelectedTokenAddress((token.address || requestDetails?.tokenAddress) ?? '') // Note: decimals automatically derived by useTokenPrice hook + } else if (isExternalRecipient && !selectedTokenAddress && selectedChainID) { + // default to USDC for external recipients if no token specified + const chainData = supportedSquidChainsAndTokens[selectedChainID] + const defaultToken = chainData?.tokens.find((t) => t.symbol.toLowerCase() === 'usdc') + if (defaultToken) { + setSelectedTokenAddress(defaultToken.address) + } } setInitialSetupDone(true) - }, [chain, token, amount, initialSetupDone, requestDetails, showRequestPotInitialView, isRequestPotLink]) + }, [ + chain, + token, + amount, + initialSetupDone, + requestDetails, + showRequestPotInitialView, + isRequestPotLink, + recipient?.recipientType, + selectedChainID, + selectedTokenAddress, + supportedSquidChainsAndTokens, + ]) // reset error when component mounts or recipient changes useEffect(() => { @@ -244,12 +263,27 @@ export const PaymentForm = ({ } } else { // regular send/pay + const isExternalRecipient = recipient?.recipientType === 'ADDRESS' || recipient?.recipientType === 'ENS' + if ( !showRequestPotInitialView && // don't apply balance check on request pot payment initial view isActivePeanutWallet && + !isExternalRecipient + ) { + // peanut wallet payment for USERNAME recipients + const walletNumeric = parseFloat(String(peanutWalletBalance).replace(/,/g, '')) + if (walletNumeric < parsedInputAmount) { + dispatch(paymentActions.setError('Insufficient balance')) + } else { + dispatch(paymentActions.setError(null)) + } + } else if ( + !showRequestPotInitialView && + isActivePeanutWallet && + isExternalRecipient && areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN) ) { - // peanut wallet payment + // for external recipients (ADDRESS/ENS) paying with peanut wallet, check peanut wallet balance directly const walletNumeric = parseFloat(String(peanutWalletBalance).replace(/,/g, '')) if (walletNumeric < parsedInputAmount) { dispatch(paymentActions.setError('Insufficient balance')) @@ -274,6 +308,9 @@ export const PaymentForm = ({ } else { dispatch(paymentActions.setError(null)) } + } else if (isExternalRecipient && isActivePeanutWallet) { + // for external recipients with peanut wallet using non-USDC tokens, balance will be checked via cross-chain route + dispatch(paymentActions.setError(null)) } else { dispatch(paymentActions.setError(null)) } @@ -304,6 +341,7 @@ export const PaymentForm = ({ currentView, isProcessing, hasPendingTransactions, + recipient?.recipientType, ]) // Calculate USD value when requested token price is available @@ -339,7 +377,12 @@ export const PaymentForm = ({ (!!inputTokenAmount && parseFloat(inputTokenAmount) > 0) || (!!usdValue && parseFloat(usdValue) > 0) } - const tokenSelected = !!selectedTokenAddress && !!selectedChainID + const isExternalRecipient = recipient?.recipientType === 'ADDRESS' || recipient?.recipientType === 'ENS' + // for external recipients, token selection is required + // for USERNAME recipients, token is always PEANUT_WALLET_TOKEN + const tokenSelected = isExternalRecipient + ? !!selectedTokenAddress && !!selectedChainID + : !!selectedTokenAddress && !!selectedChainID const recipientExists = !!recipient const walletConnected = isConnected @@ -391,9 +434,10 @@ export const PaymentForm = ({ if (inviteError) { setInviteError(false) } - // Invites will be handled in the payment page, skip this step for request pots initial view + // redirect to add money if insufficient balance if (!showRequestPotInitialView && isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) { - // If the user doesn't have app access, accept the invite before claiming the link + // if the user doesn't have app access, accept the invite before redirecting + // only applies to USERNAME recipients (invite links) if (recipient.recipientType === 'USERNAME' && !user?.user.hasAppAccess) { const isAccepted = await handleAcceptInvite() if (!isAccepted) return @@ -481,7 +525,7 @@ export const PaymentForm = ({ chargeId: chargeDetails?.uuid, currency, currencyAmount, - isExternalWalletFlow: !!isExternalWalletFlow || fulfillUsingManteca, + isExternalWalletFlow: !!isExternalWalletFlow, transactionType: isExternalWalletFlow ? 'DEPOSIT' : isDirectUsdPayment || !requestId @@ -499,7 +543,7 @@ export const PaymentForm = ({ triggerHaptic() dispatch(paymentActions.setView('STATUS')) } else if (result.status === 'Charge Created') { - if (!fulfillUsingManteca && !showRequestPotInitialView) { + if (!showRequestPotInitialView) { dispatch(paymentActions.setView('CONFIRM')) } } else if (result.status === 'Error') { @@ -508,7 +552,6 @@ export const PaymentForm = ({ console.warn('Unexpected status from usePaymentInitiator:', result.status) } }, [ - fulfillUsingManteca, canInitiatePayment, isDepositRequest, isConnected, @@ -606,21 +649,6 @@ export const PaymentForm = ({ } }, [amount, inputTokenAmount, initialSetupDone, showRequestPotInitialView]) - useEffect(() => { - const stepFromURL = searchParams.get('step') - if (user && stepFromURL === 'regional-req-fulfill') { - setFulfillUsingManteca(true) - } else { - setFulfillUsingManteca(false) - } - }, [user, searchParams]) - - useEffect(() => { - if (fulfillUsingManteca && !chargeDetails) { - handleInitiatePayment() - } - }, [fulfillUsingManteca, chargeDetails, handleInitiatePayment]) - // Trigger payment with peanut from action list useEffect(() => { if (triggerPayWithPeanut) { @@ -630,7 +658,7 @@ export const PaymentForm = ({ }, [triggerPayWithPeanut, handleInitiatePayment, setTriggerPayWithPeanut]) const isInsufficientBalanceError = useMemo(() => { - return error?.includes("You don't have enough balance.") + return error?.includes("You don't have enough balance.") || error?.includes('Insufficient balance') }, [error]) const isButtonDisabled = useMemo(() => { @@ -678,11 +706,7 @@ export const PaymentForm = ({ }, [recipient]) const handleGoBack = () => { - if (isExternalWalletFlow) { - setShowExternalWalletFulfillMethods(true) - setExternalWalletFulfillMethod(null) - return - } else if (window.history.length > 1) { + if (window.history.length > 1) { router.back() } else { router.push('/') @@ -737,10 +761,6 @@ export const PaymentForm = ({ return { percentage: Math.min(percentage, 100), suggestedAmount } }, [requestDetails?.charges, requestDetails?.tokenAmount, totalAmountCollected]) - if (fulfillUsingManteca && chargeDetails) { - return - } - return (
@@ -809,30 +829,27 @@ export const PaymentForm = ({ defaultSliderSuggestedAmount={defaultSliderValue.suggestedAmount} /> - {/* - Url request flow (peanut.me/
) - If we are paying from peanut wallet we only need to - select a token if it's not included in the url - From other wallets we always need to select a token - */} - {/* we dont need this as daimo will handle token selection */} - {/* {!(chain && isPeanutWalletConnected) && isConnected && !isAddMoneyFlow && ( -
- {!isPeanutWalletUSDC && !selectedTokenAddress && !selectedChainID && ( -
Select token and chain to receive
- )} - - {!isPeanutWalletUSDC && selectedTokenAddress && selectedChainID && ( -
- Use USDC on Arbitrum for free transactions! -
- )} -
- )} */} - - {/* {isExternalWalletConnected && isAddMoneyFlow && ( - - )} */} + {/* Token selector for external ADDRESS/ENS recipients */} + {/* only show if chain is not specified in URL */} + {!isExternalWalletFlow && + !showRequestPotInitialView && + !chain?.chainId && + (recipient?.recipientType === 'ADDRESS' || recipient?.recipientType === 'ENS') && + isConnected && ( +
+ + {selectedTokenAddress && + selectedChainID && + !( + areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN) && + selectedChainID === PEANUT_WALLET_CHAIN.id.toString() + ) && ( +
+ Use USDC on Arbitrum for free transactions! +
+ )} +
+ )} {isDirectUsdPayment && ( { setFulfillUsingManteca(false) } + const handleBackClick = () => { + // reset manteca fulfillment state to show payment options again + setFulfillUsingManteca(false) + setSelectedCountry(null) + } + useEffect(() => { if (!isUserMantecaKycApproved) { setIsKYCModalOpen(true) @@ -74,7 +80,13 @@ const MantecaFulfillment = () => { return (
- {depositData?.data && } + {depositData?.data && ( + + )} {errorMessage && } {isKYCModalOpen && ( diff --git a/src/components/Payment/Views/Status.payment.view.tsx b/src/components/Payment/Views/Status.payment.view.tsx index 91ca85a28..c8b916d61 100644 --- a/src/components/Payment/Views/Status.payment.view.tsx +++ b/src/components/Payment/Views/Status.payment.view.tsx @@ -27,6 +27,7 @@ import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import { usePointsConfetti } from '@/hooks/usePointsConfetti' import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif' import { useHaptic } from 'use-haptic' +import PointsCard from '@/components/Common/PointsCard' type DirectSuccessViewProps = { user?: ApiUser @@ -252,14 +253,7 @@ const DirectSuccessView = ({
- {points && ( -
- star -

- You've earned {points} {points === 1 ? 'point' : 'points'}! -

-
- )} + {points && }
{!!authUser?.user.userId ? ( diff --git a/src/components/Profile/components/CountryListSection.tsx b/src/components/Profile/components/CountryListSection.tsx index 803cb5f48..e6ea182fd 100644 --- a/src/components/Profile/components/CountryListSection.tsx +++ b/src/components/Profile/components/CountryListSection.tsx @@ -1,6 +1,7 @@ import { ActionListCard } from '@/components/ActionListCard' import { type CountryData } from '@/components/AddMoney/consts' import { getCardPosition } from '@/components/Global/Card' +import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Icon } from '@/components/Global/Icons/Icon' import * as Accordion from '@radix-ui/react-accordion' import Image from 'next/image' @@ -40,6 +41,14 @@ const CountryListSection = ({ {description &&

{description}

} + {countries.length === 0 && ( + + )} + {countries.map((country, index) => { const position = getCardPosition(index, countries.length) return ( diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index b89d385b8..334330807 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -1,13 +1,9 @@ import { Button } from '@/components/0_Bruddle' -import Divider from '@/components/0_Bruddle/Divider' import { BASE_URL } from '@/components/Global/DirectSendQR/utils' import { Icon } from '@/components/Global/Icons/Icon' -import QRCodeWrapper from '@/components/Global/QRCodeWrapper' -import ShareButton from '@/components/Global/ShareButton' import React, { useState } from 'react' import { twMerge } from 'tailwind-merge' import AvatarWithBadge from '../AvatarWithBadge' -import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer' import { VerifiedUserLabel } from '@/components/UserHeader' import { useAuth } from '@/context/authContext' import useKycStatus from '@/hooks/useKycStatus' @@ -65,8 +61,17 @@ const ProfileHeader: React.FC = ({ shadowSize="4" className="flex h-10 w-fit items-center justify-center rounded-full py-3 pl-6 pr-4" onClick={() => { - navigator.clipboard.writeText(profileUrl) - setIsDrawerOpen(true) + if (navigator.share) { + navigator + .share({ + url: profileUrl, + }) + .catch((error) => { + console.error('Error sharing:', error) + }) + } else { + navigator.clipboard.writeText(profileUrl) + } }} >
{profileUrl.replace('https://', '')}
@@ -76,24 +81,6 @@ const ProfileHeader: React.FC = ({ )}
- {isDrawerOpen && ( - <> - - - -

Your Peanut profile is public

-

Share it to receive payments!

-
- - - - - Share Profile link - -
-
- - )} ) } diff --git a/src/components/Profile/components/ShowNameToggle.tsx b/src/components/Profile/components/ShowNameToggle.tsx index e64bcf4eb..6b47d6ca3 100644 --- a/src/components/Profile/components/ShowNameToggle.tsx +++ b/src/components/Profile/components/ShowNameToggle.tsx @@ -1,51 +1,42 @@ 'use client' import { updateUserById } from '@/app/actions/users' -import Loading from '@/components/Global/Loading' import { useAuth } from '@/context/authContext' import { useState } from 'react' const ShowNameToggle = () => { const { fetchUser, user } = useAuth() - const [isToggleLoading, setIsToggleLoading] = useState(false) const [showFullName, setShowFullName] = useState(user?.user.showFullName ?? false) const handleToggleChange = async () => { - if (isToggleLoading) return + const newValue = !showFullName + setShowFullName(newValue) - setIsToggleLoading(true) - try { - setShowFullName(!showFullName) - await updateUserById({ - userId: user?.user.userId, - showFullName: !showFullName, + // Fire-and-forget: don't await fetchUser() to allow quick navigation + updateUserById({ + userId: user?.user.userId, + showFullName: newValue, + }) + .then(() => { + // Refetch user data in background without blocking + fetchUser() + }) + .catch((error) => { + console.error('Failed to update preferences:', error) + // Revert on error + setShowFullName(!newValue) }) - await fetchUser() - } catch (error) { - console.error('Failed to update preferences:', error) - } finally { - setIsToggleLoading(false) - } } return ( ) } diff --git a/src/components/Profile/views/RegionsPage.view.tsx b/src/components/Profile/views/RegionsPage.view.tsx index a02fb1720..a59d4f6b8 100644 --- a/src/components/Profile/views/RegionsPage.view.tsx +++ b/src/components/Profile/views/RegionsPage.view.tsx @@ -11,6 +11,8 @@ const RegionsPage = ({ path }: { path: string }) => { const router = useRouter() const { lockedRegions } = useIdentityVerification() + const hideVerifyButtonPaths = ['latam', 'rest-of-the-world'] + const region = lockedRegions.find((region) => region.path === path) if (!region) { @@ -24,7 +26,7 @@ const RegionsPage = ({ path }: { path: string }) => {
- {region.path !== 'latam' && ( + {!hideVerifyButtonPaths.includes(region.path) && (
+ } + /> +
+
+ ) + } + + return ( +
+ + + {contacts.length > 0 ? ( +
+ {/* search input */} + setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + placeholder="Search contacts..." + /> + + {/* contacts list */} + {filteredContacts.length > 0 ? ( +
+

Your contacts

+
+ {filteredContacts.map((contact, index) => { + const isVerified = contact.bridgeKycStatus === 'approved' + const displayName = contact.showFullName + ? contact.fullName || contact.username + : contact.username + return ( + + } + description={`@${contact.username}`} + leftIcon={} + onClick={() => handleUserSelect(contact.username)} + /> + ) + })} +
+ + {/* infinite scroll loader - only active when not searching */} + {!searchQuery && ( +
+ {isFetchingNextPage && ( +
Loading more...
+ )} +
+ )} +
+ ) : ( + // no search results + + )} +
+ ) : ( + // empty state - no contacts at all +
+ + Send via link + + } + /> +
+ )} +
+ ) +} diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx index 8ccea511e..c24f83ec4 100644 --- a/src/components/Send/views/SendRouter.view.tsx +++ b/src/components/Send/views/SendRouter.view.tsx @@ -15,11 +15,11 @@ import { ACTION_METHODS, type PaymentMethod } from '@/constants/actionlist.const import Image from 'next/image' import StatusBadge from '@/components/Global/Badges/StatusBadge' import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions' -import { useRecentUsers } from '@/hooks/useRecentUsers' +import { useContacts } from '@/hooks/useContacts' import { getInitialsFromName } from '@/utils/general.utils' import { useCallback, useMemo } from 'react' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' -import { VerifiedUserLabel } from '@/components/UserHeader' +import ContactsView from './Contacts.view' export const SendRouterView = () => { const router = useRouter() @@ -27,25 +27,28 @@ export const SendRouterView = () => { const searchParams = useSearchParams() const isSendingByLink = searchParams.get('view') === 'link' || searchParams.get('createLink') === 'true' const isSendingToContacts = searchParams.get('view') === 'contacts' - const { recentTransactions, isFetchingRecentUsers } = useRecentUsers() + // only fetch 3 contacts for avatar display + const { contacts, isLoading: isFetchingContacts } = useContacts({ limit: 3 }) - // fallback initials when no recent transactions + // fallback initials when no contacts const fallbackInitials = ['PE', 'AN', 'UT'] - const recentUsersAvatarInitials = useCallback(() => { - // if we have recent transactions, use them (max 3) - if (recentTransactions.length > 0) { - return recentTransactions.slice(0, 3).map((transaction) => { - return getInitialsFromName(transaction.username) + const recentContactsAvatarInitials = useCallback(() => { + // if we have contacts, use them (already limited to 3 by API) + if (contacts.length > 0) { + return contacts.map((contact) => { + return getInitialsFromName( + contact.showFullName ? contact.fullName || contact.username : contact.username + ) }) } // fallback to default initials if no data return fallbackInitials - }, [recentTransactions]) + }, [contacts]) - const recentUsersAvatars = useMemo(() => { + const contactsAvatars = useMemo(() => { // show loading skeleton while fetching - if (isFetchingRecentUsers) { + if (isFetchingContacts) { return (
{[0, 1, 2].map((index) => ( @@ -62,7 +65,7 @@ export const SendRouterView = () => { // show avatars (either real data or fallback) return (
- {recentUsersAvatarInitials().map((initial, index) => { + {recentContactsAvatarInitials().map((initial, index) => { return (
@@ -71,7 +74,7 @@ export const SendRouterView = () => { })}
) - }, [isFetchingRecentUsers, recentUsersAvatarInitials]) + }, [isFetchingContacts, recentContactsAvatarInitials]) const redirectToSendByLink = () => { // reset send flow state when entering link creation flow @@ -124,11 +127,6 @@ export const SendRouterView = () => { } } - // handle user selection from contacts - const handleUserSelect = (username: string) => { - router.push(`/send/${username}`) - } - // extend ACTION_METHODS with component-specific identifier icons const extendedActionMethods = useMemo(() => { return ACTION_METHODS.map((method) => { @@ -197,81 +195,7 @@ export const SendRouterView = () => { // contacts view if (isSendingToContacts) { - return ( -
- - - {isFetchingRecentUsers ? ( - // show loading state -
-
Loading contacts...
-
- ) : recentTransactions.length > 0 ? ( - // show contacts list -
-

Recent activity

-
- {recentTransactions.map((user, index) => { - const isVerified = user.bridgeKycStatus === 'approved' - return ( - - } - description={`@${user.username}`} - leftIcon={ - - } - onClick={() => handleUserSelect(user.username)} - /> - ) - })} -
-
- ) : ( - // empty state -
- -
-
-
- -
-
-
Send money with a link
-
No account needed to receive.
-
-
- -
-
-
- )} -
- ) + return } return ( @@ -303,7 +227,7 @@ export const SendRouterView = () => { let rightContent switch (option.id) { case 'peanut-contacts': - rightContent = recentUsersAvatars + rightContent = contactsAvatars break case 'mercadopago': rightContent = diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx index f6d39f9b1..169326064 100644 --- a/src/components/Setup/Views/InstallPWA.tsx +++ b/src/components/Setup/Views/InstallPWA.tsx @@ -53,7 +53,7 @@ const InstallPWA = ({ { platform: string; url?: string; id?: string; version?: string }[] > } - const installedApps = (await _navigator.getInstalledRelatedApps()) ?? [] + const installedApps = _navigator.getInstalledRelatedApps ? await _navigator.getInstalledRelatedApps() : [] if (installedApps.length > 0) { setIsPWAInstalled(true) } else { diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 260c24811..7f154dbb3 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -2,51 +2,15 @@ import { Button, Card } from '@/components/0_Bruddle' import { useToast } from '@/components/0_Bruddle/Toast' -import { useAuth } from '@/context/authContext' import { useSetupFlow } from '@/hooks/useSetupFlow' import { useLogin } from '@/hooks/useLogin' -import { getRedirectUrl, sanitizeRedirectURL, clearRedirectUrl } from '@/utils' import * as Sentry from '@sentry/nextjs' -import { useRouter, useSearchParams } from 'next/navigation' -import { useEffect } from 'react' import Link from 'next/link' const LandingStep = () => { const { handleNext } = useSetupFlow() const { handleLoginClick, isLoggingIn } = useLogin() - const { user } = useAuth() - const { push } = useRouter() const toast = useToast() - const searchParams = useSearchParams() - - useEffect(() => { - if (!!user) { - const localStorageRedirect = getRedirectUrl() - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri) - // Only redirect if the URL is safe (same-origin) - if (sanitizedRedirectUrl) { - push(sanitizedRedirectUrl) - } else { - // Reject external redirects, go to home instead - push('/home') - } - } else if (localStorageRedirect) { - clearRedirectUrl() - const sanitizedLocalRedirect = sanitizeRedirectURL(localStorageRedirect) - // Only redirect if the URL is safe (same-origin) - if (sanitizedLocalRedirect) { - push(sanitizedLocalRedirect) - } else { - // Reject external redirects, go to home instead - push('/home') - } - } else { - push('/home') - } - } - }, [user, push, searchParams]) const handleError = (error: any) => { const errorMessage = diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 2f83a8daf..3c0a1936b 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -1,7 +1,6 @@ import Card, { type CardPosition } from '@/components/Global/Card' import { Icon, type IconName } from '@/components/Global/Icons/Icon' import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionAvatarBadge' -import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer' import { type TransactionDirection } from '@/components/TransactionDetails/TransactionDetailsHeaderCard' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' @@ -14,7 +13,7 @@ import { isStableCoin, shortenStringLong, } from '@/utils' -import React from 'react' +import React, { lazy, Suspense } from 'react' import Image from 'next/image' import StatusPill, { type StatusPillType } from '../Global/StatusPill' import { VerifiedUserLabel } from '../UserHeader' @@ -23,6 +22,16 @@ import { EHistoryEntryType } from '@/utils/history.utils' import { PerkIcon } from './PerkIcon' import { useHaptic } from 'use-haptic' +// Lazy load transaction details drawer (~40KB) to reduce initial bundle size +// Only loaded when user taps a transaction to view details +// Wrapped in error boundary to gracefully handle chunk load failures +const TransactionDetailsDrawer = lazy(() => + import('@/components/TransactionDetails/TransactionDetailsDrawer').then((mod) => ({ + default: mod.TransactionDetailsDrawer, + })) +) +import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' + export type TransactionType = | 'send' | 'withdraw' @@ -154,7 +163,7 @@ const TransactionCard: React.FC = ({
{/* display the action icon and type text */} -
+
{getActionIcon(type, transaction.direction)} {isPerkReward ? 'Refund' : getActionText(type)} {status && } @@ -175,13 +184,17 @@ const TransactionCard: React.FC = ({ {/* Transaction Details Drawer */} - + + + + + ) } diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index 1c31790c7..9467e786f 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -111,7 +111,7 @@ export const VerifiedUserLabel = ({ {(isInvitedByLoggedInUser || isInviter) && ( diff --git a/src/config/wagmi.config.tsx b/src/config/wagmi.config.tsx index d98b041cc..639589c06 100644 --- a/src/config/wagmi.config.tsx +++ b/src/config/wagmi.config.tsx @@ -19,9 +19,28 @@ import { import { createAppKit } from '@reown/appkit/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { WagmiProvider, cookieToInitialState, type Config } from 'wagmi' +import { RETRY_STRATEGIES } from '@/utils/retry.utils' -// 0. Setup queryClient -const queryClient = new QueryClient() +// 0. Setup queryClient with network resilience defaults +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + ...RETRY_STRATEGIES.FAST, + staleTime: 30 * 1000, // Cache data as fresh for 30s + gcTime: 5 * 60 * 1000, // Keep inactive queries in memory for 5min + refetchOnWindowFocus: true, // Refetch stale data when user returns + refetchOnReconnect: true, // Refetch when connectivity restored + // Allow queries when offline to read from TanStack Query in-memory cache + // Service Worker provides additional HTTP API response caching (user data, history, prices) + networkMode: 'always', // Run queries even when offline (reads from cache) + }, + mutations: { + retry: 1, // Total 2 attempts: immediate + 1 retry (conservative for write operations) + retryDelay: 1000, // Fixed 1s delay + networkMode: 'online', // Pause mutations while offline (writes require network) + }, + }, +}) // 1. Get projectId at https://cloud.reown.com const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? '' diff --git a/src/constants/actionlist.consts.ts b/src/constants/actionlist.consts.ts index e3295b371..320e345f8 100644 --- a/src/constants/actionlist.consts.ts +++ b/src/constants/actionlist.consts.ts @@ -15,7 +15,7 @@ export const ACTION_METHODS: PaymentMethod[] = [ { id: 'bank', title: 'Bank', - description: 'EUR, USD, ARS (more coming soon)', + description: 'EUR, USD, MXN, ARS & more', icons: [ 'https://flagcdn.com/w160/ar.png', 'https://flagcdn.com/w160/de.png', diff --git a/src/constants/cache.consts.ts b/src/constants/cache.consts.ts new file mode 100644 index 000000000..5583b26fb --- /dev/null +++ b/src/constants/cache.consts.ts @@ -0,0 +1,35 @@ +/** + * Service Worker cache name constants + * Used across sw.ts and authContext.tsx to ensure consistent cache management + */ + +/** + * Base cache names (without version suffix) + */ +export const CACHE_NAMES = { + USER_API: 'user-api', + TRANSACTIONS: 'transactions-api', + KYC_MERCHANT: 'kyc-merchant-api', + PRICES: 'prices-api', + EXTERNAL_RESOURCES: 'external-resources', +} as const + +/** + * Cache names that contain user-specific data + * These should be cleared on logout to prevent data leakage between users + */ +export const USER_DATA_CACHE_PATTERNS = [ + CACHE_NAMES.USER_API, + CACHE_NAMES.TRANSACTIONS, + CACHE_NAMES.KYC_MERCHANT, +] as const + +/** + * Generates a versioned cache name + * @param name - Base cache name + * @param version - Cache version string + * @returns Versioned cache name (e.g., "user-api-v1") + */ +export const getCacheNameWithVersion = (name: string, version: string): string => { + return `${name}-${version}` +} diff --git a/src/constants/index.ts b/src/constants/index.ts index f4a42c5f7..d47cbb342 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -6,5 +6,6 @@ export * from './loadingStates.consts' export * from './query.consts' export * from './zerodev.consts' export * from './manteca.consts' +export * from './payment.consts' export * from './routes' export * from './stateCodes.consts' diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts new file mode 100644 index 000000000..7d62bd236 --- /dev/null +++ b/src/constants/payment.consts.ts @@ -0,0 +1,32 @@ +// minimum amount requirements for different payment methods (in USD) +export const MIN_BANK_TRANSFER_AMOUNT = 5 +export const MIN_MERCADOPAGO_AMOUNT = 5 +export const MIN_PIX_AMOUNT = 5 + +// deposit limits for manteca regional onramps (in USD) +export const MAX_MANTECA_DEPOSIT_AMOUNT = 2000 +export const MIN_MANTECA_DEPOSIT_AMOUNT = 1 + +// QR payment limits for manteca (PIX, MercadoPago, QR3) +export const MIN_MANTECA_QR_PAYMENT_AMOUNT = 0.1 // Manteca provider minimum + +// time constants for devconnect intent cleanup +export const DEVCONNECT_INTENT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +// maximum number of devconnect intents to store per user +export const MAX_DEVCONNECT_INTENTS = 10 + +/** + * validate if amount meets minimum requirement for a payment method + * @param amount - amount in USD + * @param methodId - payment method identifier + * @returns true if amount is valid, false otherwise + */ +export const validateMinimumAmount = (amount: number, methodId: string): boolean => { + const minimums: Record = { + bank: MIN_BANK_TRANSFER_AMOUNT, + mercadopago: MIN_MERCADOPAGO_AMOUNT, + pix: MIN_PIX_AMOUNT, + } + return amount >= (minimums[methodId] ?? 0) +} diff --git a/src/constants/query.consts.ts b/src/constants/query.consts.ts index 72714ea5b..adcfb970c 100644 --- a/src/constants/query.consts.ts +++ b/src/constants/query.consts.ts @@ -1,5 +1,6 @@ export const USER = 'user' export const TRANSACTIONS = 'transactions' +export const CONTACTS = 'contacts' export const CLAIM_LINK = 'claimLink' export const CLAIM_LINK_XCHAIN = 'claimLinkXChain' diff --git a/src/context/RequestFulfillmentFlowContext.tsx b/src/context/RequestFulfillmentFlowContext.tsx index 82ee98a12..d9400aa24 100644 --- a/src/context/RequestFulfillmentFlowContext.tsx +++ b/src/context/RequestFulfillmentFlowContext.tsx @@ -5,8 +5,6 @@ import { type CountryData } from '@/components/AddMoney/consts' import { type IOnrampData } from './OnrampFlowContext' import { type User } from '@/interfaces' -export type ExternalWalletFulfilMethod = 'exchange' | 'wallet' - export enum RequestFulfillmentBankFlowStep { BankCountryList = 'bank-country-list', DepositBankDetails = 'deposit-bank-details', @@ -16,12 +14,8 @@ export enum RequestFulfillmentBankFlowStep { interface RequestFulfillmentFlowContextType { resetFlow: () => void - showExternalWalletFulfillMethods: boolean - setShowExternalWalletFulfillMethods: (showExternalWalletFulfillMethods: boolean) => void showRequestFulfilmentBankFlowManager: boolean setShowRequestFulfilmentBankFlowManager: (showRequestFulfilmentBankFlowManager: boolean) => void - externalWalletFulfillMethod: ExternalWalletFulfilMethod | null - setExternalWalletFulfillMethod: (externalWalletFulfillMethod: ExternalWalletFulfilMethod | null) => void flowStep: RequestFulfillmentBankFlowStep | null setFlowStep: (step: RequestFulfillmentBankFlowStep | null) => void selectedCountry: CountryData | null @@ -43,10 +37,6 @@ interface RequestFulfillmentFlowContextType { const RequestFulfillmentFlowContext = createContext(undefined) export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [showExternalWalletFulfillMethods, setShowExternalWalletFulfillMethods] = useState(false) - const [externalWalletFulfillMethod, setExternalWalletFulfillMethod] = useState( - null - ) const [showRequestFulfilmentBankFlowManager, setShowRequestFulfilmentBankFlowManager] = useState(false) const [flowStep, setFlowStep] = useState(null) const [selectedCountry, setSelectedCountry] = useState(null) @@ -58,8 +48,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod const [triggerPayWithPeanut, setTriggerPayWithPeanut] = useState(false) // To trigger the pay with peanut from Action List const resetFlow = useCallback(() => { - setExternalWalletFulfillMethod(null) - setShowExternalWalletFulfillMethods(false) setFlowStep(null) setShowRequestFulfilmentBankFlowManager(false) setSelectedCountry(null) @@ -73,10 +61,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod const value = useMemo( () => ({ resetFlow, - externalWalletFulfillMethod, - setExternalWalletFulfillMethod, - showExternalWalletFulfillMethods, - setShowExternalWalletFulfillMethods, flowStep, setFlowStep, showRequestFulfilmentBankFlowManager, @@ -98,8 +82,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod }), [ resetFlow, - externalWalletFulfillMethod, - showExternalWalletFulfillMethods, flowStep, showRequestFulfilmentBankFlowManager, selectedCountry, diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index b7e9de6a9..165bcd4d7 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -16,6 +16,8 @@ import { useQueryClient } from '@tanstack/react-query' import { useRouter, usePathname } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' import { captureException } from '@sentry/nextjs' +// import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' +import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' interface AuthContextType { user: interfaces.IUserProfile | null @@ -143,6 +145,27 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear JWT cookie by setting it to expire document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;' + // Clear service worker caches to prevent user data leakage + // When User A logs out and User B logs in on the same device, cached API responses + // could expose User A's data (profile, transactions, KYC) to User B + // Only clears user-specific caches; preserves prices and external resources + if ('caches' in window) { + try { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter((name) => USER_DATA_CACHE_PATTERNS.some((pattern) => name.includes(pattern))) + .map((name) => { + console.log('Logout: Clearing cache:', name) + return caches.delete(name) + }) + ) + } catch (error) { + console.error('Failed to clear caches on logout:', error) + // Non-fatal: logout continues even if cache clearing fails + } + } + // clear the iOS PWA prompt session flag if (typeof window !== 'undefined') { sessionStorage.removeItem('hasSeenIOSPWAPromptThisSession') diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index 52e415f3d..e6d75d4a6 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -190,30 +190,42 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { } const initializeClients = async () => { + // NETWORK RESILIENCE: Parallelize kernel client initialization across chains + // Currently only 1 chain configured (Arbitrum), but this enables future multi-chain support + const clientPromises = Object.entries(PUBLIC_CLIENTS_BY_CHAIN).map( + async ([chainId, { client, chain, bundlerUrl, paymasterUrl }]) => { + try { + const kernelClient = await createKernelClientForChain( + client, + chain, + isAfterZeroDevMigration, + webAuthnKey, + isAfterZeroDevMigration + ? undefined + : (user?.accounts.find((a) => a.type === 'peanut-wallet')!.identifier as Address), + { + bundlerUrl, + paymasterUrl, + } + ) + return { chainId, kernelClient, success: true } as const + } catch (error) { + console.error(`Error creating kernel client for chain ${chainId}:`, error) + captureException(error) + return { chainId, error, success: false } as const + } + } + ) + + const results = await Promise.allSettled(clientPromises) + const newClientsByChain: Record = {} - for (const chainId in PUBLIC_CLIENTS_BY_CHAIN) { - const { client, chain, bundlerUrl, paymasterUrl } = PUBLIC_CLIENTS_BY_CHAIN[chainId] - try { - const kernelClient = await createKernelClientForChain( - client, - chain, - isAfterZeroDevMigration, - webAuthnKey, - isAfterZeroDevMigration - ? undefined - : (user?.accounts.find((a) => a.type === 'peanut-wallet')!.identifier as Address), - { - bundlerUrl, - paymasterUrl, - } - ) - newClientsByChain[chainId] = kernelClient - } catch (error) { - console.error(`Error creating kernel client for chain ${chainId}:`, error) - captureException(error) - continue + for (const result of results) { + if (result.status === 'fulfilled' && result.value.success) { + newClientsByChain[result.value.chainId] = result.value.kernelClient } } + if (isMounted) { fetchUser() setClientsByChain(newClientsByChain) diff --git a/src/context/pushProvider.tsx b/src/context/pushProvider.tsx index 0f212eaa3..61663e41f 100644 --- a/src/context/pushProvider.tsx +++ b/src/context/pushProvider.tsx @@ -29,17 +29,21 @@ export function PushProvider({ children }: { children: React.ReactNode }) { const [subscription, setSubscription] = useState(null) const registerServiceWorker = async () => { - console.log('Registering service worker') + console.log('[PushProvider] Getting service worker registration') try { - const reg = await navigator.serviceWorker.register('/sw.js', { - scope: '/', - updateViaCache: 'none', - }) - console.log({ reg }) + // Use existing SW registration (registered in layout.tsx for offline support) + // navigator.serviceWorker.ready waits for SW to be registered and active + // Timeout after 10s to prevent infinite wait if SW registration fails + const reg = (await Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('SW registration timeout')), 10000)), + ])) as ServiceWorkerRegistration + + console.log('[PushProvider] SW already registered:', reg.scope) setRegistration(reg) const sub = await reg.pushManager.getSubscription() - console.log({ sub }) + console.log('[PushProvider] Push subscription:', sub) if (sub) { // @ts-ignore @@ -47,8 +51,9 @@ export function PushProvider({ children }: { children: React.ReactNode }) { setIsSubscribed(true) } } catch (error) { - console.error('Service Worker registration failed:', error) + console.error('[PushProvider] Failed to get SW registration:', error) captureException(error) + // toast.error('Failed to initialize notifications') } } @@ -65,7 +70,7 @@ export function PushProvider({ children }: { children: React.ReactNode }) { .catch((error) => { console.error('Service Worker not ready:', error) captureException(error) - toast.error('Failed to initialize notifications') + // toast.error('Failed to initialize notifications') }) } else { console.log('Service Worker and Push Manager are not supported') diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index 1b1404800..9f08f765c 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -8,7 +8,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { usePWAStatus } from '../usePWAStatus' import { useDeviceType } from '../useGetDeviceType' -export const useUserQuery = (dependsOn?: boolean) => { +export const useUserQuery = (dependsOn: boolean = true) => { const isPwa = usePWAStatus() const { deviceType } = useDeviceType() const dispatch = useAppDispatch() @@ -45,19 +45,25 @@ export const useUserQuery = (dependsOn?: boolean) => { queryKey: [USER], queryFn: fetchUser, retry: 0, - // only enable the query if: - // 1. dependsOn is true - // 2. no user is currently in the Redux store + // Enable if dependsOn is true (defaults to true) and no Redux user exists yet enabled: dependsOn && !authUser?.user.userId, - // cache the data for 10 minutes - staleTime: 1000 * 60 * 10, - // refetch only when window is focused if data is stale + // Two-tier caching strategy for optimal performance: + // TIER 1: TanStack Query in-memory cache (5 min) + // - Zero latency for active sessions + // - Lost on page refresh (intentional - forces SW cache check) + // TIER 2: Service Worker disk cache (1 week StaleWhileRevalidate) + // - <50ms response on cold start/offline + // - Persists across sessions + // Flow: TQ cache → if stale → fetch() → SW intercepts → SW cache → Network + staleTime: 5 * 60 * 1000, // 5 min (balance: fresh enough + reduces SW hits) + gcTime: 10 * 60 * 1000, // Keep unused data 10 min before garbage collection + // Refetch on mount - TQ automatically skips if data is fresh (< staleTime) + refetchOnMount: true, + // Refetch on focus - TQ automatically skips if data is fresh (< staleTime) refetchOnWindowFocus: true, - // prevent unnecessary refetches - refetchOnMount: false, - // add initial data from Redux if available + // Initialize with Redux data if available (hydration) initialData: authUser || undefined, - // keep previous data + // Keep previous data during refetch (smooth UX, no flicker) placeholderData: keepPreviousData, }) } diff --git a/src/hooks/useContacts.ts b/src/hooks/useContacts.ts new file mode 100644 index 000000000..84ee7074a --- /dev/null +++ b/src/hooks/useContacts.ts @@ -0,0 +1,58 @@ +'use client' +import { useInfiniteQuery } from '@tanstack/react-query' +import { CONTACTS } from '@/constants/query.consts' +import { getContacts } from '@/app/actions/users' +import { type Contact, type ContactsResponse } from '@/interfaces' + +export type { Contact } + +interface UseContactsOptions { + limit?: number +} + +/** + * hook to fetch all contacts for the current user with infinite scroll + * includes: inviter, invitees, and all transaction counterparties (sent/received money, request pots) + */ +export function useContacts(options: UseContactsOptions = {}) { + const { limit = 50 } = options + + const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = useInfiniteQuery({ + queryKey: [CONTACTS, limit], + queryFn: async ({ pageParam = 0 }): Promise => { + const result = await getContacts({ + limit, + offset: pageParam * limit, + }) + + if (result.error) { + throw new Error(result.error) + } + + if (!result.data) { + throw new Error('No data returned from server') + } + + return result.data + }, + getNextPageParam: (lastPage, allPages) => { + // if hasMore is true, return next page number + return lastPage.hasMore ? allPages.length : undefined + }, + initialPageParam: 0, + staleTime: 5 * 60 * 1000, // 5 minutes + }) + + // flatten all pages into single contacts array + const allContacts = data?.pages.flatMap((page) => page.contacts) || [] + + return { + contacts: allContacts, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + } +} diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index bdea5d4e7..ce30d24ce 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -8,6 +8,9 @@ import { useRouter } from 'next/navigation' import useKycStatus from './useKycStatus' import type { StaticImageData } from 'next/image' import { useQrCodeContext } from '@/context/QrCodeContext' +import { getUserPreferences, updateUserPreferences } from '@/utils' +import { DEVCONNECT_LOGO } from '@/assets' +import { DEVCONNECT_INTENT_EXPIRY_MS } from '@/constants' export type CarouselCTA = { id: string @@ -34,6 +37,51 @@ export const useHomeCarouselCTAs = () => { const { setIsQRScannerOpen } = useQrCodeContext() + // -------------------------------------------------------------------------------------------------- + /** + * check if there's a pending devconnect intent and clean up old ones + * + * @dev: note, this code needs to be deleted post devconnect, this is just to temporarily support onramp to devconnect wallet using bank accounts + */ + const [pendingDevConnectIntent, setPendingDevConnectIntent] = useState< + | { + id: string + recipientAddress: string + chain: string + amount: string + onrampId?: string + createdAt: number + status: 'pending' | 'completed' + } + | undefined + >(undefined) + + useEffect(() => { + if (!user?.user?.userId) { + setPendingDevConnectIntent(undefined) + return + } + + const prefs = getUserPreferences(user.user.userId) + const intents = prefs?.devConnectIntents ?? [] + + // clean up intents older than 7 days + const expiryTime = Date.now() - DEVCONNECT_INTENT_EXPIRY_MS + const recentIntents = intents.filter((intent) => intent.createdAt >= expiryTime && intent.status === 'pending') + + // update user preferences if we cleaned up any old intents + if (recentIntents.length !== intents.length) { + updateUserPreferences(user.user.userId, { + devConnectIntents: recentIntents, + }) + } + + // get the most recent pending intent (sorted by createdAt descending) + const mostRecentIntent = recentIntents.sort((a, b) => b.createdAt - a.createdAt)[0] + setPendingDevConnectIntent(mostRecentIntent) + }, [user?.user?.userId]) + // -------------------------------------------------------------------------------------------------- + const generateCarouselCTAs = useCallback(() => { const _carouselCTAs: CarouselCTA[] = [] @@ -60,6 +108,33 @@ export const useHomeCarouselCTAs = () => { }) } + // ------------------------------------------------------------------------------------------------ + // add devconnect payment cta if there's a pending intent + // @dev: note, this code needs to be deleted post devconnect, this is just to temporarily support onramp to devconnect wallet using bank accounts + if (pendingDevConnectIntent) { + _carouselCTAs.push({ + id: 'devconnect-payment', + title: 'Fund your DevConnect wallet', + description: `Deposit funds to your DevConnect wallet`, + logo: DEVCONNECT_LOGO, + icon: 'arrow-up-right', + onClick: () => { + // navigate to the semantic request flow where user can pay with peanut wallet + const paymentUrl = `/${pendingDevConnectIntent.recipientAddress}@${pendingDevConnectIntent.chain}` + router.push(paymentUrl) + }, + onClose: () => { + // remove the intent when user dismisses the cta + if (user?.user?.userId) { + updateUserPreferences(user.user.userId, { + devConnectIntents: [], + }) + } + }, + }) + } + // -------------------------------------------------------------------------------------------------- + // add notification prompt as first item if it should be shown if (showReminderBanner) { _carouselCTAs.push({ @@ -101,6 +176,8 @@ export const useHomeCarouselCTAs = () => { setCarouselCTAs(_carouselCTAs) }, [ + pendingDevConnectIntent, + user?.user?.userId, showReminderBanner, isPermissionDenied, isUserKycApproved, diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts new file mode 100644 index 000000000..6c12f9570 --- /dev/null +++ b/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,56 @@ +'use client' + +import { useEffect, useRef } from 'react' + +interface UseInfiniteScrollOptions { + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => void + enabled?: boolean // optional flag to disable infinite scroll (e.g., when searching) + threshold?: number // intersection observer threshold +} + +/** + * custom hook for viewport-based infinite scroll using intersection observer + * abstracts the common pattern used across history, contacts, etc. + */ +export function useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + enabled = true, + threshold = 0.1, +}: UseInfiniteScrollOptions) { + const loaderRef = useRef(null) + + useEffect(() => { + // skip if disabled + if (!enabled) return + + const observer = new IntersectionObserver( + (entries) => { + const target = entries[0] + // trigger fetchNextPage when loader comes into view + if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { + threshold, + } + ) + + const currentLoaderRef = loaderRef.current + if (currentLoaderRef) { + observer.observe(currentLoaderRef) + } + + return () => { + if (currentLoaderRef) { + observer.unobserve(currentLoaderRef) + } + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage, enabled, threshold]) + + return { loaderRef } +} diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts new file mode 100644 index 000000000..74ffdfb29 --- /dev/null +++ b/src/hooks/useNetworkStatus.ts @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react' + +/** + * NETWORK RESILIENCE: Detects online/offline status using navigator.onLine + * + * ⚠️ LIMITATION: navigator.onLine has false positives (WiFi connected but no internet, + * captive portals, VPN/firewall issues). Use as UI hint only. TanStack Query's network + * detection tests actual connectivity and is more reliable for request retries. + * + * 🔄 AUTO-RELOAD: When connection is restored, page automatically reloads to fetch fresh data + * + * @returns isOnline - Current connection status per navigator.onLine + * @returns wasOffline - Deprecated (kept for backward compatibility, always false) + * @returns isInitialized - True after component has mounted (prevents showing offline screen on initial load) + */ +export function useNetworkStatus() { + const [isOnline, setIsOnline] = useState(() => + typeof navigator !== 'undefined' ? navigator.onLine : true + ) + const [wasOffline, setWasOffline] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) + + useEffect(() => { + let timeoutId: ReturnType | null = null + let pollIntervalId: ReturnType | null = null + + const handleOnline = () => { + setIsOnline(true) + // reload immediately when connection is restored to get fresh content + // skip the "back online" screen for faster/cleaner ux + window.location.reload() + } + + const handleOffline = () => { + setIsOnline(false) + setWasOffline(false) + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + // check current status after mount and mark as initialized + const checkOnlineStatus = () => { + const currentStatus = navigator.onLine + if (currentStatus !== isOnline) { + if (currentStatus) { + handleOnline() + } else { + handleOffline() + } + } + } + + // initial check and mark as initialized after short delay + // this ensures we catch the actual status after mount + setTimeout(() => { + checkOnlineStatus() + setIsInitialized(true) + }, 100) + + // poll every 2 seconds to catch DevTools offline toggle + // necessary because online/offline events don't always fire reliably in DevTools + pollIntervalId = setInterval(checkOnlineStatus, 2000) + + // listen to standard events (works in production/real network changes) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + // also check on visibility change (user returns to tab) + const handleVisibilityChange = () => { + if (!document.hidden) { + checkOnlineStatus() + } + } + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + document.removeEventListener('visibilitychange', handleVisibilityChange) + if (timeoutId) { + clearTimeout(timeoutId) + } + if (pollIntervalId) { + clearInterval(pollIntervalId) + } + } + }, [isOnline]) + + return { isOnline, wasOffline, isInitialized } +} diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 000000000..166faa92d --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,65 @@ +import { useRouter } from 'next/navigation' +import { useEffect, useRef } from 'react' +import PullToRefresh from 'pulltorefreshjs' + +// pull-to-refresh configuration constants +const DIST_THRESHOLD = 70 // minimum pull distance to trigger refresh +const DIST_MAX = 120 // maximum pull distance (visual limit) +const DIST_RELOAD = 80 // distance at which refresh is triggered when released + +interface UsePullToRefreshOptions { + // custom function to determine if pull-to-refresh should be enabled + // defaults to checking if window is at the top + shouldPullToRefresh?: () => boolean + // whether to enable pull-to-refresh (defaults to true) + enabled?: boolean +} + +/** + * hook to enable pull-to-refresh functionality on mobile devices + * native pull-to-refresh is disabled via css (overscroll-behavior-y: none in globals.css) + * this hook uses pulltorefreshjs library for consistent behavior across ios and android + */ +export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => { + const router = useRouter() + const { shouldPullToRefresh, enabled = true } = options + + // store callback in ref to avoid re-initialization when function reference changes + const shouldPullToRefreshRef = useRef(shouldPullToRefresh) + + // update ref when callback changes + useEffect(() => { + shouldPullToRefreshRef.current = shouldPullToRefresh + }, [shouldPullToRefresh]) + + useEffect(() => { + if (typeof window === 'undefined' || !enabled) return + + // default behavior: allow pull-to-refresh when window is at the top + const defaultShouldPullToRefresh = () => window.scrollY === 0 + + PullToRefresh.init({ + mainElement: 'body', + onRefresh: () => { + // router.refresh() returns void, wrap in promise for pulltorefreshjs + router.refresh() + return Promise.resolve() + }, + instructionsPullToRefresh: 'Pull down to refresh', + instructionsReleaseToRefresh: 'Release to refresh', + instructionsRefreshing: 'Refreshing...', + shouldPullToRefresh: () => { + // use latest callback from ref + const callback = shouldPullToRefreshRef.current + return callback ? callback() : defaultShouldPullToRefresh() + }, + distThreshold: DIST_THRESHOLD, + distMax: DIST_MAX, + distReload: DIST_RELOAD, + }) + + return () => { + PullToRefresh.destroyAll() + } + }, [router, enabled]) +} diff --git a/src/hooks/useRecentUsers.ts b/src/hooks/useRecentUsers.ts deleted file mode 100644 index e403618f5..000000000 --- a/src/hooks/useRecentUsers.ts +++ /dev/null @@ -1,37 +0,0 @@ -'use client' -import { useMemo } from 'react' -import { useTransactionHistory, type HistoryEntry } from '@/hooks/useTransactionHistory' -import { type RecentUser } from '@/services/users' - -export function useRecentUsers() { - const { data, isLoading } = useTransactionHistory({ mode: 'latest', limit: 20 }) - - const recentTransactions = useMemo(() => { - if (!data) { - return [] - } - return data.entries.reduce((acc: RecentUser[], entry: HistoryEntry) => { - let account - if (entry.userRole === 'SENDER') { - account = entry.recipientAccount - } else if (entry.userRole === 'RECIPIENT') { - account = entry.senderAccount - } - if (!account?.isUser || !account.username) return acc - const isDuplicate = acc.some( - (user) => - user.userId === account.userId || user.username.toLowerCase() === account.username.toLowerCase() - ) - if (isDuplicate) return acc - acc.push({ - userId: account.userId!, - username: account.username!, - fullName: account.fullName!, - bridgeKycStatus: entry.isVerified ? 'approved' : 'not_started', - }) - return acc - }, []) - }, [data]) - - return { recentTransactions, isFetchingRecentUsers: isLoading } -} diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts index cbb7689e0..87f078dbf 100644 --- a/src/hooks/useTransactionHistory.ts +++ b/src/hooks/useTransactionHistory.ts @@ -83,6 +83,8 @@ export function useTransactionHistory({ } // Latest transactions mode (for home page) + // Two-tier caching: TQ in-memory (30s) → SW disk cache (1 week) → Network + // Balance: Fresh enough for home page + reduces redundant SW cache hits if (mode === 'latest') { // if filterMutualTxs is true, we need to add the username to the query key to invalidate the query when the username changes const queryKeyTxn = TRANSACTIONS + (filterMutualTxs ? username : '') @@ -90,17 +92,26 @@ export function useTransactionHistory({ queryKey: [queryKeyTxn, 'latest', { limit }], queryFn: () => fetchHistory({ limit }), enabled, - staleTime: 5 * 60 * 1000, // 5 minutes + // 30s cache: Fresh enough for home page widget + // On cold start, will fetch → SW responds <50ms from cache + staleTime: 30 * 1000, // 30 seconds (balance: freshness vs performance) + gcTime: 5 * 60 * 1000, // Keep in memory for 5min + // Refetch on mount - TQ automatically skips if data is fresh (< staleTime) + refetchOnMount: true, + // Refetch on focus - TQ automatically skips if data is fresh (< staleTime) + refetchOnWindowFocus: true, }) } // Infinite query mode (for main history page) + // Uses longer staleTime since user is actively browsing (less critical for instant updates) return useInfiniteQuery({ queryKey: [TRANSACTIONS, 'infinite', { limit }], queryFn: ({ pageParam }) => fetchHistory({ cursor: pageParam, limit }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.cursor : undefined), enabled, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 30 * 1000, // 30 seconds (infinite scroll doesn't need instant updates) + gcTime: 5 * 60 * 1000, // Keep in memory for 5min }) } diff --git a/src/hooks/wallet/useBalance.ts b/src/hooks/wallet/useBalance.ts index 99f595aa8..ef6a89fa4 100644 --- a/src/hooks/wallet/useBalance.ts +++ b/src/hooks/wallet/useBalance.ts @@ -6,12 +6,27 @@ import { PEANUT_WALLET_TOKEN, peanutPublicClient } from '@/constants' /** * Hook to fetch and auto-refresh wallet balance using TanStack Query * + * ⚠️ NOTE: Service Worker CANNOT cache RPC POST requests + * - Blockchain RPC calls use POST method (not cacheable by Cache Storage API) + * - See: https://w3c.github.io/ServiceWorker/#cache-put (point 4) + * - Future: Consider server-side proxy to enable SW caching + * + * Current caching strategy (in-memory only): + * - TanStack Query caches balance for 30 seconds in memory + * - Cache is lost on page refresh/reload + * - Balance refetches from blockchain RPC on every app open + * + * Why staleTime: 30s: + * - Balances data that's 30s old during active session + * - Reduces RPC calls during navigation (balance displayed on multiple pages) + * - Prevents rate limiting from RPC providers + * - Balance still updates every 30s automatically + * * Features: + * - In-memory cache for 30s (fast during active session) * - Auto-refreshes every 30 seconds - * - Refetches when window regains focus - * - Refetches after network reconnection - * - Built-in retry on failure - * - Caching and deduplication + * - Built-in retry with exponential backoff + * - Refetches on window focus and network reconnection */ export const useBalance = (address: Address | undefined) => { return useQuery({ @@ -27,7 +42,8 @@ export const useBalance = (address: Address | undefined) => { return balance }, enabled: !!address, // Only run query if address exists - staleTime: 10 * 1000, // Consider data stale after 10 seconds + staleTime: 30 * 1000, // Cache balance for 30s in memory (no SW caching for POST requests) + gcTime: 5 * 60 * 1000, // Keep in memory for 5min refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds refetchOnWindowFocus: true, // Refresh when tab regains focus refetchOnReconnect: true, // Refresh after network reconnection diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index 1630bb94c..2e4d5e9a0 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { parseUnits, encodeFunctionData, erc20Abi } from 'viem' import type { Address, Hash, Hex, TransactionReceipt } from 'viem' import { PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_CHAIN } from '@/constants' -import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.consts' import { useToast } from '@/components/0_Bruddle/Toast' @@ -40,6 +39,10 @@ export const useSendMoney = ({ address, handleSendUserOpEncoded }: UseSendMoneyO return useMutation({ mutationKey: [BALANCE_DECREASE, SEND_MONEY], + // Disable retry for financial transactions to prevent duplicate payments + // Blockchain transactions are not idempotent at the mutation level + // If a transaction succeeds but times out, retrying would create a duplicate payment + retry: false, mutationFn: async ({ toAddress, amountInUsd }: SendMoneyParams) => { const amountToSend = parseUnits(amountInUsd, PEANUT_WALLET_TOKEN_DECIMALS) @@ -89,12 +92,17 @@ export const useSendMoney = ({ address, handleSendUserOpEncoded }: UseSendMoneyO // On success, refresh real data from blockchain onSuccess: () => { - // Invalidate balance to fetch real value + // Invalidate TanStack Query balance cache to fetch fresh value queryClient.invalidateQueries({ queryKey: ['balance', address] }) // Invalidate transaction history to show new transaction queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + // NOTE: We intentionally do NOT clear Service Worker RPC cache here + // This prioritizes instant load (<50ms) over immediate accuracy + // User sees cached balance instantly, then it updates via background refresh (1-2s) + // Tradeoff: After payment, user may see old balance briefly on next app open + console.log('[useSendMoney] Transaction successful, refreshing balance and history') }, diff --git a/src/hooks/wallet/useSignUserOp.ts b/src/hooks/wallet/useSignUserOp.ts new file mode 100644 index 000000000..1735d6602 --- /dev/null +++ b/src/hooks/wallet/useSignUserOp.ts @@ -0,0 +1,104 @@ +'use client' + +import { useCallback } from 'react' +import { useKernelClient } from '@/context/kernelClient.context' +import { signUserOperation } from '@zerodev/sdk/actions' +import { + PEANUT_WALLET_CHAIN, + PEANUT_WALLET_TOKEN, + PEANUT_WALLET_TOKEN_DECIMALS, + USER_OP_ENTRY_POINT, +} from '@/constants/zerodev.consts' +import { parseUnits, encodeFunctionData, erc20Abi } from 'viem' +import type { Hex, Address } from 'viem' +import type { SignUserOperationReturnType } from '@zerodev/sdk/actions' +import { captureException } from '@sentry/nextjs' + +export interface SignedUserOpData { + signedUserOp: SignUserOperationReturnType + chainId: string + entryPointAddress: Address +} + +/** + * Hook to sign UserOperations without broadcasting them to the network. + * This allows for a two-phase commit pattern where the transaction is signed first, + * then submitted from the backend after confirming external dependencies (e.g., Manteca payment). + */ +export const useSignUserOp = () => { + const { getClientForChain } = useKernelClient() + + /** + * Signs a USDC transfer UserOperation without broadcasting it. + * + * @param toAddress - Recipient address + * @param amountInUsd - Amount in USD (will be converted to USDC token decimals) + * @param chainId - Chain ID (defaults to Peanut wallet chain) + * @returns Signed UserOperation data ready for backend submission + * + * @throws Error if signing fails (e.g., user cancels, invalid parameters) + */ + const signTransferUserOp = useCallback( + async ( + toAddress: Address, + amountInUsd: string, + chainId: string = PEANUT_WALLET_CHAIN.id.toString() + ): Promise => { + try { + const client = getClientForChain(chainId) + + // Ensure account is initialized + if (!client.account) { + throw new Error('Smart account not initialized') + } + + // Parse amount to USDC decimals (6 decimals) + const amount = parseUnits(amountInUsd.replace(/,/g, ''), PEANUT_WALLET_TOKEN_DECIMALS) + + // Encode USDC transfer function call + const txData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [toAddress, amount], + }) as Hex + + // Build USDC transfer call (not native token transfer) + const calls = [ + { + to: PEANUT_WALLET_TOKEN as Hex, // USDC contract address + value: 0n, // No native token sent + data: txData, // Encoded transfer call + }, + ] + + // Sign the UserOperation (does NOT broadcast) + // This fills in all required fields (gas, nonce, paymaster, signature) + const signedUserOp = await signUserOperation(client, { + account: client.account, + calls, + }) + + // Return everything the backend needs to submit the UserOp + return { + signedUserOp, + chainId, + entryPointAddress: USER_OP_ENTRY_POINT.address, + } + } catch (error) { + console.error('[useSignUserOp] Error signing UserOperation:', error) + captureException(error, { + tags: { feature: 'sign-user-op' }, + extra: { + toAddress, + amountInUsd, + chainId, + }, + }) + throw error + } + }, + [getClientForChain] + ) + + return { signTransferUserOp } +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index bab25903e..890ce4037 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -330,25 +330,26 @@ export interface IUserProfile { invitedBy: string | null // Username of the person who invited this user } -interface Contact { - user_id: string - contact_id: string - peanut_account_id: string | null - account_identifier: string - account_type: string - nickname: string | null - ens_name: string | null - created_at: string - updated_at: string - n_interactions: number - usd_volume_transacted: string - last_interacted_with: string | null - username: string | null - profile_picture: string | null -} - export type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue } export type JSONObject = { [key: string]: JSONValue } + +export interface Contact { + userId: string + username: string + fullName: string | null + bridgeKycStatus: string | null + showFullName: boolean + relationshipTypes: ('inviter' | 'invitee' | 'sent_money' | 'received_money')[] + firstInteractionDate: string + lastInteractionDate: string + transactionCount: number +} + +export interface ContactsResponse { + contacts: Contact[] + total: number + hasMore: boolean +} diff --git a/src/lib/url-parser/parser.ts b/src/lib/url-parser/parser.ts index 8df2f5ad7..0cd0c247b 100644 --- a/src/lib/url-parser/parser.ts +++ b/src/lib/url-parser/parser.ts @@ -6,7 +6,6 @@ import { validateAndResolveRecipient } from '../validation/recipient' import { getChainDetails, getTokenAndChainDetails } from '../validation/token' import { AmountValidationError } from './errors' import { type ParsedURL } from './types/payment' -import { areEvmAddressesEqual } from '@/utils' export function parseAmountAndToken(amountString: string): { amount?: string; token?: string } { // remove all whitespace @@ -160,13 +159,23 @@ export async function parsePaymentURL( tokenDetails = chainDetails.tokens.find((t) => t.symbol.toLowerCase() === 'USDC'.toLowerCase()) } - // 6. Construct and return the final result + // 6. Determine if this is a DevConnect flow + // @dev: note, this needs to be deleted post devconnect + // devconnect flow: external address + base chain specified in URL + const isDevConnectFlow = + recipientDetails.recipientType === 'ADDRESS' && + chainId !== undefined && + chainId.toLowerCase() === 'base' && + chainDetails !== undefined + + // 7. Construct and return the final result return { parsedUrl: { recipient: recipientDetails, amount: parsedAmount?.amount, token: tokenDetails, chain: chainDetails, + isDevConnectFlow, }, error: null, } diff --git a/src/lib/url-parser/types/payment.ts b/src/lib/url-parser/types/payment.ts index 1c7a9b31e..37f7d46ce 100644 --- a/src/lib/url-parser/types/payment.ts +++ b/src/lib/url-parser/types/payment.ts @@ -13,4 +13,6 @@ export interface ParsedURL { amount?: string token?: interfaces.ISquidToken chain?: interfaces.ISquidChain & { tokens: interfaces.ISquidToken[] } + /** @dev: flag indicating if this is a devconnect flow (external address + base chain), to be deleted post devconnect */ + isDevConnectFlow?: boolean } diff --git a/src/services/manteca.ts b/src/services/manteca.ts index 5c04a156a..68394e9ff 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -5,9 +5,10 @@ import { type MantecaWithdrawResponse, type CreateMantecaOnrampParams, } from '@/types/manteca.types' -import { fetchWithSentry } from '@/utils' +import { fetchWithSentry, jsonStringify } from '@/utils' import Cookies from 'js-cookie' -import type { Address, Hash } from 'viem' +import type { Address } from 'viem' +import type { SignUserOperationReturnType } from '@zerodev/sdk/actions' export interface QrPaymentRequest { qrCode: string @@ -107,7 +108,7 @@ export const mantecaApi = { 'Content-Type': 'application/json', Authorization: `Bearer ${Cookies.get('jwt-token')}`, }, - body: JSON.stringify(data), + body: jsonStringify(data), }) if (!response.ok) { @@ -117,25 +118,66 @@ export const mantecaApi = { return response.json() }, - completeQrPayment: async ({ + /** + * Complete QR payment with a pre-signed UserOperation. + * This allows the backend to complete the Manteca payment BEFORE broadcasting the transaction, + * preventing funds from being stuck in Manteca if their payment fails. + * + * Flow: + * 1. Frontend signs UserOp (funds still in user's wallet) + * 2. Backend receives signed UserOp + * 3. Backend completes Manteca payment first + * 4. If Manteca succeeds, backend broadcasts UserOp + * 5. If Manteca fails, UserOp is never broadcasted (funds safe) + */ + completeQrPaymentWithSignedTx: async ({ paymentLockCode, - txHash, + signedUserOp, + chainId, + entryPointAddress, }: { paymentLockCode: string - txHash: Hash + signedUserOp: Pick< + SignUserOperationReturnType, + | 'sender' + | 'nonce' + | 'callData' + | 'signature' + | 'callGasLimit' + | 'verificationGasLimit' + | 'preVerificationGas' + | 'maxFeePerGas' + | 'maxPriorityFeePerGas' + | 'paymaster' + | 'paymasterData' + | 'paymasterVerificationGasLimit' + | 'paymasterPostOpGasLimit' + | 'initCode' + > + chainId: string + entryPointAddress: Address }): Promise => { - const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment/complete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${Cookies.get('jwt-token')}`, + const response = await fetchWithSentry( + `${PEANUT_API_URL}/manteca/qr-payment/complete-with-signed-tx`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-token')}`, + }, + body: jsonStringify({ + paymentLockCode, + signedUserOp, + chainId, + entryPointAddress, + }), }, - body: JSON.stringify({ paymentLockCode, txHash }), - }) + 120000 + ) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.message || `QR payment failed: ${response.statusText}`) + throw new Error(errorData?.message || errorData?.error || `QR payment failed: ${response.statusText}`) } return response.json() @@ -152,7 +194,7 @@ export const mantecaApi = { 'Content-Type': 'application/json', Authorization: `Bearer ${Cookies.get('jwt-token')}`, }, - body: JSON.stringify({ mantecaTransferId }), + body: jsonStringify({ mantecaTransferId }), }) if (!response.ok) { @@ -188,7 +230,7 @@ export const mantecaApi = { 'Content-Type': 'application/json', Authorization: `Bearer ${Cookies.get('jwt-token')}`, }, - body: JSON.stringify(params), + body: jsonStringify(params), }) if (!response.ok) { @@ -209,7 +251,7 @@ export const mantecaApi = { 'Content-Type': 'application/json', Authorization: `Bearer ${Cookies.get('jwt-token')}`, }, - body: JSON.stringify({ + body: jsonStringify({ usdAmount: params.usdAmount, currency: params.currency, chargeId: params.chargeId, @@ -269,7 +311,7 @@ export const mantecaApi = { 'Content-Type': 'application/json', Authorization: `Bearer ${Cookies.get('jwt-token')}`, }, - body: JSON.stringify(data), + body: jsonStringify(data), }) const result = await response.json() diff --git a/src/styles/globals.css b/src/styles/globals.css index 3e0907d5f..a431af22e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -8,6 +8,12 @@ color-scheme: light; } + html, + body { + /* disable native pull-to-refresh on mobile devices - we use custom implementation */ + overscroll-behavior-y: none; + } + body { /* disable text selection */ @apply select-none; @@ -281,6 +287,7 @@ Firefox input[type='number'] { 100% { transform: translateX(0) rotate(0deg); } + 10%, 30%, 50%, @@ -288,6 +295,7 @@ Firefox input[type='number'] { 90% { transform: translateX(-4px) rotate(-0.5deg); } + 20%, 40%, 60%, @@ -301,6 +309,7 @@ Firefox input[type='number'] { 100% { transform: translateX(0) rotate(0deg); } + 10%, 30%, 50%, @@ -308,6 +317,7 @@ Firefox input[type='number'] { 90% { transform: translateX(-8px) rotate(-1deg); } + 20%, 40%, 60%, @@ -321,6 +331,7 @@ Firefox input[type='number'] { 100% { transform: translate(0, 0) rotate(0deg); } + 10%, 30%, 50%, @@ -328,6 +339,7 @@ Firefox input[type='number'] { 90% { transform: translate(-12px, -2px) rotate(-1.5deg); } + 20%, 40%, 60%, @@ -341,6 +353,7 @@ Firefox input[type='number'] { 100% { transform: translate(0, 0) rotate(0deg); } + 10%, 30%, 50%, @@ -348,6 +361,7 @@ Firefox input[type='number'] { 90% { transform: translate(-16px, -3px) rotate(-2deg); } + 20%, 40%, 60%, diff --git a/src/utils/__tests__/url-parser.test.ts b/src/utils/__tests__/url-parser.test.ts index d45325baa..cdd19fe73 100644 --- a/src/utils/__tests__/url-parser.test.ts +++ b/src/utils/__tests__/url-parser.test.ts @@ -267,6 +267,7 @@ describe('URL Parser Tests', () => { chain: expect.objectContaining({ chainId: 42161 }), amount: '0.1', token: expect.objectContaining({ symbol: 'USDC' }), + isDevConnectFlow: false, }) }) @@ -318,6 +319,7 @@ describe('URL Parser Tests', () => { chain: undefined, token: undefined, amount: undefined, + isDevConnectFlow: false, }) }) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 3992ca303..f13c7a6ea 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -1,15 +1,8 @@ import * as consts from '@/constants' -import { - PEANUT_WALLET_SUPPORTED_TOKENS, - STABLE_COINS, - USER_OPERATION_REVERT_REASON_TOPIC, - ENS_NAME_REGEX, -} from '@/constants' -import * as interfaces from '@/interfaces' +import { STABLE_COINS, USER_OPERATION_REVERT_REASON_TOPIC, ENS_NAME_REGEX } from '@/constants' import { AccountType } from '@/interfaces' import * as Sentry from '@sentry/nextjs' import peanut, { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' -import { SiweMessage } from 'siwe' import type { Address, TransactionReceipt } from 'viem' import { getAddress, isAddress, erc20Abi } from 'viem' import * as wagmiChains from 'wagmi/chains' @@ -17,6 +10,7 @@ import { getPublicClient, type ChainId } from '@/app/actions/clients' import { NATIVE_TOKEN_ADDRESS, SQUID_ETH_ADDRESS } from './token.utils' import { type ChargeEntry } from '@/services/services.types' import { toWebAuthnKey } from '@zerodev/passkey-validator' +import type { ParsedURL } from '@/lib/url-parser/types/payment' export function urlBase64ToUint8Array(base64String: string) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) @@ -462,6 +456,16 @@ export type UserPreferences = { notifBannerShowAt?: number notifModalClosed?: boolean hasSeenBalanceWarning?: { value: boolean; expiry: number } + // @dev: note, this needs to be deleted post devconnect + devConnectIntents?: Array<{ + id: string + recipientAddress: string + chain: string + amount: string + onrampId?: string + createdAt: number + status: 'pending' | 'completed' + }> } export const updateUserPreferences = ( @@ -935,3 +939,109 @@ export const getContributorsFromCharge = (charges: ChargeEntry[]) => { } }) } + +/** + * helper function to save devconnect intent to user preferences + * @dev: note, this needs to be deleted post devconnect + */ +/** + * create deterministic id for devconnect intent based on recipient + chain only + * amount is not included as it can change during the flow + * @dev: to be deleted post devconnect + */ +const createDevConnectIntentId = (recipientAddress: string, chain: string): string => { + const str = `${recipientAddress.toLowerCase()}-${chain.toLowerCase()}` + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // convert to 32bit integer + } + return Math.abs(hash).toString(36) +} + +export const saveDevConnectIntent = ( + userId: string | undefined, + parsedPaymentData: ParsedURL | null, + amount: string, + onrampId?: string +): void => { + if (!userId) return + + // check both redux state and user preferences (fallback if state was reset) + const devconnectFlowData = + parsedPaymentData?.isDevConnectFlow && parsedPaymentData.recipient && parsedPaymentData.chain + ? { + recipientAddress: parsedPaymentData.recipient.resolvedAddress, + chain: parsedPaymentData.chain.chainId, + } + : (() => { + try { + const prefs = getUserPreferences(userId) + const intents = prefs?.devConnectIntents ?? [] + // get the most recent pending intent + return intents.find((i) => i.status === 'pending') ?? null + } catch (e) { + console.error('Failed to read devconnect intent from user preferences:', e) + } + return null + })() + + if (devconnectFlowData) { + // validate required fields + const recipientAddress = devconnectFlowData.recipientAddress + const chain = devconnectFlowData.chain + const cleanedAmount = amount.replace(/,/g, '') + + if (!recipientAddress || !chain || !cleanedAmount) { + console.warn('Skipping DevConnect intent: missing required fields') + return + } + + try { + // create deterministic id based on address + chain only + const intentId = createDevConnectIntentId(recipientAddress, chain) + + const prefs = getUserPreferences(userId) + const existingIntents = prefs?.devConnectIntents ?? [] + + // check if intent with same id already exists + const existingIntent = existingIntents.find((intent) => intent.id === intentId) + + if (!existingIntent) { + // create new intent + const { MAX_DEVCONNECT_INTENTS } = require('@/constants/payment.consts') + const sortedIntents = existingIntents.sort((a, b) => b.createdAt - a.createdAt) + const prunedIntents = sortedIntents.slice(0, MAX_DEVCONNECT_INTENTS - 1) + + updateUserPreferences(userId, { + devConnectIntents: [ + { + id: intentId, + recipientAddress, + chain, + amount: cleanedAmount, + onrampId, + createdAt: Date.now(), + status: 'pending', + }, + ...prunedIntents, + ], + }) + } else { + // update existing intent with new amount and onrampId + const updatedIntents = existingIntents.map((intent) => + intent.id === intentId + ? { ...intent, amount: cleanedAmount, onrampId, createdAt: Date.now() } + : intent + ) + updateUserPreferences(userId, { + devConnectIntents: updatedIntents, + }) + } + } catch (intentError) { + console.error('Failed to save DevConnect intent:', intentError) + // don't block the flow if intent storage fails + } + } +} diff --git a/src/utils/retry.utils.ts b/src/utils/retry.utils.ts new file mode 100644 index 000000000..fb9fd8309 --- /dev/null +++ b/src/utils/retry.utils.ts @@ -0,0 +1,56 @@ +/** + * Shared retry utilities for network resilience + * Provides consistent retry strategies across the application + */ + +/** + * Creates an exponential backoff function + * Delays increase exponentially: baseDelay, baseDelay*2, baseDelay*4, etc., up to maxDelay + * + * @param baseDelay - Initial delay in milliseconds (default: 1000ms) + * @param maxDelay - Maximum delay in milliseconds (default: 5000ms) + * @returns Function that calculates delay for a given attempt index + */ +export const createExponentialBackoff = (baseDelay: number = 1000, maxDelay: number = 5000) => { + return (attemptIndex: number) => Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +/** + * Predefined retry strategies for different use cases + */ +export const RETRY_STRATEGIES = { + /** + * Fast retry: 2 retries with exponential backoff (1s, 2s, max 5s) + * Use for: User-facing queries that need quick feedback + */ + FAST: { + retry: 2, + retryDelay: createExponentialBackoff(1000, 5000), + }, + + /** + * Standard retry: 3 retries with exponential backoff (1s, 2s, 4s, max 30s) + * Use for: Background queries, non-critical data + */ + STANDARD: { + retry: 3, + retryDelay: createExponentialBackoff(1000, 30000), + }, + + /** + * Aggressive retry: 4 retries with exponential backoff (1s, 2s, 4s, 8s, max 10s) + * Use for: Critical data that must succeed + */ + AGGRESSIVE: { + retry: 4, + retryDelay: createExponentialBackoff(1000, 10000), + }, + + /** + * No retry: Financial transactions must not be retried automatically + * Use for: Payments, withdrawals, claims - any operation that transfers funds + */ + FINANCIAL: { + retry: false, + }, +} as const