feat: add PEZ epoch rewards display and claim functionality

This commit is contained in:
2026-02-21 17:53:30 +03:00
parent 123183038f
commit a14bd57bbe
11 changed files with 472 additions and 5 deletions
+1 -1
View File
@@ -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",
+14
View File
@@ -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: {
+14
View File
@@ -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: {
+14
View File
@@ -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: {
+14
View File
@@ -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: {
+14
View File
@@ -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: {
+14
View File
@@ -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: {
+14
View File
@@ -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
+200
View File
@@ -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<PezRewardInfo | null> {
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<typeof peopleApi.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 }));
});
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// ========================================
// COMPREHENSIVE SCORE FETCHING
// ========================================
+170 -1
View File
@@ -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<CitizenshipStatus>('NotStarted');
const [citizenCount, setCitizenCount] = useState<number | null>(null);
const [pezRewards, setPezRewards] = useState<PezRewardInfo | null>(null);
const [claimingEpoch, setClaimingEpoch] = useState<number | null>(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() {
</div>
)}
{/* PEZ Epoch Rewards */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
<Gift className="w-4 h-4 text-purple-400" />
{t('rewards.pezRewardsTitle')}
</h3>
{/* Epoch Info */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-muted-foreground">
{t('rewards.epoch')}: {pezRewards?.currentEpoch ?? '...'}
</span>
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
pezRewards?.epochStatus === 'ClaimPeriod'
? 'bg-green-500/20 text-green-400'
: pezRewards?.epochStatus === 'Closed'
? 'bg-red-500/20 text-red-400'
: 'bg-blue-500/20 text-blue-400'
)}
>
{pezRewards?.epochStatus === 'ClaimPeriod'
? t('rewards.claimPeriod')
: pezRewards?.epochStatus === 'Closed'
? t('rewards.epochClosed')
: t('rewards.epochOpen')}
</span>
{pezRewards?.hasRecordedThisEpoch && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400">
<Check className="w-3 h-3 inline mr-1" />
{t('rewards.scoreRecorded')}
</span>
)}
</div>
{/* Claimable PEZ Rewards */}
<div className="bg-purple-500/10 rounded-lg p-3 mb-3 border border-purple-500/20">
<p className="text-xs text-purple-300 mb-1">{t('rewards.claimablePez')}</p>
<p className="text-2xl font-bold text-purple-400">
{pezRewards && parseFloat(pezRewards.totalClaimable) > 0
? `${pezRewards.totalClaimable} PEZ`
: '0 PEZ'}
</p>
</div>
{/* Claimable Epochs List */}
{pezRewards && pezRewards.claimableRewards.length > 0 ? (
<div className="space-y-2 mb-3">
{pezRewards.claimableRewards.map((reward) => (
<div
key={reward.epoch}
className="flex items-center justify-between py-2 px-3 bg-purple-500/5 rounded-lg border border-purple-500/10"
>
<div>
<p className="text-sm text-foreground">
{t('rewards.epoch')} #{reward.epoch}
</p>
<p className="text-xs text-purple-400 font-medium">
{reward.amount} PEZ
</p>
</div>
<button
onClick={() => handleClaimReward(reward.epoch)}
disabled={!keypair || claimingEpoch !== null}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-purple-500/20 text-purple-300 hover:bg-purple-500/30 transition-all disabled:opacity-50"
>
{claimingEpoch === reward.epoch ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
t('rewards.claim')
)}
</button>
</div>
))}
{/* Claim All Button */}
{pezRewards.claimableRewards.length > 1 && (
<button
onClick={handleClaimAll}
disabled={!keypair || claimingEpoch !== null}
className="w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-pink-600 text-white hover:opacity-90 transition-all disabled:opacity-50"
>
<Gift className="w-5 h-5" />
{t('rewards.claimAll')} ({pezRewards.totalClaimable} PEZ)
</button>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
{t('rewards.noPezRewards')}
</p>
)}
{/* Single Claim Button when only 1 reward */}
{pezRewards && pezRewards.claimableRewards.length === 1 && (
<button
onClick={() => handleClaimReward(pezRewards.claimableRewards[0].epoch)}
disabled={!keypair || claimingEpoch !== null}
className="w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-pink-600 text-white hover:opacity-90 transition-all disabled:opacity-50"
>
<Gift className="w-5 h-5" />
{t('rewards.claimPez')} ({pezRewards.totalClaimable} PEZ)
</button>
)}
</div>
{/* Staking Rewards from SubQuery */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
+3 -3
View File
@@ -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
}