feat: in-app citizenship modal + referral approvals + bot DKS link

- Add CitizenshipModal component for in-app citizenship application
  (uses connected wallet keypair, no seed phrase needed)
- Replace /citizens redirect with in-app modal in Rewards section
- Add pending approvals to ReferralContext
- Add approveReferral and getPendingApprovals to citizenship lib
- Add applyingCitizenship/applicationSuccess translations (6 langs)
- Add DKS Kurdistan bot link to telegram-bot welcome message
This commit is contained in:
2026-03-02 00:48:35 +03:00
parent faf0faed69
commit abd4dc7189
12 changed files with 858 additions and 7 deletions
+578
View File
@@ -0,0 +1,578 @@
/**
* In-App Citizenship Application Modal
* Self-contained modal with 3 steps: form → processing → success
* Uses the already-connected wallet keypair (no seed phrase needed)
*/
import { useState, useEffect, useRef } from 'react';
import { X, Plus, Trash2, Shield, CheckCircle, Clock, ArrowRight, AlertCircle } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { useTranslation } from '@/i18n';
import { KurdistanSun } from '@/components/KurdistanSun';
import { formatAddress } from '@/lib/utils';
import type { Region, MaritalStatus, ChildInfo } from '@/lib/citizenship';
import {
calculateIdentityHash,
saveCitizenshipLocally,
uploadToIPFS,
applyCitizenship,
} from '@/lib/citizenship';
interface CitizenshipModalProps {
isOpen: boolean;
onClose: () => void;
}
type Step = 'form' | 'processing' | 'success';
const REGIONS: { value: Region; labelKey: string }[] = [
{ value: 'bakur', labelKey: 'citizen.regionBakur' },
{ value: 'basur', labelKey: 'citizen.regionBasur' },
{ value: 'rojava', labelKey: 'citizen.regionRojava' },
{ value: 'rojhelat', labelKey: 'citizen.regionRojhelat' },
{ value: 'kurdistan_a_sor', labelKey: 'citizen.regionKurdistanASor' },
{ value: 'diaspora', labelKey: 'citizen.regionDiaspora' },
];
export function CitizenshipModal({ isOpen, onClose }: CitizenshipModalProps) {
const { peopleApi, keypair, address } = useWallet();
const { hapticImpact, hapticNotification } = useTelegram();
const { t } = useTranslation();
const [step, setStep] = useState<Step>('form');
const [error, setError] = useState('');
// Form fields
const [fullName, setFullName] = useState('');
const [fatherName, setFatherName] = useState('');
const [grandfatherName, setGrandfatherName] = useState('');
const [motherName, setMotherName] = useState('');
const [tribe, setTribe] = useState('');
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>('nezewici');
const [childrenCount, setChildrenCount] = useState(0);
const [children, setChildren] = useState<ChildInfo[]>([]);
const [region, setRegion] = useState<Region | ''>('');
const [email, setEmail] = useState('');
const [profession, setProfession] = useState('');
const [referrerAddress, setReferrerAddress] = useState('');
const [consent, setConsent] = useState(false);
// Success data
const [identityHash, setIdentityHash] = useState('');
// Processing cancel ref
const cancelledRef = useRef(false);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('form');
setError('');
setFullName('');
setFatherName('');
setGrandfatherName('');
setMotherName('');
setTribe('');
setMaritalStatus('nezewici');
setChildrenCount(0);
setChildren([]);
setRegion('');
setEmail('');
setProfession('');
setReferrerAddress('');
setConsent(false);
setIdentityHash('');
cancelledRef.current = false;
}
}, [isOpen]);
// --- Form handlers ---
const handleMaritalChange = (status: MaritalStatus) => {
hapticImpact('light');
setMaritalStatus(status);
if (status === 'nezewici') {
setChildrenCount(0);
setChildren([]);
}
};
const handleChildrenCountChange = (count: number) => {
const c = Math.max(0, Math.min(20, count));
setChildrenCount(c);
setChildren((prev) => {
if (c > prev.length) {
return [
...prev,
...Array.from({ length: c - prev.length }, () => ({ name: '', birthYear: 2000 })),
];
}
return prev.slice(0, c);
});
};
const updateChild = (index: number, field: keyof ChildInfo, value: string | number) => {
setChildren((prev) =>
prev.map((child, i) => (i === index ? { ...child, [field]: value } : child))
);
};
const addChild = () => {
hapticImpact('light');
handleChildrenCountChange(childrenCount + 1);
};
const removeChild = (index: number) => {
hapticImpact('light');
setChildren((prev) => prev.filter((_, i) => i !== index));
setChildrenCount((prev) => prev - 1);
};
const handleFormSubmit = () => {
setError('');
if (
!fullName ||
!fatherName ||
!grandfatherName ||
!motherName ||
!tribe ||
!region ||
!email ||
!profession
) {
setError(t('citizen.fillAllFields'));
hapticNotification('error');
return;
}
if (!consent) {
setError(t('citizen.acceptConsent'));
hapticNotification('error');
return;
}
if (!peopleApi) {
setError(t('citizen.peopleChainNotConnected'));
hapticNotification('error');
return;
}
if (!keypair || !address) {
setError(t('citizen.walletNotConnected'));
hapticNotification('error');
return;
}
hapticImpact('medium');
setStep('processing');
};
// --- Processing logic ---
useEffect(() => {
if (step !== 'processing') return;
cancelledRef.current = false;
const process = async () => {
try {
if (!peopleApi || !keypair || !address) {
setError(t('citizen.walletNotConnected'));
setStep('form');
return;
}
// Build citizenship data for local save
const citizenshipData = {
fullName,
fatherName,
grandfatherName,
motherName,
tribe,
maritalStatus,
childrenCount: maritalStatus === 'zewici' ? childrenCount : undefined,
children: maritalStatus === 'zewici' && children.length > 0 ? children : undefined,
region: region as Region,
email,
profession,
referrerAddress: referrerAddress || undefined,
walletAddress: address,
seedPhrase: '',
timestamp: Date.now(),
};
// Mock IPFS upload
const ipfsCid = await uploadToIPFS(citizenshipData);
if (cancelledRef.current) return;
// Calculate identity hash
const hash = calculateIdentityHash(fullName, email, [ipfsCid]);
if (cancelledRef.current) return;
setIdentityHash(hash);
// Save encrypted data locally
saveCitizenshipLocally(citizenshipData);
// Sign and submit extrinsic
const result = await applyCitizenship(peopleApi, keypair, hash, referrerAddress || null);
if (cancelledRef.current) return;
if (result.success) {
hapticNotification('success');
setStep('success');
} else {
hapticNotification('error');
setError(result.error || t('citizen.submissionFailed'));
setStep('form');
}
} catch (err) {
if (cancelledRef.current) return;
hapticNotification('error');
setError(err instanceof Error ? err.message : t('citizen.submissionFailed'));
setStep('form');
}
};
process();
return () => {
cancelledRef.current = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
if (!isOpen) return null;
const inputClass = 'w-full px-4 py-3 bg-muted rounded-xl text-sm';
const labelClass = 'text-sm text-muted-foreground mb-1 block';
// --- Processing Step ---
if (step === 'processing') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border p-6">
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<KurdistanSun size={100} />
<div className="text-center space-y-2">
<p className="text-lg font-medium">{t('citizen.applyingCitizenship')}</p>
<p className="text-sm text-muted-foreground">{fullName}</p>
</div>
</div>
</div>
</div>
);
}
// --- Success Step ---
if (step === 'success') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
<div className="flex flex-col items-center p-6 space-y-6">
{/* Success Icon */}
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center">
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
{/* Title */}
<h2 className="text-xl font-bold">{t('citizen.applicationSuccess')}</h2>
{/* 3-Step Process */}
<div className="w-full space-y-3">
<div className="flex items-start gap-3 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-400">{t('citizen.stepApplicationSent')}</p>
</div>
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl">
<Clock className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-400">{t('citizen.stepReferrerApproval')}</p>
</div>
<div className="flex items-start gap-3 p-3 bg-muted/50 border border-border rounded-xl">
<ArrowRight className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">{t('citizen.stepConfirm')}</p>
</div>
</div>
{/* Application Info */}
<div className="w-full bg-muted/50 rounded-2xl p-5 space-y-4 border border-border">
<div>
<p className="text-xs text-muted-foreground">{t('citizen.identityHash')}</p>
<p className="text-sm font-mono break-all">{formatAddress(identityHash)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('citizen.walletAddress')}</p>
<p className="text-sm font-mono">{formatAddress(address || '')}</p>
</div>
</div>
{/* Next Steps Info */}
<p className="text-xs text-muted-foreground text-center">
{t('citizen.nextStepsInfo')}
</p>
{/* Close Button */}
<button
onClick={onClose}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold"
>
{t('common.close')}
</button>
</div>
</div>
</div>
);
}
// --- Form Step ---
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
<h2 className="text-lg font-semibold">{t('citizen.pageTitle')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Form Content */}
<div className="p-4 space-y-4">
{/* Privacy Notice */}
<div className="flex gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<Shield className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-300">{t('citizen.privacyNotice')}</p>
</div>
{/* Full Name */}
<div>
<label className={labelClass}>{t('citizen.fullName')}</label>
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className={inputClass}
placeholder={t('citizen.fullNamePlaceholder')}
/>
</div>
{/* Father's Name */}
<div>
<label className={labelClass}>{t('citizen.fatherName')}</label>
<input
type="text"
value={fatherName}
onChange={(e) => setFatherName(e.target.value)}
className={inputClass}
placeholder={t('citizen.fatherNamePlaceholder')}
/>
</div>
{/* Grandfather's Name */}
<div>
<label className={labelClass}>{t('citizen.grandfatherName')}</label>
<input
type="text"
value={grandfatherName}
onChange={(e) => setGrandfatherName(e.target.value)}
className={inputClass}
placeholder={t('citizen.grandfatherNamePlaceholder')}
/>
</div>
{/* Mother's Name */}
<div>
<label className={labelClass}>{t('citizen.motherName')}</label>
<input
type="text"
value={motherName}
onChange={(e) => setMotherName(e.target.value)}
className={inputClass}
placeholder={t('citizen.motherNamePlaceholder')}
/>
</div>
{/* Tribe */}
<div>
<label className={labelClass}>{t('citizen.tribe')}</label>
<input
type="text"
value={tribe}
onChange={(e) => setTribe(e.target.value)}
className={inputClass}
placeholder={t('citizen.tribePlaceholder')}
/>
</div>
{/* Marital Status */}
<div>
<label className={labelClass}>{t('citizen.maritalStatus')}</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleMaritalChange('nezewici')}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition-colors ${
maritalStatus === 'nezewici'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('citizen.single')}
</button>
<button
type="button"
onClick={() => handleMaritalChange('zewici')}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition-colors ${
maritalStatus === 'zewici'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('citizen.married')}
</button>
</div>
</div>
{/* Children (if married) */}
{maritalStatus === 'zewici' && (
<div className="space-y-3">
<label className={labelClass}>{t('citizen.childrenCount')}</label>
{children.map((child, index) => (
<div key={index} className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-muted-foreground">
{t('citizen.childName', { index: String(index + 1) })}
</label>
<input
type="text"
value={child.name}
onChange={(e) => updateChild(index, 'name', e.target.value)}
className={inputClass}
placeholder={t('citizen.childNamePlaceholder')}
/>
</div>
<div className="w-24">
<label className="text-xs text-muted-foreground">
{t('citizen.childBirthYear')}
</label>
<input
type="number"
value={child.birthYear}
onChange={(e) =>
updateChild(index, 'birthYear', parseInt(e.target.value) || 2000)
}
className={inputClass}
min={1950}
max={2026}
/>
</div>
<button
type="button"
onClick={() => removeChild(index)}
className="p-3 text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={addChild}
className="flex items-center gap-2 text-sm text-primary"
>
<Plus className="w-4 h-4" />
{t('citizen.addChild')}
</button>
</div>
)}
{/* Region */}
<div>
<label className={labelClass}>{t('citizen.region')}</label>
<select
value={region}
onChange={(e) => {
setRegion(e.target.value as Region);
hapticImpact('light');
}}
className={`${inputClass} appearance-none`}
>
<option value="">{t('citizen.regionPlaceholder')}</option>
{REGIONS.map((r) => (
<option key={r.value} value={r.value}>
{t(r.labelKey)}
</option>
))}
</select>
</div>
{/* Email */}
<div>
<label className={labelClass}>{t('citizen.email')}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
placeholder={t('citizen.emailPlaceholder')}
/>
</div>
{/* Profession */}
<div>
<label className={labelClass}>{t('citizen.profession')}</label>
<input
type="text"
value={profession}
onChange={(e) => setProfession(e.target.value)}
className={inputClass}
placeholder={t('citizen.professionPlaceholder')}
/>
</div>
{/* Referrer Address */}
<div>
<label className={labelClass}>{t('citizen.referrerAddress')}</label>
<input
type="text"
value={referrerAddress}
onChange={(e) => setReferrerAddress(e.target.value)}
className={inputClass}
placeholder={t('citizen.referrerPlaceholder')}
/>
</div>
{/* Consent */}
<label className="flex items-start gap-3 p-3 bg-muted/50 rounded-xl cursor-pointer">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-1 w-5 h-5 rounded accent-primary flex-shrink-0"
/>
<span className="text-sm text-muted-foreground">{t('citizen.consentCheckbox')}</span>
</label>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Submit Button */}
<button
onClick={handleFormSubmit}
disabled={!consent || !fullName || !region || !email}
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50"
>
{t('citizen.submit')}
</button>
</div>
</div>
</div>
);
}
+14
View File
@@ -14,10 +14,12 @@ import {
subscribeToReferralEvents, subscribeToReferralEvents,
type ReferralStats, type ReferralStats,
} from '@/lib/referral'; } from '@/lib/referral';
import { getPendingApprovals, getCitizenshipStatus, type PendingApproval } from '@/lib/citizenship';
interface ReferralContextValue { interface ReferralContextValue {
stats: ReferralStats | null; stats: ReferralStats | null;
myReferrals: string[]; myReferrals: string[];
pendingApprovals: PendingApproval[];
loading: boolean; loading: boolean;
refreshStats: () => Promise<void>; refreshStats: () => Promise<void>;
} }
@@ -31,6 +33,7 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
const [stats, setStats] = useState<ReferralStats | null>(null); const [stats, setStats] = useState<ReferralStats | null>(null);
const [myReferrals, setMyReferrals] = useState<string[]>([]); const [myReferrals, setMyReferrals] = useState<string[]>([]);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Fetch referral statistics from People Chain // Fetch referral statistics from People Chain
@@ -38,6 +41,7 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
if (!peopleApi || !address) { if (!peopleApi || !address) {
setStats(null); setStats(null);
setMyReferrals([]); setMyReferrals([]);
setPendingApprovals([]);
setLoading(false); setLoading(false);
return; return;
} }
@@ -52,6 +56,15 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
setStats(fetchedStats); setStats(fetchedStats);
setMyReferrals(fetchedReferrals); setMyReferrals(fetchedReferrals);
// Fetch pending approvals only if user is an approved citizen (can be a referrer)
const citizenStatus = await getCitizenshipStatus(peopleApi, address);
if (citizenStatus === 'Approved') {
const approvals = await getPendingApprovals(peopleApi, address);
setPendingApprovals(approvals);
} else {
setPendingApprovals([]);
}
} catch (error) { } catch (error) {
console.error('Error fetching referral stats:', error); console.error('Error fetching referral stats:', error);
showAlert(translate('context.referralStatsError')); showAlert(translate('context.referralStatsError'));
@@ -92,6 +105,7 @@ export function ReferralProvider({ children }: { children: ReactNode }) {
const value: ReferralContextValue = { const value: ReferralContextValue = {
stats, stats,
myReferrals, myReferrals,
pendingApprovals,
loading, loading,
refreshStats: fetchStats, refreshStats: fetchStats,
}; };
+8
View File
@@ -171,6 +171,12 @@ const ar: Translations = {
noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها', noUnclaimedRewards: 'لا توجد مكافآت غير مطالب بها',
rewardHistory: 'سجل المكافآت', rewardHistory: 'سجل المكافآت',
era: 'حقبة', era: 'حقبة',
pendingApprovals: 'الموافقات المعلّقة',
approveReferral: 'موافقة',
approvingReferral: 'جاري الموافقة...',
referralApprovalSuccess: 'تمت الموافقة على الإحالة!',
referralApprovalFailed: 'فشلت الموافقة',
pendingReferralStatus: 'بانتظار موافقتك',
}, },
wallet: { wallet: {
@@ -826,6 +832,8 @@ const ar: Translations = {
alreadyPending: 'لديك طلب قيد الانتظار', alreadyPending: 'لديك طلب قيد الانتظار',
alreadyApproved: 'مواطنتك معتمدة بالفعل!', alreadyApproved: 'مواطنتك معتمدة بالفعل!',
insufficientBalance: 'رصيد غير كافٍ (١ HEZ وديعة مطلوبة)', insufficientBalance: 'رصيد غير كافٍ (١ HEZ وديعة مطلوبة)',
applyingCitizenship: 'جاري تقديم طلب المواطنة...',
applicationSuccess: 'تم تقديم الطلب!',
selectLanguage: 'اختر اللغة', selectLanguage: 'اختر اللغة',
}, },
}; };
+8
View File
@@ -172,6 +172,12 @@ const ckb: Translations = {
noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە', noUnclaimedRewards: 'خەڵاتی داوانەکراو نییە',
rewardHistory: 'مێژووی خەڵاتەکان', rewardHistory: 'مێژووی خەڵاتەکان',
era: 'سەردەم', era: 'سەردەم',
pendingApprovals: 'پەسەندکردنە چاوەڕوانەکان',
approveReferral: 'پەسەندکردن',
approvingReferral: 'پەسەند دەکرێت...',
referralApprovalSuccess: 'بانگهێشتکردن پەسەندکرا!',
referralApprovalFailed: 'پەسەندکردن سەرنەکەوت',
pendingReferralStatus: 'چاوەڕوانی پەسەندکردنی تۆیە',
}, },
wallet: { wallet: {
@@ -829,6 +835,8 @@ const ckb: Translations = {
alreadyPending: 'داواکارییەکی چاوەڕوانت هەیە', alreadyPending: 'داواکارییەکی چاوەڕوانت هەیە',
alreadyApproved: 'هاوڵاتیبوونت پێشتر پەسەند کراوە!', alreadyApproved: 'هاوڵاتیبوونت پێشتر پەسەند کراوە!',
insufficientBalance: 'باڵانسی پێویست نییە (١ HEZ ئەمانەت پێویستە)', insufficientBalance: 'باڵانسی پێویست نییە (١ HEZ ئەمانەت پێویستە)',
applyingCitizenship: 'داواکاری هاوڵاتیبوون دەنێردرێت...',
applicationSuccess: 'داواکاری نێردرا!',
selectLanguage: 'زمان هەڵبژێرە', selectLanguage: 'زمان هەڵبژێرە',
}, },
}; };
+8
View File
@@ -171,6 +171,12 @@ const en: Translations = {
noUnclaimedRewards: 'No unclaimed rewards', noUnclaimedRewards: 'No unclaimed rewards',
rewardHistory: 'Reward History', rewardHistory: 'Reward History',
era: 'Era', era: 'Era',
pendingApprovals: 'Pending Approvals',
approveReferral: 'Approve',
approvingReferral: 'Approving...',
referralApprovalSuccess: 'Referral approved!',
referralApprovalFailed: 'Approval failed',
pendingReferralStatus: 'Awaiting your approval',
}, },
wallet: { wallet: {
@@ -829,6 +835,8 @@ const en: Translations = {
alreadyPending: 'You already have a pending application', alreadyPending: 'You already have a pending application',
alreadyApproved: 'Your citizenship is already approved!', alreadyApproved: 'Your citizenship is already approved!',
insufficientBalance: 'Insufficient balance (1 HEZ deposit required)', insufficientBalance: 'Insufficient balance (1 HEZ deposit required)',
applyingCitizenship: 'Applying for citizenship...',
applicationSuccess: 'Application submitted!',
selectLanguage: 'Select language', selectLanguage: 'Select language',
}, },
}; };
+8
View File
@@ -171,6 +171,12 @@ const fa: Translations = {
noUnclaimedRewards: 'پاداش مطالبه نشده‌ای وجود ندارد', noUnclaimedRewards: 'پاداش مطالبه نشده‌ای وجود ندارد',
rewardHistory: 'تاریخچه پاداش‌ها', rewardHistory: 'تاریخچه پاداش‌ها',
era: 'دوره', era: 'دوره',
pendingApprovals: 'تأییدهای در انتظار',
approveReferral: 'تأیید',
approvingReferral: 'در حال تأیید...',
referralApprovalSuccess: 'دعوت تأیید شد!',
referralApprovalFailed: 'تأیید ناموفق بود',
pendingReferralStatus: 'در انتظار تأیید شما',
}, },
wallet: { wallet: {
@@ -829,6 +835,8 @@ const fa: Translations = {
alreadyPending: 'درخواست در انتظار دارید', alreadyPending: 'درخواست در انتظار دارید',
alreadyApproved: 'شهروندی شما قبلاً تأیید شده!', alreadyApproved: 'شهروندی شما قبلاً تأیید شده!',
insufficientBalance: 'موجودی ناکافی (۱ HEZ سپرده لازم است)', insufficientBalance: 'موجودی ناکافی (۱ HEZ سپرده لازم است)',
applyingCitizenship: 'در حال ارسال درخواست شهروندی...',
applicationSuccess: 'درخواست ارسال شد!',
selectLanguage: 'انتخاب زبان', selectLanguage: 'انتخاب زبان',
}, },
}; };
+8
View File
@@ -176,6 +176,12 @@ const krd: Translations = {
noUnclaimedRewards: 'Xelatên nedaxwazkir tune ne', noUnclaimedRewards: 'Xelatên nedaxwazkir tune ne',
rewardHistory: 'Dîroka Xelatan', rewardHistory: 'Dîroka Xelatan',
era: 'Era', era: 'Era',
pendingApprovals: 'Pejirandina li bendê',
approveReferral: 'Pejirîne',
approvingReferral: 'Tê pejirandin...',
referralApprovalSuccess: 'Referans hat pejirandin!',
referralApprovalFailed: 'Pejirandin biserneket',
pendingReferralStatus: 'Li benda pejirandina we',
}, },
wallet: { wallet: {
@@ -856,6 +862,8 @@ const krd: Translations = {
alreadyPending: 'Serlêdanek te ya li bendê heye', alreadyPending: 'Serlêdanek te ya li bendê heye',
alreadyApproved: 'Welatîbûna te berê hatiye pejirandin!', alreadyApproved: 'Welatîbûna te berê hatiye pejirandin!',
insufficientBalance: 'Balansa têr nîne (1 HEZ depozîto pêwîst e)', insufficientBalance: 'Balansa têr nîne (1 HEZ depozîto pêwîst e)',
applyingCitizenship: 'Daxwaza welatîbûnê tê şandin...',
applicationSuccess: 'Daxwaz hat şandin!',
selectLanguage: 'Ziman hilbijêre', selectLanguage: 'Ziman hilbijêre',
}, },
}; };
+8
View File
@@ -171,6 +171,12 @@ const tr: Translations = {
noUnclaimedRewards: 'Talep edilmemiş ödül yok', noUnclaimedRewards: 'Talep edilmemiş ödül yok',
rewardHistory: 'Ödül Geçmişi', rewardHistory: 'Ödül Geçmişi',
era: 'Era', era: 'Era',
pendingApprovals: 'Bekleyen Onaylar',
approveReferral: 'Onayla',
approvingReferral: 'Onaylanıyor...',
referralApprovalSuccess: 'Referans onaylandı!',
referralApprovalFailed: 'Onaylama başarısız',
pendingReferralStatus: 'Onayınızı bekliyor',
}, },
wallet: { wallet: {
@@ -829,6 +835,8 @@ const tr: Translations = {
alreadyPending: 'Bekleyen bir başvurunuz var', alreadyPending: 'Bekleyen bir başvurunuz var',
alreadyApproved: 'Vatandaşlığınız zaten onaylanmış!', alreadyApproved: 'Vatandaşlığınız zaten onaylanmış!',
insufficientBalance: 'Yetersiz bakiye (1 HEZ depozito gerekli)', insufficientBalance: 'Yetersiz bakiye (1 HEZ depozito gerekli)',
applyingCitizenship: 'Vatandaşlık başvurusu yapılıyor...',
applicationSuccess: 'Başvuru gönderildi!',
selectLanguage: 'Dil seçin', selectLanguage: 'Dil seçin',
}, },
}; };
+9
View File
@@ -173,6 +173,12 @@ export interface Translations {
noUnclaimedRewards: string; noUnclaimedRewards: string;
rewardHistory: string; rewardHistory: string;
era: string; era: string;
pendingApprovals: string;
approveReferral: string;
approvingReferral: string;
referralApprovalSuccess: string;
referralApprovalFailed: string;
pendingReferralStatus: string;
}; };
// Wallet section // Wallet section
@@ -851,6 +857,9 @@ export interface Translations {
alreadyPending: string; alreadyPending: string;
alreadyApproved: string; alreadyApproved: string;
insufficientBalance: string; insufficientBalance: string;
// In-app modal
applyingCitizenship: string;
applicationSuccess: string;
// Language selector // Language selector
selectLanguage: string; selectLanguage: string;
}; };
+113
View File
@@ -46,6 +46,11 @@ export interface CitizenshipResult {
identityHash?: string; identityHash?: string;
} }
export interface PendingApproval {
applicantAddress: string;
identityHash: string;
}
// ── Identity Hash (Keccak-256) ────────────────────────────────────── // ── Identity Hash (Keccak-256) ──────────────────────────────────────
export function calculateIdentityHash(name: string, email: string, documentCids: string[]): string { export function calculateIdentityHash(name: string, email: string, documentCids: string[]): string {
@@ -140,6 +145,114 @@ export async function getCitizenshipStatus(
} }
} }
// ── Pending Approvals ───────────────────────────────────────────────
/**
* Get pending referral applications that need approval from the given referrer.
* Queries identityKyc.applications entries and filters by referrer + PendingReferral status.
*/
export async function getPendingApprovals(
api: ApiPromise,
referrerAddress: string
): Promise<PendingApproval[]> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(api?.query as any)?.identityKyc?.applications) {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entries = await (api.query as any).identityKyc.applications.entries();
const pending: PendingApproval[] = [];
for (const [key, value] of entries) {
const applicantAddress = key.args[0].toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const appData = value.toJSON() as any;
// Check if this application's referrer matches
if (!appData?.referrer || appData.referrer !== referrerAddress) {
continue;
}
// Check if status is PendingReferral
const status = await getCitizenshipStatus(api, applicantAddress);
if (status === 'PendingReferral') {
pending.push({
applicantAddress,
identityHash: appData.identityHash || '',
});
}
}
return pending;
} catch (error) {
console.error('[Citizenship] Error fetching pending approvals:', error);
return [];
}
}
// ── Approve Referral ────────────────────────────────────────────────
export async function approveReferral(
api: ApiPromise,
keypair: KeyringPair,
applicantAddress: string
): Promise<CitizenshipResult> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = api.tx as any;
if (!tx?.identityKyc?.approveReferral) {
return { success: false, error: 'Identity KYC pallet not available' };
}
const result = await new Promise<CitizenshipResult>((resolve) => {
tx.identityKyc
.approveReferral(applicantAddress)
.signAndSend(
keypair,
{ nonce: -1 },
({
status,
dispatchError,
}: {
status: {
isInBlock: boolean;
isFinalized: boolean;
asInBlock?: { toString: () => string };
asFinalized?: { toString: () => string };
};
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Referral approval failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(
dispatchError.asModule as Parameters<typeof api.registry.findMetaError>[0]
);
errorMessage = `${decoded.section}.${decoded.name}`;
}
resolve({ success: false, error: errorMessage });
return;
}
const blockHash = status.asFinalized?.toString() || status.asInBlock?.toString();
resolve({ success: true, blockHash });
}
}
)
.catch((error: Error) => resolve({ success: false, error: error.message }));
});
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ── Blockchain Submission ─────────────────────────────────────────── // ── Blockchain Submission ───────────────────────────────────────────
export async function applyCitizenship( export async function applyCitizenship(
+93 -7
View File
@@ -24,10 +24,12 @@ import {
Play, Play,
PenTool, PenTool,
Globe2, Globe2,
Clock,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { cn, formatAddress } from '@/lib/utils'; import { cn, formatAddress } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram'; import { useTelegram } from '@/hooks/useTelegram';
import { useAuth } from '@/contexts/AuthContext';
import { useReferral } from '@/contexts/ReferralContext'; import { useReferral } from '@/contexts/ReferralContext';
import { useWallet } from '@/contexts/WalletContext'; import { useWallet } from '@/contexts/WalletContext';
import { SocialLinks } from '@/components/SocialLinks'; import { SocialLinks } from '@/components/SocialLinks';
@@ -62,9 +64,11 @@ import {
getCitizenshipStatus, getCitizenshipStatus,
getCitizenCount, getCitizenCount,
confirmCitizenship, confirmCitizenship,
approveReferral,
type CitizenshipStatus, type CitizenshipStatus,
} from '@/lib/citizenship'; } from '@/lib/citizenship';
import { KurdistanSun } from '@/components/KurdistanSun'; import { KurdistanSun } from '@/components/KurdistanSun';
import { CitizenshipModal } from '@/components/CitizenshipModal';
// Activity tracking constants // Activity tracking constants
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active'; const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
@@ -72,8 +76,8 @@ const ACTIVITY_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
export function RewardsSection() { export function RewardsSection() {
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram(); const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
const { user: authUser } = useAuth();
const { stats, myReferrals, loading, refreshStats } = useReferral(); const { stats, myReferrals, pendingApprovals, loading, refreshStats } = useReferral();
const { isConnected, address, peopleApi, assetHubApi, keypair } = useWallet(); const { isConnected, address, peopleApi, assetHubApi, keypair } = useWallet();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -95,6 +99,8 @@ export function RewardsSection() {
const [unclaimedRewards, setUnclaimedRewards] = useState<UnclaimedRewardsResult | null>(null); const [unclaimedRewards, setUnclaimedRewards] = useState<UnclaimedRewardsResult | null>(null);
const [claimingStaking, setClaimingStaking] = useState(false); const [claimingStaking, setClaimingStaking] = useState(false);
const [claimingStakingEra, setClaimingStakingEra] = useState<number | null>(null); const [claimingStakingEra, setClaimingStakingEra] = useState<number | null>(null);
const [approvingAddress, setApprovingAddress] = useState<string | null>(null);
const [showCitizenshipModal, setShowCitizenshipModal] = useState(false);
// Check activity status // Check activity status
const checkActivityStatus = useCallback(() => { const checkActivityStatus = useCallback(() => {
@@ -358,9 +364,31 @@ export function RewardsSection() {
showAlert(t('rewards.activatedAlert')); showAlert(t('rewards.activatedAlert'));
}; };
// Citizenship referral link - wallet address in start param for auto-fill const handleApproveReferral = async (applicantAddress: string) => {
const referralLink = address if (!peopleApi || !keypair) return;
? `https://t.me/pezkuwichainBot?start=${address}` setApprovingAddress(applicantAddress);
hapticImpact('medium');
try {
const result = await approveReferral(peopleApi, keypair, applicantAddress);
if (result.success) {
hapticNotification('success');
showAlert(t('rewards.referralApprovalSuccess'));
refreshStats();
} else {
hapticNotification('error');
showAlert(result.error || t('rewards.referralApprovalFailed'));
}
} catch (err) {
hapticNotification('error');
showAlert(err instanceof Error ? err.message : t('rewards.referralApprovalFailed'));
} finally {
setApprovingAddress(null);
}
};
// Telegram referral link (for sharing) - use authenticated user ID
const referralLink = authUser?.telegram_id
? `https://t.me/pezkuwichainBot?start=ref_${authUser.telegram_id}`
: 'https://t.me/pezkuwichainBot'; : 'https://t.me/pezkuwichainBot';
// Full share message: invitation text + link + wallet address for manual paste // Full share message: invitation text + link + wallet address for manual paste
@@ -496,7 +524,7 @@ export function RewardsSection() {
<button <button
onClick={() => { onClick={() => {
hapticImpact('medium'); hapticImpact('medium');
window.location.href = `${window.location.origin}/citizens${window.location.hash}`; setShowCitizenshipModal(true);
}} }}
className="w-full py-2.5 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-colors" className="w-full py-2.5 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-colors"
> >
@@ -820,6 +848,53 @@ export function RewardsSection() {
</button> </button>
</div> </div>
{/* Pending Approvals Section */}
{pendingApprovals.length > 0 && (
<div className="bg-gradient-to-r from-orange-500/20 to-amber-500/20 border border-orange-500/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-5 h-5 text-orange-400" />
<h3 className="font-semibold text-orange-100">{t('rewards.pendingApprovals')}</h3>
<span className="bg-orange-500/30 text-orange-300 text-xs font-medium px-2 py-0.5 rounded-full">
{pendingApprovals.length}
</span>
</div>
<div className="space-y-2">
{pendingApprovals.map((approval) => (
<div
key={approval.applicantAddress}
className="flex items-center gap-3 bg-black/20 rounded-lg p-3"
>
<div className="flex-1 min-w-0">
<code className="text-sm text-foreground">
{formatAddress(approval.applicantAddress, 8)}
</code>
<p className="text-xs text-orange-300">
{t('rewards.pendingReferralStatus')}
</p>
</div>
<button
onClick={() => handleApproveReferral(approval.applicantAddress)}
disabled={approvingAddress !== null}
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/30 text-orange-200 hover:bg-orange-500/40 transition-all disabled:opacity-50 flex items-center gap-2"
>
{approvingAddress === approval.applicantAddress ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t('rewards.approvingReferral')}
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
{t('rewards.approveReferral')}
</>
)}
</button>
</div>
))}
</div>
</div>
)}
{loading ? ( {loading ? (
<div className="space-y-3"> <div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
@@ -1302,6 +1377,17 @@ export function RewardsSection() {
<p className="text-white/60 text-sm mt-2">{t('rewards.signingBlockchain')}</p> <p className="text-white/60 text-sm mt-2">{t('rewards.signingBlockchain')}</p>
</div> </div>
)} )}
{/* Citizenship Application Modal */}
<CitizenshipModal
isOpen={showCitizenshipModal}
onClose={() => {
setShowCitizenshipModal(false);
if (peopleApi && address) {
getCitizenshipStatus(peopleApi, address).then(setCitizenshipStatus);
}
}}
/>
</div> </div>
); );
} }
+3
View File
@@ -59,6 +59,9 @@ Cûzdanê xwe biafirînin, zimanê xwe hilbijêrin û welatiyê Pezkuwî bibin.
<i>Start your digital journey with Pezkuwi. <i>Start your digital journey with Pezkuwi.
Create your wallet, choose your language and become a citizen.</i> Create your wallet, choose your language and become a citizen.</i>
🤖 Dijital Kurdistan AI agentıyla sohbet etmek ve daha detaylı bilgi almak için → @DKSkurdistanBot
<i>Chat with Digital Kurdistan AI agent for more info → @DKSkurdistanBot</i>
`; `;
// ── DKS bot (@DKSKurdistanBot) welcome ────────────────────────────── // ── DKS bot (@DKSKurdistanBot) welcome ──────────────────────────────