mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-06-21 05:01:07 +00:00
refactor(scores): remove frontend fallback, read all scores from blockchain
- Remove localStorage staking tracking and manual score calculation - Read trust, referral, tiki, perwerde scores directly from People Chain - Add SubQuery integration for staking reward history display - Align score system with pwap/shared implementation
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pezkuwi-telegram-miniapp",
|
"name": "pezkuwi-telegram-miniapp",
|
||||||
"version": "1.0.183",
|
"version": "1.0.184",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
|
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
|
||||||
"author": "Pezkuwichain Team",
|
"author": "Pezkuwichain Team",
|
||||||
|
|||||||
+136
-288
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Score Systems Integration for Telegram Mini App
|
* Score Systems Integration for Telegram Mini App
|
||||||
* Based on pwap/shared/lib/scores.ts
|
* Aligned with pwap/shared/lib/scores.ts
|
||||||
*
|
*
|
||||||
* All scores come from People Chain (people-rpc.pezkuwichain.io)
|
* All scores come from People Chain (people-rpc.pezkuwichain.io)
|
||||||
* - Trust Score: pezpallet-trust
|
* - Trust Score: pezpallet-trust (composite, on-chain calculated)
|
||||||
* - Referral Score: pezpallet-referral
|
* - Referral Score: pezpallet-referral
|
||||||
* - Staking Score: pezpallet-staking-score (with frontend fallback)
|
* - Staking Score: pezpallet-staking-score
|
||||||
* - Tiki Score: pezpallet-tiki
|
* - Tiki Score: pezpallet-tiki
|
||||||
|
* - Perwerde Score: pezpallet-perwerde
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ApiPromise } from '@pezkuwi/api';
|
import type { ApiPromise } from '@pezkuwi/api';
|
||||||
import { calculateReferralScore, getReferralCount } from './referral';
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TYPE DEFINITIONS
|
// TYPE DEFINITIONS
|
||||||
@@ -21,221 +21,120 @@ export interface UserScores {
|
|||||||
referralScore: number;
|
referralScore: number;
|
||||||
stakingScore: number;
|
stakingScore: number;
|
||||||
tikiScore: number;
|
tikiScore: number;
|
||||||
|
perwerdeScore: number;
|
||||||
totalScore: number;
|
totalScore: number;
|
||||||
|
isCitizen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StakingScoreStatus {
|
||||||
|
isTracking: boolean;
|
||||||
|
startBlock: number | null;
|
||||||
|
currentBlock: number;
|
||||||
|
durationBlocks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// STAKING SCORE FRONTEND FALLBACK
|
// TRUST SCORE (pezpallet-trust on People Chain)
|
||||||
// ========================================
|
// ========================================
|
||||||
// Until runtime upgrade deploys, calculate staking score
|
|
||||||
// directly from Relay Chain data without People Chain pallet
|
|
||||||
|
|
||||||
const STAKING_SCORE_STORAGE_KEY = 'pez_staking_score_tracking';
|
|
||||||
const UNITS = 1_000_000_000_000; // 10^12
|
|
||||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
||||||
const MONTH_IN_MS = 30 * DAY_IN_MS;
|
|
||||||
|
|
||||||
interface StakingTrackingData {
|
|
||||||
[address: string]: {
|
|
||||||
startTime: number;
|
|
||||||
lastChecked: number;
|
|
||||||
lastStakeAmount: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStakingTrackingData(): StakingTrackingData {
|
|
||||||
if (typeof window === 'undefined') return {};
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STAKING_SCORE_STORAGE_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveStakingTrackingData(data: StakingTrackingData): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STAKING_SCORE_STORAGE_KEY, JSON.stringify(data));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save staking tracking data:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch staking details directly from Relay Chain
|
* Fetch user's trust score from blockchain
|
||||||
* In newer Substrate versions, ledger is keyed by stash address
|
* Storage: trust.trustScores(address)
|
||||||
|
* This is the composite score calculated on-chain
|
||||||
*/
|
*/
|
||||||
export async function fetchRelayStakingDetails(
|
export async function getTrustScore(peopleApi: ApiPromise, address: string): Promise<number> {
|
||||||
relayApi: ApiPromise,
|
|
||||||
address: string
|
|
||||||
): Promise<{ stakedAmount: bigint; nominationsCount: number } | null> {
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (!(relayApi?.query as any)?.staking) {
|
if (!(peopleApi?.query as any)?.trust?.trustScores) {
|
||||||
console.warn('[Staking] staking pallet not found');
|
return 0;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let stashAddress = address;
|
|
||||||
let active = 0n;
|
|
||||||
|
|
||||||
// In newer Substrate, ledger is keyed by stash address directly
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let ledger = await (relayApi.query.staking as any).ledger?.(address);
|
const score = await (peopleApi.query as any).trust.trustScores(address);
|
||||||
|
|
||||||
// Check if ledger exists and has data
|
if (score.isEmpty) {
|
||||||
if (ledger && !ledger.isEmpty && !ledger.isNone) {
|
return 0;
|
||||||
// Ledger might be wrapped in Option
|
|
||||||
const unwrapped = ledger.isSome ? ledger.unwrap() : ledger;
|
|
||||||
const ledgerJson = unwrapped.toJSON() as { active?: string | number; stash?: string };
|
|
||||||
console.warn('[Staking] Ledger found for', address, ':', ledgerJson);
|
|
||||||
active = BigInt(ledgerJson?.active || 0);
|
|
||||||
if (ledgerJson?.stash) {
|
|
||||||
stashAddress = ledgerJson.stash;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: check if this is a stash account with a controller
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const bonded = await (relayApi.query.staking as any).bonded?.(address);
|
|
||||||
if (bonded && !bonded.isEmpty && !bonded.isNone) {
|
|
||||||
const controller = bonded.toString();
|
|
||||||
console.warn('[Staking] Address', address, 'is stash, controller:', controller);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
ledger = await (relayApi.query.staking as any).ledger?.(controller);
|
|
||||||
if (ledger && !ledger.isEmpty && !ledger.isNone) {
|
|
||||||
const unwrapped = ledger.isSome ? ledger.unwrap() : ledger;
|
|
||||||
const ledgerJson = unwrapped.toJSON() as { active?: string | number };
|
|
||||||
console.warn('[Staking] Ledger from controller:', ledgerJson);
|
|
||||||
active = BigInt(ledgerJson?.active || 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[Staking] No ledger or bonded found for', address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active === 0n) {
|
return Number(score.toString());
|
||||||
return null;
|
} catch (error) {
|
||||||
}
|
console.error('Error fetching trust score:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get nominations
|
// ========================================
|
||||||
|
// REFERRAL SCORE (pezpallet-referral on People Chain)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user's referral score based on referral count
|
||||||
|
* Storage: referral.referralCount(address)
|
||||||
|
*/
|
||||||
|
export async function getReferralScore(peopleApi: ApiPromise, address: string): Promise<number> {
|
||||||
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const nominations = await (relayApi.query.staking as any).nominators?.(stashAddress);
|
if (!(peopleApi?.query as any)?.referral?.referralCount) {
|
||||||
const nominationsJson = nominations?.toJSON() as { targets?: unknown[] } | null;
|
return 0;
|
||||||
const nominationsCount = nominationsJson?.targets?.length || 0;
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const count = await (peopleApi.query as any).referral.referralCount(address);
|
||||||
|
const referralCount = Number(count.toString());
|
||||||
|
|
||||||
|
if (referralCount === 0) return 0;
|
||||||
|
if (referralCount <= 5) return referralCount * 4;
|
||||||
|
if (referralCount <= 20) return 20 + (referralCount - 5) * 2;
|
||||||
|
return 50; // Capped at 50 points
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching referral score:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// STAKING SCORE (pezpallet-staking-score on People Chain)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check staking score tracking status
|
||||||
|
* Storage: stakingScore.stakingStartBlock(address)
|
||||||
|
*/
|
||||||
|
export async function getStakingScoreStatus(
|
||||||
|
peopleApi: ApiPromise,
|
||||||
|
address: string
|
||||||
|
): Promise<StakingScoreStatus> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (!(peopleApi?.query as any)?.stakingScore?.stakingStartBlock) {
|
||||||
|
return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const startBlockResult = await (peopleApi.query as any).stakingScore.stakingStartBlock(address);
|
||||||
|
const currentBlock = Number((await peopleApi.query.system.number()).toString());
|
||||||
|
|
||||||
|
if (startBlockResult.isEmpty || startBlockResult.isNone) {
|
||||||
|
return { isTracking: false, startBlock: null, currentBlock, durationBlocks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBlock = Number(startBlockResult.toString());
|
||||||
|
const durationBlocks = currentBlock - startBlock;
|
||||||
|
|
||||||
console.log(
|
|
||||||
'[Staking] Final result - active:',
|
|
||||||
active.toString(),
|
|
||||||
'nominations:',
|
|
||||||
nominationsCount
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
stakedAmount: active,
|
isTracking: true,
|
||||||
nominationsCount,
|
startBlock,
|
||||||
|
currentBlock,
|
||||||
|
durationBlocks,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch relay staking details:', err);
|
console.error('Error fetching staking score status:', error);
|
||||||
return null;
|
return { isTracking: false, startBlock: null, currentBlock: 0, durationBlocks: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate base score from staked HEZ amount
|
|
||||||
*/
|
|
||||||
function calculateBaseStakingScore(stakedHez: number): number {
|
|
||||||
if (stakedHez <= 0) return 0;
|
|
||||||
if (stakedHez <= 100) return 20;
|
|
||||||
if (stakedHez <= 250) return 30;
|
|
||||||
if (stakedHez <= 750) return 40;
|
|
||||||
return 50; // 751+ HEZ
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get time multiplier based on months staked
|
|
||||||
*/
|
|
||||||
function getStakingTimeMultiplier(monthsStaked: number): number {
|
|
||||||
if (monthsStaked >= 12) return 2.0;
|
|
||||||
if (monthsStaked >= 6) return 1.7;
|
|
||||||
if (monthsStaked >= 3) return 1.4;
|
|
||||||
if (monthsStaked >= 1) return 1.2;
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FrontendStakingScoreResult {
|
|
||||||
score: number;
|
|
||||||
stakedAmount: bigint;
|
|
||||||
stakedHez: number;
|
|
||||||
trackingStarted: Date | null;
|
|
||||||
monthsStaked: number;
|
|
||||||
timeMultiplier: number;
|
|
||||||
nominationsCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get staking score using frontend fallback
|
|
||||||
*/
|
|
||||||
export async function getFrontendStakingScore(
|
|
||||||
relayApi: ApiPromise,
|
|
||||||
address: string
|
|
||||||
): Promise<FrontendStakingScoreResult> {
|
|
||||||
const emptyResult: FrontendStakingScoreResult = {
|
|
||||||
score: 0,
|
|
||||||
stakedAmount: 0n,
|
|
||||||
stakedHez: 0,
|
|
||||||
trackingStarted: null,
|
|
||||||
monthsStaked: 0,
|
|
||||||
timeMultiplier: 1.0,
|
|
||||||
nominationsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!relayApi || !address) return emptyResult;
|
|
||||||
|
|
||||||
const details = await fetchRelayStakingDetails(relayApi, address);
|
|
||||||
|
|
||||||
if (!details || details.stakedAmount === 0n) {
|
|
||||||
return emptyResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackingData = getStakingTrackingData();
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (!trackingData[address]) {
|
|
||||||
trackingData[address] = {
|
|
||||||
startTime: now,
|
|
||||||
lastChecked: now,
|
|
||||||
lastStakeAmount: details.stakedAmount.toString(),
|
|
||||||
};
|
|
||||||
saveStakingTrackingData(trackingData);
|
|
||||||
} else {
|
|
||||||
trackingData[address].lastChecked = now;
|
|
||||||
trackingData[address].lastStakeAmount = details.stakedAmount.toString();
|
|
||||||
saveStakingTrackingData(trackingData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userTracking = trackingData[address];
|
|
||||||
const trackingStarted = new Date(userTracking.startTime);
|
|
||||||
const msStaked = now - userTracking.startTime;
|
|
||||||
const monthsStaked = Math.floor(msStaked / MONTH_IN_MS);
|
|
||||||
|
|
||||||
const stakedHez = Number(details.stakedAmount) / UNITS;
|
|
||||||
const baseScore = calculateBaseStakingScore(stakedHez);
|
|
||||||
const timeMultiplier = getStakingTimeMultiplier(monthsStaked);
|
|
||||||
const finalScore = Math.min(Math.floor(baseScore * timeMultiplier), 100);
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: finalScore,
|
|
||||||
stakedAmount: details.stakedAmount,
|
|
||||||
stakedHez,
|
|
||||||
trackingStarted,
|
|
||||||
monthsStaked,
|
|
||||||
timeMultiplier,
|
|
||||||
nominationsCount: details.nominationsCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TIKI SCORE
|
// TIKI SCORE (pezpallet-tiki on People Chain)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
export interface TikiInfo {
|
export interface TikiInfo {
|
||||||
@@ -244,18 +143,6 @@ export interface TikiInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIKI_ROLE_SCORES: Record<number, number> = {
|
|
||||||
1: 10, // Basic
|
|
||||||
2: 20, // Bronze
|
|
||||||
3: 30, // Silver
|
|
||||||
4: 40, // Gold
|
|
||||||
5: 50, // Platinum
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tiki role name to score mapping
|
|
||||||
* Welati (citizen) is the basic role with score 10
|
|
||||||
*/
|
|
||||||
const TIKI_NAME_SCORES: Record<string, number> = {
|
const TIKI_NAME_SCORES: Record<string, number> = {
|
||||||
welati: 10,
|
welati: 10,
|
||||||
parlementer: 30,
|
parlementer: 30,
|
||||||
@@ -278,11 +165,9 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (!(peopleApi?.query as any)?.tiki) {
|
if (!(peopleApi?.query as any)?.tiki) {
|
||||||
console.warn('[Tiki] tiki pallet not found');
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try userTikis first (actual storage name)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let result = await (peopleApi.query.tiki as any).userTikis?.(address);
|
let result = await (peopleApi.query.tiki as any).userTikis?.(address);
|
||||||
|
|
||||||
@@ -293,26 +178,21 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result || result.isEmpty) {
|
if (!result || result.isEmpty) {
|
||||||
console.warn('[Tiki] No tikis found for', address);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is Vec<TikiRole> which are enum variants as strings
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const tikis = result.toJSON() as any[];
|
const tikis = result.toJSON() as any[];
|
||||||
console.warn('[Tiki] Raw tikis for', address, ':', tikis);
|
|
||||||
|
|
||||||
return tikis.map((tiki, index) => {
|
return tikis.map((tiki, index) => {
|
||||||
// Tiki can be a string (enum variant name) or object
|
|
||||||
const name = typeof tiki === 'string' ? tiki : tiki.name || tiki.role || 'Unknown';
|
const name = typeof tiki === 'string' ? tiki : tiki.name || tiki.role || 'Unknown';
|
||||||
const nameLower = name.toLowerCase();
|
const nameLower = name.toLowerCase();
|
||||||
const score = TIKI_NAME_SCORES[nameLower] || 10; // Default to 10 if unknown
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roleId: index + 1,
|
roleId: index + 1,
|
||||||
level: 1,
|
level: 1,
|
||||||
name: name,
|
name: name,
|
||||||
score: score,
|
score: TIKI_NAME_SCORES[nameLower] || 10,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -323,25 +203,20 @@ export async function fetchUserTikis(peopleApi: ApiPromise, address: string): Pr
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate tiki score from user's tikis
|
* Calculate tiki score from user's tikis
|
||||||
* Uses the score property set during fetch, or looks up by name
|
|
||||||
*/
|
*/
|
||||||
export function calculateTikiScore(tikis: TikiInfo[]): number {
|
export function calculateTikiScore(tikis: TikiInfo[]): number {
|
||||||
if (!tikis.length) return 0;
|
if (!tikis.length) return 0;
|
||||||
|
|
||||||
// Get highest role score
|
|
||||||
let maxScore = 0;
|
let maxScore = 0;
|
||||||
for (const tiki of tikis) {
|
for (const tiki of tikis) {
|
||||||
// Use score from tiki if available, otherwise lookup by roleId or name
|
|
||||||
const tikiScore =
|
const tikiScore =
|
||||||
(tiki as TikiInfo & { score?: number }).score ||
|
(tiki as TikiInfo & { score?: number }).score ||
|
||||||
TIKI_ROLE_SCORES[tiki.roleId] ||
|
|
||||||
TIKI_NAME_SCORES[tiki.name.toLowerCase()] ||
|
TIKI_NAME_SCORES[tiki.name.toLowerCase()] ||
|
||||||
10; // Default welati score
|
10;
|
||||||
maxScore = Math.max(maxScore, tikiScore);
|
maxScore = Math.max(maxScore, tikiScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[Tiki] Calculated score:', maxScore, 'from tikis:', tikis);
|
return Math.min(maxScore, 50);
|
||||||
return Math.min(maxScore, 50); // Capped at 50
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,23 +233,8 @@ export async function getTikiScore(peopleApi: ApiPromise, address: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TRUST SCORE CALCULATION
|
// CITIZENSHIP & PERWERDE
|
||||||
// ========================================
|
// ========================================
|
||||||
// Formula from pezpallet-trust:
|
|
||||||
// weighted_sum = staking*100 + referral*300 + perwerde*300 + tiki*300
|
|
||||||
// trust_score = (staking * weighted_sum) / 100
|
|
||||||
|
|
||||||
const SCORE_MULTIPLIER_BASE = 100;
|
|
||||||
|
|
||||||
export interface FrontendTrustScoreResult {
|
|
||||||
trustScore: number;
|
|
||||||
stakingScore: number;
|
|
||||||
referralScore: number;
|
|
||||||
perwerdeScore: number;
|
|
||||||
tikiScore: number;
|
|
||||||
weightedSum: number;
|
|
||||||
isCitizen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is a citizen (KYC approved)
|
* Check if user is a citizen (KYC approved)
|
||||||
@@ -433,70 +293,52 @@ export async function getPerwerdeScore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COMPREHENSIVE SCORE FETCHING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate all scores with frontend fallback
|
* Fetch all scores for a user from People Chain
|
||||||
|
* Trust pallet computes composite score on-chain (includes staking, referral, tiki, perwerde)
|
||||||
*/
|
*/
|
||||||
export async function getAllScoresWithFallback(
|
export async function getAllScores(
|
||||||
peopleApi: ApiPromise | null,
|
peopleApi: ApiPromise | null,
|
||||||
relayApi: ApiPromise | null,
|
|
||||||
address: string
|
address: string
|
||||||
): Promise<FrontendTrustScoreResult & { isFromFrontend: boolean }> {
|
): Promise<UserScores> {
|
||||||
const emptyResult: FrontendTrustScoreResult & { isFromFrontend: boolean } = {
|
const empty: UserScores = {
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
stakingScore: 0,
|
|
||||||
referralScore: 0,
|
referralScore: 0,
|
||||||
perwerdeScore: 0,
|
stakingScore: 0,
|
||||||
tikiScore: 0,
|
tikiScore: 0,
|
||||||
weightedSum: 0,
|
perwerdeScore: 0,
|
||||||
|
totalScore: 0,
|
||||||
isCitizen: false,
|
isCitizen: false,
|
||||||
isFromFrontend: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!address) return emptyResult;
|
if (!peopleApi || !address) return empty;
|
||||||
|
|
||||||
// Check citizenship status
|
try {
|
||||||
const isCitizen = await checkCitizenshipStatus(peopleApi, address);
|
const [trustScore, referralScore, tikiScore, perwerdeScore, isCitizen] = await Promise.all([
|
||||||
|
getTrustScore(peopleApi, address),
|
||||||
|
getReferralScore(peopleApi, address),
|
||||||
|
getTikiScore(peopleApi, address),
|
||||||
|
getPerwerdeScore(peopleApi, address),
|
||||||
|
checkCitizenshipStatus(peopleApi, address),
|
||||||
|
]);
|
||||||
|
|
||||||
// Get component scores in parallel
|
|
||||||
const [stakingResult, referralCount, perwerdeScore, tikiScore] = await Promise.all([
|
|
||||||
relayApi ? getFrontendStakingScore(relayApi, address) : Promise.resolve({ score: 0 }),
|
|
||||||
peopleApi ? getReferralCount(peopleApi, address) : Promise.resolve(0),
|
|
||||||
getPerwerdeScore(peopleApi, address),
|
|
||||||
peopleApi ? getTikiScore(peopleApi, address) : Promise.resolve(0),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stakingScore = stakingResult.score;
|
|
||||||
const referralScore = calculateReferralScore(referralCount);
|
|
||||||
|
|
||||||
// Ger staking 0 be, trust jî 0 be (matches pallet logic)
|
|
||||||
if (stakingScore === 0) {
|
|
||||||
return {
|
return {
|
||||||
...emptyResult,
|
trustScore,
|
||||||
referralScore,
|
referralScore,
|
||||||
perwerdeScore,
|
stakingScore: 0, // Trust pallet already includes staking in composite
|
||||||
tikiScore,
|
tikiScore,
|
||||||
|
perwerdeScore,
|
||||||
|
totalScore: trustScore, // Trust score = composite score (on-chain calculated)
|
||||||
isCitizen,
|
isCitizen,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching scores:', error);
|
||||||
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate weighted sum (matching pallet formula)
|
|
||||||
const weightedSum =
|
|
||||||
stakingScore * 100 + referralScore * 300 + perwerdeScore * 300 + tikiScore * 300;
|
|
||||||
|
|
||||||
// Calculate final trust score
|
|
||||||
// trust_score = (staking * weighted_sum) / 100
|
|
||||||
const trustScore = Math.floor((stakingScore * weightedSum) / SCORE_MULTIPLIER_BASE);
|
|
||||||
|
|
||||||
return {
|
|
||||||
trustScore,
|
|
||||||
stakingScore,
|
|
||||||
referralScore,
|
|
||||||
perwerdeScore,
|
|
||||||
tikiScore,
|
|
||||||
weightedSum,
|
|
||||||
isCitizen,
|
|
||||||
isFromFrontend: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -524,9 +366,15 @@ export function getScoreRating(score: number): string {
|
|||||||
return 'Sifir';
|
return 'Sifir';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatStakedAmount(amount: bigint): string {
|
export function formatDuration(blocks: number): string {
|
||||||
const hez = Number(amount) / UNITS;
|
const BLOCKS_PER_MINUTE = 10;
|
||||||
if (hez >= 1000000) return `${(hez / 1000000).toFixed(2)}M`;
|
const minutes = blocks / BLOCKS_PER_MINUTE;
|
||||||
if (hez >= 1000) return `${(hez / 1000).toFixed(2)}K`;
|
const hours = minutes / 60;
|
||||||
return hez.toFixed(2);
|
const days = hours / 24;
|
||||||
|
const months = days / 30;
|
||||||
|
|
||||||
|
if (months >= 1) return `${Math.floor(months)} meh`;
|
||||||
|
if (days >= 1) return `${Math.floor(days)} roj`;
|
||||||
|
if (hours >= 1) return `${Math.floor(hours)} saet`;
|
||||||
|
return `${Math.floor(minutes)} deqîqe`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* SubQuery GraphQL Client
|
||||||
|
* Queries the pezkuwi-subquery indexer for historical blockchain data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUBQUERY_ENDPOINT = 'https://subquery.pezkuwichain.io';
|
||||||
|
|
||||||
|
const UNITS = 1_000_000_000_000; // 10^12
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TYPES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface StakingRewardEntry {
|
||||||
|
id: string;
|
||||||
|
blockNumber: number;
|
||||||
|
timestamp: string;
|
||||||
|
amount: string;
|
||||||
|
accumulatedAmount: string;
|
||||||
|
type: 'REWARD' | 'SLASH';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StakingRewardsResult {
|
||||||
|
rewards: StakingRewardEntry[];
|
||||||
|
totalAccumulated: string;
|
||||||
|
totalAccumulatedHez: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// GRAPHQL CLIENT
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
async function querySubGraph<T>(query: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(SUBQUERY_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('[SubQuery] HTTP error:', res.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
console.error('[SubQuery] GraphQL errors:', json.errors);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data as T;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SubQuery] Fetch error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// REWARD QUERIES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch staking rewards for a specific address
|
||||||
|
*/
|
||||||
|
export async function getStakingRewards(
|
||||||
|
address: string,
|
||||||
|
limit = 20
|
||||||
|
): Promise<StakingRewardsResult> {
|
||||||
|
const empty: StakingRewardsResult = {
|
||||||
|
rewards: [],
|
||||||
|
totalAccumulated: '0',
|
||||||
|
totalAccumulatedHez: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!address) return empty;
|
||||||
|
|
||||||
|
const data = await querySubGraph<{
|
||||||
|
accountRewards: { nodes: StakingRewardEntry[] };
|
||||||
|
accumulatedReward: { amount: string } | null;
|
||||||
|
}>(`{
|
||||||
|
accountRewards(
|
||||||
|
filter: { address: { equalTo: "${address}" } }
|
||||||
|
orderBy: BLOCK_NUMBER_DESC
|
||||||
|
first: ${limit}
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
blockNumber
|
||||||
|
timestamp
|
||||||
|
amount
|
||||||
|
accumulatedAmount
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accumulatedReward(id: "${address}") {
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
|
||||||
|
if (!data) return empty;
|
||||||
|
|
||||||
|
const totalAccumulated = data.accumulatedReward?.amount || '0';
|
||||||
|
|
||||||
|
return {
|
||||||
|
rewards: data.accountRewards.nodes,
|
||||||
|
totalAccumulated,
|
||||||
|
totalAccumulatedHez: Number(BigInt(totalAccumulated)) / UNITS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw amount (planck) to HEZ display
|
||||||
|
*/
|
||||||
|
export function formatRewardAmount(amount: string): string {
|
||||||
|
const hez = Number(BigInt(amount)) / UNITS;
|
||||||
|
if (hez >= 1000) return `${(hez / 1000).toFixed(2)}K`;
|
||||||
|
if (hez >= 1) return hez.toFixed(4);
|
||||||
|
if (hez >= 0.001) return hez.toFixed(6);
|
||||||
|
return hez.toFixed(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp to locale string
|
||||||
|
*/
|
||||||
|
export function formatRewardDate(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString('ku', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
+88
-61
@@ -21,6 +21,7 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
Clock,
|
||||||
} 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';
|
||||||
@@ -29,14 +30,20 @@ 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';
|
||||||
import {
|
import {
|
||||||
getAllScoresWithFallback,
|
getAllScores,
|
||||||
getFrontendStakingScore,
|
getStakingScoreStatus,
|
||||||
formatStakedAmount,
|
formatDuration,
|
||||||
getScoreColor,
|
getScoreColor,
|
||||||
getScoreRating,
|
getScoreRating,
|
||||||
type FrontendTrustScoreResult,
|
type UserScores,
|
||||||
type FrontendStakingScoreResult,
|
type StakingScoreStatus,
|
||||||
} from '@/lib/scores';
|
} from '@/lib/scores';
|
||||||
|
import {
|
||||||
|
getStakingRewards,
|
||||||
|
formatRewardAmount,
|
||||||
|
formatRewardDate,
|
||||||
|
type StakingRewardsResult,
|
||||||
|
} from '@/lib/subquery';
|
||||||
|
|
||||||
// Activity tracking constants
|
// Activity tracking constants
|
||||||
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
|
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
|
||||||
@@ -46,16 +53,15 @@ export function RewardsSection() {
|
|||||||
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
|
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser } = useAuth();
|
||||||
const { stats, myReferrals, loading, refreshStats } = useReferral();
|
const { stats, myReferrals, loading, refreshStats } = useReferral();
|
||||||
const { isConnected, address, api, peopleApi } = useWallet();
|
const { isConnected, address, peopleApi } = useWallet();
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'referrals' | 'scores'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'referrals' | 'scores'>('overview');
|
||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActive] = useState(false);
|
||||||
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
||||||
const [userScores, setUserScores] = useState<
|
const [userScores, setUserScores] = useState<UserScores | null>(null);
|
||||||
(FrontendTrustScoreResult & { isFromFrontend: boolean }) | null
|
const [stakingStatus, setStakingStatus] = useState<StakingScoreStatus | null>(null);
|
||||||
>(null);
|
const [stakingRewards, setStakingRewards] = useState<StakingRewardsResult | null>(null);
|
||||||
const [stakingDetails, setStakingDetails] = useState<FrontendStakingScoreResult | null>(null);
|
|
||||||
const [scoresLoading, setScoresLoading] = useState(false);
|
const [scoresLoading, setScoresLoading] = useState(false);
|
||||||
|
|
||||||
// Check activity status
|
// Check activity status
|
||||||
@@ -84,7 +90,6 @@ export function RewardsSection() {
|
|||||||
|
|
||||||
// Check activity status on mount and every minute
|
// Check activity status on mount and every minute
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Run check after a microtask to avoid synchronous setState in effect
|
|
||||||
const timeoutId = setTimeout(checkActivityStatus, 0);
|
const timeoutId = setTimeout(checkActivityStatus, 0);
|
||||||
const interval = setInterval(checkActivityStatus, 60000);
|
const interval = setInterval(checkActivityStatus, 60000);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -97,28 +102,27 @@ export function RewardsSection() {
|
|||||||
const fetchUserScores = useCallback(async () => {
|
const fetchUserScores = useCallback(async () => {
|
||||||
if (!address) {
|
if (!address) {
|
||||||
setUserScores(null);
|
setUserScores(null);
|
||||||
setStakingDetails(null);
|
setStakingStatus(null);
|
||||||
|
setStakingRewards(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[Scores] Fetching scores for', address);
|
|
||||||
console.warn('[Scores] API connected:', !!api, 'People API:', !!peopleApi);
|
|
||||||
|
|
||||||
setScoresLoading(true);
|
setScoresLoading(true);
|
||||||
try {
|
try {
|
||||||
const [scores, staking] = await Promise.all([
|
const [scores, staking, rewards] = await Promise.all([
|
||||||
getAllScoresWithFallback(peopleApi, api, address),
|
getAllScores(peopleApi, address),
|
||||||
api ? getFrontendStakingScore(api, address) : Promise.resolve(null),
|
peopleApi ? getStakingScoreStatus(peopleApi, address) : Promise.resolve(null),
|
||||||
|
getStakingRewards(address),
|
||||||
]);
|
]);
|
||||||
console.warn('[Scores] Results:', { scores, staking });
|
|
||||||
setUserScores(scores);
|
setUserScores(scores);
|
||||||
setStakingDetails(staking);
|
setStakingStatus(staking);
|
||||||
|
setStakingRewards(rewards);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching scores:', err);
|
console.error('Error fetching scores:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setScoresLoading(false);
|
setScoresLoading(false);
|
||||||
}
|
}
|
||||||
}, [api, peopleApi, address]);
|
}, [peopleApi, address]);
|
||||||
|
|
||||||
// Fetch scores when tab changes to scores or on initial load
|
// Fetch scores when tab changes to scores or on initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -603,13 +607,16 @@ export function RewardsSection() {
|
|||||||
<span className="text-xs text-muted-foreground">Staking</span>
|
<span className="text-xs text-muted-foreground">Staking</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-blue-400">
|
<p className="text-2xl font-bold text-blue-400">
|
||||||
{userScores?.stakingScore ?? 0}
|
{stakingStatus?.isTracking ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatDuration(stakingStatus.durationBlocks)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Nehatiye destpêkirin</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{stakingDetails && stakingDetails.stakedAmount > 0n && (
|
<p className="text-xs text-muted-foreground mt-1">Di Trust de tê hesibandin</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{formatStakedAmount(stakingDetails.stakedAmount)} HEZ
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Referral Score */}
|
{/* Referral Score */}
|
||||||
@@ -651,41 +658,61 @@ export function RewardsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staking Details Card */}
|
{/* Staking Rewards from SubQuery */}
|
||||||
{stakingDetails && stakingDetails.stakedAmount > 0n && (
|
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||||
<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">
|
||||||
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
<Coins className="w-4 h-4 text-amber-400" />
|
||||||
<TrendingUp className="w-4 h-4 text-blue-400" />
|
Xelatên Staking
|
||||||
Staking Hûrgelan
|
</h3>
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 text-sm">
|
{/* Total Accumulated */}
|
||||||
<div className="flex justify-between py-2 border-b border-border/30">
|
<div className="bg-amber-500/10 rounded-lg p-3 mb-3 border border-amber-500/20">
|
||||||
<span className="text-muted-foreground">HEZ Staked</span>
|
<p className="text-xs text-amber-300 mb-1">Tevahiya xelatên wergirtî</p>
|
||||||
<span className="text-foreground font-medium">
|
<p className="text-2xl font-bold text-amber-400">
|
||||||
{formatStakedAmount(stakingDetails.stakedAmount)} HEZ
|
{stakingRewards && stakingRewards.totalAccumulatedHez > 0
|
||||||
</span>
|
? `${stakingRewards.totalAccumulatedHez.toFixed(4)} HEZ`
|
||||||
</div>
|
: '0 HEZ'}
|
||||||
<div className="flex justify-between py-2 border-b border-border/30">
|
</p>
|
||||||
<span className="text-muted-foreground">Demjimêr</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{stakingDetails.monthsStaked} meh
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2 border-b border-border/30">
|
|
||||||
<span className="text-muted-foreground">Pir Zêdeker</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{stakingDetails.timeMultiplier}x
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2">
|
|
||||||
<span className="text-muted-foreground">Nominasyon</span>
|
|
||||||
<span className="text-foreground font-medium">
|
|
||||||
{stakingDetails.nominationsCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Recent Rewards List */}
|
||||||
|
{stakingRewards && stakingRewards.rewards.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Xelatên dawî</p>
|
||||||
|
{stakingRewards.rewards.map((reward) => (
|
||||||
|
<div
|
||||||
|
key={reward.id}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-border/30 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
reward.type === 'REWARD' ? 'bg-green-400' : 'bg-red-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{reward.type === 'REWARD' ? '+' : '-'}
|
||||||
|
{formatRewardAmount(reward.amount)} HEZ
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Block #{reward.blockNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatRewardDate(reward.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-3">
|
||||||
|
Hêj xelatek nehatiye tomarkirin
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Score Formula Info */}
|
{/* Score Formula Info */}
|
||||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.183",
|
"version": "1.0.184",
|
||||||
"buildTime": "2026-02-08T02:58:45.175Z",
|
"buildTime": "2026-02-13T03:26:27.567Z",
|
||||||
"buildNumber": 1770519525176
|
"buildNumber": 1770953187568
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user