From a14bd57bbebedb638d554806531180567ed84402 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Sat, 21 Feb 2026 17:53:30 +0300 Subject: [PATCH] feat: add PEZ epoch rewards display and claim functionality --- package.json | 2 +- src/i18n/translations/ar.ts | 14 +++ src/i18n/translations/ckb.ts | 14 +++ src/i18n/translations/en.ts | 14 +++ src/i18n/translations/fa.ts | 14 +++ src/i18n/translations/krd.ts | 14 +++ src/i18n/translations/tr.ts | 14 +++ src/i18n/types.ts | 14 +++ src/lib/scores.ts | 200 +++++++++++++++++++++++++++++++++++ src/sections/Rewards.tsx | 171 +++++++++++++++++++++++++++++- src/version.json | 6 +- 11 files changed, 472 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5a0dd3b..29d47bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.216", + "version": "1.0.217", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/src/i18n/translations/ar.ts b/src/i18n/translations/ar.ts index a76a1a8..d669f55 100644 --- a/src/i18n/translations/ar.ts +++ b/src/i18n/translations/ar.ts @@ -147,6 +147,20 @@ const ar: Translations = { citizenCountTitle: 'الأكراد في العالم', citizenCountDesc: 'المواطنون المسجلون على PezkuwiChain', beCitizen: 'كن مواطناً', + pezRewardsTitle: 'مكافآت PEZ الدورية', + epoch: 'الدورة', + claimPeriod: 'فترة المطالبة', + epochClosed: 'مغلقة', + epochOpen: 'مفتوحة', + scoreRecorded: 'مسجّل', + claimablePez: 'PEZ قابل للمطالبة', + claim: 'طالب', + claimAll: 'طالب بالكل', + claimPez: 'طالب بـ PEZ', + claimingReward: 'جاري المطالبة بالمكافأة...', + claimSuccess: 'تم المطالبة بالمكافأة بنجاح!', + claimFailed: 'فشلت المطالبة بالمكافأة', + noPezRewards: 'لا توجد مكافآت PEZ قابلة للمطالبة', }, wallet: { diff --git a/src/i18n/translations/ckb.ts b/src/i18n/translations/ckb.ts index 8918fa5..050265e 100644 --- a/src/i18n/translations/ckb.ts +++ b/src/i18n/translations/ckb.ts @@ -148,6 +148,20 @@ const ckb: Translations = { citizenCountTitle: 'ژمارەی کورد لە جیهان', citizenCountDesc: 'هاوڵاتییانی تۆمارکراو لە PezkuwiChain', beCitizen: 'ببە هاوڵاتی', + pezRewardsTitle: 'خەڵاتەکانی PEZ ی سەردەم', + epoch: 'سەردەم', + claimPeriod: 'ماوەی داواکردن', + epochClosed: 'داخراوە', + epochOpen: 'کراوەیە', + scoreRecorded: 'تۆمارکرا', + claimablePez: 'PEZ ی داواکراو', + claim: 'داوا بکە', + claimAll: 'هەموو داوا بکە', + claimPez: 'PEZ داوا بکە', + claimingReward: 'خەڵات داوا دەکرێت...', + claimSuccess: 'خەڵات بە سەرکەوتوویی داوا کرا!', + claimFailed: 'داواکردنی خەڵات سەرنەکەوت', + noPezRewards: 'خەڵاتی PEZ ی داواکراو نییە', }, wallet: { diff --git a/src/i18n/translations/en.ts b/src/i18n/translations/en.ts index 162eb13..359000b 100644 --- a/src/i18n/translations/en.ts +++ b/src/i18n/translations/en.ts @@ -147,6 +147,20 @@ const en: Translations = { citizenCountTitle: 'Kurds in the World', citizenCountDesc: 'Citizens registered on PezkuwiChain', beCitizen: 'Be Citizen', + pezRewardsTitle: 'PEZ Epoch Rewards', + epoch: 'Epoch', + claimPeriod: 'Claim Period', + epochClosed: 'Closed', + epochOpen: 'Open', + scoreRecorded: 'Recorded', + claimablePez: 'Claimable PEZ', + claim: 'Claim', + claimAll: 'Claim All', + claimPez: 'Claim PEZ', + claimingReward: 'Claiming reward...', + claimSuccess: 'Reward claimed successfully!', + claimFailed: 'Failed to claim reward', + noPezRewards: 'No claimable PEZ rewards', }, wallet: { diff --git a/src/i18n/translations/fa.ts b/src/i18n/translations/fa.ts index 24dca60..10891b3 100644 --- a/src/i18n/translations/fa.ts +++ b/src/i18n/translations/fa.ts @@ -147,6 +147,20 @@ const fa: Translations = { citizenCountTitle: 'کوردها در جهان', citizenCountDesc: 'شهروندان ثبت‌شده در PezkuwiChain', beCitizen: 'شهروند شوید', + pezRewardsTitle: 'پاداش‌های PEZ دوره‌ای', + epoch: 'دوره', + claimPeriod: 'دوره مطالبه', + epochClosed: 'بسته', + epochOpen: 'باز', + scoreRecorded: 'ثبت شد', + claimablePez: 'PEZ قابل مطالبه', + claim: 'مطالبه', + claimAll: 'مطالبه همه', + claimPez: 'مطالبه PEZ', + claimingReward: 'در حال مطالبه پاداش...', + claimSuccess: 'پاداش با موفقیت مطالبه شد!', + claimFailed: 'مطالبه پاداش ناموفق بود', + noPezRewards: 'پاداش PEZ قابل مطالبه‌ای وجود ندارد', }, wallet: { diff --git a/src/i18n/translations/krd.ts b/src/i18n/translations/krd.ts index 4d1201f..fb96547 100644 --- a/src/i18n/translations/krd.ts +++ b/src/i18n/translations/krd.ts @@ -152,6 +152,20 @@ const krd: Translations = { citizenCountTitle: 'Hejmara Kurd Le Cîhanê', citizenCountDesc: 'Welatiyên ku li ser PezkuwiChain qeyd bûne', beCitizen: 'Bibe Welatî', + pezRewardsTitle: 'Xelatên PEZ yên Serdemê', + epoch: 'Serdem', + claimPeriod: 'Dema Daxwazkirinê', + epochClosed: 'Girtî', + epochOpen: 'Vekirî', + scoreRecorded: 'Hat tomarkirin', + claimablePez: 'PEZ yên Daxwazkir', + claim: 'Daxwaz Bike', + claimAll: 'Hemûyan Daxwaz Bike', + claimPez: 'PEZ Daxwaz Bike', + claimingReward: 'Xelat tê daxwazkirin...', + claimSuccess: 'Xelat bi serkeftin hat daxwazkirin!', + claimFailed: 'Daxwazkirina xelatê biserneket', + noPezRewards: 'Xelatên PEZ yên daxwazkir tune ne', }, wallet: { diff --git a/src/i18n/translations/tr.ts b/src/i18n/translations/tr.ts index 05b2374..6d8cb12 100644 --- a/src/i18n/translations/tr.ts +++ b/src/i18n/translations/tr.ts @@ -147,6 +147,20 @@ const tr: Translations = { citizenCountTitle: 'Dünyadaki Kürt Sayısı', citizenCountDesc: "PezkuwiChain'de kayıtlı vatandaşlar", beCitizen: 'Vatandaş Ol', + pezRewardsTitle: 'PEZ Dönem Ödülleri', + epoch: 'Dönem', + claimPeriod: 'Talep Dönemi', + epochClosed: 'Kapalı', + epochOpen: 'Açık', + scoreRecorded: 'Kaydedildi', + claimablePez: 'Talep Edilebilir PEZ', + claim: 'Talep Et', + claimAll: 'Tümünü Talep Et', + claimPez: 'PEZ Talep Et', + claimingReward: 'Ödül talep ediliyor...', + claimSuccess: 'Ödül başarıyla talep edildi!', + claimFailed: 'Ödül talep edilemedi', + noPezRewards: 'Talep edilebilir PEZ ödülü yok', }, wallet: { diff --git a/src/i18n/types.ts b/src/i18n/types.ts index f11882d..f16f20b 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -149,6 +149,20 @@ export interface Translations { citizenCountTitle: string; citizenCountDesc: string; beCitizen: string; + pezRewardsTitle: string; + epoch: string; + claimPeriod: string; + epochClosed: string; + epochOpen: string; + scoreRecorded: string; + claimablePez: string; + claim: string; + claimAll: string; + claimPez: string; + claimingReward: string; + claimSuccess: string; + claimFailed: string; + noPezRewards: string; }; // Wallet section diff --git a/src/lib/scores.ts b/src/lib/scores.ts index f409120..2d4c33b 100644 --- a/src/lib/scores.ts +++ b/src/lib/scores.ts @@ -36,6 +36,18 @@ export interface StakingScoreStatus { durationBlocks: number; } +export type EpochStatus = 'Open' | 'ClaimPeriod' | 'Closed'; + +export interface PezRewardInfo { + currentEpoch: number; + epochStatus: EpochStatus; + hasRecordedThisEpoch: boolean; + userScoreCurrentEpoch: number; + claimableRewards: { epoch: number; amount: string }[]; + totalClaimable: string; + hasPendingClaim: boolean; +} + // ======================================== // TRUST SCORE (pezpallet-trust on People Chain) // ======================================== @@ -550,6 +562,194 @@ export async function recordTrustScore( } } +// ======================================== +// PEZ REWARDS (pezRewards pallet on People Chain) +// ======================================== + +function formatBalancePlanck(planck: string): string { + const num = BigInt(planck); + const whole = num / BigInt(10 ** 12); + const frac = num % BigInt(10 ** 12); + const fracStr = frac.toString().padStart(12, '0').slice(0, 4); + return `${whole}.${fracStr}`; +} + +/** + * Get PEZ rewards information for an account + * Queries pezRewards pallet on People Chain + */ +export async function getPezRewards( + peopleApi: ApiPromise, + address: string +): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(peopleApi?.query as any)?.pezRewards?.epochInfo) { + console.warn('PezRewards pallet not available on People Chain'); + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochInfoResult = await (peopleApi.query as any).pezRewards.epochInfo(); + if (!epochInfoResult) return null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochInfo = epochInfoResult.toJSON() as any; + const currentEpoch: number = epochInfo.currentEpoch ?? epochInfo.current_epoch ?? 0; + + // Get current epoch status + let epochStatus: EpochStatus = 'Open'; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const statusResult = await (peopleApi.query as any).pezRewards.epochStatus(currentEpoch); + const statusStr = statusResult.toString(); + if (statusStr === 'ClaimPeriod') epochStatus = 'ClaimPeriod'; + else if (statusStr === 'Closed') epochStatus = 'Closed'; + else epochStatus = 'Open'; + } catch { + // Default to Open + } + + // Check if user has recorded their score this epoch + let hasRecordedThisEpoch = false; + let userScoreCurrentEpoch = 0; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userScoreResult = await (peopleApi.query as any).pezRewards.userEpochScores( + currentEpoch, + address + ); + if (userScoreResult.isSome) { + hasRecordedThisEpoch = true; + const scoreCodec = userScoreResult.unwrap() as { toString: () => string }; + userScoreCurrentEpoch = Number(scoreCodec.toString()); + } + } catch { + // User hasn't recorded + } + + // Check for claimable rewards from completed epochs + const claimableRewards: { epoch: number; amount: string }[] = []; + let totalClaimable = BigInt(0); + + for (let i = Math.max(0, currentEpoch - 3); i < currentEpoch; i++) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pastStatusResult = await (peopleApi.query as any).pezRewards.epochStatus(i); + const pastStatus = pastStatusResult.toString(); + if (pastStatus !== 'ClaimPeriod') continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const claimedResult = await (peopleApi.query as any).pezRewards.claimedRewards(i, address); + if (claimedResult.isSome) continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userScoreResult = await (peopleApi.query as any).pezRewards.userEpochScores( + i, + address + ); + if (!userScoreResult.isSome) continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochPoolResult = await (peopleApi.query as any).pezRewards.epochRewardPools(i); + if (!epochPoolResult.isSome) continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const epochPool = (epochPoolResult.unwrap() as any).toJSON(); + const userScore = BigInt( + (userScoreResult.unwrap() as { toString: () => string }).toString() + ); + const rewardPerPoint = BigInt( + epochPool.rewardPerTrustPoint || epochPool.reward_per_trust_point || '0' + ); + + const rewardAmount = userScore * rewardPerPoint; + const rewardFormatted = formatBalancePlanck(rewardAmount.toString()); + + if (parseFloat(rewardFormatted) > 0) { + claimableRewards.push({ epoch: i, amount: rewardFormatted }); + totalClaimable += rewardAmount; + } + } catch (err) { + console.warn(`Error checking epoch ${i} rewards:`, err); + } + } + + return { + currentEpoch, + epochStatus, + hasRecordedThisEpoch, + userScoreCurrentEpoch, + claimableRewards, + totalClaimable: formatBalancePlanck(totalClaimable.toString()), + hasPendingClaim: claimableRewards.length > 0, + }; + } catch (error) { + console.warn('PEZ rewards not available:', error); + return null; + } +} + +/** + * Claim PEZ reward for a specific epoch + * Calls pezRewards.claimReward(epochIndex) + */ +export async function claimPezReward( + peopleApi: ApiPromise, + keypair: KeyringPair, + epochIndex: number +): Promise<{ success: boolean; error?: string }> { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tx = peopleApi.tx as any; + if (!tx?.pezRewards?.claimReward) { + return { success: false, error: 'pezRewards pallet not available' }; + } + + const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { + tx.pezRewards + .claimReward(epochIndex) + .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 = 'claimReward failed'; + if (dispatchError.isModule) { + const decoded = peopleApi.registry.findMetaError( + dispatchError.asModule as Parameters[0] + ); + errorMessage = `${decoded.section}.${decoded.name}`; + } + resolve({ success: false, error: errorMessage }); + return; + } + resolve({ success: true }); + } + } + ) + .catch((error: Error) => resolve({ success: false, error: error.message })); + }); + + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + // ======================================== // COMPREHENSIVE SCORE FETCHING // ======================================== diff --git a/src/sections/Rewards.tsx b/src/sections/Rewards.tsx index 4754085..5d09889 100644 --- a/src/sections/Rewards.tsx +++ b/src/sections/Rewards.tsx @@ -37,11 +37,14 @@ import { getStakingScoreStatus, startScoreTracking, recordTrustScore, + getPezRewards, + claimPezReward, formatDuration, getScoreColor, getScoreRating, type UserScores, type StakingScoreStatus, + type PezRewardInfo, } from '@/lib/scores'; import { getStakingRewards, @@ -78,6 +81,8 @@ export function RewardsSection() { const [scoresLoading, setScoresLoading] = useState(false); const [citizenshipStatus, setCitizenshipStatus] = useState('NotStarted'); const [citizenCount, setCitizenCount] = useState(null); + const [pezRewards, setPezRewards] = useState(null); + const [claimingEpoch, setClaimingEpoch] = useState(null); const [showConfirmAnimation, setShowConfirmAnimation] = useState(false); const [showTrackingAnimation, setShowTrackingAnimation] = useState(false); const [trackingAnimationText, setTrackingAnimationText] = useState(''); @@ -122,19 +127,22 @@ export function RewardsSection() { setUserScores(null); setStakingStatus(null); setStakingRewards(null); + setPezRewards(null); return; } setScoresLoading(true); try { - const [scores, staking, rewards] = await Promise.all([ + const [scores, staking, rewards, pezRewardsData] = await Promise.all([ getAllScores(peopleApi, address), peopleApi ? getStakingScoreStatus(peopleApi, address) : Promise.resolve(null), getStakingRewards(address), + peopleApi ? getPezRewards(peopleApi, address) : Promise.resolve(null), ]); setUserScores(scores); setStakingStatus(staking); setStakingRewards(rewards); + setPezRewards(pezRewardsData); } catch (err) { console.error('Error fetching scores:', err); } finally { @@ -226,6 +234,60 @@ export function RewardsSection() { } }; + const handleClaimReward = async (epochIndex: number) => { + if (!peopleApi || !keypair) return; + setClaimingEpoch(epochIndex); + setTrackingAnimationText(t('rewards.claimingReward')); + setShowTrackingAnimation(true); + hapticImpact('medium'); + try { + const result = await claimPezReward(peopleApi, keypair, epochIndex); + if (result.success) { + hapticNotification('success'); + showAlert(t('rewards.claimSuccess')); + fetchUserScores(); + } else { + hapticNotification('error'); + showAlert(result.error || t('rewards.claimFailed')); + } + } catch (err) { + hapticNotification('error'); + showAlert(err instanceof Error ? err.message : t('rewards.claimFailed')); + } finally { + setShowTrackingAnimation(false); + setClaimingEpoch(null); + } + }; + + const handleClaimAll = async () => { + if (!peopleApi || !keypair || !pezRewards?.claimableRewards.length) return; + setTrackingAnimationText(t('rewards.claimingReward')); + setShowTrackingAnimation(true); + hapticImpact('medium'); + try { + for (const reward of pezRewards.claimableRewards) { + setClaimingEpoch(reward.epoch); + const result = await claimPezReward(peopleApi, keypair, reward.epoch); + if (!result.success) { + hapticNotification('error'); + showAlert(result.error || t('rewards.claimFailed')); + setShowTrackingAnimation(false); + setClaimingEpoch(null); + return; + } + } + hapticNotification('success'); + showAlert(t('rewards.claimSuccess')); + fetchUserScores(); + } catch (err) { + hapticNotification('error'); + showAlert(err instanceof Error ? err.message : t('rewards.claimFailed')); + } finally { + setShowTrackingAnimation(false); + setClaimingEpoch(null); + } + }; + const handleActivate = () => { hapticNotification('success'); localStorage.setItem(ACTIVITY_STORAGE_KEY, Date.now().toString()); @@ -877,6 +939,113 @@ export function RewardsSection() { )} + {/* PEZ Epoch Rewards */} +
+

