feat: add Be Citizen page with 6-language support

This commit is contained in:
2026-02-14 20:44:17 +03:00
parent c4282f5870
commit b8ab86028f
16 changed files with 1653 additions and 44 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.190",
"version": "1.0.192",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
+18
View File
@@ -20,6 +20,9 @@ const RewardsSection = lazy(() =>
const WalletSection = lazy(() =>
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
);
const CitizenPage = lazy(() =>
import('@/pages/CitizenPage').then((m) => ({ default: m.CitizenPage }))
);
// Loading fallback component
function SectionLoader() {
@@ -51,7 +54,22 @@ const NAV_ITEMS: NavItem[] = [
// P2P Web App URL - Mobile-optimized P2P
const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p';
// Check for standalone pages via URL query params (evaluated once at module level)
const PAGE_PARAM = new URLSearchParams(window.location.search).get('page');
export default function App() {
if (PAGE_PARAM === 'citizen') {
return (
<Suspense fallback={<SectionLoader />}>
<CitizenPage />
</Suspense>
);
}
return <MainApp />;
}
function MainApp() {
const [activeSection, setActiveSection] = useState<Section>('announcements');
const [showP2PModal, setShowP2PModal] = useState(false);
const { sessionToken } = useAuth();
+362
View File
@@ -0,0 +1,362 @@
/**
* Citizen Application Form
* Collects citizenship data from the user
*/
import { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import type { CitizenshipData, Region, MaritalStatus, ChildInfo } from '@/lib/citizenship';
interface Props {
walletAddress: string;
onSubmit: (data: CitizenshipData) => void;
}
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 CitizenForm({ walletAddress, onSubmit }: Props) {
const { t } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
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 [referralCode, setReferralCode] = useState('');
const [consent, setConsent] = useState(false);
const [error, setError] = useState('');
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 handleSubmit = () => {
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;
}
hapticImpact('medium');
const data: 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,
referralCode: referralCode || undefined,
walletAddress,
timestamp: Date.now(),
};
onSubmit(data);
};
const inputClass = 'w-full px-4 py-3 bg-muted rounded-xl text-sm';
const labelClass = 'text-sm text-muted-foreground mb-1 block';
return (
<div className="p-4 space-y-4 pb-24">
{/* 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>
{/* Referral Code */}
<div>
<label className={labelClass}>{t('citizen.referralCode')}</label>
<input
type="text"
value={referralCode}
onChange={(e) => setReferralCode(e.target.value)}
className={inputClass}
placeholder={t('citizen.referralCodePlaceholder')}
/>
</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="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{/* Submit Button */}
<button
onClick={handleSubmit}
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>
);
}
@@ -0,0 +1,143 @@
/**
* Citizen Processing Component
* Shows KurdistanSun animation while preparing data,
* then enables sign button when ready
*/
import { useState, useEffect, useCallback } from 'react';
import { KurdistanSun } from '@/components/KurdistanSun';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { useWallet } from '@/contexts/WalletContext';
import type { CitizenshipData } from '@/lib/citizenship';
import {
generateCommitmentHash,
generateNullifierHash,
saveCitizenshipLocally,
uploadToIPFS,
submitCitizenshipApplication,
} from '@/lib/citizenship';
interface Props {
citizenshipData: CitizenshipData;
onSuccess: (blockHash?: string) => void;
onError: (error: string) => void;
}
type ProcessingState = 'preparing' | 'ready' | 'signing';
export function CitizenProcessing({ citizenshipData, onSuccess, onError }: Props) {
const { t } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const { peopleApi, keypair } = useWallet();
const [state, setState] = useState<ProcessingState>('preparing');
const [ipfsCid, setIpfsCid] = useState<string>('');
// Prepare data on mount
useEffect(() => {
const prepare = async () => {
try {
// Generate commitment hash
generateCommitmentHash(citizenshipData);
generateNullifierHash(citizenshipData.walletAddress, citizenshipData.timestamp);
// Save encrypted data locally
saveCitizenshipLocally(citizenshipData);
// Mock IPFS upload
const cid = await uploadToIPFS(citizenshipData);
setIpfsCid(cid);
// Small delay to show animation
await new Promise((resolve) => setTimeout(resolve, 1500));
setState('ready');
hapticNotification('success');
} catch (err) {
onError(err instanceof Error ? err.message : 'Preparation failed');
}
};
prepare();
}, [citizenshipData, hapticNotification, onError]);
const handleSign = useCallback(async () => {
if (!peopleApi || !keypair) {
onError(t('citizen.walletNotConnected'));
return;
}
setState('signing');
hapticImpact('medium');
try {
const result = await submitCitizenshipApplication(
peopleApi,
keypair,
citizenshipData.fullName,
citizenshipData.email,
ipfsCid,
`Citizenship application - ${citizenshipData.region}`
);
if (result.success) {
hapticNotification('success');
onSuccess(result.blockHash);
} else {
hapticNotification('error');
onError(result.error || t('citizen.submissionFailed'));
}
} catch (err) {
hapticNotification('error');
onError(err instanceof Error ? err.message : t('citizen.submissionFailed'));
}
}, [
peopleApi,
keypair,
citizenshipData,
ipfsCid,
hapticImpact,
hapticNotification,
onSuccess,
onError,
t,
]);
const isReady = state === 'ready';
const isSigning = state === 'signing';
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-6 space-y-8">
{/* Kurdistan Sun Animation */}
<div className={state === 'ready' ? 'opacity-80' : ''}>
<KurdistanSun size={100} />
</div>
{/* Status Message */}
<div className="text-center space-y-2">
<p className="text-lg font-medium">
{state === 'preparing' && t('citizen.preparingData')}
{state === 'ready' && t('citizen.readyToSign')}
{state === 'signing' && t('citizen.signingTx')}
</p>
{state === 'preparing' && (
<p className="text-sm text-muted-foreground">{citizenshipData.fullName}</p>
)}
</div>
{/* Sign Button */}
<button
onClick={handleSign}
disabled={!isReady || isSigning}
className={`w-full max-w-xs py-4 rounded-xl font-bold text-lg transition-all ${
isReady
? 'bg-green-600 text-white hover:bg-green-500 active:scale-95'
: 'bg-muted text-muted-foreground cursor-not-allowed'
}`}
>
{isSigning ? t('citizen.signingTx') : t('citizen.sign')}
</button>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Citizen Success Screen
* Shows after successful citizenship application submission
*/
import { CheckCircle } from 'lucide-react';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { formatAddress } from '@/lib/wallet-service';
import { generateCitizenNumber } from '@/lib/citizenship';
interface Props {
address: string;
onOpenApp: () => void;
}
export function CitizenSuccess({ address, onOpenApp }: Props) {
const { t } = useTranslation();
const { hapticImpact } = useTelegram();
// Generate a citizen number based on address
const citizenNumber = generateCitizenNumber(address, 42, 0);
const citizenId = `#42-0-${citizenNumber}`;
const handleOpenApp = () => {
hapticImpact('medium');
onOpenApp();
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] 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 */}
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">{t('citizen.successTitle')}</h1>
<p className="text-muted-foreground">{t('citizen.successSubtitle')}</p>
</div>
{/* Citizen ID Card */}
<div className="w-full max-w-sm bg-muted/50 rounded-2xl p-5 space-y-4 border border-border">
<div>
<p className="text-xs text-muted-foreground">{t('citizen.citizenId')}</p>
<p className="text-xl font-mono font-bold text-primary">{citizenId}</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>
{/* Open App Button */}
<button
onClick={handleOpenApp}
className="w-full max-w-sm py-3 bg-primary text-primary-foreground rounded-xl font-semibold"
>
{t('citizen.openApp')}
</button>
</div>
);
}
+56
View File
@@ -574,6 +574,62 @@ const ar: Translations = {
wrongPasswordError: 'كلمة مرور خاطئة',
walletSyncFailed: 'فشل مزامنة عنوان المحفظة مع قاعدة البيانات',
},
citizen: {
pageTitle: 'كن مواطناً',
fullName: 'الاسم الكامل',
fullNamePlaceholder: 'أدخل اسمك الكامل',
fatherName: 'اسم الأب',
fatherNamePlaceholder: 'أدخل اسم والدك',
grandfatherName: 'اسم الجد',
grandfatherNamePlaceholder: 'أدخل اسم جدك',
motherName: 'اسم الأم',
motherNamePlaceholder: 'أدخل اسم والدتك',
tribe: 'العشيرة',
tribePlaceholder: 'أدخل اسم عشيرتك',
maritalStatus: 'الحالة الاجتماعية',
married: 'متزوج',
single: 'أعزب',
childrenCount: 'عدد الأطفال',
childName: 'اسم الطفل {index}',
childNamePlaceholder: 'الاسم',
childBirthYear: 'سنة الميلاد',
addChild: 'إضافة طفل',
removeChild: 'إزالة',
region: 'المنطقة',
regionPlaceholder: 'اختر منطقتك',
regionBakur: 'باكور (تركيا)',
regionBasur: 'باشور (العراق)',
regionRojava: 'روج آفا (سوريا)',
regionRojhelat: 'روج هلات (إيران)',
regionKurdistanASor: 'كردستان الحمراء',
regionDiaspora: 'الشتات',
email: 'البريد الإلكتروني',
emailPlaceholder: 'name@mail.com',
profession: 'المهنة',
professionPlaceholder: 'أدخل مهنتك',
referralCode: 'رمز الإحالة (اختياري)',
referralCodePlaceholder: 'أدخل رمز الإحالة',
consentCheckbox: 'بياناتي محمية بـ ZK-proof، فقط الهاش يُخزن على البلوكشين',
submit: 'إرسال',
sign: 'وقّع',
openApp: 'فتح التطبيق',
preparingData: 'جاري تحضير البيانات...',
readyToSign: 'جاهز للتوقيع',
signingTx: 'جاري التوقيع...',
successTitle: 'أصبحت مواطناً في بزكوي!',
successSubtitle: 'مرحباً بك في وطنك الرقمي!',
citizenId: 'رقم المواطنة',
walletAddress: 'عنوان المحفظة',
fillAllFields: 'يرجى ملء جميع الحقول',
acceptConsent: 'يرجى تحديد مربع الموافقة',
walletNotConnected: 'المحفظة غير متصلة',
peopleChainNotConnected: 'People Chain غير متصل',
submissionFailed: 'فشل الإرسال',
alreadyPending: 'لديك طلب قيد الانتظار',
alreadyApproved: 'مواطنتك معتمدة بالفعل!',
selectLanguage: 'اختر اللغة',
},
};
export default ar;
+56
View File
@@ -576,6 +576,62 @@ const ckb: Translations = {
wrongPasswordError: 'وشەی نهێنی هەڵەیە',
walletSyncFailed: 'هاوکاتکردنی ناونیشانی جزدان لەگەڵ DB سەرنەکەوت',
},
citizen: {
pageTitle: 'ببە هاوڵاتی',
fullName: 'ناوی تەواو',
fullNamePlaceholder: 'ناوی تەواوت بنووسە',
fatherName: 'ناوی باوک',
fatherNamePlaceholder: 'ناوی باوکت بنووسە',
grandfatherName: 'ناوی باپیر',
grandfatherNamePlaceholder: 'ناوی باپیرت بنووسە',
motherName: 'ناوی دایک',
motherNamePlaceholder: 'ناوی دایکت بنووسە',
tribe: 'هۆز / عەشیرەت',
tribePlaceholder: 'ناوی هۆزەکەت بنووسە',
maritalStatus: 'بارودۆخی خێزانی',
married: 'خێزاندار',
single: 'بێ خێزان',
childrenCount: 'ژمارەی منداڵان',
childName: 'ناوی منداڵی {index}',
childNamePlaceholder: 'ناو',
childBirthYear: 'ساڵی لەدایکبوون',
addChild: 'منداڵ زیاد بکە',
removeChild: 'لاببە',
region: 'هەرێم',
regionPlaceholder: 'هەرێمەکەت هەڵبژێرە',
regionBakur: 'باکوور (تورکیا)',
regionBasur: 'باشوور (عێراق)',
regionRojava: 'ڕۆژاڤا (سووریا)',
regionRojhelat: 'ڕۆژهەڵات (ئێران)',
regionKurdistanASor: 'کوردستانی سوور',
regionDiaspora: 'دیاسپۆرا',
email: 'ئیمەیل',
emailPlaceholder: 'ناو@mail.com',
profession: 'پیشە',
professionPlaceholder: 'پیشەکەت بنووسە',
referralCode: 'کۆدی ڕیفێڕاڵ (ئارەزوومەندانە)',
referralCodePlaceholder: 'کۆدی ڕیفێڕاڵ بنووسە',
consentCheckbox: 'داتاکانم بە ZK-proof پارێزراون، تەنها هاش لە بلۆکچەین تۆمار دەکرێت',
submit: 'ناردن',
sign: 'واژووبکە',
openApp: 'ئەپ بکەرەوە',
preparingData: 'داتا ئامادە دەکرێت...',
readyToSign: 'ئامادەیە بۆ واژوو',
signingTx: 'واژوو دەکرێت...',
successTitle: 'بوویت بە هاوڵاتی پێزکووی!',
successSubtitle: 'بەخێر بێیت بۆ نیشتمانی دیجیتاڵت!',
citizenId: 'ناسنامەی هاوڵاتی',
walletAddress: 'ناونیشانی جزدان',
fillAllFields: 'تکایە هەموو خانەکان پڕ بکەرەوە',
acceptConsent: 'تکایە خانەی ڕەزامەندی نیشان بدە',
walletNotConnected: 'جزدان پەیوەندی نییە',
peopleChainNotConnected: 'People Chain پەیوەندی نییە',
submissionFailed: 'ناردن سەرنەکەوت',
alreadyPending: 'داواکارییەکی چاوەڕوانت هەیە',
alreadyApproved: 'هاوڵاتیبوونت پێشتر پەسەند کراوە!',
selectLanguage: 'زمان هەڵبژێرە',
},
};
export default ckb;
+56
View File
@@ -575,6 +575,62 @@ const en: Translations = {
wrongPasswordError: 'Wrong password',
walletSyncFailed: 'Wallet address sync to DB failed',
},
citizen: {
pageTitle: 'Be Citizen',
fullName: 'Full Name',
fullNamePlaceholder: 'Enter your full name',
fatherName: "Father's Name",
fatherNamePlaceholder: "Enter your father's name",
grandfatherName: "Grandfather's Name",
grandfatherNamePlaceholder: "Enter your grandfather's name",
motherName: "Mother's Name",
motherNamePlaceholder: "Enter your mother's name",
tribe: 'Tribe',
tribePlaceholder: 'Enter your tribe name',
maritalStatus: 'Marital Status',
married: 'Married',
single: 'Single',
childrenCount: 'Number of Children',
childName: 'Child {index} Name',
childNamePlaceholder: 'Name',
childBirthYear: 'Birth Year',
addChild: 'Add child',
removeChild: 'Remove',
region: 'Region',
regionPlaceholder: 'Select your region',
regionBakur: 'Bakur (Turkey)',
regionBasur: 'Bashur (Iraq)',
regionRojava: 'Rojava (Syria)',
regionRojhelat: 'Rojhelat (Iran)',
regionKurdistanASor: 'Red Kurdistan',
regionDiaspora: 'Diaspora',
email: 'E-mail',
emailPlaceholder: 'name@mail.com',
profession: 'Profession',
professionPlaceholder: 'Enter your profession',
referralCode: 'Referral Code (Optional)',
referralCodePlaceholder: 'Enter referral code',
consentCheckbox: 'My data is protected with ZK-proof, only a hash is stored on the blockchain',
submit: 'Submit',
sign: 'Sign',
openApp: 'Open App',
preparingData: 'Preparing data...',
readyToSign: 'Ready to sign',
signingTx: 'Signing...',
successTitle: 'You are now a Pezkuwi Citizen!',
successSubtitle: 'Welcome to your digital homeland!',
citizenId: 'Citizen ID',
walletAddress: 'Wallet Address',
fillAllFields: 'Please fill in all fields',
acceptConsent: 'Please accept the consent checkbox',
walletNotConnected: 'Wallet not connected',
peopleChainNotConnected: 'People Chain not connected',
submissionFailed: 'Submission failed',
alreadyPending: 'You already have a pending application',
alreadyApproved: 'Your citizenship is already approved!',
selectLanguage: 'Select language',
},
};
export default en;
+56
View File
@@ -575,6 +575,62 @@ const fa: Translations = {
wrongPasswordError: 'رمز عبور اشتباه',
walletSyncFailed: 'همگام‌سازی آدرس کیف پول با DB ناموفق',
},
citizen: {
pageTitle: 'شهروند شوید',
fullName: 'نام کامل',
fullNamePlaceholder: 'نام کامل خود را وارد کنید',
fatherName: 'نام پدر',
fatherNamePlaceholder: 'نام پدر خود را وارد کنید',
grandfatherName: 'نام پدربزرگ',
grandfatherNamePlaceholder: 'نام پدربزرگ خود را وارد کنید',
motherName: 'نام مادر',
motherNamePlaceholder: 'نام مادر خود را وارد کنید',
tribe: 'قبیله / عشیره',
tribePlaceholder: 'نام قبیله خود را وارد کنید',
maritalStatus: 'وضعیت تأهل',
married: 'متأهل',
single: 'مجرد',
childrenCount: 'تعداد فرزندان',
childName: 'نام فرزند {index}',
childNamePlaceholder: 'نام',
childBirthYear: 'سال تولد',
addChild: 'افزودن فرزند',
removeChild: 'حذف',
region: 'منطقه',
regionPlaceholder: 'منطقه خود را انتخاب کنید',
regionBakur: 'باکور (ترکیه)',
regionBasur: 'باشور (عراق)',
regionRojava: 'روژاوا (سوریه)',
regionRojhelat: 'روژهلات (ایران)',
regionKurdistanASor: 'کردستان سرخ',
regionDiaspora: 'دیاسپورا',
email: 'ایمیل',
emailPlaceholder: 'name@mail.com',
profession: 'شغل',
professionPlaceholder: 'شغل خود را وارد کنید',
referralCode: 'کد معرف (اختیاری)',
referralCodePlaceholder: 'کد معرف را وارد کنید',
consentCheckbox: 'داده‌های من با ZK-proof محافظت می‌شوند، فقط هش در بلاکچین ثبت می‌شود',
submit: 'ارسال',
sign: 'امضا کنید',
openApp: 'باز کردن برنامه',
preparingData: 'آماده‌سازی داده‌ها...',
readyToSign: 'آماده برای امضا',
signingTx: 'در حال امضا...',
successTitle: 'شما شهروند پزکوی شدید!',
successSubtitle: 'به سرزمین دیجیتالتان خوش آمدید!',
citizenId: 'شناسه شهروندی',
walletAddress: 'آدرس کیف پول',
fillAllFields: 'لطفاً همه فیلدها را پر کنید',
acceptConsent: 'لطفاً کادر رضایت را علامت بزنید',
walletNotConnected: 'کیف پول متصل نیست',
peopleChainNotConnected: 'People Chain متصل نیست',
submissionFailed: 'ارسال ناموفق',
alreadyPending: 'درخواست در انتظار دارید',
alreadyApproved: 'شهروندی شما قبلاً تأیید شده!',
selectLanguage: 'انتخاب زبان',
},
};
export default fa;
+56
View File
@@ -600,6 +600,62 @@ const krd: Translations = {
wrongPasswordError: '\u015e\u00eefre (password) \u00e7ewt e',
walletSyncFailed: 'Wallet adresa DB-\u00ea re senkron\u00eeze neb\u00fb',
},
citizen: {
pageTitle: 'Bibe Welatî',
fullName: 'Navê Te',
fullNamePlaceholder: 'Navê xwe yê temam binivîse',
fatherName: 'Navê Bavê Te',
fatherNamePlaceholder: 'Navê bavê xwe binivîse',
grandfatherName: 'Navê Bavkalê Te',
grandfatherNamePlaceholder: 'Navê bavkalê xwe binivîse',
motherName: 'Navê Dayika Te',
motherNamePlaceholder: 'Navê dayika xwe binivîse',
tribe: 'Eşîra Te',
tribePlaceholder: 'Navê eşîra xwe binivîse',
maritalStatus: 'Rewşa Zewacê',
married: 'Zewicî',
single: 'Nezewicî',
childrenCount: 'Hejmara Zarokan',
childName: 'Navê Zaroka {index}',
childNamePlaceholder: 'Nav',
childBirthYear: 'Sala Bûyînê',
addChild: 'Zaroka zêde bike',
removeChild: 'Jê bibe',
region: 'Herêm',
regionPlaceholder: 'Herêmê hilbijêre',
regionBakur: 'Bakur (Tirkiye)',
regionBasur: 'Başûr (Iraq)',
regionRojava: 'Rojava (Sûriye)',
regionRojhelat: 'Rojhelat (Îran)',
regionKurdistanASor: 'Kurdistan a Sor',
regionDiaspora: 'Diaspora',
email: 'E-mail',
emailPlaceholder: 'navê@mail.com',
profession: 'Pîşeya Te',
professionPlaceholder: 'Pîşeya xwe binivîse',
referralCode: 'Koda Referral (Opsiyonel)',
referralCodePlaceholder: 'Koda referral binivîse',
consentCheckbox: 'Daneyên min bi ZK-proof ewle ne, tenê hash li blockchain tê tomarkirin',
submit: 'Bişîne',
sign: 'Îmze Bike',
openApp: 'Serîlêdanê Veke',
preparingData: 'Dane têne amadekirin...',
readyToSign: 'Ji bo îmzekirinê amade ye',
signingTx: 'Tê îmzekirin...',
successTitle: 'Welatiyê Pezkuwî bûn!',
successSubtitle: 'Serî hatî welatê xwe yê dîjîtal!',
citizenId: 'Nasnameya Welatî',
walletAddress: 'Navnîşana Cûzdan',
fillAllFields: 'Ji kerema xwe hemû qadan tije bike',
acceptConsent: 'Ji kerema xwe qutiya pejirandinê nîşan bide',
walletNotConnected: 'Cûzdan girêdayî nîne',
peopleChainNotConnected: 'People Chain girêdayî nîne',
submissionFailed: 'Serlêdan neserketî',
alreadyPending: 'Serlêdanek te ya li bendê heye',
alreadyApproved: 'Welatîbûna te berê hatiye pejirandin!',
selectLanguage: 'Ziman hilbijêre',
},
};
export default krd;
+56
View File
@@ -576,6 +576,62 @@ const tr: Translations = {
wrongPasswordError: 'Yanlış şifre',
walletSyncFailed: 'Cüzdan adresi DB ile senkronize edilemedi',
},
citizen: {
pageTitle: 'Vatandaş Ol',
fullName: 'Tam İsim',
fullNamePlaceholder: 'Tam isminizi girin',
fatherName: 'Baba Adı',
fatherNamePlaceholder: 'Babanızın adını girin',
grandfatherName: 'Dede Adı',
grandfatherNamePlaceholder: 'Dedenizin adını girin',
motherName: 'Anne Adı',
motherNamePlaceholder: 'Annenizin adını girin',
tribe: 'Aşiret',
tribePlaceholder: 'Aşiretinizin adını girin',
maritalStatus: 'Medeni Durum',
married: 'Evli',
single: 'Bekar',
childrenCount: 'Çocuk Sayısı',
childName: 'Çocuk {index} Adı',
childNamePlaceholder: 'Ad',
childBirthYear: 'Doğum Yılı',
addChild: 'Çocuk ekle',
removeChild: 'Kaldır',
region: 'Bölge',
regionPlaceholder: 'Bölgenizi seçin',
regionBakur: 'Bakur (Türkiye)',
regionBasur: 'Başur (Irak)',
regionRojava: 'Rojava (Suriye)',
regionRojhelat: 'Rojhelat (İran)',
regionKurdistanASor: 'Kızıl Kürdistan',
regionDiaspora: 'Diaspora',
email: 'E-posta',
emailPlaceholder: 'isim@mail.com',
profession: 'Meslek',
professionPlaceholder: 'Mesleğinizi girin',
referralCode: 'Referans Kodu (Opsiyonel)',
referralCodePlaceholder: 'Referans kodunu girin',
consentCheckbox: "Verilerim ZK-proof ile güvende, sadece hash blockchain'e kaydedilir",
submit: 'Gönder',
sign: 'İmzala',
openApp: 'Uygulamayı Aç',
preparingData: 'Veriler hazırlanıyor...',
readyToSign: 'İmzalanmaya hazır',
signingTx: 'İmzalanıyor...',
successTitle: 'Pezkuwi Vatandaşı Oldunuz!',
successSubtitle: 'Dijital vatanınıza hoş geldiniz!',
citizenId: 'Vatandaş No',
walletAddress: 'Cüzdan Adresi',
fillAllFields: 'Lütfen tüm alanları doldurun',
acceptConsent: 'Lütfen onay kutusunu işaretleyin',
walletNotConnected: 'Cüzdan bağlı değil',
peopleChainNotConnected: 'People Chain bağlı değil',
submissionFailed: 'Başvuru başarısız',
alreadyPending: 'Bekleyen bir başvurunuz var',
alreadyApproved: 'Vatandaşlığınız zaten onaylanmış!',
selectLanguage: 'Dil seçin',
},
};
export default tr;
+64
View File
@@ -580,6 +580,70 @@ export interface Translations {
wrongPasswordError: string;
walletSyncFailed: string;
};
// Citizen page
citizen: {
pageTitle: string;
// Form labels
fullName: string;
fullNamePlaceholder: string;
fatherName: string;
fatherNamePlaceholder: string;
grandfatherName: string;
grandfatherNamePlaceholder: string;
motherName: string;
motherNamePlaceholder: string;
tribe: string;
tribePlaceholder: string;
maritalStatus: string;
married: string;
single: string;
childrenCount: string;
childName: string;
childNamePlaceholder: string;
childBirthYear: string;
addChild: string;
removeChild: string;
region: string;
regionPlaceholder: string;
regionBakur: string;
regionBasur: string;
regionRojava: string;
regionRojhelat: string;
regionKurdistanASor: string;
regionDiaspora: string;
email: string;
emailPlaceholder: string;
profession: string;
professionPlaceholder: string;
referralCode: string;
referralCodePlaceholder: string;
// Consent
consentCheckbox: string;
// Buttons
submit: string;
sign: string;
openApp: string;
// Processing
preparingData: string;
readyToSign: string;
signingTx: string;
// Success
successTitle: string;
successSubtitle: string;
citizenId: string;
walletAddress: string;
// Errors
fillAllFields: string;
acceptConsent: string;
walletNotConnected: string;
peopleChainNotConnected: string;
submissionFailed: string;
alreadyPending: string;
alreadyApproved: string;
// Language selector
selectLanguage: string;
};
}
export type LanguageCode = 'krd' | 'en' | 'tr' | 'ckb' | 'fa' | 'ar';
+274
View File
@@ -0,0 +1,274 @@
/**
* Citizenship utilities for Telegram MiniApp
* Adapted from pwap/shared/lib/citizenship-workflow.ts
* Uses native KeyringPair signing (no browser extension)
*/
import type { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
// ── Type Definitions ────────────────────────────────────────────────
export type KycStatus = 'NotStarted' | 'Pending' | 'Approved' | 'Rejected';
export type Region = 'bakur' | 'basur' | 'rojava' | 'rojhelat' | 'diaspora' | 'kurdistan_a_sor';
export type MaritalStatus = 'zewici' | 'nezewici';
export interface ChildInfo {
name: string;
birthYear: number;
}
export interface CitizenshipData {
fullName: string;
fatherName: string;
grandfatherName: string;
motherName: string;
tribe: string;
maritalStatus: MaritalStatus;
childrenCount?: number;
children?: ChildInfo[];
region: Region;
email: string;
profession: string;
referralCode?: string;
walletAddress: string;
timestamp: number;
}
export interface CitizenshipResult {
success: boolean;
error?: string;
blockHash?: string;
}
// ── Hash Generation ─────────────────────────────────────────────────
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash;
}
export function generateCommitmentHash(data: CitizenshipData): string {
const str = JSON.stringify(data);
return simpleHash(str).toString(16);
}
export function generateNullifierHash(address: string, timestamp: number): string {
const str = address + timestamp.toString();
return 'nullifier_' + simpleHash(str).toString(16);
}
// ── Encryption & Storage ────────────────────────────────────────────
export function encryptCitizenshipData(data: CitizenshipData): string {
return btoa(JSON.stringify(data));
}
export function decryptCitizenshipData(encrypted: string): CitizenshipData | null {
try {
return JSON.parse(atob(encrypted));
} catch {
return null;
}
}
export function saveCitizenshipLocally(data: CitizenshipData): void {
const encrypted = encryptCitizenshipData(data);
localStorage.setItem('pezkuwi_citizenship_data', encrypted);
}
export function getLocalCitizenshipData(): CitizenshipData | null {
const encrypted = localStorage.getItem('pezkuwi_citizenship_data');
if (!encrypted) return null;
return decryptCitizenshipData(encrypted);
}
// ── IPFS (Mock) ─────────────────────────────────────────────────────
export async function uploadToIPFS(_data: CitizenshipData): Promise<string> {
// Mock IPFS upload - same as pwap/web
const mockCID = 'Qm' + Math.random().toString(36).substring(2, 15);
// In production, use Pinata or other IPFS service
return mockCID;
}
// ── KYC Status ──────────────────────────────────────────────────────
export async function getKycStatus(api: ApiPromise, address: string): Promise<KycStatus> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(api?.query as any)?.identityKyc) {
return 'NotStarted';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const status = await (api.query as any).identityKyc.kycStatuses(address);
if (status.isEmpty) {
return 'NotStarted';
}
const statusStr = status.toString();
if (statusStr === 'Approved') return 'Approved';
if (statusStr === 'Pending') return 'Pending';
if (statusStr === 'Rejected') return 'Rejected';
return 'NotStarted';
} catch (error) {
console.error('[Citizenship] Error fetching KYC status:', error);
return 'NotStarted';
}
}
// ── Blockchain Submission ───────────────────────────────────────────
export async function submitCitizenshipApplication(
api: ApiPromise,
keypair: KeyringPair,
name: string,
email: string,
ipfsCid: string,
notes: string = 'Citizenship application via Telegram MiniApp'
): Promise<CitizenshipResult> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = api.tx as any;
if (!tx?.identityKyc?.setIdentity || !tx?.identityKyc?.applyForKyc) {
return { success: false, error: 'Identity KYC pallet not available' };
}
const address = keypair.address;
// Check for pending application
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pendingApp = await (api.query as any).identityKyc.pendingKycApplications(address);
if (!pendingApp.isEmpty) {
return {
success: false,
error: 'You already have a pending citizenship application.',
};
}
// Check if already approved
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const kycStatus = await (api.query as any).identityKyc.kycStatuses(address);
if (kycStatus.toString() === 'Approved') {
return {
success: false,
error: 'Your citizenship is already approved!',
};
}
const cidString = String(ipfsCid);
if (!cidString || cidString === 'undefined') {
return { success: false, error: 'Invalid IPFS CID' };
}
// Step 1: Set identity
const identityResult = await new Promise<CitizenshipResult>((resolve) => {
tx.identityKyc
.setIdentity(name, email)
.signAndSend(
keypair,
{ nonce: -1 },
({
status,
dispatchError,
}: {
status: { isInBlock: boolean; isFinalized: boolean };
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Identity transaction 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;
}
resolve({ success: true });
}
}
)
.catch((error: Error) => resolve({ success: false, error: error.message }));
});
if (!identityResult.success) {
return identityResult;
}
// Step 2: Apply for KYC
const kycResult = await new Promise<CitizenshipResult>((resolve) => {
tx.identityKyc
.applyForKyc(cidString, notes)
.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 = 'KYC application 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 kycResult;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ── Citizen Number Generation ───────────────────────────────────────
export function generateCitizenNumber(
ownerAddress: string,
collectionId: number,
itemId: number
): string {
let hash = 0;
for (let i = 0; i < ownerAddress.length; i++) {
hash = (hash << 5) - hash + ownerAddress.charCodeAt(i);
hash = hash & hash;
}
hash += collectionId * 1000 + itemId;
hash = Math.abs(hash);
return (hash % 1000000).toString().padStart(6, '0');
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Citizen Page
* Standalone page for citizenship application flow
* Accessed via ?page=citizen URL parameter
* No bottom navigation bar
*/
import { useState, useCallback, lazy, Suspense } from 'react';
import { Globe, Loader2 } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTranslation, LANGUAGE_NAMES, VALID_LANGS } from '@/i18n';
import type { LanguageCode } from '@/i18n';
import type { CitizenshipData } from '@/lib/citizenship';
// Lazy load sub-components
const WalletSetup = lazy(() =>
import('@/components/wallet/WalletSetup').then((m) => ({ default: m.WalletSetup }))
);
const WalletCreate = lazy(() =>
import('@/components/wallet/WalletCreate').then((m) => ({ default: m.WalletCreate }))
);
const WalletImport = lazy(() =>
import('@/components/wallet/WalletImport').then((m) => ({ default: m.WalletImport }))
);
const WalletConnect = lazy(() =>
import('@/components/wallet/WalletConnect').then((m) => ({ default: m.WalletConnect }))
);
const CitizenForm = lazy(() =>
import('@/components/citizen/CitizenForm').then((m) => ({ default: m.CitizenForm }))
);
const CitizenProcessing = lazy(() =>
import('@/components/citizen/CitizenProcessing').then((m) => ({ default: m.CitizenProcessing }))
);
const CitizenSuccess = lazy(() =>
import('@/components/citizen/CitizenSuccess').then((m) => ({ default: m.CitizenSuccess }))
);
type Step =
| 'wallet-setup'
| 'wallet-create'
| 'wallet-import'
| 'wallet-connect'
| 'form'
| 'processing'
| 'success';
function SectionLoader() {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
export function CitizenPage() {
const { hasWallet, isConnected, address, deleteWalletData } = useWallet();
const { t, lang, setLang } = useTranslation();
const [showLangMenu, setShowLangMenu] = useState(false);
const [citizenshipData, setCitizenshipData] = useState<CitizenshipData | null>(null);
const [error, setError] = useState<string | null>(null);
// Determine initial step based on wallet state
const getInitialStep = (): Step => {
if (!hasWallet) return 'wallet-setup';
if (!isConnected) return 'wallet-connect';
return 'form';
};
const [step, setStep] = useState<Step>(getInitialStep);
// Wallet setup handlers
const handleWalletCreate = useCallback(() => setStep('wallet-create'), []);
const handleWalletImport = useCallback(() => setStep('wallet-import'), []);
const handleWalletCreated = useCallback(() => setStep('wallet-connect'), []);
const handleWalletImported = useCallback(() => setStep('wallet-connect'), []);
const handleWalletConnected = useCallback(() => setStep('form'), []);
const handleWalletDelete = useCallback(() => {
deleteWalletData();
setStep('wallet-setup');
}, [deleteWalletData]);
// Form submission
const handleFormSubmit = useCallback((data: CitizenshipData) => {
setCitizenshipData(data);
setError(null);
setStep('processing');
}, []);
// Processing result
const handleSuccess = useCallback(() => {
setStep('success');
}, []);
const handleError = useCallback((err: string) => {
setError(err);
setStep('form');
}, []);
// Open main app
const handleOpenApp = useCallback(() => {
// Remove ?page=citizen and navigate to main app
const url = new URL(window.location.href);
url.searchParams.delete('page');
window.location.href = url.toString();
}, []);
return (
<div className="flex flex-col h-screen bg-background">
{/* Header */}
<header className="flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-border">
<h1 className="text-lg font-bold">{t('citizen.pageTitle')}</h1>
{/* Language Selector */}
<div className="relative">
<button
onClick={() => setShowLangMenu(!showLangMenu)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-muted rounded-lg text-sm"
>
<Globe className="w-4 h-4" />
<span>{LANGUAGE_NAMES[lang]}</span>
</button>
{showLangMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowLangMenu(false)} />
<div className="absolute end-0 top-full mt-1 z-20 bg-secondary border border-border rounded-xl shadow-lg py-1 min-w-[140px]">
{VALID_LANGS.map((code) => (
<button
key={code}
onClick={() => {
setLang(code as LanguageCode);
setShowLangMenu(false);
}}
className={`w-full px-4 py-2 text-start text-sm hover:bg-muted transition-colors ${
lang === code ? 'text-primary font-medium' : ''
}`}
>
{LANGUAGE_NAMES[code as LanguageCode]}
</button>
))}
</div>
</>
)}
</div>
</header>
{/* Error Banner */}
{error && (
<div className="mx-4 mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{/* Content */}
<main className="flex-1 overflow-y-auto">
<Suspense fallback={<SectionLoader />}>
{step === 'wallet-setup' && (
<WalletSetup onCreate={handleWalletCreate} onImport={handleWalletImport} />
)}
{step === 'wallet-create' && (
<WalletCreate onComplete={handleWalletCreated} onBack={() => setStep('wallet-setup')} />
)}
{step === 'wallet-import' && (
<WalletImport
onComplete={handleWalletImported}
onBack={() => setStep('wallet-setup')}
/>
)}
{step === 'wallet-connect' && (
<WalletConnect onConnected={handleWalletConnected} onDelete={handleWalletDelete} />
)}
{step === 'form' && address && (
<CitizenForm walletAddress={address} onSubmit={handleFormSubmit} />
)}
{step === 'processing' && citizenshipData && (
<CitizenProcessing
citizenshipData={citizenshipData}
onSuccess={handleSuccess}
onError={handleError}
/>
)}
{step === 'success' && address && (
<CitizenSuccess address={address} onOpenApp={handleOpenApp} />
)}
</Suspense>
</main>
</div>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.190",
"buildTime": "2026-02-14T15:16:08.944Z",
"buildNumber": 1771082168945
"version": "1.0.192",
"buildTime": "2026-02-14T17:44:17.743Z",
"buildNumber": 1771091057743
}
+193 -40
View File
@@ -1,18 +1,38 @@
/**
* PezkuwiChain Telegram Bot - Supabase Edge Function
* Handles webhook updates from Telegram
* Handles webhook updates from two separate bots:
* - @Pezkuwichain_Bot (main) → telegram.pezkuwichain.io
* - @pezkuwichainBot (krd) → telegram.pezkiwi.app
*/
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { Keyring } from 'npm:@pezkuwi/api@16.5.36';
import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25';
import * as bip39 from 'https://esm.sh/@scure/bip39@1.2.1';
import { wordlist } from 'https://esm.sh/@scure/bip39@1.2.1/wordlists/english';
const BOT_TOKEN = Deno.env.get('TELEGRAM_BOT_TOKEN') || '';
const MINI_APP_URL = 'https://telegram.pezkuwichain.io';
// ── Bot configuration ───────────────────────────────────────────────
const BOT_TOKENS: Record<string, string> = {
main: Deno.env.get('TELEGRAM_BOT_TOKEN') || '',
krd: Deno.env.get('TELEGRAM_BOT_TOKEN_KRD') || '',
};
// Welcome image URL (hosted on GitHub)
const MINI_APP_URLS: Record<string, string> = {
main: 'https://telegram.pezkuwichain.io',
krd: 'https://telegram.pezkiwi.app',
};
function getBotId(req: Request): string {
const url = new URL(req.url);
return url.searchParams.get('bot') || 'main';
}
// ── Welcome image ───────────────────────────────────────────────────
const WELCOME_IMAGE_URL =
'https://raw.githubusercontent.com/pezkuwichain/pezkuwi-telegram-miniapp/main/public/images/welcome.png';
const WELCOME_MESSAGE = `
// ── Main bot (@Pezkuwichain_Bot) welcome ────────────────────────────
const MAIN_WELCOME_MESSAGE = `
🌍 <b>Welcome to PezkuwiChain!</b>
The first blockchain platform connecting Kurds worldwide — building a digital Kurdistan where borders don't limit our unity.
@@ -25,6 +45,18 @@ Join millions of Kurds in creating a decentralized digital economy. Your wallet,
<i>Together, we can.</i>
`;
// ── KRD bot (@pezkuwichainBot) welcome ──────────────────────────────
const KRD_WELCOME_MESSAGE = `
🌐 <b>Pezkuwî</b>
Bi Pezkuwî re dest bi rêwîtiya xwe ya dîjîtal bikin.
Cûzdanê xwe biafirînin, zimanê xwe hilbijêrin û welatiyê Pezkuwî bibin.
<i>Start your digital journey with Pezkuwi.
Create your wallet, choose your language and become a citizen.</i>
`;
// ── Types ────────────────────────────────────────────────────────────
interface TelegramUpdate {
update_id: number;
message?: {
@@ -45,20 +77,25 @@ interface TelegramUpdate {
from: {
id: number;
};
message?: {
chat: {
id: number;
};
};
data: string;
};
}
// Send message via Telegram API
async function sendTelegramRequest(method: string, body: Record<string, unknown>) {
// ── Telegram API helper ─────────────────────────────────────────────
async function sendTelegramRequest(token: string, method: string, body: Record<string, unknown>) {
console.log(`[Telegram] Calling ${method}`, JSON.stringify(body));
if (!BOT_TOKEN) {
if (!token) {
console.error('[Telegram] BOT_TOKEN is not set!');
return { ok: false, error: 'BOT_TOKEN not configured' };
}
const response = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/${method}`, {
const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -66,18 +103,17 @@ async function sendTelegramRequest(method: string, body: Record<string, unknown>
const result = await response.json();
console.log(`[Telegram] Response:`, JSON.stringify(result));
return result;
}
// Send welcome message with photo
async function sendWelcomeMessage(chatId: number) {
// ── Main bot: welcome ───────────────────────────────────────────────
async function sendMainWelcome(token: string, chatId: number) {
const keyboard = {
inline_keyboard: [
[
{
text: '📱 Open App on Telegram',
web_app: { url: MINI_APP_URL },
web_app: { url: MINI_APP_URLS.main },
},
],
[
@@ -89,12 +125,11 @@ async function sendWelcomeMessage(chatId: number) {
],
};
// Try to send photo with caption
if (WELCOME_IMAGE_URL) {
const result = await sendTelegramRequest('sendPhoto', {
const result = await sendTelegramRequest(token, 'sendPhoto', {
chat_id: chatId,
photo: WELCOME_IMAGE_URL,
caption: WELCOME_MESSAGE,
caption: MAIN_WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
@@ -102,19 +137,126 @@ async function sendWelcomeMessage(chatId: number) {
console.log('[Bot] Photo failed, falling back to text');
}
// Send text-only message
await sendTelegramRequest('sendMessage', {
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: WELCOME_MESSAGE,
text: MAIN_WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
}
// Handle callback query (button clicks)
async function handleCallbackQuery(callbackQueryId: string, data: string) {
if (data === 'playstore_coming_soon') {
await sendTelegramRequest('answerCallbackQuery', {
// ── KRD bot: welcome ────────────────────────────────────────────────
async function sendKrdWelcome(token: string, chatId: number) {
const appUrl = MINI_APP_URLS.krd;
const keyboard = {
inline_keyboard: [
// Row 1: Create Wallet (callback - bot generates wallet in chat)
[
{
text: '🔐 Create Wallet / Cûzdan Biafirîne',
callback_data: 'create_wallet',
},
],
// Row 2: Languages (top row)
[
{ text: 'Kurmancî', web_app: { url: `${appUrl}/krd` } },
{ text: 'English', web_app: { url: `${appUrl}/en` } },
{ text: 'Türkçe', web_app: { url: `${appUrl}/tr` } },
],
// Row 3: Languages (bottom row)
[
{ text: 'سۆرانی', web_app: { url: `${appUrl}/ckb` } },
{ text: 'فارسی', web_app: { url: `${appUrl}/fa` } },
{ text: 'العربية', web_app: { url: `${appUrl}/ar` } },
],
// Row 4: Be Citizen
[
{
text: '🏛️ Be Citizen / Bibe Welatî',
web_app: { url: `${appUrl}?page=citizen` },
},
],
],
};
if (WELCOME_IMAGE_URL) {
const result = await sendTelegramRequest(token, 'sendPhoto', {
chat_id: chatId,
photo: WELCOME_IMAGE_URL,
caption: KRD_WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
if (result.ok) return;
console.log('[Bot] Photo failed, falling back to text');
}
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: KRD_WELCOME_MESSAGE,
parse_mode: 'HTML',
reply_markup: keyboard,
});
}
// ── Create wallet handler ───────────────────────────────────────────
async function handleCreateWallet(token: string, chatId: number) {
try {
await cryptoWaitReady();
const mnemonic = bip39.generateMnemonic(wordlist, 128);
const keyring = new Keyring({ type: 'sr25519' });
const pair = keyring.addFromUri(mnemonic);
const address = pair.address;
const walletMessage = `
🔐 <b>Cûzdanê Te Hate Afirandin!</b>
<b>Your Wallet Has Been Created!</b>
📍 <b>Address / Navnîşan:</b>
<code>${address}</code>
🔑 <b>Seed Phrase (12 words):</b>
<tg-spoiler>${mnemonic}</tg-spoiler>
⚠️ <b>GIRÎNG / IMPORTANT:</b>
<i>Ev 12 peyvan binivîsin û li cihekî ewle bihêlin.
Kesî re nîşan nedin! Eger winda bikin, cûzdanê xwe winda dikin.
Write down these 12 words and keep them safe.
Never share them! If you lose them, you lose your wallet.</i>
`;
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: walletMessage,
parse_mode: 'HTML',
});
} catch (error) {
console.error('[Bot] Wallet generation error:', error);
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: '❌ Wallet creation failed. Please try again.',
});
}
}
// ── Callback handler ────────────────────────────────────────────────
async function handleCallbackQuery(
token: string,
callbackQueryId: string,
data: string,
chatId: number | undefined
) {
if (data === 'create_wallet' && chatId) {
await sendTelegramRequest(token, 'answerCallbackQuery', {
callback_query_id: callbackQueryId,
text: '🔐 Creating your wallet...',
});
await handleCreateWallet(token, chatId);
} else if (data === 'playstore_coming_soon') {
await sendTelegramRequest(token, 'answerCallbackQuery', {
callback_query_id: callbackQueryId,
text: '🚀 Android app coming soon! Stay tuned.',
show_alert: true,
@@ -122,8 +264,9 @@ async function handleCallbackQuery(callbackQueryId: string, data: string) {
}
}
// Send help message
async function sendHelpMessage(chatId: number) {
// ── Help & App commands ─────────────────────────────────────────────
async function sendHelpMessage(token: string, chatId: number, botId: string) {
const appUrl = MINI_APP_URLS[botId] || MINI_APP_URLS.main;
const helpText = `
<b>PezkuwiChain Bot Commands:</b>
@@ -133,39 +276,38 @@ async function sendHelpMessage(chatId: number) {
<b>Links:</b>
🌐 Website: pezkuwichain.io
📱 App: t.me/pezkuwichain_bot/app
🐦 Twitter: @pezkuwichain
📱 App: ${appUrl}
`;
await sendTelegramRequest('sendMessage', {
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: helpText,
parse_mode: 'HTML',
});
}
// Send app link
async function sendAppLink(chatId: number) {
async function sendAppLink(token: string, chatId: number, botId: string) {
const appUrl = MINI_APP_URLS[botId] || MINI_APP_URLS.main;
const keyboard = {
inline_keyboard: [
[
{
text: '📱 Open PezkuwiChain App',
web_app: { url: MINI_APP_URL },
web_app: { url: appUrl },
},
],
],
};
await sendTelegramRequest('sendMessage', {
await sendTelegramRequest(token, 'sendMessage', {
chat_id: chatId,
text: 'Click below to open the app:',
reply_markup: keyboard,
});
}
// ── Main handler ────────────────────────────────────────────────────
serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
@@ -176,14 +318,15 @@ serve(async (req: Request) => {
});
}
// Only accept POST requests
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
try {
const botId = getBotId(req);
const botToken = BOT_TOKENS[botId] || BOT_TOKENS.main;
const update: TelegramUpdate = await req.json();
console.log('[Bot] Received update:', JSON.stringify(update));
console.log(`[Bot:${botId}] Received update:`, JSON.stringify(update));
// Handle message
if (update.message?.text) {
@@ -191,17 +334,27 @@ serve(async (req: Request) => {
const text = update.message.text;
if (text === '/start' || text.startsWith('/start ')) {
await sendWelcomeMessage(chatId);
if (botId === 'krd') {
await sendKrdWelcome(botToken, chatId);
} else {
await sendMainWelcome(botToken, chatId);
}
} else if (text === '/help') {
await sendHelpMessage(chatId);
await sendHelpMessage(botToken, chatId, botId);
} else if (text === '/app') {
await sendAppLink(chatId);
await sendAppLink(botToken, chatId, botId);
}
}
// Handle callback query (button clicks)
// Handle callback query
if (update.callback_query) {
await handleCallbackQuery(update.callback_query.id, update.callback_query.data);
const chatId = update.callback_query.message?.chat?.id;
await handleCallbackQuery(
botToken,
update.callback_query.id,
update.callback_query.data,
chatId
);
}
return new Response(JSON.stringify({ ok: true }), {