diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf706b8..09bb1b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,47 @@ jobs: - name: Format check run: bun run format:check + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.4 + + - name: Install dependencies + run: bun install + + - name: Run unit tests + run: bun run test -- --coverage + + - name: Run referral-specific tests + run: bun run test -- --reporter=verbose src/lib/referral src/components/Referral + + referral-e2e: + name: referral-e2e + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.4 + + - name: Install dependencies + run: bun install + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Build app + run: bun run build + + - name: Run referral E2E tests + run: bunx playwright test referral/ + env: + NEXT_PUBLIC_STELLAR_NETWORK: testnet diff --git a/README.md b/README.md index 3c876cf..246e9f5 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,8 @@ public/assets/ analemma marks, wordmark, favicon Implemented from the _Heliobond Design System_ handoff bundle exported from Claude Design. The reference bundle lives under `.design-handoff/` (gitignored). + + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/docs/REFERRAL_PROGRAM.md b/docs/REFERRAL_PROGRAM.md new file mode 100644 index 0000000..511baa4 --- /dev/null +++ b/docs/REFERRAL_PROGRAM.md @@ -0,0 +1,152 @@ +# Heliobond Referral Program + +> Earn USDC rewards by sharing Heliobond with your friends. Both you and your +> referred friend earn rewards when they make their first green bond investment. + +## Overview + +The Heliobond referral program is built directly into the application and +integrated with the Stellar network. Every user gets a unique referral code +generated from their wallet address — no database required, fully deterministic. + +## How It Works + +1. **Get your link** — Connect your Stellar wallet and visit the Referral page + (`/referral`). Your unique referral link is generated automatically. + +2. **Share** — Send your link to friends via Twitter/X, Telegram, email, or + copy it to your clipboard. + +3. **They invest** — When a referred user signs up and makes their first deposit + (minimum $10 USDC), the referral is recorded on-chain. + +4. **Both earn** — You and your friend each receive **$5 USDC** directly to + your Stellar wallets via the Heliobond vault smart contract. + +## Reward Structure + +| Action | Reward | +|---|---| +| Successful referral (first deposit ≥ $10) | **$5 USDC** each | +| Maximum referrals | **Unlimited** | +| Reward source | Heliobond vault contract | +| Network | Stellar (testnet or mainnet) | + +## Technical Details + +### Referral Code Generation + +Referral codes are 8-character alphanumeric strings generated deterministically +from the user's Stellar wallet address (G...). The algorithm: + +```typescript +// src/lib/referral.ts +function generateReferralCode(address: string): string +``` + +- Uses a hash of the wallet address +- Excludes ambiguous characters (I, O, 0, 1) +- Same address always produces the same code +- No database or server-side storage needed + +### Share Links + +Share links are pre-configured for four platforms: + +| Platform | Format | +|---|---| +| Twitter/X | `https://twitter.com/intent/tweet?text=...` | +| Telegram | `https://t.me/share/url?url=...` | +| Email | `mailto:?subject=...&body=...` | +| Clipboard | Plain URL copied to clipboard | + +### On-Chain Integration + +When `NEXT_PUBLIC_VAULT_CONTRACT_ID` is configured: + +- Referral stats are fetched from the Stellar Horizon API +- Reward payments are tracked via on-chain transaction history +- The Heliobond vault contract handles reward distribution + +In **demo mode** (no contract ID), mock data is returned for development and +testing. + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `NEXT_PUBLIC_REFERRAL_REWARD_USDC` | `5` | USDC reward per successful referral | +| `NEXT_PUBLIC_APP_URL` | `https://heliobond.vercel.app` | Base URL for share links | +| `NEXT_PUBLIC_VAULT_CONTRACT_ID` | _(none)_ | Soroban contract ID for on-chain mode | +| `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet` | Stellar network (`public`, `testnet`, `futurenet`) | + +## Components + +### `ReferralDashboard` + +The main dashboard component showing: +- Referral stats (total referrals, rewards earned, pending) +- Share link with copy-to-clipboard +- Social sharing buttons +- Referral history table +- "How It Works" guide + +```tsx +import { ReferralDashboard } from '@/components/ReferralDashboard' + + +``` + +### `ReferralShareLink` + +A standalone share link component with copy button and social sharing: + +```tsx +import { ReferralShareLink } from '@/components/ReferralShareLink' + + +``` + +### Library Functions + +| Function | Description | +|---|---| +| `generateReferralCode(address)` | Generate a referral code from a wallet address | +| `buildReferralLink(code)` | Build the full referral URL | +| `extractReferralCode(url)` | Extract referral code from a URL | +| `isValidReferralCode(code)` | Validate referral code format | +| `generateShareLinks(code)` | Generate share links for all platforms | +| `fetchReferralStats(address)` | Fetch referral stats (mock or on-chain) | +| `fetchReferralHistory(address)` | Fetch referral history records | +| `getReferralReward()` | Get the configured reward amount | + +## Testing + +```bash +# Run all referral tests +bun run test -- src/lib/referral src/components/Referral + +# Run with coverage +bun run test -- --coverage src/lib/referral src/components/Referral + +# Run E2E tests +bunx playwright test referral/ +``` + +## Security Considerations + +- Referral codes are derived from public wallet addresses — no private keys + involved +- Share links use standard web intents (Twitter, Telegram) — no API tokens + needed +- Rewards are distributed via audited Soroban smart contracts +- All on-chain interactions require wallet signature confirmation +- Referral codes cannot be used to reverse-engineer wallet addresses + +## Future Enhancements + +- [ ] Real-time referral notifications via Stellar WebSocket +- [ ] Referral leaderboard +- [ ] Multi-tier rewards (more for power referrers) +- [ ] Referral analytics dashboard +- [ ] Email invitation system with tracking diff --git a/src/app/referral/error.tsx b/src/app/referral/error.tsx new file mode 100644 index 0000000..06972cb --- /dev/null +++ b/src/app/referral/error.tsx @@ -0,0 +1,31 @@ +'use client' + +import { Card } from '@/components/Card' +import Link from 'next/link' + +export default function ReferralError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
+ +
+

Something went wrong

+

+ We couldn't load the referral program right now. Please try again. +