+ + {t('rewards.pezRewardsTitle')} +

+ + {/* Epoch Info */} +
+ + {t('rewards.epoch')}: {pezRewards?.currentEpoch ?? '...'} + + + {pezRewards?.epochStatus === 'ClaimPeriod' + ? t('rewards.claimPeriod') + : pezRewards?.epochStatus === 'Closed' + ? t('rewards.epochClosed') + : t('rewards.epochOpen')} + + {pezRewards?.hasRecordedThisEpoch && ( + + + {t('rewards.scoreRecorded')} + + )} +
+ + {/* Claimable PEZ Rewards */} +
+

{t('rewards.claimablePez')}

+

+ {pezRewards && parseFloat(pezRewards.totalClaimable) > 0 + ? `${pezRewards.totalClaimable} PEZ` + : '0 PEZ'} +

+
+ + {/* Claimable Epochs List */} + {pezRewards && pezRewards.claimableRewards.length > 0 ? ( +
+ {pezRewards.claimableRewards.map((reward) => ( +
+
+

+ {t('rewards.epoch')} #{reward.epoch} +

+

+ {reward.amount} PEZ +

+
+ +
+ ))} + + {/* Claim All Button */} + {pezRewards.claimableRewards.length > 1 && ( + + )} +
+ ) : ( +

+ {t('rewards.noPezRewards')} +

+ )} + + {/* Single Claim Button when only 1 reward */} + {pezRewards && pezRewards.claimableRewards.length === 1 && ( + + )} +
+ {/* Staking Rewards from SubQuery */}

diff --git a/src/version.json b/src/version.json index 3e5359e..37925e9 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.216", - "buildTime": "2026-02-21T12:12:08.352Z", - "buildNumber": 1771675928353 + "version": "1.0.217", + "buildTime": "2026-02-21T14:53:30.534Z", + "buildNumber": 1771685610534 }