diff --git a/shared/lib/referral.ts b/shared/lib/referral.ts new file mode 100644 index 00000000..1e7e02bf --- /dev/null +++ b/shared/lib/referral.ts @@ -0,0 +1,276 @@ +import type { ApiPromise } from '@polkadot/api'; +import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types'; + +/** + * Referral System Integration with pallet_referral + * + * Provides functions to interact with the referral pallet on PezkuwiChain. + * + * Workflow: + * 1. User A calls initiateReferral(userB_address) -> creates pending referral + * 2. User B completes KYC and gets approved + * 3. Pallet automatically confirms referral via OnKycApproved hook + * 4. User A's referral count increases + */ + +export interface ReferralInfo { + referrer: string; + createdAt: number; +} + +export interface ReferralStats { + referralCount: number; + referralScore: number; + whoInvitedMe: string | null; + pendingReferral: string | null; // Who invited me (if pending) +} + +/** + * Initiate a referral for a new user + * + * @param api Polkadot API instance + * @param signer User's Polkadot account with extension + * @param referredAddress Address of the user being referred + * @returns Transaction hash + */ +export async function initiateReferral( + api: ApiPromise, + signer: InjectedAccountWithMeta, + referredAddress: string +): Promise { + return new Promise(async (resolve, reject) => { + try { + const tx = api.tx.referral.initiateReferral(referredAddress); + + await tx.signAndSend( + signer.address, + { signer: signer.signer }, + ({ status, events, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + const error = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + reject(new Error(error)); + } else { + reject(new Error(dispatchError.toString())); + } + return; + } + + if (status.isInBlock || status.isFinalized) { + const hash = status.asInBlock?.toString() || status.asFinalized?.toString() || ''; + resolve(hash); + } + } + ); + } catch (error) { + reject(error); + } + }); +} + +/** + * Get the pending referral for a user (who invited them, if they haven't completed KYC) + * + * @param api Polkadot API instance + * @param address User address + * @returns Referrer address if pending, null otherwise + */ +export async function getPendingReferral( + api: ApiPromise, + address: string +): Promise { + try { + const result = await api.query.referral.pendingReferrals(address); + + if (result.isEmpty) { + return null; + } + + return result.toString(); + } catch (error) { + console.error('Error fetching pending referral:', error); + return null; + } +} + +/** + * Get the number of successful referrals for a user + * + * @param api Polkadot API instance + * @param address User address + * @returns Number of confirmed referrals + */ +export async function getReferralCount( + api: ApiPromise, + address: string +): Promise { + try { + const count = await api.query.referral.referralCount(address); + return count.toNumber(); + } catch (error) { + console.error('Error fetching referral count:', error); + return 0; + } +} + +/** + * Get referral info for a user (who referred them, when) + * + * @param api Polkadot API instance + * @param address User address who was referred + * @returns ReferralInfo if exists, null otherwise + */ +export async function getReferralInfo( + api: ApiPromise, + address: string +): Promise { + try { + const result = await api.query.referral.referrals(address); + + if (result.isEmpty) { + return null; + } + + const data = result.toJSON() as any; + return { + referrer: data.referrer, + createdAt: parseInt(data.createdAt), + }; + } catch (error) { + console.error('Error fetching referral info:', error); + return null; + } +} + +/** + * Calculate referral score based on referral count + * + * This mirrors the logic in pallet_referral::ReferralScoreProvider + * Score calculation: + * - 0 referrals = 0 points + * - 1-10 referrals = count * 10 points (10, 20, 30, ..., 100) + * - 11-50 referrals = 100 + (count - 10) * 5 points (105, 110, ..., 300) + * - 51-100 referrals = 300 + (count - 50) * 4 points (304, 308, ..., 500) + * - 101+ referrals = 500 points (maximum capped) + * + * @param referralCount Number of confirmed referrals + * @returns Referral score + */ +export function calculateReferralScore(referralCount: number): number { + if (referralCount === 0) return 0; + if (referralCount <= 10) return referralCount * 10; + if (referralCount <= 50) return 100 + (referralCount - 10) * 5; + if (referralCount <= 100) return 300 + (referralCount - 50) * 4; + return 500; // Max score +} + +/** + * Get comprehensive referral statistics for a user + * + * @param api Polkadot API instance + * @param address User address + * @returns Complete referral stats + */ +export async function getReferralStats( + api: ApiPromise, + address: string +): Promise { + try { + const [referralCount, referralInfo, pendingReferral] = await Promise.all([ + getReferralCount(api, address), + getReferralInfo(api, address), + getPendingReferral(api, address), + ]); + + const referralScore = calculateReferralScore(referralCount); + + return { + referralCount, + referralScore, + whoInvitedMe: referralInfo?.referrer || null, + pendingReferral, + }; + } catch (error) { + console.error('Error fetching referral stats:', error); + return { + referralCount: 0, + referralScore: 0, + whoInvitedMe: null, + pendingReferral: null, + }; + } +} + +/** + * Get list of all users who were referred by this user + * (Note: requires iterating storage which can be expensive) + * + * @param api Polkadot API instance + * @param referrerAddress Referrer's address + * @returns Array of addresses referred by this user + */ +export async function getMyReferrals( + api: ApiPromise, + referrerAddress: string +): Promise { + try { + const entries = await api.query.referral.referrals.entries(); + + const myReferrals = entries + .filter(([_key, value]) => { + if (value.isEmpty) return false; + const data = value.toJSON() as any; + return data.referrer === referrerAddress; + }) + .map(([key]) => { + // Extract the referred address from the storage key + const addressHex = key.args[0].toString(); + return addressHex; + }); + + return myReferrals; + } catch (error) { + console.error('Error fetching my referrals:', error); + return []; + } +} + +/** + * Subscribe to referral events for real-time updates + * + * @param api Polkadot API instance + * @param callback Callback function for events + * @returns Unsubscribe function + */ +export async function subscribeToReferralEvents( + api: ApiPromise, + callback: (event: { type: 'initiated' | 'confirmed'; referrer: string; referred: string; count?: number }) => void +): Promise<() => void> { + const unsub = await api.query.system.events((events) => { + events.forEach((record) => { + const { event } = record; + + if (event.section === 'referral') { + if (event.method === 'ReferralInitiated') { + const [referrer, referred] = event.data as any; + callback({ + type: 'initiated', + referrer: referrer.toString(), + referred: referred.toString(), + }); + } else if (event.method === 'ReferralConfirmed') { + const [referrer, referred, newCount] = event.data as any; + callback({ + type: 'confirmed', + referrer: referrer.toString(), + referred: referred.toString(), + count: newCount.toNumber(), + }); + } + } + }); + }); + + return unsub; +} diff --git a/web/src/components/referral/InviteUserModal.tsx b/web/src/components/referral/InviteUserModal.tsx new file mode 100644 index 00000000..16d9782e --- /dev/null +++ b/web/src/components/referral/InviteUserModal.tsx @@ -0,0 +1,284 @@ +import React, { useState, useMemo } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { usePolkadot } from '@/contexts/PolkadotContext'; +import { + Copy, + Check, + Share2, + Mail, + MessageCircle, + Twitter, + Facebook, + Linkedin +} from 'lucide-react'; + +interface InviteUserModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const InviteUserModal: React.FC = ({ isOpen, onClose }) => { + const { api, selectedAccount } = usePolkadot(); + const [copied, setCopied] = useState(false); + const [inviteeAddress, setInviteeAddress] = useState(''); + const [initiating, setInitiating] = useState(false); + const [initiateSuccess, setInitiateSuccess] = useState(false); + const [initiateError, setInitiateError] = useState(null); + + // Generate referral link with user's address + const referralLink = useMemo(() => { + if (!selectedAccount?.address) return ''; + const baseUrl = window.location.origin; + return `${baseUrl}/be-citizen?ref=${selectedAccount.address}`; + }, [selectedAccount?.address]); + + // Share text for social media + const shareText = useMemo(() => { + return `Join me on Digital Kurdistan (PezkuwiChain)! 🏛️\n\nBecome a citizen and get your Welati Tiki NFT.\n\nUse my referral link:\n${referralLink}`; + }, [referralLink]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(referralLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + const handleShare = (platform: string) => { + const encodedText = encodeURIComponent(shareText); + const encodedUrl = encodeURIComponent(referralLink); + + const urls: Record = { + whatsapp: `https://wa.me/?text=${encodedText}`, + telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent('Join me on Digital Kurdistan! 🏛️')}`, + twitter: `https://twitter.com/intent/tweet?text=${encodedText}`, + facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, + linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, + email: `mailto:?subject=${encodeURIComponent('Join Digital Kurdistan')}&body=${encodedText}`, + }; + + if (urls[platform]) { + window.open(urls[platform], '_blank', 'width=600,height=400'); + } + }; + + const handleInitiateReferral = async () => { + if (!api || !selectedAccount || !inviteeAddress) { + setInitiateError('Please enter a valid address'); + return; + } + + setInitiating(true); + setInitiateError(null); + setInitiateSuccess(false); + + try { + const { web3FromAddress } = await import('@polkadot/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + console.log(`Initiating referral from ${selectedAccount.address} to ${inviteeAddress}...`); + + const tx = api.tx.referral.initiateReferral(inviteeAddress); + + await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => { + if (dispatchError) { + let errorMessage = 'Transaction failed'; + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; + } else { + errorMessage = dispatchError.toString(); + } + console.error(errorMessage); + setInitiateError(errorMessage); + setInitiating(false); + return; + } + + if (status.isInBlock || status.isFinalized) { + console.log('Referral initiated successfully!'); + setInitiateSuccess(true); + setInitiating(false); + setInviteeAddress(''); + } + }); + } catch (err: any) { + console.error('Failed to initiate referral:', err); + setInitiateError(err.message || 'Failed to initiate referral'); + setInitiating(false); + } + }; + + return ( + + + + + + Invite Friends to PezkuwiChain + + + Share your referral link. When your friends complete KYC, you'll earn trust score points! + + + +
+ {/* Referral Link Display */} +
+ +
+ + +
+

+ Anyone who signs up with this link will be counted as your referral +

+
+ + {/* Manual Referral Initiation */} +
+ +

+ If you know your friend's wallet address, you can pre-register them on-chain. + They must then complete KYC to finalize the referral. +

+
+ setInviteeAddress(e.target.value)} + placeholder="Friend's wallet address" + className="bg-gray-800 border-gray-700 text-white font-mono text-sm placeholder:text-gray-500 placeholder:opacity-50" + /> + +
+ {initiateSuccess && ( +

Referral initiated successfully!

+ )} + {initiateError && ( +

{initiateError}

+ )} +
+ + {/* Share Options */} +
+ +
+ {/* WhatsApp */} + + + {/* Telegram */} + + + {/* Twitter */} + + + {/* Facebook */} + + + {/* LinkedIn */} + + + {/* Email */} + +
+
+ + {/* Rewards Info */} +
+