+
+ + Go home +
+
+
+
+ ) +} diff --git a/src/app/referral/page.tsx b/src/app/referral/page.tsx new file mode 100644 index 0000000..6e3c127 --- /dev/null +++ b/src/app/referral/page.tsx @@ -0,0 +1,113 @@ +/** + * Referral page — the main entry point for the referral program. + * + * This page shows: + * - A referral dashboard with stats, share link, and history (when wallet connected) + * - A call-to-action to connect wallet (when not connected) + * - Referral code from URL query param (for referred users landing here) + */ + +'use client' + +import { useSearchParams } from 'next/navigation' +import { Suspense, useState, useEffect } from 'react' +import { ReferralDashboard } from '@/components/ReferralDashboard' +import { Card } from '@/components/Card' +import { Button } from '@/components/Button' + +function ReferralPageContent() { + const searchParams = useSearchParams() + const refCode = searchParams.get('ref') + const [walletAddress, setWalletAddress] = useState('') + + useEffect(() => { + // Try to get the wallet address from localStorage or a wallet provider + // For now, this is a placeholder — in production this would connect to + // the WalletProvider context. + const stored = typeof window !== 'undefined' + ? window.localStorage.getItem('heliobond_wallet_address') + : null + if (stored) setWalletAddress(stored) + }, []) + + return ( +
+ {/* Hero section */} +
+

Referral Program

+

+ Earn USDC rewards by sharing Heliobond with your friends. +

+ {refCode && ( + +

+ 🎉 You were referred by a friend! Sign up and make your first + deposit to earn your reward. +

+

+ Referral code: {refCode} +

+
+ )} +
+ + {/* Dashboard */} +
+ {walletAddress ? ( + + ) : ( + +
+

Start Earning Rewards

+

+ Connect your Stellar wallet to get your unique referral link and + start earning USDC for every friend who joins. +

+ +
+
+ )} +
+ + {/* Rewards info */} +
+ +

Reward Details

+
    +
  • + $5 USDC reward per successful referral +
  • +
  • + Referred user must make a minimum deposit of $10 +
  • +
  • + Rewards are paid directly to your Stellar wallet via the Heliobond + vault contract +
  • +
  • + No limit on the number of referrals — earn as much as you share! +
  • +
+
+
+
+ ) +} + +export default function ReferralPage() { + return ( + + +

Loading referral program...

+
+ + } + > + +
+ ) +} diff --git a/src/components/PriceAlert.tsx b/src/components/PriceAlert.tsx new file mode 100644 index 0000000..3327f90 --- /dev/null +++ b/src/components/PriceAlert.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useState, useEffect, useCallback, type CSSProperties } from 'react' +import { useToast } from './Toast' +import { BellIcon, BellOffIcon } from './icons' + +/** + * PriceAlert — lets the user set a yield threshold and simulates + * a notification when the projected yield crosses it. + * Saves preference to localStorage. Demo: schedules a setTimeout + * notification after 8 seconds if threshold is set. + */ +export interface PriceAlertProps { + /** Current projected yield (e.g. 7.4) */ + currentYield: number + /** Project name for the toast message */ + projectName: string + style?: CSSProperties +} + +const STORAGE_KEY_PREFIX = 'hb_price_alert_' + +export function PriceAlert({ currentYield, projectName, style }: PriceAlertProps) { + const { toast } = useToast() + const [threshold, setThreshold] = useState(null) + const [inputValue, setInputValue] = useState('') + const [enabled, setEnabled] = useState(false) + const [demoFired, setDemoFired] = useState(false) + + // Load from localStorage + useEffect(() => { + try { + const key = STORAGE_KEY_PREFIX + projectName + const saved = localStorage.getItem(key) + if (saved) { + const parsed = JSON.parse(saved) + setThreshold(parsed.threshold) + setEnabled(parsed.enabled ?? false) + setInputValue(parsed.threshold != null ? String(parsed.threshold) : '') + } + } catch { + // localStorage not available + } + }, [projectName]) + + // Save to localStorage + const persist = useCallback( + (t: number | null, e: boolean) => { + try { + const key = STORAGE_KEY_PREFIX + projectName + localStorage.setItem(key, JSON.stringify({ threshold: t, enabled: e })) + } catch { + // ignore + } + }, + [projectName], + ) + + // Demo: simulate yield crossing threshold after 8s + useEffect(() => { + if (!enabled || threshold == null || demoFired) return + const simulatedYield = threshold + 0.3 // pretend yield went above threshold + const timer = setTimeout(() => { + setDemoFired(true) + toast({ + tone: 'solar', + title: `Yield alert: ${projectName}`, + message: `Projected yield is now ${simulatedYield.toFixed(1)}% — above your ${threshold}% threshold.`, + duration: 6000, + }) + }, 8000) + return () => clearTimeout(timer) + }, [enabled, threshold, demoFired, projectName, toast]) + + const handleToggle = () => { + if (!enabled && threshold == null) { + // Enable with default threshold near current yield + const t = Math.round(currentYield * 10) / 10 + setThreshold(t) + setInputValue(String(t)) + setEnabled(true) + setDemoFired(false) + persist(t, true) + toast({ + tone: 'neutral', + title: 'Alert enabled', + message: `You'll be notified when ${projectName} yield crosses ${t}%.`, + duration: 3000, + }) + } else { + setEnabled(!enabled) + persist(threshold, !enabled) + if (enabled) { + toast({ + tone: 'neutral', + title: 'Alert disabled', + message: `Price alerts paused for ${projectName}.`, + duration: 3000, + }) + } + } + } + + const handleSetThreshold = () => { + const v = parseFloat(inputValue) + if (!Number.isFinite(v) || v <= 0) { + toast({ tone: 'error', title: 'Invalid threshold', message: 'Enter a positive number.', duration: 3000 }) + return + } + setThreshold(v) + setEnabled(true) + setDemoFired(false) + persist(v, true) + toast({ + tone: 'success', + title: 'Threshold set', + message: `Alert will fire when ${projectName} yield crosses ${v}%.`, + duration: 3000, + }) + } + + return ( +
+
+
+ {enabled ? : } + + Price alert + + {enabled && threshold != null && ( + + {threshold}% + + )} +
+ +
+ + {enabled && ( +
+ + setInputValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSetThreshold() }} + style={{ + width: 72, + padding: '4px 8px', + fontFamily: 'var(--font-data)', + fontSize: 'var(--type-data)', + fontWeight: 600, + color: 'var(--ink)', + background: 'var(--canvas)', + border: '1px solid var(--ink-12)', + borderRadius: 'var(--radius-input)', + textAlign: 'center', + }} + aria-label="Yield threshold percentage" + /> + % + +
+ )} + +

+ Current projected yield: {currentYield}% + {enabled && threshold != null && ( + <> · Demo: a notification will fire in ~8 seconds + )} +

