From 2d3109ac800bafedfebfb73f9ae827452d0dc9f8 Mon Sep 17 00:00:00 2001
From: sundayonah
Date: Tue, 18 Nov 2025 18:11:47 +0100
Subject: [PATCH 01/15] feat(referrals): implement referral system with code
gen, tracking, and rewards
---
.env.example | 1 +
app/api/aggregator.ts | 68 ++
app/api/internal/credit-wallet/route.ts | 170 +++++
app/api/referral/claim/route.ts | 284 ++++++++
.../referral/generate-referral-code/route.ts | 154 +++++
app/api/referral/referral-data/route.ts | 146 ++++
app/api/referral/submit/route.ts | 191 ++++++
app/components/MainPageContent.tsx | 626 +++++++++++++++++-
app/components/NetworkSelectionModal.tsx | 25 +-
app/components/ReferralCTA.tsx | 51 ++
app/components/ReferralDashboard.tsx | 419 ++++++++++++
app/components/ReferralModal.tsx | 134 ++++
app/components/WalletDetails.tsx | 10 +-
app/components/index.ts | 2 +
.../wallet-mobile-modal/WalletView.tsx | 4 +-
app/types.ts | 28 +-
middleware.ts | 2 +
public/images/referral-cta-dollar.png | Bin 0 -> 550 bytes
public/images/referral-cta.png | Bin 0 -> 2407 bytes
public/images/referral-graphic.png | Bin 0 -> 3406 bytes
20 files changed, 2280 insertions(+), 35 deletions(-)
create mode 100644 app/api/internal/credit-wallet/route.ts
create mode 100644 app/api/referral/claim/route.ts
create mode 100644 app/api/referral/generate-referral-code/route.ts
create mode 100644 app/api/referral/referral-data/route.ts
create mode 100644 app/api/referral/submit/route.ts
create mode 100644 app/components/ReferralCTA.tsx
create mode 100644 app/components/ReferralDashboard.tsx
create mode 100644 app/components/ReferralModal.tsx
create mode 100644 public/images/referral-cta-dollar.png
create mode 100644 public/images/referral-cta.png
create mode 100644 public/images/referral-graphic.png
diff --git a/.env.example b/.env.example
index b975df27..3c0a33c8 100644
--- a/.env.example
+++ b/.env.example
@@ -58,6 +58,7 @@ MIXPANEL_PRIVACY_MODE=strict
MIXPANEL_INCLUDE_IP=false
MIXPANEL_INCLUDE_ERROR_STACKS=false
NEXT_PUBLIC_ENABLE_EMAIL_IN_ANALYTICS=false
+NEXT_PUBLIC_FEE_RECIPIENT_ADDRESS=
# =============================================================================
# Security
diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts
index 4627e3e8..5eb438cd 100644
--- a/app/api/aggregator.ts
+++ b/app/api/aggregator.ts
@@ -18,6 +18,7 @@ import type {
RecipientDetails,
RecipientDetailsWithId,
SavedRecipientsResponse,
+ ReferralData,
} from "../types";
import {
trackServerEvent,
@@ -508,6 +509,73 @@ export const fetchTokens = async (): Promise => {
}
};
+/**
+ * Submit a referral code for a new user
+ */
+export async function submitReferralCode(
+ code: string,
+ accessToken?: string,
+): Promise {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+
+ if (accessToken) {
+ headers.Authorization = `Bearer ${accessToken}`;
+ }
+
+ const response = await axios.post(`/api/referral/submit`, { referral_code: code }, { headers });
+
+ if (!response.data?.success) {
+ throw new Error(response.data?.error || response.data?.message || "Failed to submit referral code");
+ }
+
+ return response.data;
+}
+
+/**
+ * Get user's referral data (code, earnings, referral list)
+ */
+export async function getReferralData(
+ accessToken?: string,
+ walletAddress?: string,
+): Promise {
+ const headers: Record = {};
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
+
+ const url = walletAddress
+ ? `/api/referral/data?wallet_address=${encodeURIComponent(walletAddress)}`
+ : `/api/referral/data`;
+
+ const response = await axios.get(url, { headers });
+
+ if (!response.data?.success) {
+ throw new Error(response.data?.error || "Failed to fetch referral data");
+ }
+
+ return response.data.data as ReferralData;
+}
+
+/**
+ * Generate or get user's referral code
+ */
+export async function generateReferralCode(
+ accessToken?: string,
+): Promise {
+ const headers: Record = {};
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
+
+ const response = await axios.get(`/api/referral/generate-referral-code`, { headers });
+
+ if (!response.data?.success) {
+ throw new Error(response.data?.error || "Failed to generate referral code");
+ }
+
+ // normalize the payload shapes we might get back
+ const payload = response.data;
+ return payload.data?.referral_code || payload.data?.referralCode || payload.code || "";
+}
+
/**
* Fetches saved recipients for a wallet address
* @param {string} accessToken - The access token for authentication
diff --git a/app/api/internal/credit-wallet/route.ts b/app/api/internal/credit-wallet/route.ts
new file mode 100644
index 00000000..2014d214
--- /dev/null
+++ b/app/api/internal/credit-wallet/route.ts
@@ -0,0 +1,170 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabaseAdmin } from "@/app/lib/supabase";
+import * as ethers from "ethers";
+
+/**
+ * Minimal, idempotent internal credit endpoint (stubbed).
+ * Protect with x-internal-auth = process.env.INTERNAL_API_KEY
+ * Expected body:
+ * {
+ * idempotency_key: string,
+ * wallet_address: string,
+ * amount: number, // integer, micro-units (USDC = 6dp)
+ * currency: string,
+ * referral_id?: string | number,
+ * reason?: string,
+ * metadata?: Record
+ * }
+ */
+export const POST = async (request: NextRequest) => {
+ const internalAuth = process.env.INTERNAL_API_KEY;
+ const headerAuth = request.headers.get("x-internal-auth");
+
+ if (!internalAuth || headerAuth !== internalAuth) {
+ return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
+ }
+
+ let body: any;
+ try {
+ body = await request.json();
+ } catch (err) {
+ return NextResponse.json({ success: false, error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const {
+ idempotency_key,
+ wallet_address,
+ amount,
+ currency = "USDC",
+ referral_id,
+ reason,
+ metadata,
+ } = body || {};
+
+ if (!idempotency_key || !wallet_address || typeof amount !== "number") {
+ return NextResponse.json({ success: false, error: "Missing required fields" }, { status: 400 });
+ }
+
+ try {
+ // Check for existing credit by idempotency_key
+ const { data: existing } = await supabaseAdmin
+ .from("credits")
+ .select("*")
+ .eq("idempotency_key", idempotency_key)
+ .limit(1)
+ .single();
+
+ if (existing) {
+ return NextResponse.json({ success: true, data: existing });
+ }
+
+ const now = new Date().toISOString();
+ const basePayload: any = {
+ referral_id: referral_id || null,
+ idempotency_key,
+ wallet_address: wallet_address.toLowerCase(),
+ amount_micro: amount,
+ currency,
+ status: "pending",
+ external_tx: null,
+ reason: reason || "credit",
+ metadata: metadata || null,
+ created_at: now,
+ updated_at: now,
+ };
+
+ // Insert pending record (idempotency_key is UNIQUE in migration). Handle race with select on conflict.
+ let created: any = null;
+ try {
+ const insertRes = await supabaseAdmin
+ .from("credits")
+ .insert(basePayload)
+ .select()
+ .single();
+
+ if (insertRes.error) throw insertRes.error;
+ created = insertRes.data;
+ } catch (insErr) {
+ // If insertion failed due to unique constraint, try to fetch existing row
+ console.warn("Insert error for credit row, attempting to fetch existing:", (insErr as any)?.message || insErr);
+ const { data: existing2 } = await supabaseAdmin
+ .from("credits")
+ .select("*")
+ .eq("idempotency_key", idempotency_key)
+ .limit(1)
+ .single();
+
+ if (existing2) {
+ return NextResponse.json({ success: true, data: existing2 });
+ }
+
+ console.error("Failed to insert or retrieve existing credit row:", insErr);
+ return NextResponse.json({ success: false, error: "Failed to create credit record" }, { status: 500 });
+ }
+
+ // If hot wallet config present, perform on-chain ERC20 transfer
+ const HOT_KEY = process.env.HOT_WALLET_PRIVATE_KEY;
+ const RPC_URL = process.env.RPC_URL;
+ const TOKEN_ADDRESS = process.env.TOKEN_CONTRACT_ADDRESS;
+ const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? 6);
+
+ if (HOT_KEY && RPC_URL && TOKEN_ADDRESS) {
+ try {
+ const provider = new (ethers as any).providers.JsonRpcProvider(RPC_URL);
+ const wallet = new (ethers as any).Wallet(HOT_KEY, provider);
+ const erc20Abi = ["function transfer(address to, uint256 amount) public returns (bool)"];
+ const contract = new (ethers as any).Contract(TOKEN_ADDRESS, erc20Abi, wallet);
+
+ // amount is already in micro-units, convert to BigNumber
+ const bnAmount = (ethers as any).BigNumber.from(amount.toString());
+ const tx = await contract.transfer(wallet_address, bnAmount);
+ const receipt = await tx.wait();
+
+ // Update credits row as sent with tx hash
+ const { error: updateErr } = await supabaseAdmin
+ .from("credits")
+ .update({ status: "sent", external_tx: receipt.transactionHash, updated_at: new Date().toISOString() })
+ .eq("id", created.id);
+
+ if (updateErr) {
+ console.error("Failed to update credit row after transfer:", updateErr);
+ }
+
+ const { data: finalRow } = await supabaseAdmin.from("credits").select("*").eq("id", created.id).single();
+ return NextResponse.json({ success: true, data: finalRow });
+ } catch (txErr) {
+ console.error("Transfer failed:", txErr);
+ // mark row as failed
+ try {
+ await supabaseAdmin
+ .from("credits")
+ .update({ status: "failed", error: String((txErr as any)?.message || txErr), updated_at: new Date().toISOString() })
+ .eq("id", created.id);
+ } catch (markErr) {
+ console.error("Failed to mark credit as failed:", markErr);
+ }
+ return NextResponse.json({ success: false, error: "Transfer failed" }, { status: 500 });
+ }
+ }
+
+ // No hot-wallet configured: mark as sent (stub)
+ try {
+ const { error: finalErr } = await supabaseAdmin
+ .from("credits")
+ .update({ status: "sent", external_tx: "stubbed-credit", updated_at: new Date().toISOString() })
+ .eq("id", created.id);
+
+ if (finalErr) {
+ console.error("Failed to finalize stub credit row:", finalErr);
+ }
+ const { data: finalRow } = await supabaseAdmin.from("credits").select("*").eq("id", created.id).single();
+ return NextResponse.json({ success: true, data: finalRow });
+ } catch (finalizeErr) {
+ console.error("Error finalizing stub credit:", finalizeErr);
+ return NextResponse.json({ success: false, error: "Internal error finalizing credit" }, { status: 500 });
+ }
+ } catch (error) {
+ console.error("Error in internal credit-wallet:", error);
+ return NextResponse.json({ success: false, error: "Internal error" }, { status: 500 });
+ }
+};
diff --git a/app/api/referral/claim/route.ts b/app/api/referral/claim/route.ts
new file mode 100644
index 00000000..fabd5fdc
--- /dev/null
+++ b/app/api/referral/claim/route.ts
@@ -0,0 +1,284 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabaseAdmin } from "@/app/lib/supabase";
+import { withRateLimit } from "@/app/lib/rate-limit";
+import {
+ trackApiRequest,
+ trackApiResponse,
+ trackApiError,
+ trackBusinessEvent,
+} from "@/app/lib/server-analytics";
+import { fetchKYCStatus } from "@/app/api/aggregator";
+
+// This should be called after a user completes KYC + first transaction
+export const POST = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+
+ try {
+ // Get wallet address from middleware
+ const walletAddress = request.headers
+ .get("x-wallet-address")
+ ?.toLowerCase();
+
+ if (!walletAddress) {
+ trackApiError(
+ request,
+ "/api/referral/claim",
+ "POST",
+ new Error("Unauthorized"),
+ 401
+ );
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // Track API request
+ trackApiRequest(request, "/api/referral/claim", "POST", {
+ wallet_address: walletAddress,
+ });
+
+ // Do not trust client-provided flags. Claim will perform authoritative server-side
+ // verification of KYC and qualifying transaction below.
+
+ // Find pending referral for this user
+ const { data: referral, error: referralError } = await supabaseAdmin
+ .from("referrals")
+ .select("*")
+ .eq("referred_wallet_address", walletAddress)
+ .eq("status", "pending")
+ .single();
+
+ if (referralError && referralError.code !== "PGRST116") {
+ throw referralError;
+ }
+
+ if (!referral) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "No pending referral found",
+ },
+ { status: 404 }
+ );
+ }
+
+ // Verify KYC for both referrer and referred using authoritative aggregator
+ try {
+ const [referrerKyc, referredKyc] = await Promise.all([
+ fetchKYCStatus(referral.referrer_wallet_address),
+ fetchKYCStatus(walletAddress),
+ ]);
+
+ const referrerVerified = referrerKyc?.data?.status === "verified";
+ const referredVerified = referredKyc?.data?.status === "verified";
+
+ if (!referrerVerified || !referredVerified) {
+ const missing = [] as string[];
+ if (!referrerVerified) missing.push("referrer");
+ if (!referredVerified) missing.push("referred user");
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: `KYC verification required for: ${missing.join(", ")}`,
+ },
+ { status: 400 }
+ );
+ }
+ } catch (kycErr) {
+ console.error("Error checking KYC status:", kycErr);
+ const responseTime = Date.now() - startTime;
+ trackApiError(
+ request,
+ "/api/referral/claim",
+ "POST",
+ kycErr as Error,
+ 500,
+ { response_time_ms: responseTime }
+ );
+
+ return NextResponse.json({ success: false, error: "Failed to verify KYC status" }, { status: 500 });
+ }
+
+ // SERVER-SIDE: verify referred user's qualifying transaction
+ try {
+ const { data: txs, error: txErr } = await supabaseAdmin
+ .from("transactions")
+ .select("*")
+ .eq("wallet_address", walletAddress)
+ .eq("status", "completed")
+ .order("created_at", { ascending: true })
+ .limit(1);
+
+ if (txErr) {
+ throw txErr;
+ }
+
+ if (!txs || txs.length === 0) {
+ return NextResponse.json({ success: false, error: "No qualifying completed transaction found for referred user" }, { status: 400 });
+ }
+
+ const tx = txs[0];
+ const amountUsd = tx.amount_usd ?? tx.amount_received ?? 0;
+ if (Number(amountUsd) < 20) {
+ return NextResponse.json({ success: false, error: "Referred user's first completed transaction does not meet the minimum amount requirement" }, { status: 400 });
+ }
+ } catch (txCheckErr) {
+ console.error("Error checking transactions:", txCheckErr);
+ const responseTime = Date.now() - startTime;
+ trackApiError(request, "/api/referral/claim", "POST", txCheckErr as Error, 500, { response_time_ms: responseTime });
+ return NextResponse.json({ success: false, error: "Failed to verify transactions" }, { status: 500 });
+ }
+
+ // SAFE STATUS TRANSITION: pending -> processing
+ const { data: processingRows, error: processingErr } = await supabaseAdmin
+ .from("referrals")
+ .update({ status: "processing" })
+ .eq("id", referral.id)
+ .eq("status", "pending")
+ .select();
+
+ if (processingErr) {
+ throw processingErr;
+ }
+
+ if (!processingRows || processingRows.length === 0) {
+ // Already being processed or completed by another worker
+ return NextResponse.json({ success: false, error: "Referral is already being processed or has been completed" }, { status: 409 });
+ }
+
+ // Credit rewards to both users
+ // Best-effort: call internal wallet crediting endpoint when configured.
+ // If crediting fails, roll back referral status to pending to keep consistency.
+ const internalAuth = process.env.INTERNAL_API_KEY;
+ const internalBase = process.env.INTERNAL_API_BASE_URL || new URL(request.url).origin;
+
+ async function creditWallet(wallet: string, amountMicro: number, referralId: any) {
+ if (!internalAuth) {
+ // Wallet service not configured; skip actual crediting.
+ console.warn("Internal wallet service not configured, skipping credit for", wallet);
+ return { ok: false, skipped: true };
+ }
+
+ const resp = await fetch(`${internalBase}/api/internal/credit-wallet`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-internal-auth": internalAuth,
+ },
+ body: JSON.stringify({
+ wallet_address: wallet,
+ amount: amountMicro,
+ currency: "USDC",
+ reason: "referral_reward",
+ referral_id: referralId,
+ idempotency_key: `referral:${referral.id}:${wallet}`,
+ }),
+ });
+
+ if (!resp.ok) {
+ const text = await resp.text().catch(() => "");
+ throw new Error(`Wallet credit failed: ${resp.status} ${text}`);
+ }
+
+ return { ok: true };
+ }
+
+ try {
+ // credit referrer
+ const amountMicro = Math.round((referral.reward_amount || 1.0) * 1_000_000);
+ await creditWallet(referral.referrer_wallet_address, amountMicro, referral.id);
+ // credit referred user
+ await creditWallet(walletAddress, amountMicro, referral.id);
+ } catch (walletError) {
+ console.error("Wallet crediting failed, attempting rollback:", walletError);
+ // Roll back referral status to pending
+ try {
+ await supabaseAdmin.from("referrals").update({ status: "pending", completed_at: null }).eq("id", referral.id);
+ } catch (rbErr) {
+ console.error("Failed to roll back referral status:", rbErr);
+ }
+
+ const responseTime = Date.now() - startTime;
+ trackApiError(request, "/api/referral/claim", "POST", walletError as Error, 500, { response_time_ms: responseTime });
+
+ return NextResponse.json({ success: false, error: "Failed to credit referral rewards" }, { status: 500 });
+ }
+
+ // Mark referral as earned and set completed_at
+ try {
+ const { error: earnErr } = await supabaseAdmin
+ .from("referrals")
+ .update({ status: "earned", completed_at: new Date().toISOString() })
+ .eq("id", referral.id);
+ if (earnErr) throw earnErr;
+ } catch (earnErr) {
+ console.error("Failed to finalize referral status as earned:", earnErr);
+ // Note: credits may already have been sent; log and continue
+ }
+
+ const responseTime = Date.now() - startTime;
+ trackApiResponse(
+ "/api/referral/claim",
+ "POST",
+ 200,
+ responseTime,
+ {
+ wallet_address: walletAddress,
+ referrer_wallet_address: referral.referrer_wallet_address,
+ referral_id: referral.id,
+ reward_amount: referral.reward_amount,
+ }
+ );
+
+ // Track business events
+ trackBusinessEvent("Referral Completed", {
+ referred_wallet_address: walletAddress,
+ referrer_wallet_address: referral.referrer_wallet_address,
+ referral_id: referral.id,
+ reward_amount: referral.reward_amount,
+ });
+
+ trackBusinessEvent("Referral Reward Earned", {
+ wallet_address: referral.referrer_wallet_address,
+ referred_wallet_address: walletAddress,
+ reward_amount: referral.reward_amount,
+ });
+
+ trackBusinessEvent("Referral Bonus Received", {
+ wallet_address: walletAddress,
+ referrer_wallet_address: referral.referrer_wallet_address,
+ reward_amount: referral.reward_amount,
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ referral_id: referral.id,
+ referrer_wallet_address: referral.referrer_wallet_address,
+ reward_amount: referral.reward_amount,
+ message: "Referral rewards have been credited!",
+ },
+ });
+ } catch (error) {
+ console.error("Error completing referral:", error);
+
+ const responseTime = Date.now() - startTime;
+ trackApiError(
+ request,
+ "/api/referral/claim",
+ "POST",
+ error as Error,
+ 500,
+ {
+ response_time_ms: responseTime,
+ }
+ );
+
+ return NextResponse.json(
+ { success: false, error: "Failed to claim referral" },
+ { status: 500 }
+ );
+ }
+});
\ No newline at end of file
diff --git a/app/api/referral/generate-referral-code/route.ts b/app/api/referral/generate-referral-code/route.ts
new file mode 100644
index 00000000..56e40f27
--- /dev/null
+++ b/app/api/referral/generate-referral-code/route.ts
@@ -0,0 +1,154 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabaseAdmin } from "@/app/lib/supabase";
+import { withRateLimit } from "@/app/lib/rate-limit";
+import {
+ trackApiRequest,
+ trackApiResponse,
+ trackApiError,
+ trackBusinessEvent,
+} from "@/app/lib/server-analytics";
+
+// Generate a unique 6-character referral code (NB + 4 alphanumeric)
+function generateReferralCode(): string {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ let code = "NB";
+ for (let i = 0; i < 4; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return code;
+}
+
+export const GET = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+
+ try {
+ // Get wallet address from middleware
+ const walletAddress = request.headers
+ .get("x-wallet-address")
+ ?.toLowerCase();
+
+ if (!walletAddress) {
+ trackApiError(
+ request,
+ "/api/referral/generate-referral-code",
+ "GET",
+ new Error("Unauthorized"),
+ 401
+ );
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // Track API request
+ trackApiRequest(request, "/api/referral/generate-referral-code", "GET", {
+ wallet_address: walletAddress,
+ });
+
+ // Check if user already has a referral code
+ const { data: existingUser, error: fetchError } = await supabaseAdmin
+ .from("users")
+ .select("referral_code")
+ .eq("wallet_address", walletAddress)
+ .single();
+
+ if (fetchError && fetchError.code !== "PGRST116") {
+ throw fetchError;
+ }
+
+ // If user has a code, return it
+ if (existingUser?.referral_code) {
+ const responseTime = Date.now() - startTime;
+ trackApiResponse("/api/referral/generate-referral-code", "GET", 200, responseTime, {
+ wallet_address: walletAddress,
+ referral_code: existingUser.referral_code,
+ action: "retrieved",
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ referral_code: existingUser.referral_code,
+ },
+ });
+ }
+
+ // Generate a new unique code
+ let code: string;
+ let attempts = 0;
+ const maxAttempts = 10;
+
+ do {
+ code = generateReferralCode();
+ attempts++;
+
+ // Check if code already exists
+ const { data: existing } = await supabaseAdmin
+ .from("users")
+ .select("wallet_address")
+ .eq("referral_code", code)
+ .single();
+
+ if (!existing) break;
+
+ if (attempts >= maxAttempts) {
+ throw new Error("Failed to generate unique referral code");
+ }
+ } while (true);
+
+ // Update or insert user with new code
+ const { data: userData, error: upsertError } = await supabaseAdmin
+ .from("users")
+ .upsert(
+ {
+ wallet_address: walletAddress,
+ referral_code: code,
+ updated_at: new Date().toISOString(),
+ },
+ {
+ onConflict: "wallet_address",
+ }
+ )
+ .select()
+ .single();
+
+ if (upsertError) {
+ throw upsertError;
+ }
+
+ // Track successful response
+ const responseTime = Date.now() - startTime;
+ trackApiResponse("/api/referral/generate-referral-code", "GET", 200, responseTime, {
+ wallet_address: walletAddress,
+ referral_code: code,
+ action: "generated",
+ });
+
+ // Track business event
+ trackBusinessEvent("Referral Code Generated", {
+ wallet_address: walletAddress,
+ referral_code: code,
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ referral_code: code,
+ message: "Referral code generated successfully",
+ },
+ });
+ } catch (error) {
+ console.error("Error generating referral code:", error);
+
+ const responseTime = Date.now() - startTime;
+ trackApiError(request, "/api/referral/generate-referral-code", "GET", error as Error, 500, {
+ response_time_ms: responseTime,
+ });
+
+ return NextResponse.json(
+ { success: false, error: "Failed to generate referral code" },
+ { status: 500 }
+ );
+ }
+});
\ No newline at end of file
diff --git a/app/api/referral/referral-data/route.ts b/app/api/referral/referral-data/route.ts
new file mode 100644
index 00000000..50dda2e1
--- /dev/null
+++ b/app/api/referral/referral-data/route.ts
@@ -0,0 +1,146 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabaseAdmin } from "@/app/lib/supabase";
+import { withRateLimit } from "@/app/lib/rate-limit";
+import {
+ trackApiRequest,
+ trackApiResponse,
+ trackApiError,
+} from "@/app/lib/server-analytics";
+
+export const GET = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+
+ try {
+ // Get wallet address from middleware
+ const walletAddress = request.headers
+ .get("x-wallet-address")
+ ?.toLowerCase();
+
+ if (!walletAddress) {
+ trackApiError(
+ request,
+ "/api/referral/data",
+ "GET",
+ new Error("Unauthorized"),
+ 401
+ );
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // Track API request
+ trackApiRequest(request, "/api/referral/data", "GET", {
+ wallet_address: walletAddress,
+ });
+
+ // Get user's referral code
+ const { data: userData, error: userError } = await supabaseAdmin
+ .from("users")
+ .select("referral_code")
+ .eq("wallet_address", walletAddress)
+ .single();
+
+ if (userError) {
+ throw userError;
+ }
+
+ if (!userData?.referral_code) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: "No referral code found. Please generate one first.",
+ },
+ { status: 404 }
+ );
+ }
+
+ // Get all referrals made by this user
+ const { data: referrals, error: referralsError } = await supabaseAdmin
+ .from("referrals")
+ .select(
+ `
+ id,
+ referred_wallet_address,
+ status,
+ reward_amount,
+ created_at,
+ completed_at
+ `
+ )
+ .eq("referrer_wallet_address", walletAddress)
+ .order("created_at", { ascending: false });
+
+ if (referralsError) {
+ throw referralsError;
+ }
+
+ // Calculate earnings
+ const earnedReferrals = referrals.filter((r) => r.status === "earned");
+ const pendingReferrals = referrals.filter((r) => r.status === "pending");
+
+ const totalEarned = earnedReferrals.reduce(
+ (sum, r) => sum + (r.reward_amount || 0),
+ 0
+ );
+ const totalPending = pendingReferrals.reduce(
+ (sum, r) => sum + (r.reward_amount || 0),
+ 0
+ );
+
+ // Format referral list with truncated addresses
+ const referralList = referrals.map((r) => ({
+ id: r.id,
+ wallet_address: r.referred_wallet_address,
+ wallet_address_short: `${r.referred_wallet_address.slice(0, 6)}...${r.referred_wallet_address.slice(-4)}`,
+ status: r.status,
+ amount: r.reward_amount || 1.0,
+ created_at: r.created_at,
+ completed_at: r.completed_at,
+ }));
+
+ const response = {
+ success: true,
+ data: {
+ referral_code: userData.referral_code,
+ total_earned: totalEarned,
+ total_pending: totalPending,
+ total_referrals: referrals.length,
+ earned_count: earnedReferrals.length,
+ pending_count: pendingReferrals.length,
+ referrals: referralList,
+ },
+ };
+
+ // Track successful response
+ const responseTime = Date.now() - startTime;
+ trackApiResponse("/api/referral/data", "GET", 200, responseTime, {
+ wallet_address: walletAddress,
+ total_earned: totalEarned,
+ total_pending: totalPending,
+ total_referrals: referrals.length,
+ });
+
+ return NextResponse.json(response);
+ } catch (error) {
+ console.error("Error fetching referral data:", error);
+
+ const responseTime = Date.now() - startTime;
+ trackApiError(
+ request,
+ "/api/referral/data",
+ "GET",
+ error as Error,
+ 500,
+ {
+ response_time_ms: responseTime,
+ }
+ );
+
+ return NextResponse.json(
+ { success: false, error: "Failed to fetch referral data" },
+ { status: 500 }
+ );
+ }
+});
\ No newline at end of file
diff --git a/app/api/referral/submit/route.ts b/app/api/referral/submit/route.ts
new file mode 100644
index 00000000..1d434632
--- /dev/null
+++ b/app/api/referral/submit/route.ts
@@ -0,0 +1,191 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabaseAdmin } from "@/app/lib/supabase";
+import { withRateLimit } from "@/app/lib/rate-limit";
+import {
+ trackApiRequest,
+ trackApiResponse,
+ trackApiError,
+ trackBusinessEvent,
+ trackAuthEvent,
+} from "@/app/lib/server-analytics";
+
+export const POST = withRateLimit(async (request: NextRequest) => {
+ const startTime = Date.now();
+
+ try {
+ // Get wallet address from middleware
+ const walletAddress = request.headers
+ .get("x-wallet-address")
+ ?.toLowerCase();
+
+ if (!walletAddress) {
+ trackApiError(
+ request,
+ "/api/referral/submit",
+ "POST",
+ new Error("Unauthorized"),
+ 401
+ );
+ return NextResponse.json(
+ { success: false, error: "Unauthorized" },
+ { status: 401 }
+ );
+ }
+
+ // Track API request
+ trackApiRequest(request, "/api/referral/submit", "POST", {
+ wallet_address: walletAddress,
+ });
+
+ const body = await request.json();
+ const { referral_code } = body;
+
+ if (!referral_code) {
+ trackApiError(
+ request,
+ "/api/referral/submit",
+ "POST",
+ new Error("Missing referral code"),
+ 400
+ );
+ return NextResponse.json(
+ { success: false, error: "Referral code is required" },
+ { status: 400 }
+ );
+ }
+
+ const normalizedCode = referral_code.toUpperCase().trim();
+
+ // Validate code format (6 characters, starts with NB)
+ if (!/^NB[A-Z0-9]{4}$/.test(normalizedCode)) {
+ return NextResponse.json(
+ { success: false, error: "Invalid referral code format" },
+ { status: 400 }
+ );
+ }
+
+ // Check if user already has a referral
+ const { data: existingReferral, error: existingError } =
+ await supabaseAdmin
+ .from("referrals")
+ .select("id")
+ .eq("referred_wallet_address", walletAddress)
+ .single();
+
+ if (existingError && existingError.code !== "PGRST116") {
+ throw existingError;
+ }
+
+ if (existingReferral) {
+ return NextResponse.json(
+ { success: false, error: "You have already used a referral code" },
+ { status: 409 }
+ );
+ }
+
+ // Find the referrer by code
+ const { data: referrer, error: referrerError } = await supabaseAdmin
+ .from("users")
+ .select("wallet_address, referral_code")
+ .eq("referral_code", normalizedCode)
+ .single();
+
+ if (referrerError || !referrer) {
+ trackBusinessEvent("Invalid Referral Code Used", {
+ wallet_address: walletAddress,
+ referral_code: normalizedCode,
+ });
+
+ return NextResponse.json(
+ { success: false, error: "Invalid referral code" },
+ { status: 404 }
+ );
+ }
+
+ // Prevent self-referral
+ if (referrer.wallet_address.toLowerCase() === walletAddress) {
+ return NextResponse.json(
+ { success: false, error: "You cannot refer yourself" },
+ { status: 400 }
+ );
+ }
+
+ // Create referral record with pending status
+ const { data: referralData, error: insertError } = await supabaseAdmin
+ .from("referrals")
+ .insert({
+ referrer_wallet_address: referrer.wallet_address,
+ referred_wallet_address: walletAddress,
+ referral_code: normalizedCode,
+ status: "pending",
+ reward_amount: 1.0, // $1 reward
+ created_at: new Date().toISOString(),
+ })
+ .select()
+ .single();
+
+ if (insertError) {
+ throw insertError;
+ }
+
+ // Track successful response
+ const responseTime = Date.now() - startTime;
+ trackApiResponse(
+ "/api/referral/submit",
+ "POST",
+ 201,
+ responseTime,
+ {
+ wallet_address: walletAddress,
+ referral_code: normalizedCode,
+ referrer_wallet_address: referrer.wallet_address,
+ referral_id: referralData.id,
+ }
+ );
+
+ // Track business event
+ trackBusinessEvent("Referral Code Applied", {
+ wallet_address: walletAddress,
+ referrer_wallet_address: referrer.wallet_address,
+ referral_code: normalizedCode,
+ referral_id: referralData.id,
+ });
+
+ // Track auth event
+ trackAuthEvent("Referral Code Submitted", walletAddress, {
+ referrer_wallet_address: referrer.wallet_address,
+ referral_code: normalizedCode,
+ });
+
+ return NextResponse.json(
+ {
+ success: true,
+ data: {
+ referral_id: referralData.id,
+ message:
+ "Referral code applied! Complete KYC and your first transaction to earn rewards.",
+ },
+ },
+ { status: 201 }
+ );
+ } catch (error) {
+ console.error("Error submitting referral:", error);
+
+ const responseTime = Date.now() - startTime;
+ trackApiError(
+ request,
+ "/api/referral/submit",
+ "POST",
+ error as Error,
+ 500,
+ {
+ response_time_ms: responseTime,
+ }
+ );
+
+ return NextResponse.json(
+ { success: false, error: "Failed to submit referral code" },
+ { status: 500 }
+ );
+ }
+});
\ No newline at end of file
diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx
index 6949cdc4..f66eef6e 100644
--- a/app/components/MainPageContent.tsx
+++ b/app/components/MainPageContent.tsx
@@ -1,3 +1,525 @@
+// // Updated file: components/MainPageContent.tsx (or wherever this lives)
+// "use client";
+
+// import { useForm } from "react-hook-form";
+// import { useEffect, useState, useRef, useMemo, useCallback } from "react";
+// import { AnimatePresence, motion } from "framer-motion";
+// import { toast } from "sonner";
+
+// import {
+// AnimatedPage,
+// Preloader,
+// TransactionForm,
+// TransactionPreview,
+// TransactionStatus,
+// NetworkSelectionModal,
+// CookieConsent,
+// Disclaimer,
+// ReferralInputModal,
+// } from "./";
+// import BlockFestCashbackModal from "./blockfest/BlockFestCashbackModal";
+// import { useBlockFestClaim } from "../context/BlockFestClaimContext";
+// import { BlockFestClaimGate } from "./blockfest/BlockFestClaimGate";
+// import { useBlockFestReferral } from "../hooks/useBlockFestReferral";
+// import { fetchRate, fetchSupportedInstitutions, migrateLocalStorageRecipients } from "../api/aggregator";
+// import { normalizeNetworkForRateFetch } from "../utils";
+// import {
+// STEPS,
+// type FormData,
+// type InstitutionProps,
+// type RecipientDetails,
+// type StateProps,
+// type TransactionStatusType,
+// } from "../types";
+// import { usePrivy } from "@privy-io/react-auth";
+// import { useStep } from "../context/StepContext";
+// import { clearFormState, getBannerPadding } from "../utils";
+// import { useSearchParams } from "next/navigation";
+// import { HomePage } from "./HomePage";
+// import { useNetwork } from "../context/NetworksContext";
+// import { useBlockFestModal } from "../context/BlockFestModalContext";
+// import { useInjectedWallet } from "../context";
+// // import { useReferral } from "../hooks/useReferral";
+
+// const PageLayout = ({
+// authenticated,
+// ready,
+// currentStep,
+// transactionFormComponent,
+// isRecipientFormOpen,
+// isBlockFestReferral,
+// showReferralModal,
+// onReferralModalClose,
+// }: {
+// authenticated: boolean;
+// ready: boolean;
+// currentStep: string;
+// transactionFormComponent: React.ReactNode;
+// isRecipientFormOpen: boolean;
+// isBlockFestReferral: boolean;
+// showReferralModal: boolean;
+// onReferralModalClose: () => void;
+// }) => {
+// const { claimed, resetClaim } = useBlockFestClaim();
+// const { user } = usePrivy();
+// const { isOpen, openModal, closeModal } = useBlockFestModal();
+// const { isInjectedWallet, injectedAddress } = useInjectedWallet();
+
+// // Clean up claim state when user logs out
+// useEffect(() => {
+// if (!authenticated && !isInjectedWallet) {
+// resetClaim();
+// }
+// }, [authenticated, isInjectedWallet, resetClaim]);
+
+// const walletAddress = isInjectedWallet
+// ? injectedAddress
+// : user?.linkedAccounts.find((account) => account.type === "smart_wallet")
+// ?.address;
+
+// return (
+// <>
+//
+
+//
+//
+// {!isInjectedWallet && }
+
+// {/* Referral Input Modal */}
+// {
+// toast.success("Welcome! Complete KYC and your first transaction to earn rewards.");
+// }}
+// />
+
+//
+
+// {currentStep === STEPS.FORM ? (
+//
+// ) : (
+//
+// {transactionFormComponent}
+//
+// )}
+// >
+// );
+// };
+
+// export function MainPageContent() {
+// const searchParams = useSearchParams();
+// const { authenticated, ready, getAccessToken, user } = usePrivy();
+// const { currentStep, setCurrentStep } = useStep();
+// const { isInjectedWallet, injectedReady } = useInjectedWallet();
+// const { selectedNetwork } = useNetwork();
+// const { isBlockFestReferral } = useBlockFestReferral();
+// // const { referralCode, hasReferralCode } = useReferral();
+
+// const [isPageLoading, setIsPageLoading] = useState(true);
+// const [isFetchingRate, setIsFetchingRate] = useState(false);
+// const [isFetchingInstitutions, setIsFetchingInstitutions] = useState(false);
+// const [showReferralModal, setShowReferralModal] = useState(false);
+// const [hasSeenReferralModal, setHasSeenReferralModal] = useState(false);
+// const [rate, setRate] = useState(0);
+// const [formValues, setFormValues] = useState({} as FormData);
+// const [institutions, setInstitutions] = useState([]);
+// const [selectedRecipient, setSelectedRecipient] =
+// useState(null);
+// const [transactionStatus, setTransactionStatus] =
+// useState("idle");
+// const [createdAt, setCreatedAt] = useState("");
+// const [orderId, setOrderId] = useState("");
+
+// const providerErrorShown = useRef(false);
+// const failedProviders = useRef>(new Set());
+
+// const [isUserVerified, setIsUserVerified] = useState(false);
+// const [rateError, setRateError] = useState(null);
+
+// const formMethods = useForm({
+// mode: "onChange",
+// defaultValues: {
+// token: "USDC",
+// amountSent: 0,
+// amountReceived: 0,
+// currency: "",
+// recipientName: "",
+// memo: "",
+// institution: "",
+// accountIdentifier: "",
+// accountType: "bank",
+// },
+// });
+// const { watch } = formMethods;
+// const { currency, amountSent, amountReceived, token } = watch();
+
+// // State props for child components
+// const stateProps: StateProps = {
+// formValues,
+// setFormValues,
+
+// rate,
+// setRate,
+// isFetchingRate,
+// setIsFetchingRate,
+// rateError,
+// setRateError,
+
+// institutions,
+// setInstitutions,
+// isFetchingInstitutions,
+// setIsFetchingInstitutions,
+
+// selectedRecipient,
+// setSelectedRecipient,
+
+// orderId,
+// setOrderId,
+// setCreatedAt,
+// setTransactionStatus,
+// };
+
+// // Handle showing referral modal after network selection
+// useEffect(() => {
+// if (!authenticated || !user?.wallet?.address || hasSeenReferralModal || !selectedNetwork) {
+// return;
+// }
+
+// // Check if user has already seen the referral modal
+// const storageKey = `hasSeenReferralModal-${user.wallet.address}`;
+// const hasSeenModal = localStorage.getItem(storageKey);
+// if (hasSeenModal) {
+// setHasSeenReferralModal(true);
+// return;
+// }
+
+// // Check if network modal has been completed (localStorage set on close)
+// const networkStorageKey = `hasSeenNetworkModal-${user.wallet.address}`;
+// const hasSeenNetworkModal = localStorage.getItem(networkStorageKey);
+
+// // Show referral modal after network selection for new users
+// if (hasSeenNetworkModal && !hasSeenModal) {
+// // Small delay to ensure smooth transition between modals
+// setTimeout(() => {
+// setShowReferralModal(true);
+// }, 500);
+// }
+// }, [authenticated, user?.wallet?.address, hasSeenReferralModal, selectedNetwork]); // Added selectedNetwork to deps for re-check after selection
+
+// const handleReferralModalClose = () => {
+// setShowReferralModal(false);
+// setHasSeenReferralModal(true);
+
+// if (user?.wallet?.address) {
+// const storageKey = `hasSeenReferralModal-${user.wallet.address}`;
+// localStorage.setItem(storageKey, "true");
+// }
+// };
+
+// useEffect(function setPageLoadingState() {
+// setOrderId("");
+// setIsPageLoading(false);
+// }, []);
+
+// useEffect(
+// function resetOnLogout() {
+// // Reset form when user logs out (but not for injected wallets)
+// if (!authenticated && !isInjectedWallet) {
+// setCurrentStep(STEPS.FORM);
+// setFormValues({} as FormData);
+// setHasSeenReferralModal(false); // Reset on logout for fresh start next time
+// }
+// },
+// // eslint-disable-next-line react-hooks/exhaustive-deps
+// [authenticated, isInjectedWallet],
+// );
+
+// useEffect(function ensureDefaultToken() {
+// // Make sure we always have USDC as default
+// if (!formMethods.getValues("token")) {
+// formMethods.reset({ token: "USDC" });
+// }
+// // eslint-disable-next-line react-hooks/exhaustive-deps
+// }, []);
+
+// useEffect(
+// function resetProviderErrorOnChange() {
+// // Reset error flag when switching providers
+// const newProvider =
+// searchParams.get("provider") || searchParams.get("PROVIDER");
+// if (!failedProviders.current.has(newProvider || "")) {
+// providerErrorShown.current = false;
+// }
+// },
+// [searchParams],
+// );
+
+// useEffect(
+// function fetchInstitutionData() {
+// async function getInstitutions(currencyValue: string) {
+// if (!currencyValue) return;
+
+// setIsFetchingInstitutions(true);
+
+// const institutions = await fetchSupportedInstitutions(currencyValue);
+// setInstitutions(institutions);
+
+// setIsFetchingInstitutions(false);
+// }
+
+// getInstitutions(currency);
+// },
+// [currency],
+// );
+
+// useEffect(
+// function handleRateFetch() {
+// // Debounce rate fetching
+// let timeoutId: NodeJS.Timeout;
+
+// if (!currency) return;
+
+// // Only fetch rate if at least one amount is greater than 0
+// if (!amountSent && !amountReceived) return;
+
+// const getRate = async (shouldUseProvider = true) => {
+// setIsFetchingRate(true);
+// try {
+// const lpParam =
+// searchParams.get("provider") || searchParams.get("PROVIDER");
+
+// // Skip using provider if it's already failed
+// const shouldSkipProvider =
+// lpParam && failedProviders.current.has(lpParam);
+// const providerId =
+// shouldUseProvider && lpParam && !shouldSkipProvider
+// ? lpParam
+// : undefined;
+
+// const rate = await fetchRate({
+// token,
+// amount: amountSent || 100,
+// currency,
+// providerId,
+// network: normalizeNetworkForRateFetch(selectedNetwork.chain.name),
+// });
+// setRate(rate.data);
+// setRateError(null); // Clear error on success
+// } catch (error) {
+// let errorMsg = "Unknown error";
+// if (error instanceof Error) {
+// errorMsg = error.message;
+// const lpParam =
+// searchParams.get("provider") || searchParams.get("PROVIDER");
+// if (
+// shouldUseProvider &&
+// lpParam &&
+// !failedProviders.current.has(lpParam)
+// ) {
+// toast.error(`${error.message} - defaulting to public rate`);
+// // Track failed provider
+// if (lpParam) {
+// failedProviders.current.add(lpParam);
+// }
+// providerErrorShown.current = true;
+// }
+// // Retry without provider ID if one was previously used
+// if (shouldUseProvider) {
+// await getRate(false);
+// return;
+// }
+// }
+// setRateError(errorMsg);
+// toast.error("No available quote", { description: errorMsg });
+// } finally {
+// setIsFetchingRate(false);
+// }
+// };
+
+// const debounceFetchRate = () => {
+// clearTimeout(timeoutId);
+// timeoutId = setTimeout(() => getRate(), 1000);
+// };
+
+// debounceFetchRate();
+
+// return () => {
+// clearTimeout(timeoutId);
+// };
+// },
+// [
+// amountSent,
+// amountReceived,
+// currency,
+// token,
+// searchParams,
+// selectedNetwork,
+// ],
+// );
+
+// // Migrate localStorage recipients to Supabase on app load
+// useEffect(
+// function migrateRecipients() {
+// async function runMigration() {
+// if (!authenticated || !ready || isInjectedWallet) {
+// return;
+// }
+
+// try {
+// const accessToken = await getAccessToken();
+// if (accessToken) {
+// await migrateLocalStorageRecipients(accessToken);
+// }
+// } catch (error) {
+// console.error("Recipients migration failed:", error);
+// // Don't show error to user - migration is silent
+// }
+// }
+
+// runMigration();
+// },
+// [authenticated, ready, isInjectedWallet, getAccessToken],
+// );
+
+// const handleFormSubmit = useCallback(
+// (data: FormData) => {
+// setFormValues(data);
+// setCurrentStep(STEPS.PREVIEW);
+// },
+// [setFormValues, setCurrentStep],
+// );
+
+// const handleBackToForm = useCallback(() => {
+// Object.entries(formValues).forEach(([key, value]) => {
+// if (value !== undefined && value !== null) {
+// formMethods.setValue(key as keyof FormData, value);
+// }
+// });
+// formMethods.setValue("institution", formValues.institution, {
+// shouldTouch: true,
+// });
+// formMethods.setValue("recipientName", formValues.recipientName, {
+// shouldTouch: true,
+// });
+// formMethods.setValue("accountIdentifier", formValues.accountIdentifier, {
+// shouldTouch: true,
+// });
+// setCurrentStep(STEPS.FORM);
+// }, [formValues, formMethods, setCurrentStep]);
+
+// const showLoading =
+// isPageLoading ||
+// (!ready && !isInjectedWallet) ||
+// (isInjectedWallet && !injectedReady);
+
+// const isRecipientFormOpen =
+// !!currency && (authenticated || isInjectedWallet) && isUserVerified;
+
+// const renderTransactionStep = useCallback(() => {
+// switch (currentStep) {
+// case STEPS.FORM:
+// return (
+//
+// );
+// case STEPS.PREVIEW:
+// return (
+//
+// );
+// case STEPS.STATUS:
+// return (
+// {
+// clearFormState(formMethods);
+// setSelectedRecipient(null);
+// }}
+// clearTransactionStatus={() => {
+// setTransactionStatus("idle");
+// }}
+// setTransactionStatus={setTransactionStatus}
+// setCurrentStep={setCurrentStep}
+// supportedInstitutions={institutions}
+// setOrderId={setOrderId}
+// />
+// );
+// default:
+// return null;
+// }
+// }, [
+// currentStep,
+// handleFormSubmit,
+// formMethods,
+// stateProps,
+// isUserVerified,
+// setIsUserVerified,
+// handleBackToForm,
+// createdAt,
+// transactionStatus,
+// orderId,
+// institutions,
+// setSelectedRecipient,
+// setTransactionStatus,
+// setCurrentStep,
+// setOrderId,
+// ]);
+
+// const transactionFormComponent = useMemo(
+// () => (
+//
+//
+//
+// {renderTransactionStep()}
+//
+//
+//
+// ),
+// [currentStep, renderTransactionStep],
+// );
+
+// return (
+//
+// {showLoading ? (
+//
+// ) : (
+//
+// )}
+//
+// );
+// }
+
"use client";
import { useForm } from "react-hook-form";
@@ -14,6 +536,7 @@ import {
NetworkSelectionModal,
CookieConsent,
Disclaimer,
+ ReferralInputModal,
} from "./";
import BlockFestCashbackModal from "./blockfest/BlockFestCashbackModal";
import { useBlockFestClaim } from "../context/BlockFestClaimContext";
@@ -45,6 +568,9 @@ const PageLayout = ({
transactionFormComponent,
isRecipientFormOpen,
isBlockFestReferral,
+ showReferralModal,
+ onReferralModalClose,
+ onNetworkSelected,
}: {
authenticated: boolean;
ready: boolean;
@@ -52,6 +578,9 @@ const PageLayout = ({
transactionFormComponent: React.ReactNode;
isRecipientFormOpen: boolean;
isBlockFestReferral: boolean;
+ showReferralModal: boolean;
+ onReferralModalClose: () => void;
+ onNetworkSelected: () => void;
}) => {
const { claimed, resetClaim } = useBlockFestClaim();
const { user } = usePrivy();
@@ -68,7 +597,7 @@ const PageLayout = ({
const walletAddress = isInjectedWallet
? injectedAddress
: user?.linkedAccounts.find((account) => account.type === "smart_wallet")
- ?.address;
+ ?.address;
return (
<>
@@ -82,7 +611,20 @@ const PageLayout = ({
- {!isInjectedWallet && }
+
+ {/* Network Selection Modal with callback */}
+ {!isInjectedWallet && (
+
+ )}
+
+ {/* Referral Input Modal */}
+ {
+ toast.success("Welcome! Complete KYC and your first transaction to earn rewards.");
+ }}
+ />
@@ -103,14 +645,16 @@ const PageLayout = ({
export function MainPageContent() {
const searchParams = useSearchParams();
- const { authenticated, ready, getAccessToken } = usePrivy();
+ const { authenticated, ready, getAccessToken, user } = usePrivy();
const { currentStep, setCurrentStep } = useStep();
const { isInjectedWallet, injectedReady } = useInjectedWallet();
const { selectedNetwork } = useNetwork();
const { isBlockFestReferral } = useBlockFestReferral();
+
const [isPageLoading, setIsPageLoading] = useState(true);
const [isFetchingRate, setIsFetchingRate] = useState(false);
const [isFetchingInstitutions, setIsFetchingInstitutions] = useState(false);
+ const [showReferralModal, setShowReferralModal] = useState(false);
const [rate, setRate] = useState(0);
const [formValues, setFormValues] = useState({} as FormData);
@@ -149,30 +693,55 @@ export function MainPageContent() {
// State props for child components
const stateProps: StateProps = {
- formValues,
- setFormValues,
-
- rate,
- setRate,
- isFetchingRate,
- setIsFetchingRate,
- rateError,
- setRateError,
-
- institutions,
- setInstitutions,
- isFetchingInstitutions,
- setIsFetchingInstitutions,
-
- selectedRecipient,
- setSelectedRecipient,
-
- orderId,
- setOrderId,
- setCreatedAt,
- setTransactionStatus,
+ formValues,
+ setFormValues,
+
+ rate,
+ setRate,
+ isFetchingRate,
+ setIsFetchingRate,
+ rateError,
+ setRateError,
+
+ institutions,
+ setInstitutions,
+ isFetchingInstitutions,
+ setIsFetchingInstitutions,
+
+ selectedRecipient,
+ setSelectedRecipient,
+
+ orderId,
+ setOrderId,
+ setCreatedAt,
+ setTransactionStatus,
+ };
+
+ // Handle showing referral modal - triggered by network selection callback
+ const handleNetworkSelected = useCallback(() => {
+ if (!authenticated || !user?.wallet?.address) {
+ return;
+ }
+
+ // Check if user has already seen the referral modal
+ const referralStorageKey = `hasSeenReferralModal-${user.wallet.address}`;
+ const hasSeenReferralModal = localStorage.getItem(referralStorageKey);
+
+ if (!hasSeenReferralModal) {
+ // Show referral modal after network selection
+ setShowReferralModal(true);
}
-
+ }, [authenticated, user?.wallet?.address]);
+
+ const handleReferralModalClose = useCallback(() => {
+ setShowReferralModal(false);
+
+ if (user?.wallet?.address) {
+ const storageKey = `hasSeenReferralModal-${user.wallet.address}`;
+ localStorage.setItem(storageKey, "true");
+ }
+ }, [user?.wallet?.address]);
+
useEffect(function setPageLoadingState() {
setOrderId("");
setIsPageLoading(false);
@@ -457,8 +1026,11 @@ export function MainPageContent() {
transactionFormComponent={transactionFormComponent}
isRecipientFormOpen={isRecipientFormOpen}
isBlockFestReferral={isBlockFestReferral}
+ showReferralModal={showReferralModal}
+ onReferralModalClose={handleReferralModalClose}
+ onNetworkSelected={handleNetworkSelected}
/>
)}
);
-}
+}
\ No newline at end of file
diff --git a/app/components/NetworkSelectionModal.tsx b/app/components/NetworkSelectionModal.tsx
index 9f723fb3..56d55555 100644
--- a/app/components/NetworkSelectionModal.tsx
+++ b/app/components/NetworkSelectionModal.tsx
@@ -17,7 +17,14 @@ import {
import { useSearchParams } from "next/navigation";
import { useActualTheme } from "../hooks/useActualTheme";
-export const NetworkSelectionModal = () => {
+interface NetworkSelectionModalProps {
+ onNetworkSelected?: () => void;
+}
+
+// export const NetworkSelectionModal = () => {
+export const NetworkSelectionModal = ({
+ onNetworkSelected,
+}: NetworkSelectionModalProps = {}) => {
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [showInfo, setShowInfo] = useState(false);
@@ -39,12 +46,28 @@ export const NetworkSelectionModal = () => {
}
}, [hasCheckedStorage, authenticated, user?.wallet?.address]);
+ // const handleClose = () => {
+ // if (user?.wallet?.address) {
+ // const storageKey = `hasSeenNetworkModal-${user.wallet.address}`;
+ // localStorage.setItem(storageKey, "true");
+ // }
+ // setIsOpen(false);
+ // };
+
const handleClose = () => {
if (user?.wallet?.address) {
const storageKey = `hasSeenNetworkModal-${user.wallet.address}`;
localStorage.setItem(storageKey, "true");
}
setIsOpen(false);
+
+ // Trigger callback when modal closes after network selection
+ if (onNetworkSelected) {
+ // Small delay to ensure smooth transition
+ setTimeout(() => {
+ onNetworkSelected();
+ }, 300);
+ }
};
const handleNetworkSelect = async (networkName: string) => {
diff --git a/app/components/ReferralCTA.tsx b/app/components/ReferralCTA.tsx
new file mode 100644
index 00000000..8e0eed5a
--- /dev/null
+++ b/app/components/ReferralCTA.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { motion } from "framer-motion";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+
+export const ReferralCTA = ({ onViewReferrals }: { onViewReferrals?: () => void }) => {
+ const router = useRouter();
+
+ const handleViewReferrals = () => {
+ if (onViewReferrals) return onViewReferrals();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Invite. Earn. Repeat.
+
+
+ Refer your friends and earn USDT
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/app/components/ReferralDashboard.tsx b/app/components/ReferralDashboard.tsx
new file mode 100644
index 00000000..28714176
--- /dev/null
+++ b/app/components/ReferralDashboard.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { Dialog } from "@headlessui/react";
+import { toast } from "sonner";
+import { ArrowLeft02Icon, Copy01Icon } from "hugeicons-react";
+import { PiCheck } from "react-icons/pi";
+import { getReferralData } from "../api/aggregator";
+import { usePrivy } from "@privy-io/react-auth";
+import { sidebarAnimation } from "./AnimatedComponents";
+
+// Mock data for presentation
+const MOCK_DATA = {
+ referral_code: "NB738K",
+ total_earned: 34.9,
+ total_pending: 2.0,
+ total_referrals: 11,
+ earned_count: 8,
+ pending_count: 3,
+ referrals: [
+ // Pending referrals (3)
+ {
+ id: "1",
+ wallet_address: "0x52c...b43f",
+ wallet_address_short: "0x52c...b43f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2025-06-05T10:30:00Z",
+ },
+ {
+ id: "2",
+ wallet_address: "0x52c...b43f",
+ wallet_address_short: "0x52c...b43f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2024-09-13T14:20:00Z",
+ },
+ {
+ id: "3",
+ wallet_address: "0x73a...d78f",
+ wallet_address_short: "0x73a...d78f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2024-10-31T09:15:00Z",
+ },
+ // Earned referrals (8)
+ {
+ id: "4",
+ wallet_address: "0x84b...e27a",
+ wallet_address_short: "0x84b...e27a",
+ status: "earned",
+ amount: 4.8,
+ created_at: "2024-11-01T11:00:00Z",
+ completed_at: "2024-11-02T15:30:00Z",
+ },
+ {
+ id: "5",
+ wallet_address: "0x92c...f15b",
+ wallet_address_short: "0x92c...f15b",
+ status: "earned",
+ amount: 6.1,
+ created_at: "2024-11-02T08:45:00Z",
+ completed_at: "2024-11-03T12:20:00Z",
+ },
+ {
+ id: "6",
+ wallet_address: "0xa3d...b63c",
+ wallet_address_short: "0xa3d...b63c",
+ status: "earned",
+ amount: 2.9,
+ created_at: "2024-11-03T16:30:00Z",
+ completed_at: "2024-11-04T10:15:00Z",
+ },
+ {
+ id: "7",
+ wallet_address: "0xb4e...a74d",
+ wallet_address_short: "0xb4e...a74d",
+ status: "earned",
+ amount: 8.4,
+ created_at: "2024-11-04T13:20:00Z",
+ completed_at: "2024-11-05T09:45:00Z",
+ },
+ {
+ id: "8",
+ wallet_address: "0xc5f...c85e",
+ wallet_address_short: "0xc5f...c85e",
+ status: "earned",
+ amount: 5.7,
+ created_at: "2024-11-05T10:10:00Z",
+ completed_at: "2024-11-06T14:30:00Z",
+ },
+ {
+ id: "9",
+ wallet_address: "0xa1b...d74f",
+ wallet_address_short: "0xa1b...d74f",
+ status: "earned",
+ amount: 3.2,
+ created_at: "2024-11-12T15:40:00Z",
+ completed_at: "2024-11-13T11:20:00Z",
+ },
+ {
+ id: "10",
+ wallet_address: "0xb3e...f59c",
+ wallet_address_short: "0xb3e...f59c",
+ status: "earned",
+ amount: 7.5,
+ created_at: "2024-11-19T12:25:00Z",
+ completed_at: "2024-11-20T08:50:00Z",
+ },
+ {
+ id: "11",
+ wallet_address: "0x7da...cba3",
+ wallet_address_short: "0x7da...cba3",
+ status: "earned",
+ amount: 3.2,
+ created_at: "2024-11-26T09:30:00Z",
+ completed_at: "2024-11-27T16:10:00Z",
+ },
+ ],
+};
+
+export const ReferralDashboard = ({
+ isOpen,
+ onClose,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+}) => {
+ const { getAccessToken } = usePrivy();
+ const [referralData, setReferralData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState<"pending" | "earned">("pending");
+ const [showCopiedMessage, setShowCopiedMessage] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ let mounted = true;
+
+ async function fetchData() {
+ try {
+ setIsLoading(true);
+ if (mounted) setReferralData(MOCK_DATA);
+
+ /* Production code (commented for demo):
+ const token = await getAccessToken();
+ if (!token) return;
+ const data = await getReferralData(token);
+ if (mounted) setReferralData(data);
+ */
+
+ } catch (error) {
+ console.error("Failed to fetch referral data:", error);
+ toast.error("Failed to load referral data");
+ } finally {
+ if (mounted) setIsLoading(false);
+ }
+ }
+
+ fetchData();
+
+ return () => {
+ mounted = false;
+ };
+ }, [getAccessToken, isOpen]);
+
+ const handleCopyCode = () => {
+ if (referralData?.referral_code) {
+ navigator.clipboard.writeText(referralData.referral_code);
+ setShowCopiedMessage(true);
+ setTimeout(() => setShowCopiedMessage(false), 2000);
+ }
+ };
+
+ const handleCopyLink = () => {
+ if (referralData?.referral_code) {
+ const link = `${window.location.origin}?ref=${referralData.referral_code}`;
+ navigator.clipboard.writeText(link);
+ toast.success("Referral link copied!");
+ }
+ };
+
+ const filteredReferrals: any[] = (referralData?.referrals || []).filter(
+ (r: any) => r.status === activeTab
+ );
+
+ // Generate avatar colors based on wallet address
+ const getAvatarColor = (address: string) => {
+ const colors = [
+ "from-purple-500 to-purple-600",
+ "from-blue-500 to-blue-600",
+ "from-cyan-500 to-cyan-600",
+ "from-teal-500 to-teal-600",
+ "from-orange-500 to-orange-600",
+ "from-pink-500 to-pink-600",
+ ];
+ const index = parseInt(address.slice(2, 4), 16) % colors.length;
+ return colors[index];
+ };
+
+ // if (isLoading) {
+ // return (
+ //
+ // {isOpen && (
+ //
+ // )}
+ //
+ // );
+ // }
+
+ return (
+
+ {isOpen && (
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/app/components/ReferralModal.tsx b/app/components/ReferralModal.tsx
new file mode 100644
index 00000000..7ead8bf3
--- /dev/null
+++ b/app/components/ReferralModal.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { DialogTitle } from "@headlessui/react";
+import { motion } from "framer-motion";
+import Image from "next/image";
+import { useState } from "react";
+import { toast } from "sonner";
+import { usePrivy } from "@privy-io/react-auth";
+import { AnimatedModal } from "./AnimatedComponents";
+import { useNetwork } from "../context/NetworksContext";
+import { submitReferralCode } from "../api/aggregator";
+
+interface ReferralInputModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmitSuccess: () => void;
+}
+
+export const ReferralInputModal = ({
+ isOpen,
+ onClose,
+ onSubmitSuccess,
+}: ReferralInputModalProps) => {
+ const [referralCode, setReferralCode] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const { selectedNetwork } = useNetwork();
+ const { getAccessToken } = usePrivy();
+
+ const sponsorChain = selectedNetwork?.chain.name || "Sponsor Chain";
+
+ const handleSubmit = async () => {
+ if (!referralCode.trim()) {
+ toast.error("Please enter a referral code");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const code = referralCode.trim().toUpperCase();
+ if (!/^NB[A-Z0-9]{4}$/.test(code)) {
+ toast.error("Invalid referral code format");
+ return;
+ }
+
+ const token = await getAccessToken();
+
+ try {
+ const payload = await submitReferralCode(code, token ?? undefined);
+ toast.success(payload?.data?.message || "Referral code applied! Complete KYC and your first transaction to earn rewards.");
+ onSubmitSuccess();
+ onClose();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to submit referral code. Please try again.";
+ toast.error(message);
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Invalid referral code. Please check and try again."
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleSkip = () => {
+ setReferralCode("");
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ Were you referred by a friend?
+
+
+
+
+ Enter your referral code below and get $1 on your first $20 transaction on {sponsorChain}
+
+
+
+
+
+ setReferralCode(e.target.value.toUpperCase())}
+ placeholder="NB738K"
+ className="w-full border-0 bg-transparent pt-2 text-base font-medium text-text-body placeholder:text-text-secondary focus:outline-none focus:ring-0 dark:text-white dark:placeholder:text-white/30"
+ maxLength={6}
+ disabled={isSubmitting}
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/app/components/WalletDetails.tsx b/app/components/WalletDetails.tsx
index 5c012ec1..21c4f7ad 100644
--- a/app/components/WalletDetails.tsx
+++ b/app/components/WalletDetails.tsx
@@ -35,7 +35,8 @@ import { BalanceSkeleton, BalanceCardSkeleton } from "./BalanceSkeleton";
import { useCNGNRate } from "../hooks/useCNGNRate";
import { useActualTheme } from "../hooks/useActualTheme";
import TransactionList from "./transaction/TransactionList";
-import { FundWalletForm, TransferForm } from "./index";
+import { FundWalletForm, ReferralCTA, TransferForm } from "./index";
+import { ReferralDashboard } from "./ReferralDashboard";
import { CopyAddressWarningModal } from "./CopyAddressWarningModal";
export const WalletDetails = () => {
@@ -46,6 +47,7 @@ export const WalletDetails = () => {
"balances",
);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
+ const [isReferralOpen, setIsReferralOpen] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState(null);
const [isAddressCopied, setIsAddressCopied] = useState(false);
@@ -275,6 +277,9 @@ export const WalletDetails = () => {
)}
+
+ { setIsSidebarOpen(false); setIsReferralOpen(true); }} />
+
{/* Tab navigation */}
@@ -404,6 +409,9 @@ export const WalletDetails = () => {
)}
+ {/* Referral sidebar (opens when CTA clicked) */}
+ setIsReferralOpen(false)} />
+
{/* Transfer and Fund modals */}
{!isInjectedWallet && (
<>
diff --git a/app/components/index.ts b/app/components/index.ts
index fcb264eb..30d3672b 100644
--- a/app/components/index.ts
+++ b/app/components/index.ts
@@ -40,6 +40,8 @@ export { SearchInput } from "./recipient/SearchInput";
export { RecipientListItem } from "./recipient/RecipientListItem";
export { SavedBeneficiariesModal } from "./recipient/SavedBeneficiariesModal";
export { TransactionHelperText } from "./TransactionHelperText";
+export { ReferralInputModal } from "./ReferralModal";
+export { ReferralCTA } from "./ReferralCTA";
export {
inputClasses,
primaryBtnClasses,
diff --git a/app/components/wallet-mobile-modal/WalletView.tsx b/app/components/wallet-mobile-modal/WalletView.tsx
index 263f2044..fd52d153 100644
--- a/app/components/wallet-mobile-modal/WalletView.tsx
+++ b/app/components/wallet-mobile-modal/WalletView.tsx
@@ -12,6 +12,7 @@ import {
} from "hugeicons-react";
import { BalanceCardSkeleton } from "../BalanceSkeleton";
import { classNames, getNetworkImageUrl } from "../../utils";
+import { ReferralCTA } from "../ReferralCTA";
// Types for props
interface WalletViewProps {
@@ -259,7 +260,7 @@ export const WalletView: React.FC = ({
{networks
.filter(
(network) =>
- isInjectedWallet ||
+ isInjectedWallet ||
(network.chain.name !== "Celo" && network.chain.name !== "Hedera Mainnet"),
)
.map((network) => (
@@ -293,6 +294,7 @@ export const WalletView: React.FC = ({
)}
+ {!isInjectedWallet && }
);
};
diff --git a/app/types.ts b/app/types.ts
index 32361212..6fcdb23f 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -261,11 +261,11 @@ export type Config = {
export type Network = {
chain: any;
imageUrl:
- | string
- | {
- light: string;
- dark: string;
- };
+ | string
+ | {
+ light: string;
+ dark: string;
+ };
};
export interface TransactionResponse {
@@ -400,3 +400,21 @@ export interface SaveRecipientResponse {
success: boolean;
data: RecipientDetailsWithId;
}
+
+export interface ReferralData {
+ referral_code: string;
+ total_earned: number;
+ total_pending: number;
+ total_referrals?: number;
+ earned_count?: number;
+ pending_count?: number;
+ referrals: Array<{
+ id: string;
+ wallet_address: string;
+ wallet_address_short: string;
+ status: string;
+ amount: number;
+ created_at: string;
+ completed_at?: string | null;
+ }>;
+}
diff --git a/middleware.ts b/middleware.ts
index 5be04d04..da48f3f0 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -264,6 +264,8 @@ export const config = {
"/api/v1/account/:path*",
"/api/v1/recipients",
"/api/blockfest/cashback",
+ "/api/referral",
+ "/api/referral/:path*",
// (optional) add other instrumented API routes:
// '/api/v1/kyc/:path*', '/api/v1/rates', '/api/v1/rates/:path*'
],
diff --git a/public/images/referral-cta-dollar.png b/public/images/referral-cta-dollar.png
new file mode 100644
index 0000000000000000000000000000000000000000..01e5e7b6b833b72a73c5ceac03466fabadcf78ae
GIT binary patch
literal 550
zcmV+>0@?kEP)hKNQ6X=Fiv1N0rLcK1GoVT7=N;@wS{%I
zh{X6MP21Ogec$^j!Ul9&uM;sjabz9Uyxr;gIe~uw)#AyU6%w147?5KG?xyB|G5(TE
zV!!MiP7PAG{dNT`@i<^%ivyoVQCW~f`q-MgoUxwfRAQRWj%IVCTuwfEfg!ElbqO5coQgRVqmV}UBPN=}#g
zq`aY67qgcTy=r~030r`v?osA#g{c^Ukk;4bW+WA`#q1%P*~%V31n;_@?;9iqnSSJ?
zB&@}CrY;*dyu3pjAwb1?jEZTJ+>526T;>7$0Pb=se7Q
z5p)T*K(x=e+Q^?Mc~H4Ma5=C`Zt7RpLs8_WB4nO)bnX{LASe?Oitm2%r|%xqSX~M?
o5hUokVdoRV$VMMI58)Sl0d6w74)g;%(f|Me07*qoM6N<$f=DFh-T(jq
literal 0
HcmV?d00001
diff --git a/public/images/referral-cta.png b/public/images/referral-cta.png
new file mode 100644
index 0000000000000000000000000000000000000000..ed3447c13cb9c9838a02b2edf4d33bce6dc3bd7e
GIT binary patch
literal 2407
zcmV-t37GbYP)1^@s6eMzK~#7F?VMd~
zTW1-^pZA;-+ezz>Elr%z(xyo5)-`Sy)(pZjsbxizpcj-~0UD%mgHSIB7hICz6SdKt;XY5_mUl-|@%W;2D#bGVQ)e>bU2SPTg5
z@i4K!UC=bTa5T~!0fC{2nyNl!cEa9$%7iRafA#)+UgM+UU1~q9OlV!$p6xAOYlYC;
z0?tqqsS)ufKhh2mrTu0n><+NcQG=8VeJ`yU9s_Y%l{MeD@COrGCHnbjlLZ7dB1+ok
zV=6PA<97R{er`F3$whLxqPSV{Z>`X}m=LwE++?JS02K3Sw-FPK}MR
zB)LR|h>Xd@W(N)o$j6NWN~?hz4-D2P;E^#FBNr)&_|qR}_*|!%1{8mgPE#B3$fJ@7
zK6daZoNR@Fpd_NYl{0nKJz@E<(j)Do9VQr(X!mYs^B|u(&$dE9AbukR6egRyawsHC
z&<+CuF&r`-iLg0p5z6wiHkG=g_TE`jLz#PpLhSwA4JJvC@(I$51CZAEdK=FTn?XGS
zHoF>e0{iE(W|Z|V5~Elusm}B5h$6A`NR(Ao54O)nci}QHO_tai&xzJlVt&}X63$OF~{QGn_nW4?iV!iVG
z^ZOhL$XVPe0RemQ#lLD+2SCV3WPlDI3Rw#JAn4OXHd~;VU-^4!|JT2IgnyZSyVFsP
zjJcUi+o9MUkXTJq;*YoJH9l}blh5p<1NIkI7qB*;kN8l;7nMpCEpg%6hjt>mUX3`x
zR>X4R@sH_p{D!#D$!GS`vB(~3Hir*ABCLLpdq8R5`+%j7Q)9BpMHW3gA
z#Ui77gli%q>%fg}-n>h1y!ip2qjHvSGC%(TEiK*Vf#C60SmpE+$JiBWBiJ9VrnGYL
zjCh%+LHcBG1q3|(+!SWOd!T;%J+Q`E+Yz=9yMw7kv}>rDydSgG3MTdYYD^G);`Mrwpq8jwHgJDgSYs6hK48_
z5KnWrL_@)hTk#DML=J2RmuisGppCV}mFxuEC&_+t5wbgt_
zxo|lO1jB%pSTzq2i4i#BTtpqS?oHT2M0`#%wMmH&duVm@$4
z*aE?tj~;pHhZ|p}4#ZSMi1W+nKWt0CP|#DeFK!@gIT=3xrqel6_?+0^FVFF{zjGMe
zr$=UH?uMwtsL2yCXQ2XdQa2+Fs*d2-9oE~Z%|HKze)9CN*pboRD0MK(3U@7lh>QQ5
zSrG&)FYOS?(SWUa3Yw^PVEN$8mp&x~h6VY2vglqL1*0=x
zJjkn-8@5XdI*d_}Ivm@w4;J|BcHRirwM)Pu?03KOq=@Km@aT>Fv)i$~QwVvWlf|d%
zP7o~Bs%ryl?iMU95uF#@LShF35pu=|Z|3Tv+jYgmgS*6*CqwU0hhr(?_=#bwmqi}=
z+MnX3NRF-Esj-XhgSpatlKgURX%fB;l~Wr6ubo
zFD$&|>Ra?WbvU+x1s0`SRkM~1$}D`YQ@Yny)b)^8GX#Vc>Wx`O6s0bprO$pMT`Ftx
z@=AqWq%J@T8q~gSfC>j|EQs6)NUQ^vLY*QZF-Dc)rkG->;cG`kpAqmb&xLN73w0`<
z9$4aizPEV$-mIb82q+m*W9!lQ+TRkfZ+vs;AaxN6lHz0hLg8jetTE8VhM=gE8xa()
zi@PXLwdiFcT=@4_|4r|$te>MUL##VPKtxQV5)XMFL`Cx(i*7(x}`c0tvjiKRi
zpKx0Uhc%Y+k`LV>Gvls@Xc=Ma_`|If!_b#J@9
z7L#T50(BuZ$u`P1echz^Jd0nrWH8|2*0#&?%@PT|0JVCLi?=~5E6IwylDE8OqS1*M
zbs=@%_>QUt9T+{D>wQD3W&;mhS1w)pjB8K6?=*Eif&pM%81>w@s3__*buq42ESjcX
z=vFt@aa+XLrAret)aBIe&d$-2vaEGYX=jCNA{GkT^z3YRyRnW7?yMyX1@+wJ%M($0
zNNBbt@`O5nc6N5MWAE^4geF;{mP8bmWzSeN`fv#dxFu?HJm)@14WMb-oFvf|N%CHd
zM#pyand6;A{B3}#skcMV#igL4)PN1`1b1~Wyi)ac=eHDOk0#?-<5}1xc}2+
Z{s)Bl%Knr4W10W}002ovPDHLkV1jvMiJ1TZ
literal 0
HcmV?d00001
diff --git a/public/images/referral-graphic.png b/public/images/referral-graphic.png
new file mode 100644
index 0000000000000000000000000000000000000000..befced6033d561cdad1695656bc716ccb4cfbfa2
GIT binary patch
literal 3406
zcmV-U4YBfxP)De
zfsl@5YLu4nmx!#6jHFW%iHX@S3t0q5p4*(RK{_%PxDCkiU9Y>v&o+3nxWzq;!7rMa1y8i@f>&Yx32vRNDDJOTE3Lm+ZStkN_u9R0tgzHpb=u!)
z_HEe1yFa@`w~_xnWBznynSQSl^)*VYrN_|2Ou$UOC+U=Qk_EN6HGx6M{@w=sWUh2O
z_a5hVM+9i$MXKqOi`0Ri5)!HI3q1Xn8wLYwb+VnLy(B}UtWfUQdntw;F?+UkKz7i>
zG3kXk5W=>@pPzm~0(v=~FZs&d%fSG?_gjA5wFtc>wvMKu^Y@mv-P7#K5q8abZ{j`S
zO$VL37B7aseZ(Fmk`dQAHX8Q5^9uVkLpdrBzq1
zD=YKQb-R67z#vL!**jkrpjMH3V(ZVzt`%+OrtNYxtdcT0YyCp0NB!RKZWWr){l_1M
zqIwouhWFLWBG_1Yf2RNW*s@`fcuU+xO<+;AbJLY!WGm7
zEY`vt;c(|Br*+Pz@%Y^O>re~NE2_2nrv~9T{spjq>IVPU&jZCXP~Tk&_navQPGH~y#ljaf12QLq=nR2W7W9kN{vM$`=qGOO
zB@gJgaZKl3T#@&8Q)XxD@)6SM0;RdL4f4PXSG(N*jf5J4vNVVGTRhAa$DGn@Hs{O$
z1C=8dh>(6AC=EluNrAB64z`LBDC@UF4NsHcQlPIg0B6g17$^w?Ul9P=mVTvYrRmm=
z+J&u<9V9|HWH`I{&7&jx)rOW}hg5#02g#9f)+2#3M*(HR{fa|m!XbfV7U0;34T=~R
zEW^Zs{2cHm38KW6M+FlEX%;RKYw|W8B8-XTXGLOh7MPGJBjHpye*U~C7~vZ^&eXZ6
z{?5eNnfmyYma;Kt`mzmO0r$yau_dgCO_DhXk|_i-%Y#fu!F;&{SVE5V7m-gZ&;qNv
zrBE>n5+TG_UMDUZo10!MZbSiLV2qsk?8Go`E2n{TRYLRhdYD;U3e}`zcUINInqn8M
zch+S5{^*JCZ;764Zc23>EsTT>??4bi1Cv#O)ouwMu99KnQ3{4*IKSld!OmWWxFOL4
ziy3B?kzrn9z#9y}KsX3f34x#2%z|k}cIcWaz?SR15c%IkMybK#gog4vVG0wUWjYud
zhEvfX{LncKW?QYv$r7T9%rB=)>b9qNd|4-)6_D)H?y+H}xenl0T@%;tnK2dqFiU}!
zP8z=L3WKL81PtSXQBw{4IdTo=ne1>^QJLCyK1n;RQAF6waG>{DK$c+h$VK&dy|Wq`
zNe686Uxup^ua=jFF2LWN^OHW8A_iLM(%H*Qb+4roUUeQVC)Qqnzi~y18=%Z2!;-3r
zG1Z=A$zdbi__YcO1kMVjrKR9-IH1N-1Z2`r9$FZ0cSYo(eY6j*#ME^}{Nv;{4t2xk
z;frt}G&J%52SalLdyXef4l30v7NsGJkdg4D839eF-jN$36aI}}4*InGzSlo_D!#|Z
zz;06BJj24FetMkTP7?DwN3X)FzSHV?E3qUJO9gc6XB{qRCo!zM_+z#TMY!T`(c54m
zP(QZTG-aAU$dvY)LJ=6mqaR3Zf~+)EnH5;#lHo6(Gr*mjxW4pZ3EmrwH~XrYgYdp|
zDUKW}Zh$1~Fg8p6J(JANiq-lBcMD)!cnRJPUW1J#(_yLR{)mIb(#ve+N32$>nn@j9
zvegqk)4PW%Hf=BOdVM=&B1=(0-KA4n^xV);(uFvZMbUCu;%|MRTmkG19eBLRW`pn8
zCKd;1EmYi%C2o~eF*V^OcCv!PYfFntVJ?Yx9jb>A_y5`OgvS;iTJNKqXp}@c-2L6X!-Sn=i#gnR_itncdH(9I5ZO1*1o&hSM=P%2J?LP
zE-*k?pLn2ZOp>3vnle8ramjEaqJ^ZFxFjN8RGbH(Wv8-cI6{PJm90{(I~fhYNuDE%
zjxm=;l}4fnB>3#W8FdUkrx8yE>!v3$b~DAVY8g7_#L)0$M6^(-_V`%Xae+}wP;&xQ
zWF)(l3^S;{NLC+$bRgka%r)8I+2SdxcO;I@SwM(O(`qMz(YyPjluCNHrpB@G`YBbN
zb-Z_(f#!=>hzJd+3a#T{cu2M2Jac?@X$osnT^?f?3I4crYy`sN_R4ygYc{N}u^8tT
zYaS7@0E*ViYKg>YeE55u3OrUFZ>43{GS!9AHFe-I#xDxjT?zAYB!~5@>=j_ACOQ@KN_Z>x~t36M=U98+V*
zSREL{W8|)agAa{Vjh^>CM=86S9aFpyI-TAhmDOdFoWM+FT&TtoCKt(Gwt|_qlu5l|
zf%aqkZuSW3u#-%*^f<;?KQ>6KZC#qzZo7V2Ra_LSlVmZ_Xmt>kwx}wN+p8ai108p~
z^&xCI)#qz?F1SlP)_$OP!|6824U?oj+5Rxrp)#aQ-|jdE&i=6IdBon^aPZ`*rvIE@
z?vrK8L(;V+-*`xZ4@Tl$)G_4}JX6i8^JrbTNayx&3UruRwzK~8+K&5eWj=kg4L8e~
z`e}aLP7S_!-&!y`ytcK6vdv*l+8a(vSR-vXIUl?0fO|)WIngu9(;iV$X7HeR$VQqc
zQqc`Uale=B5PBW7<;W68aYvS}nJv_5WU>n>*TMO2pWZ^GeI+ra+iJm{+ZMpBML6C2
zmua_Iv=}fvA-N5J8)j*T5Tox}qH5^GL_q)XmQbi4Zxvj8VqSq_wa;w-2fC`hR4wR_v;b~`+G-u3
k??Y3N{-=0Sp$R79|GNC20QfmZ^8f$<07*qoM6N<$f~ehq6951J
literal 0
HcmV?d00001
From 0e3de1343931c980496b3742191101ebc2439813 Mon Sep 17 00:00:00 2001
From: sundayonah
Date: Wed, 19 Nov 2025 05:45:25 +0100
Subject: [PATCH 02/15] fix: coderabbit comments
---
app/api/aggregator.ts | 66 +++++++++++++------
app/api/internal/credit-wallet/route.ts | 20 ++----
app/api/referral/claim/route.ts | 9 +--
.../referral/generate-referral-code/route.ts | 26 +++++---
app/api/referral/submit/route.ts | 6 +-
app/components/MobileDropdown.tsx | 21 +++---
app/components/Navbar.tsx | 13 +++-
app/components/ReferralCTA.tsx | 4 +-
app/components/ReferralDashboard.tsx | 4 +-
app/components/ReferralModal.tsx | 18 +++--
app/components/WalletDetails.tsx | 7 +-
.../wallet-mobile-modal/WalletView.tsx | 11 +++-
app/types.ts | 6 ++
13 files changed, 139 insertions(+), 72 deletions(-)
diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts
index 5eb438cd..fb541a77 100644
--- a/app/api/aggregator.ts
+++ b/app/api/aggregator.ts
@@ -19,6 +19,8 @@ import type {
RecipientDetailsWithId,
SavedRecipientsResponse,
ReferralData,
+ ApiResponse,
+ SubmitReferralResult,
} from "../types";
import {
trackServerEvent,
@@ -515,7 +517,7 @@ export const fetchTokens = async (): Promise => {
export async function submitReferralCode(
code: string,
accessToken?: string,
-): Promise {
+): Promise> {
const headers: Record = {
"Content-Type": "application/json",
};
@@ -524,22 +526,31 @@ export async function submitReferralCode(
headers.Authorization = `Bearer ${accessToken}`;
}
- const response = await axios.post(`/api/referral/submit`, { referral_code: code }, { headers });
+ try {
+ const response = await axios.post(`/api/referral/submit`, { referral_code: code }, { headers });
- if (!response.data?.success) {
- throw new Error(response.data?.error || response.data?.message || "Failed to submit referral code");
- }
+ if (!response.data?.success) {
+ return { success: false, error: response.data?.error || response.data?.message || "Failed to submit referral code", status: response.status };
+ }
- return response.data;
+ return { success: true, data: response.data?.data || response.data } as ApiResponse;
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const message = error.response?.data?.message || error.message || "Failed to submit referral code";
+ return { success: false, error: message, status: error.response?.status };
+ }
+ return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
+ }
}
+
/**
* Get user's referral data (code, earnings, referral list)
*/
export async function getReferralData(
accessToken?: string,
walletAddress?: string,
-): Promise {
+): Promise> {
const headers: Record = {};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
@@ -547,13 +558,21 @@ export async function getReferralData(
? `/api/referral/data?wallet_address=${encodeURIComponent(walletAddress)}`
: `/api/referral/data`;
- const response = await axios.get(url, { headers });
+ try {
+ const response = await axios.get(url, { headers });
- if (!response.data?.success) {
- throw new Error(response.data?.error || "Failed to fetch referral data");
- }
+ if (!response.data?.success) {
+ return { success: false, error: response.data?.error || "Failed to fetch referral data", status: response.status };
+ }
- return response.data.data as ReferralData;
+ return { success: true, data: response.data.data as ReferralData };
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const message = error.response?.data?.message || error.message || "Failed to fetch referral data";
+ return { success: false, error: message, status: error.response?.status };
+ }
+ return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
+ }
}
/**
@@ -561,19 +580,26 @@ export async function getReferralData(
*/
export async function generateReferralCode(
accessToken?: string,
-): Promise {
+): Promise> {
const headers: Record = {};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
- const response = await axios.get(`/api/referral/generate-referral-code`, { headers });
+ try {
+ const response = await axios.get(`/api/referral/generate-referral-code`, { headers });
- if (!response.data?.success) {
- throw new Error(response.data?.error || "Failed to generate referral code");
- }
+ if (!response.data?.success) {
+ return { success: false, error: response.data?.error || "Failed to generate referral code", status: response.status };
+ }
- // normalize the payload shapes we might get back
- const payload = response.data;
- return payload.data?.referral_code || payload.data?.referralCode || payload.code || "";
+ const payload = response.data;
+ return { success: true, data: payload.data || payload };
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ const message = error.response?.data?.message || error.message || "Failed to generate referral code";
+ return { success: false, error: message, status: error.response?.status };
+ }
+ return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
+ }
}
/**
diff --git a/app/api/internal/credit-wallet/route.ts b/app/api/internal/credit-wallet/route.ts
index 2014d214..65989cc9 100644
--- a/app/api/internal/credit-wallet/route.ts
+++ b/app/api/internal/credit-wallet/route.ts
@@ -2,20 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/app/lib/supabase";
import * as ethers from "ethers";
-/**
- * Minimal, idempotent internal credit endpoint (stubbed).
- * Protect with x-internal-auth = process.env.INTERNAL_API_KEY
- * Expected body:
- * {
- * idempotency_key: string,
- * wallet_address: string,
- * amount: number, // integer, micro-units (USDC = 6dp)
- * currency: string,
- * referral_id?: string | number,
- * reason?: string,
- * metadata?: Record
- * }
- */
export const POST = async (request: NextRequest) => {
const internalAuth = process.env.INTERNAL_API_KEY;
const headerAuth = request.headers.get("x-internal-auth");
@@ -55,6 +41,12 @@ export const POST = async (request: NextRequest) => {
.single();
if (existing) {
+ if (existing.status === "failed") {
+ return NextResponse.json(
+ { success: false, error: "Existing credit is in failed state" },
+ { status: 500 }
+ );
+ }
return NextResponse.json({ success: true, data: existing });
}
diff --git a/app/api/referral/claim/route.ts b/app/api/referral/claim/route.ts
index fabd5fdc..d31f8c0d 100644
--- a/app/api/referral/claim/route.ts
+++ b/app/api/referral/claim/route.ts
@@ -38,9 +38,6 @@ export const POST = withRateLimit(async (request: NextRequest) => {
wallet_address: walletAddress,
});
- // Do not trust client-provided flags. Claim will perform authoritative server-side
- // verification of KYC and qualifying transaction below.
-
// Find pending referral for this user
const { data: referral, error: referralError } = await supabaseAdmin
.from("referrals")
@@ -153,11 +150,15 @@ export const POST = withRateLimit(async (request: NextRequest) => {
// If crediting fails, roll back referral status to pending to keep consistency.
const internalAuth = process.env.INTERNAL_API_KEY;
const internalBase = process.env.INTERNAL_API_BASE_URL || new URL(request.url).origin;
+ const isProd = process.env.NODE_ENV === "production";
async function creditWallet(wallet: string, amountMicro: number, referralId: any) {
if (!internalAuth) {
+ if (isProd) {
+ throw new Error("Internal wallet service not configured");
+ }
// Wallet service not configured; skip actual crediting.
- console.warn("Internal wallet service not configured, skipping credit for", wallet);
+ console.error("Internal wallet service not configured, skipping credit for", wallet);
return { ok: false, skipped: true };
}
diff --git a/app/api/referral/generate-referral-code/route.ts b/app/api/referral/generate-referral-code/route.ts
index 56e40f27..0952cfc4 100644
--- a/app/api/referral/generate-referral-code/route.ts
+++ b/app/api/referral/generate-referral-code/route.ts
@@ -75,27 +75,33 @@ export const GET = withRateLimit(async (request: NextRequest) => {
}
// Generate a new unique code
- let code: string;
+ let code: string | null = null;
let attempts = 0;
const maxAttempts = 10;
- do {
- code = generateReferralCode();
+ while (!code && attempts < maxAttempts) {
+ const candidate = generateReferralCode();
attempts++;
- // Check if code already exists
- const { data: existing } = await supabaseAdmin
+ const { data: existing, error: existingError } = await supabaseAdmin
.from("users")
.select("wallet_address")
- .eq("referral_code", code)
+ .eq("referral_code", candidate)
.single();
- if (!existing) break;
+ // Treat "no row" as available; any other error should bubble up
+ if (existingError && existingError.code !== "PGRST116") {
+ throw existingError;
+ }
- if (attempts >= maxAttempts) {
- throw new Error("Failed to generate unique referral code");
+ if (!existing) {
+ code = candidate;
}
- } while (true);
+ }
+
+ if (!code) {
+ throw new Error("Failed to generate unique referral code");
+ }
// Update or insert user with new code
const { data: userData, error: upsertError } = await supabaseAdmin
diff --git a/app/api/referral/submit/route.ts b/app/api/referral/submit/route.ts
index 1d434632..4db1c200 100644
--- a/app/api/referral/submit/route.ts
+++ b/app/api/referral/submit/route.ts
@@ -90,7 +90,11 @@ export const POST = withRateLimit(async (request: NextRequest) => {
.eq("referral_code", normalizedCode)
.single();
- if (referrerError || !referrer) {
+ if (referrerError && referrerError.code !== "PGRST116") {
+ throw referrerError;
+ }
+
+ if (!referrer) {
trackBusinessEvent("Invalid Referral Code Used", {
wallet_address: walletAddress,
referral_code: normalizedCode,
diff --git a/app/components/MobileDropdown.tsx b/app/components/MobileDropdown.tsx
index 4a958b08..b7577336 100644
--- a/app/components/MobileDropdown.tsx
+++ b/app/components/MobileDropdown.tsx
@@ -26,9 +26,11 @@ import { CopyAddressWarningModal } from "./CopyAddressWarningModal";
export const MobileDropdown = ({
isOpen,
onClose,
+ onViewReferrals,
}: {
isOpen: boolean;
onClose: () => void;
+ onViewReferrals?: () => void;
}) => {
const [currentView, setCurrentView] = useState<
"wallet" | "settings" | "transfer" | "fund" | "history"
@@ -140,14 +142,14 @@ export const MobileDropdown = ({
body: JSON.stringify(payload),
signal: controller.signal
})
- .catch(error => {
- if (error.name !== 'AbortError') {
- console.warn('Logout tracking failed:', error);
- }
- })
- .finally(() => {
- clearTimeout(timeoutId);
- });
+ .catch(error => {
+ if (error.name !== 'AbortError') {
+ console.warn('Logout tracking failed:', error);
+ }
+ })
+ .finally(() => {
+ clearTimeout(timeoutId);
+ });
};
const handleLogout = async () => {
@@ -232,6 +234,7 @@ export const MobileDropdown = ({
}
onSettings={() => setCurrentView("settings")}
onClose={onClose}
+ onViewReferrals={onViewReferrals}
onHistory={() => setCurrentView("history")}
setSelectedNetwork={setSelectedNetwork}
onRefreshBalance={refreshBalance}
@@ -289,7 +292,7 @@ export const MobileDropdown = ({
)}
- setIsWarningModalOpen(false)}
address={smartWallet?.address ?? ""}
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
index 8287e8ca..4e6b9939 100644
--- a/app/components/Navbar.tsx
+++ b/app/components/Navbar.tsx
@@ -27,11 +27,13 @@ import Image from "next/image";
import { useNetwork } from "../context/NetworksContext";
import { useInjectedWallet } from "../context";
import { useActualTheme } from "../hooks/useActualTheme";
+import { ReferralDashboard } from "./ReferralDashboard";
export const Navbar = () => {
const [mounted, setMounted] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isMobileDropdownOpen, setIsMobileDropdownOpen] = useState(false);
+ const [isReferralOpen, setIsReferralOpen] = useState(false);
const dropdownRef = useRef(null);
const pathname = usePathname();
const { selectedNetwork } = useNetwork();
@@ -182,9 +184,8 @@ export const Navbar = () => {
Swap
@@ -280,8 +281,14 @@ export const Navbar = () => {
setIsMobileDropdownOpen(false)}
+ onViewReferrals={() => setIsReferralOpen(true)}
/>
+ {/* Referral dashboard (mobile flow opens this after the mobile dropdown closes) */}
+ setIsReferralOpen(false)}
+ />
>
) : (
!isInjectedWallet && (
diff --git a/app/components/ReferralCTA.tsx b/app/components/ReferralCTA.tsx
index 8e0eed5a..01a3ccb2 100644
--- a/app/components/ReferralCTA.tsx
+++ b/app/components/ReferralCTA.tsx
@@ -4,7 +4,7 @@ import { motion } from "framer-motion";
import Image from "next/image";
import { useRouter } from "next/navigation";
-export const ReferralCTA = ({ onViewReferrals }: { onViewReferrals?: () => void }) => {
+export const ReferralCTA = ({ onViewReferrals }: { onViewReferrals: () => void }) => {
const router = useRouter();
const handleViewReferrals = () => {
@@ -34,7 +34,7 @@ export const ReferralCTA = ({ onViewReferrals }: { onViewReferrals?: () => void
Invite. Earn. Repeat.
- Refer your friends and earn USDT
+ Refer your friends and earn USDC
diff --git a/app/components/ReferralDashboard.tsx b/app/components/ReferralDashboard.tsx
index 28714176..ee3d61c9 100644
--- a/app/components/ReferralDashboard.tsx
+++ b/app/components/ReferralDashboard.tsx
@@ -263,7 +263,7 @@ export const ReferralDashboard = ({
Earned
- {referralData?.total_earned.toFixed(1) || "0.0"} USDC
+ {referralData?.total_earned?.toFixed(1) ?? "0.0"} USDC
@@ -271,7 +271,7 @@ export const ReferralDashboard = ({
Pending
- {referralData?.total_pending.toFixed(0) || "0"} USDC
+ {referralData?.total_pending?.toFixed(0) ?? "0"} USDC
diff --git a/app/components/ReferralModal.tsx b/app/components/ReferralModal.tsx
index 7ead8bf3..92880b15 100644
--- a/app/components/ReferralModal.tsx
+++ b/app/components/ReferralModal.tsx
@@ -46,11 +46,19 @@ export const ReferralInputModal = ({
const token = await getAccessToken();
try {
- const payload = await submitReferralCode(code, token ?? undefined);
- toast.success(payload?.data?.message || "Referral code applied! Complete KYC and your first transaction to earn rewards.");
- onSubmitSuccess();
- onClose();
+ const res = await submitReferralCode(code, token ?? undefined);
+
+ if (res && res.success) {
+ toast.success(res.data?.message || "Referral code applied! Complete KYC and your first transaction to earn rewards.");
+ onSubmitSuccess();
+ onClose();
+ } else {
+ // API returned a well-formed error response
+ const message = res && !res.success ? res.error : "Failed to submit referral code. Please try again.";
+ toast.error(message);
+ }
} catch (err) {
+ // Unexpected errors (should be rare since submitReferralCode returns ApiResponse)
const message = err instanceof Error ? err.message : "Failed to submit referral code. Please try again.";
toast.error(message);
}
@@ -115,7 +123,7 @@ export const ReferralInputModal = ({
type="button"
onClick={handleSubmit}
disabled={isSubmitting || !referralCode.trim()}
- className="min-h-11 w-full rounded-xl bg-lavender-500 py-2 text-sm font-medium text-white transition-colors hover:bg-lavender-600 disabled:cursor-not-allowed disabled:opacity-50"
+ className="min-h-11 w-full rounded-xl bg-lavender-500 py-2 text-sm font-medium text-white disabled:text-white/30 transition-colors hover:bg-lavender-600 disabled:cursor-not-allowed disabled:dark:bg-white/5"
>
{isSubmitting ? "Submitting..." : "Submit"}
diff --git a/app/components/WalletDetails.tsx b/app/components/WalletDetails.tsx
index 21c4f7ad..5e13b06f 100644
--- a/app/components/WalletDetails.tsx
+++ b/app/components/WalletDetails.tsx
@@ -278,7 +278,12 @@ export const WalletDetails = () => {
)}
- { setIsSidebarOpen(false); setIsReferralOpen(true); }} />
+ {
+ handleSidebarClose();
+ setTimeout(() => setIsReferralOpen(true), 260);
+ }}
+ />
{/* Tab navigation */}
diff --git a/app/components/wallet-mobile-modal/WalletView.tsx b/app/components/wallet-mobile-modal/WalletView.tsx
index fd52d153..a2283385 100644
--- a/app/components/wallet-mobile-modal/WalletView.tsx
+++ b/app/components/wallet-mobile-modal/WalletView.tsx
@@ -36,6 +36,7 @@ interface WalletViewProps {
onHistory: () => void;
setSelectedNetwork: (network: any) => void;
onRefreshBalance: () => void;
+ onViewReferrals?: () => void;
}
export const WalletView: React.FC = ({
@@ -58,6 +59,7 @@ export const WalletView: React.FC = ({
onClose,
onHistory,
onRefreshBalance,
+ onViewReferrals,
}) => {
return (
@@ -294,7 +296,14 @@ export const WalletView: React.FC = ({
)}
- {!isInjectedWallet && }
+ {!isInjectedWallet && (
+ {
+ onClose();
+ if (onViewReferrals) setTimeout(() => onViewReferrals(), 260);
+ }}
+ />
+ )}
);
};
diff --git a/app/types.ts b/app/types.ts
index 6fcdb23f..b314700a 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -418,3 +418,9 @@ export interface ReferralData {
completed_at?: string | null;
}>;
}
+
+export type ApiResponse =
+ | { success: true; data: T }
+ | { success: false; error: string; status?: number };
+
+export type SubmitReferralResult = { message?: string };
\ No newline at end of file
From 63dc412bde125b4f00ed4d7d83508cc82e205a35 Mon Sep 17 00:00:00 2001
From: sundayonah
Date: Wed, 19 Nov 2025 07:45:39 +0100
Subject: [PATCH 03/15] fix: add bottom-up ReferralDashboardView modal in
MobileDropdown
---
app/components/MobileDropdown.tsx | 13 +-
app/components/Navbar.tsx | 1 -
.../ReferralDashboardView.tsx | 393 ++++++++++++++++++
.../wallet-mobile-modal/WalletView.tsx | 5 +-
app/components/wallet-mobile-modal/index.ts | 1 +
5 files changed, 405 insertions(+), 8 deletions(-)
create mode 100644 app/components/wallet-mobile-modal/ReferralDashboardView.tsx
diff --git a/app/components/MobileDropdown.tsx b/app/components/MobileDropdown.tsx
index b7577336..2cd32b65 100644
--- a/app/components/MobileDropdown.tsx
+++ b/app/components/MobileDropdown.tsx
@@ -18,7 +18,7 @@ import { useSmartWallets } from "@privy-io/react-auth/smart-wallets";
import { useTransactions } from "../context/TransactionsContext";
import { networks } from "../mocks";
import { Network, Token, TransactionHistory } from "../types";
-import { WalletView, HistoryView, SettingsView } from "./wallet-mobile-modal";
+import { WalletView, HistoryView, SettingsView, ReferralDashboardView } from "./wallet-mobile-modal";
import { slideUpAnimation } from "./AnimatedComponents";
import { FundWalletForm, TransferForm } from "./index";
import { CopyAddressWarningModal } from "./CopyAddressWarningModal";
@@ -33,7 +33,7 @@ export const MobileDropdown = ({
onViewReferrals?: () => void;
}) => {
const [currentView, setCurrentView] = useState<
- "wallet" | "settings" | "transfer" | "fund" | "history"
+ "wallet" | "settings" | "transfer" | "fund" | "history" | "referrals"
>("wallet");
const [isNetworkListOpen, setIsNetworkListOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
@@ -234,7 +234,7 @@ export const MobileDropdown = ({
}
onSettings={() => setCurrentView("settings")}
onClose={onClose}
- onViewReferrals={onViewReferrals}
+ onViewReferrals={() => setCurrentView("referrals")}
onHistory={() => setCurrentView("history")}
setSelectedNetwork={setSelectedNetwork}
onRefreshBalance={refreshBalance}
@@ -281,6 +281,13 @@ export const MobileDropdown = ({
}
/>
)}
+
+ {currentView === "referrals" && (
+ setCurrentView("wallet")}
+ />
+ )}
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
index 4e6b9939..2648f3a9 100644
--- a/app/components/Navbar.tsx
+++ b/app/components/Navbar.tsx
@@ -284,7 +284,6 @@ export const Navbar = () => {
onViewReferrals={() => setIsReferralOpen(true)}
/>
- {/* Referral dashboard (mobile flow opens this after the mobile dropdown closes) */}
setIsReferralOpen(false)}
diff --git a/app/components/wallet-mobile-modal/ReferralDashboardView.tsx b/app/components/wallet-mobile-modal/ReferralDashboardView.tsx
new file mode 100644
index 00000000..23f33f16
--- /dev/null
+++ b/app/components/wallet-mobile-modal/ReferralDashboardView.tsx
@@ -0,0 +1,393 @@
+"use client";
+import { useState, useEffect } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { Dialog, DialogPanel } from "@headlessui/react";
+import { toast } from "sonner";
+import { ArrowLeft02Icon, Copy01Icon } from "hugeicons-react";
+import { PiCheck } from "react-icons/pi";
+import { getReferralData } from "../../api/aggregator";
+import { usePrivy } from "@privy-io/react-auth";
+import { slideUpAnimation } from "../AnimatedComponents";
+// Mock data for presentation
+const MOCK_DATA = {
+ referral_code: "NB738K",
+ total_earned: 34.9,
+ total_pending: 2.0,
+ total_referrals: 11,
+ earned_count: 8,
+ pending_count: 3,
+ referrals: [
+ // Pending referrals (3)
+ {
+ id: "1",
+ wallet_address: "0x52c...b43f",
+ wallet_address_short: "0x52c...b43f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2025-06-05T10:30:00Z",
+ },
+ {
+ id: "2",
+ wallet_address: "0x52c...b43f",
+ wallet_address_short: "0x52c...b43f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2024-09-13T14:20:00Z",
+ },
+ {
+ id: "3",
+ wallet_address: "0x73a...d78f",
+ wallet_address_short: "0x73a...d78f",
+ status: "pending",
+ amount: 0.5,
+ created_at: "2024-10-31T09:15:00Z",
+ },
+ // Earned referrals (8)
+ {
+ id: "4",
+ wallet_address: "0x84b...e27a",
+ wallet_address_short: "0x84b...e27a",
+ status: "earned",
+ amount: 4.8,
+ created_at: "2024-11-01T11:00:00Z",
+ completed_at: "2024-11-02T15:30:00Z",
+ },
+ {
+ id: "5",
+ wallet_address: "0x92c...f15b",
+ wallet_address_short: "0x92c...f15b",
+ status: "earned",
+ amount: 6.1,
+ created_at: "2024-11-02T08:45:00Z",
+ completed_at: "2024-11-03T12:20:00Z",
+ },
+ {
+ id: "6",
+ wallet_address: "0xa3d...b63c",
+ wallet_address_short: "0xa3d...b63c",
+ status: "earned",
+ amount: 2.9,
+ created_at: "2024-11-03T16:30:00Z",
+ completed_at: "2024-11-04T10:15:00Z",
+ },
+ {
+ id: "7",
+ wallet_address: "0xb4e...a74d",
+ wallet_address_short: "0xb4e...a74d",
+ status: "earned",
+ amount: 8.4,
+ created_at: "2024-11-04T13:20:00Z",
+ completed_at: "2024-11-05T09:45:00Z",
+ },
+ {
+ id: "8",
+ wallet_address: "0xc5f...c85e",
+ wallet_address_short: "0xc5f...c85e",
+ status: "earned",
+ amount: 5.7,
+ created_at: "2024-11-05T10:10:00Z",
+ completed_at: "2024-11-06T14:30:00Z",
+ },
+ {
+ id: "9",
+ wallet_address: "0xa1b...d74f",
+ wallet_address_short: "0xa1b...d74f",
+ status: "earned",
+ amount: 3.2,
+ created_at: "2024-11-12T15:40:00Z",
+ completed_at: "2024-11-13T11:20:00Z",
+ },
+ {
+ id: "10",
+ wallet_address: "0xb3e...f59c",
+ wallet_address_short: "0xb3e...f59c",
+ status: "earned",
+ amount: 7.5,
+ created_at: "2024-11-19T12:25:00Z",
+ completed_at: "2024-11-20T08:50:00Z",
+ },
+ {
+ id: "11",
+ wallet_address: "0x7da...cba3",
+ wallet_address_short: "0x7da...cba3",
+ status: "earned",
+ amount: 3.2,
+ created_at: "2024-11-26T09:30:00Z",
+ completed_at: "2024-11-27T16:10:00Z",
+ },
+ ],
+};
+export const ReferralDashboardView = ({
+ isOpen,
+ onClose,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+}) => {
+ const { getAccessToken } = usePrivy();
+ const [referralData, setReferralData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState<"pending" | "earned">("pending");
+ const [showCopiedMessage, setShowCopiedMessage] = useState(false);
+ useEffect(() => {
+ if (!isOpen) return;
+ let mounted = true;
+ async function fetchData() {
+ try {
+ setIsLoading(true);
+ if (mounted) setReferralData(MOCK_DATA);
+ /* Production code (commented for demo):
+ const token = await getAccessToken();
+ if (!token) return;
+ const data = await getReferralData(token);
+ if (mounted) setReferralData(data);
+ */
+ } catch (error) {
+ console.error("Failed to fetch referral data:", error);
+ toast.error("Failed to load referral data");
+ } finally {
+ if (mounted) setIsLoading(false);
+ }
+ }
+ fetchData();
+ return () => {
+ mounted = false;
+ };
+ }, [getAccessToken, isOpen]);
+ const handleCopyCode = () => {
+ if (referralData?.referral_code) {
+ navigator.clipboard.writeText(referralData.referral_code);
+ setShowCopiedMessage(true);
+ setTimeout(() => setShowCopiedMessage(false), 2000);
+ }
+ };
+ const handleCopyLink = () => {
+ if (referralData?.referral_code) {
+ const link = `${window.location.origin}?ref=${referralData.referral_code}`;
+ navigator.clipboard.writeText(link);
+ toast.success("Referral link copied!");
+ }
+ };
+
+ const filteredReferrals: any[] = (referralData?.referrals || []).filter(
+ (r: any) => r.status === activeTab
+ );
+ // Generate avatar colors based on wallet address
+ const getAvatarColor = (address: string) => {
+ const colors = [
+ "from-purple-500 to-purple-600",
+ "from-blue-500 to-blue-600",
+ "from-cyan-500 to-cyan-600",
+ "from-teal-500 to-teal-600",
+ "from-orange-500 to-orange-600",
+ "from-pink-500 to-pink-600",
+ ];
+ const index = parseInt(address.slice(2, 4), 16) % colors.length;
+ return colors[index];
+ };
+ return (
+
+ {isOpen && (
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/app/components/wallet-mobile-modal/WalletView.tsx b/app/components/wallet-mobile-modal/WalletView.tsx
index a2283385..bcf53499 100644
--- a/app/components/wallet-mobile-modal/WalletView.tsx
+++ b/app/components/wallet-mobile-modal/WalletView.tsx
@@ -298,10 +298,7 @@ export const WalletView: React.FC = ({
{!isInjectedWallet && (
{
- onClose();
- if (onViewReferrals) setTimeout(() => onViewReferrals(), 260);
- }}
+ onViewReferrals={onViewReferrals ?? (() => { })}
/>
)}
diff --git a/app/components/wallet-mobile-modal/index.ts b/app/components/wallet-mobile-modal/index.ts
index bd28dc6c..9a4d3ba5 100644
--- a/app/components/wallet-mobile-modal/index.ts
+++ b/app/components/wallet-mobile-modal/index.ts
@@ -1,3 +1,4 @@
export { WalletView } from "./WalletView";
export { HistoryView } from "./HistoryView";
export { SettingsView } from "./SettingsView";
+export { ReferralDashboardView } from "./ReferralDashboardView";
From f18e8ae1f489ad7c9fcaf578daabf931bd67af7a Mon Sep 17 00:00:00 2001
From: sundayonah
Date: Fri, 21 Nov 2025 07:08:50 +0100
Subject: [PATCH 04/15] refactor(referral): simplify claim to KYC + $100 tx
volume, integrate ethers USDC crediting with balance check and updates in ui.
---
app/api/aggregator.ts | 45 ++-
app/api/internal/credit-wallet/route.ts | 162 --------
app/api/referral/claim/route.ts | 329 +++++-----------
.../referral/generate-referral-code/route.ts | 4 +-
app/api/referral/referral-data/route.ts | 85 ++++-
app/components/ReferralDashboard.tsx | 135 ++++---
app/components/ReferralDashboardSkeleton.tsx | 64 ++++
.../ReferralDashboardViewSkeleton.tsx | 64 ++++
.../ReferralDashboardView.tsx | 358 +++++++++---------
.../wallet-mobile-modal/WalletView.tsx | 5 +-
10 files changed, 586 insertions(+), 665 deletions(-)
delete mode 100644 app/api/internal/credit-wallet/route.ts
create mode 100644 app/components/ReferralDashboardSkeleton.tsx
create mode 100644 app/components/ReferralDashboardViewSkeleton.tsx
diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts
index fb541a77..7c21eb26 100644
--- a/app/api/aggregator.ts
+++ b/app/api/aggregator.ts
@@ -548,30 +548,53 @@ export async function submitReferralCode(
* Get user's referral data (code, earnings, referral list)
*/
export async function getReferralData(
- accessToken?: string,
+ accessToken: string,
walletAddress?: string,
): Promise> {
- const headers: Record = {};
- if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
+ if (!accessToken) {
+ return {
+ success: false,
+ error: "Authentication token is required",
+ };
+ }
const url = walletAddress
- ? `/api/referral/data?wallet_address=${encodeURIComponent(walletAddress)}`
- : `/api/referral/data`;
+ ? `/api/referral/referral-data?wallet_address=${encodeURIComponent(walletAddress)}`
+ : `/api/referral/referral-data`;
try {
- const response = await axios.get(url, { headers });
+ const response = await axios.get>(url, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
if (!response.data?.success) {
- return { success: false, error: response.data?.error || "Failed to fetch referral data", status: response.status };
+ return {
+ success: false,
+ error: response.data?.error || "Failed to fetch referral data",
+ status: response.status,
+ };
}
- return { success: true, data: response.data.data as ReferralData };
+ // The endpoint auto-generates code if missing, so data.referral_code should always exist
+ return { success: true, data: response.data.data };
} catch (error) {
if (axios.isAxiosError(error)) {
- const message = error.response?.data?.message || error.message || "Failed to fetch referral data";
- return { success: false, error: message, status: error.response?.status };
+ const message =
+ error.response?.data?.message ||
+ error.message ||
+ "Failed to fetch referral data";
+ return {
+ success: false,
+ error: message,
+ status: error.response?.status,
+ };
}
- return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
}
}
diff --git a/app/api/internal/credit-wallet/route.ts b/app/api/internal/credit-wallet/route.ts
deleted file mode 100644
index 65989cc9..00000000
--- a/app/api/internal/credit-wallet/route.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { supabaseAdmin } from "@/app/lib/supabase";
-import * as ethers from "ethers";
-
-export const POST = async (request: NextRequest) => {
- const internalAuth = process.env.INTERNAL_API_KEY;
- const headerAuth = request.headers.get("x-internal-auth");
-
- if (!internalAuth || headerAuth !== internalAuth) {
- return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 });
- }
-
- let body: any;
- try {
- body = await request.json();
- } catch (err) {
- return NextResponse.json({ success: false, error: "Invalid JSON body" }, { status: 400 });
- }
-
- const {
- idempotency_key,
- wallet_address,
- amount,
- currency = "USDC",
- referral_id,
- reason,
- metadata,
- } = body || {};
-
- if (!idempotency_key || !wallet_address || typeof amount !== "number") {
- return NextResponse.json({ success: false, error: "Missing required fields" }, { status: 400 });
- }
-
- try {
- // Check for existing credit by idempotency_key
- const { data: existing } = await supabaseAdmin
- .from("credits")
- .select("*")
- .eq("idempotency_key", idempotency_key)
- .limit(1)
- .single();
-
- if (existing) {
- if (existing.status === "failed") {
- return NextResponse.json(
- { success: false, error: "Existing credit is in failed state" },
- { status: 500 }
- );
- }
- return NextResponse.json({ success: true, data: existing });
- }
-
- const now = new Date().toISOString();
- const basePayload: any = {
- referral_id: referral_id || null,
- idempotency_key,
- wallet_address: wallet_address.toLowerCase(),
- amount_micro: amount,
- currency,
- status: "pending",
- external_tx: null,
- reason: reason || "credit",
- metadata: metadata || null,
- created_at: now,
- updated_at: now,
- };
-
- // Insert pending record (idempotency_key is UNIQUE in migration). Handle race with select on conflict.
- let created: any = null;
- try {
- const insertRes = await supabaseAdmin
- .from("credits")
- .insert(basePayload)
- .select()
- .single();
-
- if (insertRes.error) throw insertRes.error;
- created = insertRes.data;
- } catch (insErr) {
- // If insertion failed due to unique constraint, try to fetch existing row
- console.warn("Insert error for credit row, attempting to fetch existing:", (insErr as any)?.message || insErr);
- const { data: existing2 } = await supabaseAdmin
- .from("credits")
- .select("*")
- .eq("idempotency_key", idempotency_key)
- .limit(1)
- .single();
-
- if (existing2) {
- return NextResponse.json({ success: true, data: existing2 });
- }
-
- console.error("Failed to insert or retrieve existing credit row:", insErr);
- return NextResponse.json({ success: false, error: "Failed to create credit record" }, { status: 500 });
- }
-
- // If hot wallet config present, perform on-chain ERC20 transfer
- const HOT_KEY = process.env.HOT_WALLET_PRIVATE_KEY;
- const RPC_URL = process.env.RPC_URL;
- const TOKEN_ADDRESS = process.env.TOKEN_CONTRACT_ADDRESS;
- const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? 6);
-
- if (HOT_KEY && RPC_URL && TOKEN_ADDRESS) {
- try {
- const provider = new (ethers as any).providers.JsonRpcProvider(RPC_URL);
- const wallet = new (ethers as any).Wallet(HOT_KEY, provider);
- const erc20Abi = ["function transfer(address to, uint256 amount) public returns (bool)"];
- const contract = new (ethers as any).Contract(TOKEN_ADDRESS, erc20Abi, wallet);
-
- // amount is already in micro-units, convert to BigNumber
- const bnAmount = (ethers as any).BigNumber.from(amount.toString());
- const tx = await contract.transfer(wallet_address, bnAmount);
- const receipt = await tx.wait();
-
- // Update credits row as sent with tx hash
- const { error: updateErr } = await supabaseAdmin
- .from("credits")
- .update({ status: "sent", external_tx: receipt.transactionHash, updated_at: new Date().toISOString() })
- .eq("id", created.id);
-
- if (updateErr) {
- console.error("Failed to update credit row after transfer:", updateErr);
- }
-
- const { data: finalRow } = await supabaseAdmin.from("credits").select("*").eq("id", created.id).single();
- return NextResponse.json({ success: true, data: finalRow });
- } catch (txErr) {
- console.error("Transfer failed:", txErr);
- // mark row as failed
- try {
- await supabaseAdmin
- .from("credits")
- .update({ status: "failed", error: String((txErr as any)?.message || txErr), updated_at: new Date().toISOString() })
- .eq("id", created.id);
- } catch (markErr) {
- console.error("Failed to mark credit as failed:", markErr);
- }
- return NextResponse.json({ success: false, error: "Transfer failed" }, { status: 500 });
- }
- }
-
- // No hot-wallet configured: mark as sent (stub)
- try {
- const { error: finalErr } = await supabaseAdmin
- .from("credits")
- .update({ status: "sent", external_tx: "stubbed-credit", updated_at: new Date().toISOString() })
- .eq("id", created.id);
-
- if (finalErr) {
- console.error("Failed to finalize stub credit row:", finalErr);
- }
- const { data: finalRow } = await supabaseAdmin.from("credits").select("*").eq("id", created.id).single();
- return NextResponse.json({ success: true, data: finalRow });
- } catch (finalizeErr) {
- console.error("Error finalizing stub credit:", finalizeErr);
- return NextResponse.json({ success: false, error: "Internal error finalizing credit" }, { status: 500 });
- }
- } catch (error) {
- console.error("Error in internal credit-wallet:", error);
- return NextResponse.json({ success: false, error: "Internal error" }, { status: 500 });
- }
-};
diff --git a/app/api/referral/claim/route.ts b/app/api/referral/claim/route.ts
index d31f8c0d..4709fd14 100644
--- a/app/api/referral/claim/route.ts
+++ b/app/api/referral/claim/route.ts
@@ -8,278 +8,143 @@ import {
trackBusinessEvent,
} from "@/app/lib/server-analytics";
import { fetchKYCStatus } from "@/app/api/aggregator";
+import { ethers } from "ethers"; // npm i ethers
-// This should be called after a user completes KYC + first transaction
+// Minimal USDC ABI (for balanceOf and transfer)
+const USDC_ABI = [
+ "function balanceOf(address account) view returns (uint256)",
+ "function transfer(address to, uint256 amount) public returns (bool)",
+];
+
+// Env vars (add to .env.local; use secrets in prod)
+const FUNDING_ADDRESS = process.env.FUNDING_WALLET_ADDRESS!; // Hardcoded AA address, e.g., "0x..."
+const FUNDING_PK = process.env.FUNDING_WALLET_PRIVATE_KEY; // PK for signing
+const USDC_ADDR = process.env.USDC_CONTRACT_ADDRESS || "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // Mainnet USDC
+const RPC_URL = process.env.RPC_URL || "https://mainnet.infura.io/v3/YOUR_KEY";
+const DECIMALS = 6;
+
+// Credit function (with balance check)
+async function creditWallet(to: string, usd: number, refId: string): Promise<{ ok: boolean; txHash?: string; error?: string }> {
+ if (!FUNDING_ADDRESS || !FUNDING_PK) return { ok: false, error: "Funding not configured (dev)" };
+
+ try {
+ const provider = new ethers.JsonRpcProvider(RPC_URL);
+ const wallet = new ethers.Wallet(FUNDING_PK, provider);
+ const contract = new ethers.Contract(USDC_ADDR, USDC_ABI, wallet);
+
+ // Check balance first
+ const required = ethers.parseUnits(usd.toFixed(DECIMALS), DECIMALS);
+ const balance = await contract.balanceOf(FUNDING_ADDRESS);
+ if (balance < required) {
+ return { ok: false, error: `Insufficient funding balance: ${ethers.formatUnits(balance, DECIMALS)} USDC < $${usd}` };
+ }
+
+ // Transfer
+ const tx = await contract.transfer(to, required, { gasLimit: 100_000 });
+ const receipt = await tx.wait();
+
+ console.log(`Credited $${usd} USDC from ${FUNDING_ADDRESS} to ${to} (ref ${refId}): ${receipt.hash}`);
+ return { ok: true, txHash: receipt.hash };
+ } catch (err) {
+ console.error(`Credit failed for ${to}:`, err);
+ return { ok: false, error: (err as Error).message };
+ }
+}
+
+// POST handler (auto-credits on requirements)
export const POST = withRateLimit(async (request: NextRequest) => {
const startTime = Date.now();
try {
- // Get wallet address from middleware
- const walletAddress = request.headers
- .get("x-wallet-address")
- ?.toLowerCase();
-
+ const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase();
if (!walletAddress) {
- trackApiError(
- request,
- "/api/referral/claim",
- "POST",
- new Error("Unauthorized"),
- 401
- );
- return NextResponse.json(
- { success: false, error: "Unauthorized" },
- { status: 401 }
- );
+ trackApiError(request, "/api/referral/claim", "POST", new Error("Unauthorized"), 401);
+ return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
}
- // Track API request
- trackApiRequest(request, "/api/referral/claim", "POST", {
- wallet_address: walletAddress,
- });
+ trackApiRequest(request, "/api/referral/claim", "POST", { wallet_address: walletAddress });
- // Find pending referral for this user
- const { data: referral, error: referralError } = await supabaseAdmin
+ // Find pending referral
+ const { data: referral, error: refErr } = await supabaseAdmin
.from("referrals")
.select("*")
.eq("referred_wallet_address", walletAddress)
.eq("status", "pending")
.single();
- if (referralError && referralError.code !== "PGRST116") {
- throw referralError;
- }
-
+ if (refErr && refErr.code !== "PGRST116") throw refErr;
if (!referral) {
- return NextResponse.json(
- {
- success: false,
- error: "No pending referral found",
- },
- { status: 404 }
- );
+ return NextResponse.json({ success: false, error: "No pending referral" }, { status: 404 });
}
- // Verify KYC for both referrer and referred using authoritative aggregator
- try {
- const [referrerKyc, referredKyc] = await Promise.all([
- fetchKYCStatus(referral.referrer_wallet_address),
- fetchKYCStatus(walletAddress),
- ]);
-
- const referrerVerified = referrerKyc?.data?.status === "verified";
- const referredVerified = referredKyc?.data?.status === "verified";
-
- if (!referrerVerified || !referredVerified) {
- const missing = [] as string[];
- if (!referrerVerified) missing.push("referrer");
- if (!referredVerified) missing.push("referred user");
+ // KYC check (both parties)
+ const [refKyc, refdKyc] = await Promise.all([
+ fetchKYCStatus(referral.referrer_wallet_address),
+ fetchKYCStatus(walletAddress),
+ ]);
- return NextResponse.json(
- {
- success: false,
- error: `KYC verification required for: ${missing.join(", ")}`,
- },
- { status: 400 }
- );
- }
- } catch (kycErr) {
- console.error("Error checking KYC status:", kycErr);
- const responseTime = Date.now() - startTime;
- trackApiError(
- request,
- "/api/referral/claim",
- "POST",
- kycErr as Error,
- 500,
- { response_time_ms: responseTime }
- );
-
- return NextResponse.json({ success: false, error: "Failed to verify KYC status" }, { status: 500 });
+ if (refKyc?.data?.status !== "verified" || refdKyc?.data?.status !== "verified") {
+ return NextResponse.json({ success: false, error: "KYC required for referrer/referred" }, { status: 400 });
}
- // SERVER-SIDE: verify referred user's qualifying transaction
- try {
- const { data: txs, error: txErr } = await supabaseAdmin
- .from("transactions")
- .select("*")
- .eq("wallet_address", walletAddress)
- .eq("status", "completed")
- .order("created_at", { ascending: true })
- .limit(1);
-
- if (txErr) {
- throw txErr;
- }
-
- if (!txs || txs.length === 0) {
- return NextResponse.json({ success: false, error: "No qualifying completed transaction found for referred user" }, { status: 400 });
- }
+ // Tx volume check ($100 total)
+ const { data: txs, error: txErr } = await supabaseAdmin
+ .from("transactions")
+ .select("amount_usd, amount_received, status, created_at")
+ .eq("wallet_address", walletAddress)
+ .eq("status", "completed")
+ .order("created_at");
+
+ if (txErr) throw txErr;
+ if (!txs?.length) {
+ return NextResponse.json({ success: false, error: "No completed txs" }, { status: 400 });
+ }
- const tx = txs[0];
- const amountUsd = tx.amount_usd ?? tx.amount_received ?? 0;
- if (Number(amountUsd) < 20) {
- return NextResponse.json({ success: false, error: "Referred user's first completed transaction does not meet the minimum amount requirement" }, { status: 400 });
- }
- } catch (txCheckErr) {
- console.error("Error checking transactions:", txCheckErr);
- const responseTime = Date.now() - startTime;
- trackApiError(request, "/api/referral/claim", "POST", txCheckErr as Error, 500, { response_time_ms: responseTime });
- return NextResponse.json({ success: false, error: "Failed to verify transactions" }, { status: 500 });
+ const totalUsd = txs.reduce((sum, t) => sum + Number(t.amount_usd || t.amount_received || 0), 0);
+ if (totalUsd < 100) {
+ return NextResponse.json({ success: false, error: `Total tx volume $${totalUsd.toFixed(2)} < $100` }, { status: 400 });
}
- // SAFE STATUS TRANSITION: pending -> processing
- const { data: processingRows, error: processingErr } = await supabaseAdmin
+ // Lock: pending -> processing
+ const { data: proc, error: procErr } = await supabaseAdmin
.from("referrals")
.update({ status: "processing" })
.eq("id", referral.id)
.eq("status", "pending")
.select();
- if (processingErr) {
- throw processingErr;
- }
-
- if (!processingRows || processingRows.length === 0) {
- // Already being processed or completed by another worker
- return NextResponse.json({ success: false, error: "Referral is already being processed or has been completed" }, { status: 409 });
- }
-
- // Credit rewards to both users
- // Best-effort: call internal wallet crediting endpoint when configured.
- // If crediting fails, roll back referral status to pending to keep consistency.
- const internalAuth = process.env.INTERNAL_API_KEY;
- const internalBase = process.env.INTERNAL_API_BASE_URL || new URL(request.url).origin;
- const isProd = process.env.NODE_ENV === "production";
-
- async function creditWallet(wallet: string, amountMicro: number, referralId: any) {
- if (!internalAuth) {
- if (isProd) {
- throw new Error("Internal wallet service not configured");
- }
- // Wallet service not configured; skip actual crediting.
- console.error("Internal wallet service not configured, skipping credit for", wallet);
- return { ok: false, skipped: true };
- }
-
- const resp = await fetch(`${internalBase}/api/internal/credit-wallet`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "x-internal-auth": internalAuth,
- },
- body: JSON.stringify({
- wallet_address: wallet,
- amount: amountMicro,
- currency: "USDC",
- reason: "referral_reward",
- referral_id: referralId,
- idempotency_key: `referral:${referral.id}:${wallet}`,
- }),
- });
-
- if (!resp.ok) {
- const text = await resp.text().catch(() => "");
- throw new Error(`Wallet credit failed: ${resp.status} ${text}`);
- }
-
- return { ok: true };
- }
-
- try {
- // credit referrer
- const amountMicro = Math.round((referral.reward_amount || 1.0) * 1_000_000);
- await creditWallet(referral.referrer_wallet_address, amountMicro, referral.id);
- // credit referred user
- await creditWallet(walletAddress, amountMicro, referral.id);
- } catch (walletError) {
- console.error("Wallet crediting failed, attempting rollback:", walletError);
- // Roll back referral status to pending
- try {
- await supabaseAdmin.from("referrals").update({ status: "pending", completed_at: null }).eq("id", referral.id);
- } catch (rbErr) {
- console.error("Failed to roll back referral status:", rbErr);
- }
-
- const responseTime = Date.now() - startTime;
- trackApiError(request, "/api/referral/claim", "POST", walletError as Error, 500, { response_time_ms: responseTime });
+ if (procErr) throw procErr;
+ if (!proc?.length) return NextResponse.json({ success: false, error: "Already processing" }, { status: 409 });
- return NextResponse.json({ success: false, error: "Failed to credit referral rewards" }, { status: 500 });
- }
+ // Auto-credit $1 each (with balance check)
+ const reward = referral.reward_amount || 1.0;
+ const [refCredit, refdCredit] = await Promise.all([
+ creditWallet(referral.referrer_wallet_address, reward, referral.id),
+ creditWallet(walletAddress, reward, referral.id),
+ ]);
- // Mark referral as earned and set completed_at
- try {
- const { error: earnErr } = await supabaseAdmin
- .from("referrals")
- .update({ status: "earned", completed_at: new Date().toISOString() })
- .eq("id", referral.id);
- if (earnErr) throw earnErr;
- } catch (earnErr) {
- console.error("Failed to finalize referral status as earned:", earnErr);
- // Note: credits may already have been sent; log and continue
+ if (!refCredit.ok || !refdCredit.ok) {
+ // Rollback
+ await supabaseAdmin.from("referrals").update({ status: "pending" }).eq("id", referral.id);
+ return NextResponse.json({ success: false, error: "Credit failed—try later" }, { status: 500 });
}
- const responseTime = Date.now() - startTime;
- trackApiResponse(
- "/api/referral/claim",
- "POST",
- 200,
- responseTime,
- {
- wallet_address: walletAddress,
- referrer_wallet_address: referral.referrer_wallet_address,
- referral_id: referral.id,
- reward_amount: referral.reward_amount,
- }
- );
-
- // Track business events
- trackBusinessEvent("Referral Completed", {
- referred_wallet_address: walletAddress,
- referrer_wallet_address: referral.referrer_wallet_address,
- referral_id: referral.id,
- reward_amount: referral.reward_amount,
- });
-
- trackBusinessEvent("Referral Reward Earned", {
- wallet_address: referral.referrer_wallet_address,
- referred_wallet_address: walletAddress,
- reward_amount: referral.reward_amount,
- });
+ // Finalize: earned
+ await supabaseAdmin.from("referrals").update({ status: "earned", completed_at: new Date().toISOString() }).eq("id", referral.id);
- trackBusinessEvent("Referral Bonus Received", {
- wallet_address: walletAddress,
- referrer_wallet_address: referral.referrer_wallet_address,
- reward_amount: referral.reward_amount,
- });
+ trackApiResponse("/api/referral/claim", "POST", 200, Date.now() - startTime, { wallet_address: walletAddress });
+ trackBusinessEvent("Referral Claimed", { referral_id: referral.id, reward });
return NextResponse.json({
success: true,
data: {
- referral_id: referral.id,
- referrer_wallet_address: referral.referrer_wallet_address,
- reward_amount: referral.reward_amount,
- message: "Referral rewards have been credited!",
- },
+ message: "Rewards credited!",
+ txHashes: { referrer: refCredit.txHash, referred: refdCredit.txHash }
+ }
});
} catch (error) {
- console.error("Error completing referral:", error);
-
- const responseTime = Date.now() - startTime;
- trackApiError(
- request,
- "/api/referral/claim",
- "POST",
- error as Error,
- 500,
- {
- response_time_ms: responseTime,
- }
- );
-
- return NextResponse.json(
- { success: false, error: "Failed to claim referral" },
- { status: 500 }
- );
+ console.error("Referral claim error:", error);
+ trackApiError(request, "/api/referral/claim", "POST", error as Error, 500);
+ return NextResponse.json({ success: false, error: "Claim failed" }, { status: 500 });
}
});
\ No newline at end of file
diff --git a/app/api/referral/generate-referral-code/route.ts b/app/api/referral/generate-referral-code/route.ts
index 0952cfc4..0fb4622d 100644
--- a/app/api/referral/generate-referral-code/route.ts
+++ b/app/api/referral/generate-referral-code/route.ts
@@ -57,8 +57,8 @@ export const GET = withRateLimit(async (request: NextRequest) => {
throw fetchError;
}
- // If user has a code, return it
- if (existingUser?.referral_code) {
+ // If user has a referral code (not null/empty), return it
+ if (existingUser?.referral_code && existingUser.referral_code.trim() !== "") {
const responseTime = Date.now() - startTime;
trackApiResponse("/api/referral/generate-referral-code", "GET", 200, responseTime, {
wallet_address: walletAddress,
diff --git a/app/api/referral/referral-data/route.ts b/app/api/referral/referral-data/route.ts
index 50dda2e1..c01ddf3e 100644
--- a/app/api/referral/referral-data/route.ts
+++ b/app/api/referral/referral-data/route.ts
@@ -5,8 +5,19 @@ import {
trackApiRequest,
trackApiResponse,
trackApiError,
+ trackBusinessEvent,
} from "@/app/lib/server-analytics";
+// Generate a unique 6-character referral code (NB + 4 alphanumeric)
+function generateReferralCode(): string {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ let code = "NB";
+ for (let i = 0; i < 4; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return code;
+}
+
export const GET = withRateLimit(async (request: NextRequest) => {
const startTime = Date.now();
@@ -36,24 +47,74 @@ export const GET = withRateLimit(async (request: NextRequest) => {
});
// Get user's referral code
- const { data: userData, error: userError } = await supabaseAdmin
+ let { data: userData, error: userError } = await supabaseAdmin
.from("users")
.select("referral_code")
.eq("wallet_address", walletAddress)
.single();
- if (userError) {
+ if (userError && userError.code !== "PGRST116") {
throw userError;
}
- if (!userData?.referral_code) {
- return NextResponse.json(
- {
- success: false,
- error: "No referral code found. Please generate one first.",
- },
- { status: 404 }
- );
+ // Auto-generate if no valid code (null/empty/missing user)
+ let referralCode = userData?.referral_code;
+ let isNewlyGenerated = false;
+ if (!referralCode || referralCode.trim() === "") {
+ // Generate unique code
+ let code: string | null = null;
+ let attempts = 0;
+ const maxAttempts = 10;
+
+ while (!code && attempts < maxAttempts) {
+ const candidate = generateReferralCode();
+ attempts++;
+
+ const { data: existing, error: existingError } = await supabaseAdmin
+ .from("users")
+ .select("wallet_address")
+ .eq("referral_code", candidate)
+ .single();
+
+ if (existingError && existingError.code !== "PGRST116") {
+ throw existingError;
+ }
+
+ if (!existing) {
+ code = candidate;
+ }
+ }
+
+ if (!code) {
+ throw new Error("Failed to generate unique referral code");
+ }
+
+ // Upsert user with code
+ const { data: upsertData, error: upsertError } = await supabaseAdmin
+ .from("users")
+ .upsert(
+ {
+ wallet_address: walletAddress,
+ referral_code: code,
+ updated_at: new Date().toISOString(),
+ },
+ { onConflict: "wallet_address" }
+ )
+ .select("referral_code")
+ .single();
+
+ if (upsertError) {
+ throw upsertError;
+ }
+
+ referralCode = upsertData.referral_code;
+ isNewlyGenerated = true;
+
+ // Track generation
+ trackBusinessEvent("Referral Code Auto-Generated", {
+ wallet_address: walletAddress,
+ referral_code: referralCode,
+ });
}
// Get all referrals made by this user
@@ -103,13 +164,14 @@ export const GET = withRateLimit(async (request: NextRequest) => {
const response = {
success: true,
data: {
- referral_code: userData.referral_code,
+ referral_code: referralCode,
total_earned: totalEarned,
total_pending: totalPending,
total_referrals: referrals.length,
earned_count: earnedReferrals.length,
pending_count: pendingReferrals.length,
referrals: referralList,
+ newly_generated: isNewlyGenerated, // Optional: For UI toast
},
};
@@ -120,6 +182,7 @@ export const GET = withRateLimit(async (request: NextRequest) => {
total_earned: totalEarned,
total_pending: totalPending,
total_referrals: referrals.length,
+ newly_generated: isNewlyGenerated,
});
return NextResponse.json(response);
diff --git a/app/components/ReferralDashboard.tsx b/app/components/ReferralDashboard.tsx
index ee3d61c9..0a234cb0 100644
--- a/app/components/ReferralDashboard.tsx
+++ b/app/components/ReferralDashboard.tsx
@@ -9,6 +9,7 @@ import { PiCheck } from "react-icons/pi";
import { getReferralData } from "../api/aggregator";
import { usePrivy } from "@privy-io/react-auth";
import { sidebarAnimation } from "./AnimatedComponents";
+import { ReferralDashboardSkeleton } from "./ReferralDashboardSkeleton";
// Mock data for presentation
const MOCK_DATA = {
@@ -16,7 +17,7 @@ const MOCK_DATA = {
total_earned: 34.9,
total_pending: 2.0,
total_referrals: 11,
- earned_count: 8,
+ earned_count: 12,
pending_count: 3,
referrals: [
// Pending referrals (3)
@@ -141,18 +142,21 @@ export const ReferralDashboard = ({
async function fetchData() {
try {
setIsLoading(true);
- if (mounted) setReferralData(MOCK_DATA);
-
- /* Production code (commented for demo):
const token = await getAccessToken();
if (!token) return;
- const data = await getReferralData(token);
- if (mounted) setReferralData(data);
- */
-
+ const response = await getReferralData(token);
+ if (mounted && response.success) {
+ setReferralData(response.data);
+ } else if (mounted) {
+ // toast.error(response.error || "Failed to load referral data");
+ setReferralData(null);
+ }
} catch (error) {
console.error("Failed to fetch referral data:", error);
- toast.error("Failed to load referral data");
+ if (mounted) {
+ toast.error("Failed to load referral data");
+ setReferralData(null);
+ }
} finally {
if (mounted) setIsLoading(false);
}
@@ -199,31 +203,31 @@ export const ReferralDashboard = ({
return colors[index];
};
- // if (isLoading) {
- // return (
- //
- // {isOpen && (
- //
- // )}
- //
- // );
- // }
+ if (isLoading) {
+ return (
+
+ {isOpen && (
+
+ )}
+
+ );
+ }
return (
@@ -251,7 +255,7 @@ export const ReferralDashboard = ({
>
-
+
Referrals
@@ -262,7 +266,7 @@ export const ReferralDashboard = ({
Earned
-
+
{referralData?.total_earned?.toFixed(1) ?? "0.0"} USDC
@@ -270,7 +274,7 @@ export const ReferralDashboard = ({
Pending
-
+
{referralData?.total_pending?.toFixed(0) ?? "0"} USDC
@@ -283,17 +287,18 @@ export const ReferralDashboard = ({
- {referralData?.referral_code || "LOADING"}
+ {referralData?.referral_code ?? "No code yet"}