Referral Rewards

+
    +
  • • 1-10 referrals: 10 points each (up to 100 points)
  • +
  • • 11-50 referrals: 5 points each (up to 300 points)
  • +
  • • 51-100 referrals: 4 points each (up to 500 points)
  • +
  • • Maximum: 500 trust score points
  • +
+
+ + {/* Close Button */} +
+ +
+
+
+
+ ); +}; diff --git a/web/src/components/referral/ReferralDashboard.tsx b/web/src/components/referral/ReferralDashboard.tsx new file mode 100644 index 00000000..56e8cf32 --- /dev/null +++ b/web/src/components/referral/ReferralDashboard.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import { useReferral } from '@/contexts/ReferralContext'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { InviteUserModal } from './InviteUserModal'; +import { Users, UserPlus, Trophy, Award, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export const ReferralDashboard: React.FC = () => { + const { t } = useTranslation(); + const { stats, myReferrals, loading } = useReferral(); + const [showInviteModal, setShowInviteModal] = useState(false); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + Referral System +

+

+ Invite friends to PezkuwiChain and earn trust score +

+
+ +
+ + {/* Stats Grid */} +
+ {/* Referral Count */} + + + + + Total Referrals + + + Confirmed referrals (KYC completed) + + + +
+ {stats?.referralCount ?? 0} +
+
+
+ + {/* Trust Score */} + + + + + Trust Score + + + Reputation score from referrals + + + +
+ {stats?.referralScore ?? 0} +
+
+ Max: 500 points +
+
+
+ + {/* Who Invited Me */} + + + + + Invited By + + + Your referrer + + + + {stats?.whoInvitedMe ? ( +
+ {stats.whoInvitedMe.slice(0, 8)}...{stats.whoInvitedMe.slice(-6)} +
+ ) : ( +
+ No referrer +
+ )} +
+
+
+ + {/* Score Breakdown */} + + + Score Calculation + + How referrals contribute to your trust score + + + +
+
+ 1-10 referrals + 10 points each +
+
+ 11-50 referrals + 100 + 5 points each +
+
+ 51-100 referrals + 300 + 4 points each +
+
+ 101+ referrals + 500 points (max) +
+
+
+
+ + {/* My Referrals List */} + + + + + My Referrals ({myReferrals.length}) + + + Users you have successfully referred + + + + {myReferrals.length === 0 ? ( +
+ +

No referrals yet

+

+ Invite friends to start building your network +

+ +
+ ) : ( +
+ {myReferrals.map((address, index) => ( +
+
+
+ + {index + 1} + +
+
+
+ {address.slice(0, 10)}...{address.slice(-8)} +
+
+ KYC Completed +
+
+
+
+ +{index < 10 ? 10 : index < 50 ? 5 : index < 100 ? 4 : 0} points +
+
+ ))} +
+ )} +
+
+ + {/* Pending Referral Notification */} + {stats?.pendingReferral && ( + + +
+
+ +
+
+
Pending Invitation
+
+ Complete KYC to confirm your referral from{' '} + + {stats.pendingReferral.slice(0, 8)}...{stats.pendingReferral.slice(-6)} + +
+
+
+
+
+ )} + + {/* Invite Modal */} + setShowInviteModal(false)} + /> +
+ ); +}; diff --git a/web/src/contexts/ReferralContext.tsx b/web/src/contexts/ReferralContext.tsx new file mode 100644 index 00000000..3cfab668 --- /dev/null +++ b/web/src/contexts/ReferralContext.tsx @@ -0,0 +1,177 @@ +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import { usePolkadot } from '@/contexts/PolkadotContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { useToast } from '@/hooks/use-toast'; +import { + getReferralStats, + getMyReferrals, + initiateReferral, + subscribeToReferralEvents, + type ReferralStats, +} from '@pezkuwi/lib/referral'; + +interface ReferralContextValue { + stats: ReferralStats | null; + myReferrals: string[]; + loading: boolean; + inviteUser: (referredAddress: string) => Promise; + refreshStats: () => Promise; +} + +const ReferralContext = createContext(undefined); + +export function ReferralProvider({ children }: { children: ReactNode }) { + const { api, isApiReady } = usePolkadot(); + const { account } = useWallet(); + const { toast } = useToast(); + + const [stats, setStats] = useState(null); + const [myReferrals, setMyReferrals] = useState([]); + const [loading, setLoading] = useState(true); + + // Fetch referral statistics + const fetchStats = useCallback(async () => { + if (!api || !isApiReady || !account) { + setStats(null); + setMyReferrals([]); + setLoading(false); + return; + } + + try { + setLoading(true); + + const [fetchedStats, fetchedReferrals] = await Promise.all([ + getReferralStats(api, account), + getMyReferrals(api, account), + ]); + + setStats(fetchedStats); + setMyReferrals(fetchedReferrals); + } catch (error) { + console.error('Error fetching referral stats:', error); + toast({ + title: 'Error', + description: 'Failed to load referral statistics', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, [api, isApiReady, account, toast]); + + // Initial fetch + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + // Subscribe to referral events for real-time updates + useEffect(() => { + if (!api || !isApiReady || !account) return; + + let unsub: (() => void) | undefined; + + subscribeToReferralEvents(api, (event) => { + // If this user is involved in the event, refresh stats + if (event.referrer === account || event.referred === account) { + if (event.type === 'initiated') { + toast({ + title: 'Referral Sent', + description: `Invitation sent to ${event.referred.slice(0, 8)}...`, + }); + } else if (event.type === 'confirmed') { + toast({ + title: 'Referral Confirmed!', + description: `Your referral completed KYC. Total: ${event.count}`, + variant: 'default', + }); + } + fetchStats(); + } + }).then((unsubFn) => { + unsub = unsubFn; + }); + + return () => { + if (unsub) unsub(); + }; + }, [api, isApiReady, account, toast, fetchStats]); + + // Invite a new user + const inviteUser = async (referredAddress: string): Promise => { + if (!api || !account) { + toast({ + title: 'Error', + description: 'Wallet not connected', + variant: 'destructive', + }); + return false; + } + + try { + // Validate address format + if (!referredAddress || referredAddress.length < 47) { + toast({ + title: 'Invalid Address', + description: 'Please enter a valid Polkadot address', + variant: 'destructive', + }); + return false; + } + + toast({ + title: 'Sending Invitation', + description: 'Please sign the transaction...', + }); + + await initiateReferral(api, { address: account, meta: { source: 'polkadot-js' } } as any, referredAddress); + + toast({ + title: 'Success!', + description: 'Referral invitation sent successfully', + }); + + // Refresh stats after successful invitation + await fetchStats(); + return true; + } catch (error: any) { + console.error('Error inviting user:', error); + + let errorMessage = 'Failed to send referral invitation'; + if (error.message) { + if (error.message.includes('SelfReferral')) { + errorMessage = 'You cannot refer yourself'; + } else if (error.message.includes('AlreadyReferred')) { + errorMessage = 'This user has already been referred'; + } else { + errorMessage = error.message; + } + } + + toast({ + title: 'Error', + description: errorMessage, + variant: 'destructive', + }); + return false; + } + }; + + const value: ReferralContextValue = { + stats, + myReferrals, + loading, + inviteUser, + refreshStats: fetchStats, + }; + + return {children}; +} + +export function useReferral() { + const context = useContext(ReferralContext); + if (context === undefined) { + throw new Error('useReferral must be used within a ReferralProvider'); + } + return context; +}