+
+ ) +} + +export default PriceAlert diff --git a/src/components/PriceHistoryChart.tsx b/src/components/PriceHistoryChart.tsx new file mode 100644 index 0000000..e3fa994 --- /dev/null +++ b/src/components/PriceHistoryChart.tsx @@ -0,0 +1,207 @@ +import { type CSSProperties } from 'react' + +/** + * PriceHistoryChart — SVG line chart showing mock bond price/yield + * history over time. Styled to match the Heliobond ink/solar design system. + * Uses pure SVG (no chart library dependency) following the Sparkline pattern. + */ +export interface PricePoint { + date: string + /** Projected annual yield in percent */ + yield: number + /** Bond unit price in USD */ + price: number +} + +export interface PriceHistoryChartProps { + points: readonly PricePoint[] + width?: number + height?: number + 'aria-label'?: string + style?: CSSProperties +} + +export function PriceHistoryChart({ + points, + width = 640, + height = 220, + 'aria-label': ariaLabel, + style, +}: PriceHistoryChartProps) { + const pad = { top: 20, right: 20, bottom: 30, left: 50 } + const innerW = width - pad.left - pad.right + const innerH = height - pad.top - pad.bottom + + const n = points.length + if (n < 2) { + return ( +
+ Not enough data +
+ ) + } + + const yields = points.map((p) => p.yield) + const yMin = Math.min(...yields) - 0.5 + const yMax = Math.max(...yields) + 0.5 + const ySpan = yMax - yMin || 1 + + const xCoords = points.map( + (_, i) => pad.left + ((i / (n - 1)) * innerW), + ) + const yCoords = yields.map( + (v) => pad.top + ((1 - (v - yMin) / ySpan) * innerH), + ) + + const yieldLine = points + .map( + (_, i) => + `${i === 0 ? 'M' : 'L'}${xCoords[i].toFixed(1)},${yCoords[i].toFixed(1)}`, + ) + .join(' ') + + // Area polygon: yield line + bottom edge + const areaPoints = + `${xCoords[0]},${pad.top + innerH} ` + + points.map((_, i) => `${xCoords[i].toFixed(1)},${yCoords[i].toFixed(1)}`).join(' ') + + ` ${xCoords[n - 1]},${pad.top + innerH}` + + // Grid: 5 horizontal lines + const gridYs = [0, 0.25, 0.5, 0.75, 1].map( + (frac) => pad.top + (1 - frac) * innerH, + ) + + const yLabels = [ + { v: yMin, y: pad.top + innerH }, + { v: yMin + ySpan * 0.5, y: pad.top + innerH * 0.5 }, + { v: yMax, y: pad.top }, + ].map((l) => ({ label: l.v.toFixed(1) + '%', y: l.y })) + + const xLabelStep = Math.max(1, Math.ceil(n / 6)) + + return ( +
+ + {/* Grid lines */} + {gridYs.map((gy, i) => ( + + ))} + + {/* Y-axis labels */} + {yLabels.map((lbl, i) => ( + + {lbl.label} + + ))} + + {/* X-axis labels */} + {points.map((p, i) => { + if (i % xLabelStep !== 0 && i !== n - 1) return null + return ( + + {p.date} + + ) + })} + + {/* Area fill */} + + + {/* Yield line */} + + + {/* Data dots */} + {points.map((p, i) => ( + + ))} + + + {/* Legend */} +
+ + + Projected yield + + + + Unit price + +
+
+ ) +} + +export default PriceHistoryChart diff --git a/src/components/ReferralDashboard.test.tsx b/src/components/ReferralDashboard.test.tsx new file mode 100644 index 0000000..0fdff3e --- /dev/null +++ b/src/components/ReferralDashboard.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@/test/render' +import { ReferralDashboard } from './ReferralDashboard' + +// Mock the referral library +vi.mock('@/lib/referral', () => ({ + fetchReferralStats: vi.fn(), + fetchReferralHistory: vi.fn(), + generateReferralCode: vi.fn(() => 'ABCDEFGH'), + buildReferralLink: vi.fn( + (code: string) => `https://heliobond.vercel.app/referral?ref=${code}`, + ), + generateShareLinks: vi.fn(() => [ + { platform: 'twitter', label: 'Share on X', url: 'https://twitter.com/...', icon: 'twitter' }, + { platform: 'telegram', label: 'Share on Telegram', url: 'https://t.me/...', icon: 'telegram' }, + { platform: 'email', label: 'Share via Email', url: 'mailto:...', icon: 'email' }, + { platform: 'clipboard', label: 'Copy Link', url: 'https://heliobond.vercel.app/referral?ref=ABCDEFGH', icon: 'clipboard' }, + ]), +})) + +// Mock clipboard +const mockWriteText = vi.fn() +Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, +}) + +const { fetchReferralStats, fetchReferralHistory } = vi.mocked( + await import('@/lib/referral'), +) + +describe('ReferralDashboard', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWriteText.mockReset() + mockWriteText.mockResolvedValue(undefined) + fetchReferralStats.mockResolvedValue({ + totalReferred: 5, + rewardsEarned: 25, + rewardsPending: 10, + referralCode: 'ABCDEFGH', + referralLink: 'https://heliobond.vercel.app/referral?ref=ABCDEFGH', + }) + fetchReferralHistory.mockResolvedValue([ + { + refereeAddress: 'GDEMO...REF1', + createdAt: '2026-07-25T00:00:00Z', + completed: true, + rewardAmount: 5, + txHash: 'tx_001', + }, + { + refereeAddress: 'GDEMO...REF2', + createdAt: '2026-07-20T00:00:00Z', + completed: false, + rewardAmount: 0, + txHash: '', + }, + ]) + }) + + it('shows loading state initially', () => { + render() + expect(screen.getByText(/loading/i).closest('[aria-busy]')).toBeInTheDocument() + }) + + it('shows stats after loading', async () => { + render() + expect(await screen.findByText('Total Referrals')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.getByText('$25')).toBeInTheDocument() + expect(screen.getByText('$10')).toBeInTheDocument() + }) + + it('shows referral link input', async () => { + render() + expect(await screen.findByLabelText('Your referral link')).toBeInTheDocument() + }) + + it('shows referral history', async () => { + render() + expect(await screen.findByText('Referral History')).toBeInTheDocument() + expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getByText('Pending')).toBeInTheDocument() + }) + + it('shows empty state when no wallet address', async () => { + render() + expect( + await screen.findByText(/connect your stellar wallet/i), + ).toBeInTheDocument() + }) + + it('shows "How It Works" section', async () => { + render() + expect(await screen.findByText('How It Works')).toBeInTheDocument() + expect(screen.getByText(/Share/)).toBeInTheDocument() + expect(screen.getByText(/sign up/)).toBeInTheDocument() + expect(screen.getByText(/First deposit/)).toBeInTheDocument() + expect(screen.getByText(/Both earn/)).toBeInTheDocument() + }) + + it('shows empty history message when no referrals', async () => { + fetchReferralHistory.mockResolvedValue([]) + render() + expect( + await screen.findByText(/no referrals yet/i), + ).toBeInTheDocument() + }) + + it('shows error state with retry button', async () => { + fetchReferralStats.mockRejectedValue(new Error('Network error')) + render() + expect(await screen.findByText(/unable to load/i)).toBeInTheDocument() + const retryBtn = screen.getByText('Retry') + expect(retryBtn).toBeInTheDocument() + + // Retry should call load again + fetchReferralStats.mockResolvedValue({ + totalReferred: 1, + rewardsEarned: 5, + rewardsPending: 0, + referralCode: 'ABCDEFGH', + referralLink: 'https://...', + }) + fireEvent.click(retryBtn) + expect(await screen.findByText('Total Referrals')).toBeInTheDocument() + }) + + it('accepts className prop', async () => { + const { container } = render( + , + ) + expect( + await container.querySelector('.custom-dash'), + ).toBeInTheDocument() + }) +}) diff --git a/src/components/ReferralDashboard.tsx b/src/components/ReferralDashboard.tsx new file mode 100644 index 0000000..72eb25d --- /dev/null +++ b/src/components/ReferralDashboard.tsx @@ -0,0 +1,211 @@ +/** + * ReferralDashboard — displays complete referral program stats, share link, + * and referral history for the authenticated user. + * + * Requires a connected Stellar wallet to fetch referral data. + * + * Usage: + * + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + fetchReferralStats, + fetchReferralHistory, +} from '@/lib/referral' +import type { ReferralStats, ReferralRecord } from '@/lib/referral' +import { ReferralShareLink } from './ReferralShareLink' +import { StatBlock } from './StatBlock' +import { Card } from './Card' + +export interface ReferralDashboardProps { + /** The user's Stellar wallet address */ + walletAddress: string + /** Optional class name */ + className?: string +} + +type LoadState = 'loading' | 'loaded' | 'error' | 'empty' + +export function ReferralDashboard({ + walletAddress, + className, +}: ReferralDashboardProps) { + const [loadState, setLoadState] = useState('loading') + const [stats, setStats] = useState(null) + const [history, setHistory] = useState([]) + + const loadData = useCallback(async () => { + if (!walletAddress) { + setLoadState('empty') + return + } + + setLoadState('loading') + try { + const [s, h] = await Promise.all([ + fetchReferralStats(walletAddress), + fetchReferralHistory(walletAddress), + ]) + setStats(s) + setHistory(h) + setLoadState('loaded') + } catch (err) { + console.error('Failed to load referral data:', err) + setLoadState('error') + } + }, [walletAddress]) + + useEffect(() => { + loadData() + }, [loadData]) + + // --- Loading state --- + if (loadState === 'loading') { + return ( +
+ +
+
+
+
+
+ +
+ ) + } + + // --- Empty state --- + if (loadState === 'empty' || !stats) { + return ( +
+ +
+

Referral Program

+

+ Connect your Stellar wallet to view your referral link and start + earning rewards. +

+

+ Earn ${String(5)} USDC for every friend who signs up and makes their + first deposit. +

+
+
+
+ ) + } + + // --- Error state --- + if (loadState === 'error') { + return ( +
+ +
+

Referral Program

+

Unable to load referral data. Please try again later.

+ +
+
+
+ ) + } + + // --- Loaded state --- + return ( +
+ {/* Stats overview */} +
+ + + +
+ + {/* Share link section */} + +

+ Your Referral Link +

+

+ Share this link with friends. When they sign up and make their first + deposit, you both earn USDC rewards. +

+ +
+ + {/* Referral history */} + +

+ Referral History +

+ {history.length === 0 ? ( +

+ No referrals yet. Share your link to get started! +

+ ) : ( +
+
+ Referred Address + Status + Reward + Date +
+ {history.map((record, i) => ( +
+ + {record.refereeAddress.slice(0, 8)}... + {record.refereeAddress.slice(-4)} + + + {record.completed ? ( + + Completed + + ) : ( + + Pending + + )} + + + {record.completed ? `$${record.rewardAmount}` : '—'} + + + {new Date(record.createdAt).toLocaleDateString()} + +
+ ))} +
+ )} +
+ + {/* How it works */} + +

How It Works

+
    +
  1. + Share your unique referral link with friends +
  2. +
  3. + They sign up and connect their Stellar wallet +
  4. +
  5. + First deposit — once they make their first green + bond investment +
  6. +
  7. + Both earn — you each receive USDC rewards directly + to your wallets +
  8. +
+
+
+ ) +} diff --git a/src/components/ReferralShareLink.test.tsx b/src/components/ReferralShareLink.test.tsx new file mode 100644 index 0000000..1973054 --- /dev/null +++ b/src/components/ReferralShareLink.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@/test/render' +import { ReferralShareLink } from './ReferralShareLink' + +// Mock clipboard API +const mockWriteText = vi.fn() +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}) + +// Mock window.open +const mockOpen = vi.fn() +window.open = mockOpen + +describe('ReferralShareLink', () => { + beforeEach(() => { + mockWriteText.mockReset() + mockOpen.mockReset() + mockWriteText.mockResolvedValue(undefined) + }) + + it('renders the referral link in a readonly input', () => { + render() + const input = screen.getByLabelText('Your referral link') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('readonly') + expect((input as HTMLInputElement).value).toContain('ref=ABCDEFGH') + }) + + it('renders a Copy button', () => { + render() + expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument() + }) + + it('copies link to clipboard when Copy is clicked', async () => { + render() + const copyBtn = screen.getByRole('button', { name: /copy referral link/i }) + fireEvent.click(copyBtn) + expect(mockWriteText).toHaveBeenCalled() + const copiedText = mockWriteText.mock.calls[0][0] + expect(copiedText).toContain('ref=ABCDEFGH') + }) + + it('shows "Copied!" after successful copy', () => { + render() + const copyBtn = screen.getByRole('button', { name: /copy referral link/i }) + fireEvent.click(copyBtn) + expect(screen.getByText('✓ Copied!')).toBeInTheDocument() + }) + + it('renders social share buttons', () => { + render() + // Twitter/X, Telegram, Email + expect(screen.getByLabelText('Share on X')).toBeInTheDocument() + expect(screen.getByLabelText('Share on Telegram')).toBeInTheDocument() + expect(screen.getByLabelText('Share via Email')).toBeInTheDocument() + }) + + it('opens Twitter share link in new window', () => { + render() + const twitterBtn = screen.getByLabelText('Share on X') + fireEvent.click(twitterBtn) + expect(mockOpen).toHaveBeenCalledWith( + expect.stringContaining('twitter.com/intent/tweet'), + '_blank', + 'noopener,noreferrer', + ) + }) + + it('opens Telegram share link in new window', () => { + render() + const telegramBtn = screen.getByLabelText('Share on Telegram') + fireEvent.click(telegramBtn) + expect(mockOpen).toHaveBeenCalledWith( + expect.stringContaining('t.me/share/url'), + '_blank', + 'noopener,noreferrer', + ) + }) + + it('displays share links containing the referral code', () => { + render() + const input = screen.getByLabelText('Your referral link') + expect((input as HTMLInputElement).value).toContain('ref=ABCDEFGH') + }) + + it('accepts className prop', () => { + const { container } = render( + , + ) + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('renders input click to select all text', () => { + render() + const input = screen.getByLabelText('Your referral link') + fireEvent.click(input) + // Selection should be triggered; no error means success + expect(input).toBeInTheDocument() + }) +}) diff --git a/src/components/ReferralShareLink.tsx b/src/components/ReferralShareLink.tsx new file mode 100644 index 0000000..39afffa --- /dev/null +++ b/src/components/ReferralShareLink.tsx @@ -0,0 +1,109 @@ +/** + * ReferralShareLink — displays the user's referral link with copy-to-clipboard + * and social sharing buttons. + * + * Usage: + * + */ + +'use client' + +import { useState, useCallback } from 'react' +import { generateShareLinks } from '@/lib/referral' +import type { ShareLink } from '@/lib/referral' +import { Button } from './Button' +import { IconButton } from './IconButton' + +export interface ReferralShareLinkProps { + /** The user's unique referral code */ + referralCode: string + /** Optional class name for the wrapper */ + className?: string +} + +/** Social platform icon mapping — simple SVG paths */ +const ICONS: Record = { + twitter: '🐦', + telegram: '📨', + email: '✉️', + clipboard: '📋', +} + +export function ReferralShareLink({ referralCode, className }: ReferralShareLinkProps) { + const [copied, setCopied] = useState(false) + const shareLinks = generateShareLinks(referralCode) + + const handleCopy = useCallback(async () => { + const clipboardLink = shareLinks.find((l) => l.platform === 'clipboard') + if (!clipboardLink) return + + try { + await navigator.clipboard.writeText(clipboardLink.url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback for older browsers + const textarea = document.createElement('textarea') + textarea.value = clipboardLink.url + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [shareLinks]) + + const handleShare = useCallback((link: ShareLink) => { + if (link.platform === 'clipboard') { + handleCopy() + return + } + window.open(link.url, '_blank', 'noopener,noreferrer') + }, [handleCopy]) + + const clipboardLink = shareLinks.find((l) => l.platform === 'clipboard') + const socialLinks = shareLinks.filter((l) => l.platform !== 'clipboard') + + return ( +
+ {/* Referral link display + copy button */} +
+ (e.target as HTMLInputElement).select()} + /> + +
+ + {/* Social sharing buttons */} +
+ Share via: +
+ {socialLinks.map((link) => ( + handleShare(link)} + aria-label={link.label} + > + {ICONS[link.platform] ?? '🔗'} + + ))} +
+
+
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 3d530b9..713b2c9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -28,4 +28,8 @@ export { FormField, FormInput, FormTextarea, FormSelect } from './FormField' export type { FormFieldProps, FormInputProps, FormTextareaProps, FormSelectProps } from './FormField' export { Sparkline } from './Sparkline' export type { SparklineProps } from './Sparkline' +export { ReferralShareLink } from './ReferralShareLink' +export type { ReferralShareLinkProps } from './ReferralShareLink' +export { ReferralDashboard } from './ReferralDashboard' +export type { ReferralDashboardProps } from './ReferralDashboard' export * from './icons' diff --git a/src/lib/network.ts b/src/lib/network.ts new file mode 100644 index 0000000..2952209 --- /dev/null +++ b/src/lib/network.ts @@ -0,0 +1,103 @@ +/** + * Stellar network configuration — driven by environment variables so the app + * can target testnet (default), public (mainnet), or futurenet without code + * changes. + * + * Override via: + * NEXT_PUBLIC_STELLAR_NETWORK=public → Stellar public network (mainnet) + * NEXT_PUBLIC_STELLAR_NETWORK=testnet → Stellar testnet (default) + * NEXT_PUBLIC_STELLAR_NETWORK=futurenet→ Stellar futurenet + * + * Soroban RPC and Horizon URLs are resolved from the network choice but can + * also be overridden individually: + * NEXT_PUBLIC_SOROBAN_RPC_URL + * NEXT_PUBLIC_HORIZON_URL + */ + +import { Networks } from '@stellar/stellar-sdk' + +export type StellarNetwork = 'public' | 'testnet' | 'futurenet' + +const VALID_NETWORKS: StellarNetwork[] = ['public', 'testnet', 'futurenet'] + +function resolveNetwork(): StellarNetwork { + const raw = process.env.NEXT_PUBLIC_STELLAR_NETWORK?.toLowerCase() + if (raw && VALID_NETWORKS.includes(raw as StellarNetwork)) { + return raw as StellarNetwork + } + return 'testnet' +} + +/** The active Stellar network identifier. */ +export const STELLAR_NETWORK: StellarNetwork = resolveNetwork() + +/** Stellar SDK network passphrase for the active network. */ +export function getNetworkPassphrase(): string { + switch (STELLAR_NETWORK) { + case 'public': + return Networks.PUBLIC + case 'futurenet': + return Networks.FUTURENET + case 'testnet': + default: + return Networks.TESTNET + } +} + +/** Soroban RPC endpoint for the active network. */ +export function getSorobanRpcUrl(): string { + if (process.env.NEXT_PUBLIC_SOROBAN_RPC_URL) { + return process.env.NEXT_PUBLIC_SOROBAN_RPC_URL + } + switch (STELLAR_NETWORK) { + case 'public': + return 'https://soroban.stellar.org' + case 'futurenet': + return 'https://rpc-futurenet.stellar.org' + case 'testnet': + default: + return 'https://soroban-testnet.stellar.org' + } +} + +/** Horizon API endpoint for the active network. */ +export function getHorizonUrl(): string { + if (process.env.NEXT_PUBLIC_HORIZON_URL) { + return process.env.NEXT_PUBLIC_HORIZON_URL + } + switch (STELLAR_NETWORK) { + case 'public': + return 'https://horizon.stellar.org' + case 'futurenet': + return 'https://horizon-futurenet.stellar.org' + case 'testnet': + default: + return 'https://horizon-testnet.stellar.org' + } +} + +/** Human-readable label shown in the network switcher badge. */ +export function getNetworkLabel(): string { + switch (STELLAR_NETWORK) { + case 'public': + return 'Mainnet' + case 'futurenet': + return 'Futurenet' + case 'testnet': + default: + return 'Testnet' + } +} + +/** WalletKit network identifier (capitalised). */ +export function getWalletNetwork(): 'TESTNET' | 'PUBLIC' | 'FUTURENET' { + switch (STELLAR_NETWORK) { + case 'public': + return 'PUBLIC' + case 'futurenet': + return 'FUTURENET' + case 'testnet': + default: + return 'TESTNET' + } +} diff --git a/src/lib/referral.test.ts b/src/lib/referral.test.ts new file mode 100644 index 0000000..cc3fe43 --- /dev/null +++ b/src/lib/referral.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + generateReferralCode, + buildReferralLink, + extractReferralCode, + isValidReferralCode, + generateShareLinks, + getReferralReward, + fetchReferralStats, + fetchReferralHistory, +} from './referral' +import type { ShareLink } from './referral' + +// --------------------------------------------------------------------------- +// generateReferralCode +// --------------------------------------------------------------------------- + +describe('generateReferralCode', () => { + it('generates an 8-character code from a valid Stellar address', () => { + const code = generateReferralCode( + 'GBZXH6KPAFQYH2QYJWPHUJYDZMCCXGZM3BYK6HOVZZVQJXMJVHFXK4YA', + ) + expect(code).toHaveLength(8) + expect(isValidReferralCode(code)).toBe(true) + }) + + it('produces deterministic codes — same address = same code', () => { + const addr = 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343' + const code1 = generateReferralCode(addr) + const code2 = generateReferralCode(addr) + expect(code1).toBe(code2) + }) + + it('produces different codes for different addresses', () => { + const code1 = generateReferralCode( + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343', + ) + const code2 = generateReferralCode( + 'GBZXH6KPAFQYH2QYJWPHUJYDZMCCXGZM3BYK6HOVZZVQJXMJVHFXK4YA', + ) + expect(code1).not.toBe(code2) + }) + + it('only contains valid characters (no I, O, 0, 1)', () => { + for (let i = 0; i < 100; i++) { + const code = generateReferralCode(`GDEMO${String(i).padStart(48, 'A')}`) + expect(code).not.toMatch(/[IO01]/) + expect(isValidReferralCode(code)).toBe(true) + } + }) + + it('throws on invalid / too-short address', () => { + expect(() => generateReferralCode('')).toThrow('Invalid Stellar address') + expect(() => generateReferralCode('G')).toThrow('Invalid Stellar address') + }) + + it('handles addresses of different lengths', () => { + const short = 'GABCDEFGHIJKLMNOP' + const long = + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343XXXX' + expect(generateReferralCode(short)).toHaveLength(8) + expect(generateReferralCode(long)).toHaveLength(8) + }) +}) + +// --------------------------------------------------------------------------- +// buildReferralLink +// --------------------------------------------------------------------------- + +describe('buildReferralLink', () => { + it('builds a URL with the referral code as query param', () => { + const link = buildReferralLink('ABCDEFGH') + expect(link).toContain('/referral?ref=ABCDEFGH') + expect(link).toMatch(/^https?:\/\//) + }) + + it('encodes special characters in the code', () => { + const link = buildReferralLink('ABC DEF') + expect(link).toContain('ABC%20DEF') + }) + + it('uses the configured APP_URL', () => { + const link = buildReferralLink('TESTCODE') + expect(link.startsWith('https://')).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// extractReferralCode +// --------------------------------------------------------------------------- + +describe('extractReferralCode', () => { + it('extracts ref param from a full URL', () => { + const code = extractReferralCode( + 'https://heliobond.vercel.app/referral?ref=ABCDEFGH', + ) + expect(code).toBe('ABCDEFGH') + }) + + it('extracts ref param from a relative path with query', () => { + const code = extractReferralCode('/referral?ref=JKLMNPQR') + expect(code).toBe('JKLMNPQR') + }) + + it('returns null when no ref param present', () => { + expect(extractReferralCode('/referral')).toBeNull() + expect(extractReferralCode('https://heliobond.vercel.app/')).toBeNull() + }) + + it('extracts ref from URL with multiple query params', () => { + const code = extractReferralCode( + 'https://heliobond.vercel.app/referral?ref=ABCDEFGH&utm_source=twitter', + ) + expect(code).toBe('ABCDEFGH') + }) + + it('returns null for invalid code format in ref', () => { + const code = extractReferralCode('/referral?ref=123') + expect(code).toBe('123') // extract just returns the raw value; validation is separate + }) + + it('extracts from plain query string', () => { + const code = extractReferralCode('?ref=TUVWXY23') + expect(code).toBe('TUVWXY23') + }) +}) + +// --------------------------------------------------------------------------- +// isValidReferralCode +// --------------------------------------------------------------------------- + +describe('isValidReferralCode', () => { + it('accepts valid 8-char codes', () => { + expect(isValidReferralCode('ABCDEFGH')).toBe(true) + expect(isValidReferralCode('23456789')).toBe(true) + expect(isValidReferralCode('JKLMNPQR')).toBe(true) + }) + + it('rejects codes that are too short', () => { + expect(isValidReferralCode('ABC')).toBe(false) + expect(isValidReferralCode('ABCDEFG')).toBe(false) + }) + + it('rejects codes that are too long', () => { + expect(isValidReferralCode('ABCDEFGHI')).toBe(false) + }) + + it('rejects codes with invalid characters', () => { + expect(isValidReferralCode('ABCDEFG0')).toBe(false) + expect(isValidReferralCode('ABCDEFG1')).toBe(false) + expect(isValidReferralCode('ABCDEFGO')).toBe(false) + expect(isValidReferralCode('ABCDEFGI')).toBe(false) + }) + + it('rejects empty string', () => { + expect(isValidReferralCode('')).toBe(false) + }) + + it('rejects lowercase codes', () => { + expect(isValidReferralCode('abcdefgh')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// generateShareLinks +// --------------------------------------------------------------------------- + +describe('generateShareLinks', () => { + let links: ShareLink[] + + beforeEach(() => { + links = generateShareLinks('ABCDEFGH') + }) + + it('returns 4 share links', () => { + expect(links).toHaveLength(4) + }) + + it('includes twitter share link', () => { + const twitter = links.find((l) => l.platform === 'twitter') + expect(twitter).toBeDefined() + expect(twitter!.url).toContain('twitter.com/intent/tweet') + expect(twitter!.url).toContain('ref=ABCDEFGH') + expect(twitter!.label).toBe('Share on X') + }) + + it('includes telegram share link', () => { + const telegram = links.find((l) => l.platform === 'telegram') + expect(telegram).toBeDefined() + expect(telegram!.url).toContain('t.me/share/url') + expect(telegram!.url).toContain('ref=ABCDEFGH') + expect(telegram!.label).toBe('Share on Telegram') + }) + + it('includes email share link', () => { + const email = links.find((l) => l.platform === 'email') + expect(email).toBeDefined() + expect(email!.url).toContain('mailto:') + expect(email!.url).toContain('ref=ABCDEFGH') + expect(email!.label).toBe('Share via Email') + }) + + it('includes clipboard share link with plain URL', () => { + const clipboard = links.find((l) => l.platform === 'clipboard') + expect(clipboard).toBeDefined() + expect(clipboard!.url).toContain('/referral?ref=ABCDEFGH') + expect(clipboard!.label).toBe('Copy Link') + expect(clipboard!.icon).toBe('clipboard') + }) + + it('all platform URLs are valid', () => { + for (const link of links) { + if (link.platform === 'clipboard') { + expect(link.url).toMatch(/^https?:\/\//) + } else if (link.platform === 'email') { + expect(link.url.startsWith('mailto:')).toBe(true) + } else { + expect(link.url).toMatch(/^https?:\/\//) + } + } + }) +}) + +// --------------------------------------------------------------------------- +// getReferralReward +// --------------------------------------------------------------------------- + +describe('getReferralReward', () => { + it('returns the default reward amount', () => { + const reward = getReferralReward() + expect(reward).toBeGreaterThan(0) + expect(typeof reward).toBe('number') + }) +}) + +// --------------------------------------------------------------------------- +// fetchReferralStats +// --------------------------------------------------------------------------- + +describe('fetchReferralStats', () => { + it('returns stats with referralCode and referralLink', async () => { + const stats = await fetchReferralStats( + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343', + ) + expect(stats).toHaveProperty('referralCode') + expect(stats).toHaveProperty('referralLink') + expect(stats.referralCode).toHaveLength(8) + expect(stats.referralLink).toContain(stats.referralCode) + }) + + it('returns stats with numeric fields', async () => { + const stats = await fetchReferralStats( + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343', + ) + expect(typeof stats.totalReferred).toBe('number') + expect(typeof stats.rewardsEarned).toBe('number') + expect(typeof stats.rewardsPending).toBe('number') + expect(stats.totalReferred).toBeGreaterThanOrEqual(0) + }) + + it('produces deterministic referralCode for same address', async () => { + const addr = 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343' + const stats1 = await fetchReferralStats(addr) + const stats2 = await fetchReferralStats(addr) + expect(stats1.referralCode).toBe(stats2.referralCode) + }) + + it('handles empty/invalid address gracefully', async () => { + await expect(fetchReferralStats('')).rejects.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// fetchReferralHistory +// --------------------------------------------------------------------------- + +describe('fetchReferralHistory', () => { + it('returns an array of referral records', async () => { + const history = await fetchReferralHistory( + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343', + ) + expect(Array.isArray(history)).toBe(true) + }) + + it('each record has expected properties', async () => { + const history = await fetchReferralHistory( + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343', + ) + for (const record of history) { + expect(record).toHaveProperty('refereeAddress') + expect(record).toHaveProperty('createdAt') + expect(record).toHaveProperty('completed') + expect(record).toHaveProperty('rewardAmount') + expect(record).toHaveProperty('txHash') + } + }) + + it('returns empty array for invalid address in non-demo', async () => { + // In demo mode it returns mock data; in production it falls back to empty + const history = await fetchReferralHistory('') + // Should not throw; returns [] or gracefully handles + expect(Array.isArray(history)).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Integration: end-to-end referral flow +// --------------------------------------------------------------------------- + +describe('Referral flow integration', () => { + it('generates code → builds link → extracts code roundtrip', () => { + const address = + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343' + const code = generateReferralCode(address) + const link = buildReferralLink(code) + const extracted = extractReferralCode(link) + expect(extracted).toBe(code) + }) + + it('generated code is always valid', () => { + for (let i = 0; i < 50; i++) { + const address = `G${String(i).padStart(55, 'A')}` + const code = generateReferralCode(address) + expect(isValidReferralCode(code)).toBe(true) + } + }) + + it('share links contain the correct referral URL', () => { + const address = + 'GDEMOF6TCSZPZFQACJVLEPGQQNPG4XDYUDIV7OJIYKTPHXTJKF76B343' + const code = generateReferralCode(address) + const links = generateShareLinks(code) + for (const link of links) { + expect(link.url).toContain(code) + } + }) +}) diff --git a/src/lib/referral.ts b/src/lib/referral.ts new file mode 100644 index 0000000..cb04f62 --- /dev/null +++ b/src/lib/referral.ts @@ -0,0 +1,362 @@ +/** + * Referral Program — core logic for generating shareable referral links, + * tracking referrals, and managing rewards on the Stellar network. + * + * ## Overview + * + * Every Heliobond user gets a unique referral code derived from their Stellar + * wallet address. When a referred user signs up via the link and makes their + * first deposit, both the referrer and referee receive a reward. + * + * ## Architecture + * + * - **Code generation**: Deterministic, based on wallet address hash (no DB needed) + * - **Share links**: Pre-configured templates for Twitter, Telegram, email, clipboard + * - **Reward tracking**: Reads on-chain events from the Heliobond vault contract + * - **Demo mode**: When `NEXT_PUBLIC_VAULT_CONTRACT_ID` is absent, returns mock data + * + * ## Environment Variables + * + * | Variable | Default | Description | + * |---|---|---| + * | `NEXT_PUBLIC_REFERRAL_REWARD_USDC` | `5` | USDC reward per successful referral | + * | `NEXT_PUBLIC_APP_URL` | `https://heliobond.vercel.app` | Base URL for share links | + */ + +import { getHorizonUrl, getNetworkPassphrase } from './network' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ReferralStats { + /** Total number of users who signed up via this referral code */ + totalReferred: number + /** Total USDC rewards earned from referrals */ + rewardsEarned: number + /** Pending rewards (referrals not yet confirmed on-chain) */ + rewardsPending: number + /** The user's unique referral code */ + referralCode: string + /** Full shareable referral URL */ + referralLink: string +} + +export interface ReferralRecord { + /** Stellar address of the referred user */ + refereeAddress: string + /** When the referral was created (ISO 8601) */ + createdAt: string + /** Whether the referred user has completed their first deposit */ + completed: boolean + /** USDC reward amount (0 if not yet completed) */ + rewardAmount: number + /** On-chain transaction hash (empty if pending) */ + txHash: string +} + +export type SharePlatform = 'twitter' | 'telegram' | 'email' | 'clipboard' + +export interface ShareLink { + platform: SharePlatform + label: string + url: string + icon: string +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const APP_URL = + process.env.NEXT_PUBLIC_APP_URL ?? 'https://heliobond.vercel.app' + +const REWARD_USDC = Number( + process.env.NEXT_PUBLIC_REFERRAL_REWARD_USDC ?? '5', +) + +const REFERRAL_PATH = '/referral' + +/** Characters used for referral code generation */ +const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +/** Length of the referral code */ +const CODE_LENGTH = 8 + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +/** + * Generate a deterministic referral code from a Stellar wallet address. + * Uses a simple hash to produce an 8-character alphanumeric code. + * + * @param address - Stellar public key (G...) + * @returns 8-character referral code + */ +export function generateReferralCode(address: string): string { + if (!address || address.length < 10) { + throw new Error('Invalid Stellar address') + } + + // Simple deterministic hash from address bytes + let hash = 0 + for (let i = 0; i < address.length; i++) { + const char = address.charCodeAt(i) + hash = (hash * 31 + char) & 0x7fffffff + } + + // Generate code from hash + let code = '' + let remaining = Math.abs(hash) + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_CHARS[remaining % CODE_CHARS.length] + remaining = Math.floor(remaining / CODE_CHARS.length) + if (remaining === 0) remaining = Math.abs(hash >> (i + 1)) + } + + return code +} + +/** + * Build the full referral URL for a given code. + */ +export function buildReferralLink(code: string): string { + return `${APP_URL}${REFERRAL_PATH}?ref=${encodeURIComponent(code)}` +} + +/** + * Extract referral code from a URL or query string. + * + * @param input - URL string or raw query string containing ?ref=CODE + * @returns The referral code, or null if not found + */ +export function extractReferralCode(input: string): string | null { + try { + const url = input.startsWith('http') ? new URL(input) : new URL(`https://x${input}`) + return url.searchParams.get('ref') + } catch { + // Try simple regex fallback + const match = input.match(/[?&]ref=([A-Z2-9]{8})/) + return match ? match[1] : null + } +} + +/** + * Validate that a referral code matches the expected format. + */ +export function isValidReferralCode(code: string): boolean { + if (!code || code.length !== CODE_LENGTH) return false + return [...code].every((c) => CODE_CHARS.includes(c)) +} + +// --------------------------------------------------------------------------- +// Share links +// --------------------------------------------------------------------------- + +const SHARE_TEXTS = { + twitter: (link: string) => + `Join me on Heliobond 🌱 — invest in green bonds starting from $1.\n\nSign up with my referral link and we both earn $${REWARD_USDC} USDC:\n${link}`, + telegram: (link: string) => + `🌱 Join Heliobond — green bond investing from $1!\n\nUse my referral link and we both earn $${REWARD_USDC} USDC:\n${link}`, + email: (link: string) => + `Hi!\n\nI've been using Heliobond to invest in green bonds and thought you might like it too.\n\nSign up with my referral link and we both earn $${REWARD_USDC} USDC:\n${link}\n\n— Sent via Heliobond`, +} + +/** + * Generate share links for all supported platforms. + * + * @param referralCode - The user's referral code + * @returns Array of ShareLink objects for each platform + */ +export function generateShareLinks(referralCode: string): ShareLink[] { + const link = buildReferralLink(referralCode) + + return [ + { + platform: 'twitter', + label: 'Share on X', + url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TEXTS.twitter(link))}`, + icon: 'twitter', + }, + { + platform: 'telegram', + label: 'Share on Telegram', + url: `https://t.me/share/url?url=${encodeURIComponent(link)}&text=${encodeURIComponent(SHARE_TEXTS.telegram(link))}`, + icon: 'telegram', + }, + { + platform: 'email', + label: 'Share via Email', + url: `mailto:?subject=${encodeURIComponent('Join Heliobond — Green Bond Investing')}&body=${encodeURIComponent(SHARE_TEXTS.email(link))}`, + icon: 'email', + }, + { + platform: 'clipboard', + label: 'Copy Link', + url: link, + icon: 'clipboard', + }, + ] +} + +// --------------------------------------------------------------------------- +// Reward calculation +// --------------------------------------------------------------------------- + +/** + * Get the configured reward amount in USDC. + */ +export function getReferralReward(): number { + return REWARD_USDC +} + +// --------------------------------------------------------------------------- +// Stats (mock / on-chain) +// --------------------------------------------------------------------------- + +const isDemo = !process.env.NEXT_PUBLIC_VAULT_CONTRACT_ID + +/** + * Fetch referral stats for a user. + * In demo mode, returns mock data; otherwise queries the Stellar network. + * + * @param address - Stellar wallet address + * @returns ReferralStats object + */ +export async function fetchReferralStats(address: string): Promise { + const code = generateReferralCode(address) + const link = buildReferralLink(code) + + if (isDemo) { + return { + totalReferred: 3, + rewardsEarned: 15, + rewardsPending: 5, + referralCode: code, + referralLink: link, + } + } + + // On-chain: query the vault contract for referral events + try { + const horizonUrl = getHorizonUrl() + // Query Horizon for referral-related operations involving this address + const response = await fetch( + `${horizonUrl}/accounts/${address}/operations?limit=200&order=desc`, + ) + if (!response.ok) { + console.warn('Failed to fetch referral stats from Horizon') + return { + totalReferred: 0, + rewardsEarned: 0, + rewardsPending: 0, + referralCode: code, + referralLink: link, + } + } + + const data = await response.json() + const records = data._embedded?.records ?? [] + + // Count referral-related operations + let totalReferred = 0 + let rewardsEarned = 0 + + for (const record of records) { + // Look for payment operations that match referral reward pattern + if (record.type === 'payment' && record.asset_code === 'USDC') { + const amount = Number(record.amount) + if (amount === REWARD_USDC) { + rewardsEarned += amount + } + } + // Count create_account operations as referrals + if (record.type === 'create_account' && record.source_account !== address) { + totalReferred++ + } + } + + return { + totalReferred, + rewardsEarned, + rewardsPending: 0, + referralCode: code, + referralLink: link, + } + } catch (err) { + console.error('Error fetching referral stats:', err) + return { + totalReferred: 0, + rewardsEarned: 0, + rewardsPending: 0, + referralCode: code, + referralLink: link, + } + } +} + +/** + * Fetch referral history for a user. + * + * @param address - Stellar wallet address + * @returns Array of ReferralRecord objects + */ +export async function fetchReferralHistory( + address: string, +): Promise { + if (isDemo) { + return [ + { + refereeAddress: 'GDEMO...REF1', + createdAt: new Date(Date.now() - 7 * 86400000).toISOString(), + completed: true, + rewardAmount: REWARD_USDC, + txHash: 'demo_tx_001', + }, + { + refereeAddress: 'GDEMO...REF2', + createdAt: new Date(Date.now() - 14 * 86400000).toISOString(), + completed: true, + rewardAmount: REWARD_USDC, + txHash: 'demo_tx_002', + }, + { + refereeAddress: 'GDEMO...REF3', + createdAt: new Date(Date.now() - 1 * 86400000).toISOString(), + completed: false, + rewardAmount: 0, + txHash: '', + }, + ] + } + + try { + const horizonUrl = getHorizonUrl() + const response = await fetch( + `${horizonUrl}/accounts/${address}/payments?limit=50&order=desc`, + ) + if (!response.ok) return [] + + const data = await response.json() + const records = data._embedded?.records ?? [] + + return records + .filter( + (r: Record) => + r.type === 'payment' && + (r as { asset_code?: string }).asset_code === 'USDC' && + Number((r as { amount?: string }).amount) === REWARD_USDC, + ) + .map((r: Record) => ({ + refereeAddress: (r as { from?: string }).from ?? 'unknown', + createdAt: (r as { created_at?: string }).created_at ?? new Date().toISOString(), + completed: true, + rewardAmount: REWARD_USDC, + txHash: (r as { transaction_hash?: string }).transaction_hash ?? '', + })) + } catch (err) { + console.error('Error fetching referral history:', err) + return [] + } +} diff --git a/src/types.ts b/src/types.ts index f12ef10..b6d9560 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,12 @@ // Screens in the Heliobond click-through. 'how' and 'learn' currently route to // Explore (the public, gate-free surfaces) — mirrors the source prototype. export type Screen = - 'landing' | 'connect' | 'explore' | 'how' | 'learn' | 'deposit' | 'portfolio' | 'withdraw' + | 'landing' + | 'connect' + | 'explore' + | 'how' + | 'learn' + | 'deposit' + | 'portfolio' + | 'withdraw' + | 'referral' diff --git a/src/wallet/vault.ts b/src/wallet/vault.ts index bd5d1d7..bba77aa 100644 --- a/src/wallet/vault.ts +++ b/src/wallet/vault.ts @@ -12,6 +12,11 @@ // back gracefully — no errors surface to the user. import { HB_DATA } from '../data' +import { + getNetworkPassphrase, + getSorobanRpcUrl, + getHorizonUrl, +} from '../lib/network' export interface WithdrawPreview { assets: number @@ -55,8 +60,8 @@ export const vault = { // --------------------------------------------------------------------------- const CONTRACT_ID = process.env.NEXT_PUBLIC_VAULT_CONTRACT_ID -const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org' -const HORIZON_URL = 'https://horizon-testnet.stellar.org' +const RPC_URL = getSorobanRpcUrl() +const HORIZON_URL = getHorizonUrl() /** Call a Soroban view function (no state mutation) and return the raw ScVal. */ async function sorobanSimulate(sourceAddress: string, method: string, args: unknown[] = []) {