Merge branch 'claude/calisma-ya-011CV6DKKRcWvDTxoEY7rYV4' into main

This commit is contained in:
2025-11-17 03:15:30 +03:00
23 changed files with 3923 additions and 1333 deletions
+282
View File
@@ -0,0 +1,282 @@
// ========================================
// Async Component Pattern
// ========================================
// Standard pattern for loading/error/empty states
import React, { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Loader2, AlertCircle, Inbox, RefreshCw } from 'lucide-react';
// ========================================
// LOADING SKELETON
// ========================================
export const CardSkeleton: React.FC = () => {
return (
<Card className="bg-gray-900 border-gray-800 animate-pulse">
<CardContent className="p-6">
<div className="h-6 bg-gray-800 rounded w-1/3 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-800 rounded"></div>
<div className="h-4 bg-gray-800 rounded w-5/6"></div>
<div className="h-4 bg-gray-800 rounded w-4/6"></div>
</div>
</CardContent>
</Card>
);
};
export const ListItemSkeleton: React.FC = () => {
return (
<div className="flex items-center gap-4 p-4 bg-gray-900 border border-gray-800 rounded-lg animate-pulse">
<div className="w-12 h-12 bg-gray-800 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-800 rounded w-1/4"></div>
<div className="h-3 bg-gray-800 rounded w-1/2"></div>
</div>
<div className="h-8 w-20 bg-gray-800 rounded"></div>
</div>
);
};
export const TableSkeleton: React.FC<{ rows?: number }> = ({ rows = 5 }) => {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 p-3 bg-gray-900 border border-gray-800 rounded animate-pulse">
<div className="h-4 bg-gray-800 rounded flex-1"></div>
<div className="h-4 bg-gray-800 rounded w-24"></div>
<div className="h-4 bg-gray-800 rounded w-32"></div>
</div>
))}
</div>
);
};
// ========================================
// LOADING COMPONENT
// ========================================
export const LoadingState: React.FC<{
message?: string;
fullScreen?: boolean;
}> = ({ message = 'Loading...', fullScreen = false }) => {
const content = (
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-12 h-12 text-green-500 animate-spin" />
<p className="text-gray-400">{message}</p>
</div>
);
if (fullScreen) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<Card className="bg-gray-900 border-gray-800 p-8">
<CardContent>{content}</CardContent>
</Card>
</div>
);
}
return (
<div className="flex items-center justify-center p-12">
{content}
</div>
);
};
// ========================================
// ERROR STATE
// ========================================
export const ErrorState: React.FC<{
message?: string;
error?: Error | string;
onRetry?: () => void;
fullScreen?: boolean;
}> = ({
message = 'An error occurred',
error,
onRetry,
fullScreen = false,
}) => {
const errorMessage = typeof error === 'string' ? error : error?.message;
const content = (
<Alert className="bg-red-900/20 border-red-500">
<AlertCircle className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">{message}</strong>
{errorMessage && (
<p className="text-sm text-gray-400 mb-4">{errorMessage}</p>
)}
{onRetry && (
<Button
onClick={onRetry}
size="sm"
className="bg-green-600 hover:bg-green-700"
>
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</AlertDescription>
</Alert>
);
if (fullScreen) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">{content}</CardContent>
</Card>
</div>
);
}
return <div className="p-4">{content}</div>;
};
// ========================================
// EMPTY STATE
// ========================================
export const EmptyState: React.FC<{
message?: string;
description?: string;
icon?: ReactNode;
action?: {
label: string;
onClick: () => void;
};
fullScreen?: boolean;
}> = ({
message = 'No data found',
description,
icon,
action,
fullScreen = false,
}) => {
const content = (
<div className="flex flex-col items-center gap-4 text-center">
{icon || <Inbox className="w-16 h-16 text-gray-600" />}
<div>
<h3 className="text-lg font-semibold text-white mb-1">{message}</h3>
{description && <p className="text-sm text-gray-400">{description}</p>}
</div>
{action && (
<Button onClick={action.onClick} className="bg-green-600 hover:bg-green-700">
{action.label}
</Button>
)}
</div>
);
if (fullScreen) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 p-8">
<CardContent>{content}</CardContent>
</Card>
</div>
);
}
return <div className="p-12">{content}</div>;
};
// ========================================
// ASYNC COMPONENT WRAPPER
// ========================================
export interface AsyncComponentProps<T> {
/** Loading state */
isLoading: boolean;
/** Error object */
error?: Error | string | null;
/** Data */
data?: T | null;
/** Children render function */
children: (data: T) => ReactNode;
/** Custom loading component */
LoadingComponent?: React.ComponentType;
/** Custom error component */
ErrorComponent?: React.ComponentType<{ error: Error | string; onRetry?: () => void }>;
/** Custom empty component */
EmptyComponent?: React.ComponentType;
/** Retry callback */
onRetry?: () => void;
/** Loading message */
loadingMessage?: string;
/** Error message */
errorMessage?: string;
/** Empty message */
emptyMessage?: string;
/** Full screen mode */
fullScreen?: boolean;
}
/**
* Standard async component pattern
* Handles loading, error, empty, and success states
*
* @example
* <AsyncComponent
* isLoading={loading}
* error={error}
* data={courses}
* onRetry={refetch}
* >
* {(courses) => <CourseList courses={courses} />}
* </AsyncComponent>
*/
export function AsyncComponent<T>({
isLoading,
error,
data,
children,
LoadingComponent,
ErrorComponent,
EmptyComponent,
onRetry,
loadingMessage = 'Loading...',
errorMessage = 'Failed to load data',
emptyMessage = 'No data available',
fullScreen = false,
}: AsyncComponentProps<T>): JSX.Element {
// Loading state
if (isLoading) {
if (LoadingComponent) {
return <LoadingComponent />;
}
return <LoadingState message={loadingMessage} fullScreen={fullScreen} />;
}
// Error state
if (error) {
if (ErrorComponent) {
return <ErrorComponent error={error} onRetry={onRetry} />;
}
return (
<ErrorState
message={errorMessage}
error={error}
onRetry={onRetry}
fullScreen={fullScreen}
/>
);
}
// Empty state
if (!data || (Array.isArray(data) && data.length === 0)) {
if (EmptyComponent) {
return <EmptyComponent />;
}
return <EmptyState message={emptyMessage} fullScreen={fullScreen} />;
}
// Success state - render children with data
return <>{children(data)}</>;
}
+537
View File
@@ -0,0 +1,537 @@
// ========================================
// Error Handler & User-Friendly Messages
// ========================================
// Convert blockchain errors to human-readable messages
import type { ApiPromise } from '@polkadot/api';
import type { DispatchError } from '@polkadot/types/interfaces';
// ========================================
// ERROR MESSAGE MAPPINGS
// ========================================
interface ErrorMessage {
en: string;
kmr: string; // Kurmanji
}
/**
* User-friendly error messages for common blockchain errors
* Key format: "palletName.errorName"
*/
const ERROR_MESSAGES: Record<string, ErrorMessage> = {
// Staking errors
'staking.InsufficientBond': {
en: 'Bond amount too small. Please check minimum staking requirement.',
kmr: 'Mîqdara bond zêde piçûk e. Ji kerema xwe mîqdara kêmtirîn kontrol bike.',
},
'staking.AlreadyBonded': {
en: 'You have already bonded tokens. Use "Bond More" to add additional stake.',
kmr: 'We berê token bond kirine. Ji bo zêdekirin "Bond More" bikar bîne.',
},
'staking.NotStash': {
en: 'This account is not a stash account. Please use your staking controller.',
kmr: 'Ev account stash nîne. Ji kerema xwe controller bikar bîne.',
},
'staking.NoMoreChunks': {
en: 'Too many unbonding chunks. Please wait for previous unbondings to complete.',
kmr: 'Zêde chunk unbonding hene. Ji kerema xwe li çavkaniyên berê bisekine.',
},
// Identity KYC errors
'identityKyc.AlreadyApplied': {
en: 'You already have a pending citizenship application. Please wait for approval.',
kmr: 'We berê serlêdana welatîtiyê heye. Ji kerema xwe li pejirandina bisekine.',
},
'identityKyc.AlreadyApproved': {
en: 'Your citizenship application is already approved!',
kmr: 'Serlêdana welatîtiya we berê hatiye pejirandin!',
},
'identityKyc.NotApproved': {
en: 'Your KYC is not approved yet. Please complete citizenship application first.',
kmr: 'KYC-ya we hîn nehatiye pejirandin. Pêşî serlêdana welatîtiyê temam bike.',
},
'identityKyc.IdentityNotSet': {
en: 'Please set your identity information first.',
kmr: 'Ji kerema xwe pêşî agahdariya nasnameya xwe saz bike.',
},
// Tiki errors
'tiki.RoleAlreadyAssigned': {
en: 'This role is already assigned to the user.',
kmr: 'Ev rol berê ji bikarhêner re hatiye veqetandin.',
},
'tiki.UnauthorizedRoleAssignment': {
en: 'You do not have permission to assign this role.',
kmr: 'We destûra veqetandina vê rolê nîne.',
},
'tiki.RoleNotFound': {
en: 'The specified role does not exist.',
kmr: 'Rola diyarkirî tune ye.',
},
// ValidatorPool errors
'validatorPool.AlreadyInPool': {
en: 'You are already registered in the validator pool.',
kmr: 'We berê di pool-a validator de tomar bûyî.',
},
'validatorPool.NotInPool': {
en: 'You are not registered in the validator pool.',
kmr: 'We di pool-a validator de tomar nebûyî.',
},
'validatorPool.InsufficientStake': {
en: 'Insufficient stake for validator pool. Please increase your stake.',
kmr: 'Stake ji bo pool-a validator kêm e. Ji kerema xwe stake-ya xwe zêde bike.',
},
// DEX/AssetConversion errors
'assetConversion.PoolNotFound': {
en: 'Liquidity pool not found for this token pair.',
kmr: 'Pool-a liquidity ji bo vê cuda-token nehat dîtin.',
},
'assetConversion.InsufficientLiquidity': {
en: 'Insufficient liquidity in pool. Try a smaller amount.',
kmr: 'Liquidity-ya pool-ê kêm e. Mîqdareke piçûktir biceribîne.',
},
'assetConversion.SlippageTooHigh': {
en: 'Price impact too high. Increase slippage tolerance or reduce amount.',
kmr: 'Bandora bihayê zêde mezin e. Toleransa slippage zêde bike an mîqdarê kêm bike.',
},
'assetConversion.AmountTooSmall': {
en: 'Swap amount too small. Minimum swap amount not met.',
kmr: 'Mîqdara swap zêde piçûk e. Mîqdara kêmtirîn nehatiye gihîştin.',
},
// Balance/Asset errors
'balances.InsufficientBalance': {
en: 'Insufficient balance. You do not have enough tokens for this transaction.',
kmr: 'Balance-ya we kêm e. Ji bo vê transaction token-ên we têr nînin.',
},
'balances.ExistentialDeposit': {
en: 'Amount is below existential deposit. Account would be reaped.',
kmr: 'Mîqdar ji existential deposit kêmtir e. Account dê were jêbirin.',
},
'assets.BalanceLow': {
en: 'Asset balance too low for this operation.',
kmr: 'Balance-ya asset-ê ji bo vê operation zêde kêm e.',
},
'assets.NoPermission': {
en: 'You do not have permission to perform this operation on this asset.',
kmr: 'We destûra vê operation-ê li ser vê asset-ê nîne.',
},
// Governance errors
'referenda.NotOngoing': {
en: 'This referendum is not currently active.',
kmr: 'Ev referendum niha ne çalak e.',
},
'referenda.AlreadyVoted': {
en: 'You have already voted on this referendum.',
kmr: 'We berê li ser vê referendum-ê deng da.',
},
'convictionVoting.NotVoter': {
en: 'You are not eligible to vote. Citizenship required.',
kmr: 'We mafê dengdanê nîne. Welatîtî pêwîst e.',
},
// Treasury errors
'treasury.InsufficientProposersBalance': {
en: 'Insufficient balance to submit treasury proposal. Bond required.',
kmr: 'Ji bo pêşniyara treasury-yê balance kêm e. Bond pêwîst e.',
},
// Welati (Elections & Governance) errors
'welati.ElectionNotFound': {
en: 'Election not found. Please check the election ID.',
kmr: 'Hilbijartin nehat dîtin. Ji kerema xwe ID-ya hilbijartinê kontrol bike.',
},
'welati.ElectionNotActive': {
en: 'This election is not currently active.',
kmr: 'Ev hilbijartin niha ne çalak e.',
},
'welati.CandidacyPeriodExpired': {
en: 'Candidate registration period has ended.',
kmr: 'Dema qeydkirina berendaman qediya.',
},
'welati.VotingPeriodNotStarted': {
en: 'Voting period has not started yet. Please wait.',
kmr: 'Dema dengdanê hîn dest pê nekiriye. Ji kerema xwe bisekine.',
},
'welati.VotingPeriodExpired': {
en: 'Voting period has ended.',
kmr: 'Dema dengdanê qediya.',
},
'welati.AlreadyCandidate': {
en: 'You are already registered as a candidate in this election.',
kmr: 'We berê wekî berendam di vê hilbijartinê de tomar bûyî.',
},
'welati.AlreadyVoted': {
en: 'You have already voted in this election.',
kmr: 'We berê di vê hilbijartinê de deng da.',
},
'welati.InsufficientEndorsements': {
en: 'Insufficient endorsements. You need more citizen supporters.',
kmr: 'Piştgiriya têr tune. We piştgiriya zêdetir ji welatiyên pêwîst e.',
},
'welati.InsufficientTrustScore': {
en: 'Your trust score is too low for this election. Build your reputation first.',
kmr: 'Skora emîniya we ji bo vê hilbijartinê zêde kêm e. Pêşî navê xwe baş bike.',
},
'welati.NotACitizen': {
en: 'You must be a verified citizen (KYC approved) to participate.',
kmr: 'Divê we welatiyeke pejirandî (KYC pejirandî) bin da beşdar bibin.',
},
'welati.DepositRequired': {
en: 'Candidacy deposit required. Please pay the registration fee.',
kmr: 'Depozîta berendamiyê pêwîst e. Ji kerema xwe lêçûna qeydkirinê bidin.',
},
'welati.NotAuthorizedToNominate': {
en: 'You are not authorized to nominate officials. Minister or President only.',
kmr: 'We destûra hilbijartina karbidestan nîne. Tenê Wezîr an Serok.',
},
'welati.NotAuthorizedToApprove': {
en: 'Only the President can approve appointments.',
kmr: 'Tenê Serok dikare bicîhbûnan bipejirîne.',
},
'welati.NotAuthorizedToPropose': {
en: 'You are not authorized to submit proposals. Parliament members only.',
kmr: 'We destûra pêşniyaran pêşkêş kirinê nîne. Tenê endamên parlamentoyê.',
},
'welati.NotAuthorizedToVote': {
en: 'You are not authorized to vote on this proposal.',
kmr: 'We destûra dengdanê li ser vê pêşniyarê nîne.',
},
'welati.ProposalNotFound': {
en: 'Proposal not found. Please check the proposal ID.',
kmr: 'Pêşniyar nehat dîtin. Ji kerema xwe ID-ya pêşniyarê kontrol bike.',
},
'welati.ProposalNotActive': {
en: 'This proposal is not currently active or voting has ended.',
kmr: 'Ev pêşniyar niha ne çalak e an dengdan qediya.',
},
'welati.ProposalAlreadyVoted': {
en: 'You have already voted on this proposal.',
kmr: 'We berê li ser vê pêşniyarê deng da.',
},
'welati.QuorumNotMet': {
en: 'Quorum not met. Insufficient participation for this decision.',
kmr: 'Quorum nehat bidest xistin. Beşdariya têr ji bo vê biryarê tune ye.',
},
'welati.InvalidDistrict': {
en: 'Invalid electoral district. Please select a valid district.',
kmr: 'Qeza hilbijartinê nederbasdar e. Ji kerema xwe qezayeke derbasdar hilbijêre.',
},
'welati.RoleAlreadyFilled': {
en: 'This government position is already filled.',
kmr: 'Ev pozîsyona hukûmetê berê hatiye dagirtin.',
},
// Perwerde (Education) errors
'perwerde.CourseNotFound': {
en: 'Course not found. Please check the course ID.',
kmr: 'Ders nehat dîtin. Ji kerema xwe ID-ya dersê kontrol bike.',
},
'perwerde.AlreadyEnrolled': {
en: 'You are already enrolled in this course.',
kmr: 'We berê di vî dersê de tomar bûyî.',
},
'perwerde.NotEnrolled': {
en: 'You must enroll in this course first before completing it.',
kmr: 'Pêşî divê we di vî dersê de tomar bibin da ku temam bikin.',
},
'perwerde.CourseNotActive': {
en: 'This course is archived and no longer accepting enrollments.',
kmr: 'Ev ders di arşîvê de ye û êdî tomaran qebûl nake.',
},
'perwerde.CourseAlreadyCompleted': {
en: 'You have already completed this course.',
kmr: 'We berê ev ders temam kiriye.',
},
'perwerde.NotCourseOwner': {
en: 'Only the course owner can perform this action.',
kmr: 'Tenê xwediyê dersê dikare vê çalakiyê bike.',
},
'perwerde.TooManyCourses': {
en: 'Course enrollment limit reached. Please complete some courses first.',
kmr: 'Sînorê tomarkirina dersê gihîşt. Ji kerema xwe pêşî hin dersan temam bikin.',
},
// System/General errors
'system.CallFiltered': {
en: 'This action is not permitted by the system filters.',
kmr: 'Ev çalakî ji hêla fîltireyên sîstemê ve nayê destûrdan.',
},
'BadOrigin': {
en: 'Unauthorized: You do not have permission for this action.',
kmr: 'Destûrnîn: We destûra vê çalakiyê nîne.',
},
'Module': {
en: 'A blockchain module error occurred. Please try again.',
kmr: 'Xeletiya module-ya blockchain-ê qewimî. Ji kerema xwe dîsa biceribîne.',
},
};
// ========================================
// ERROR EXTRACTION & FORMATTING
// ========================================
/**
* Extract error information from DispatchError
*/
export function extractDispatchError(
api: ApiPromise,
dispatchError: DispatchError
): {
section: string;
name: string;
docs: string;
raw: string;
} {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
return {
section: decoded.section,
name: decoded.name,
docs: decoded.docs.join(' ').trim(),
raw: `${decoded.section}.${decoded.name}`,
};
} else {
return {
section: 'Unknown',
name: dispatchError.type,
docs: dispatchError.toString(),
raw: dispatchError.toString(),
};
}
}
/**
* Get user-friendly error message
* Falls back to blockchain docs if no custom message exists
*/
export function getUserFriendlyError(
api: ApiPromise,
dispatchError: DispatchError,
language: 'en' | 'kmr' = 'en'
): string {
const errorInfo = extractDispatchError(api, dispatchError);
const errorKey = errorInfo.raw;
// Check if we have a custom message
const customMessage = ERROR_MESSAGES[errorKey];
if (customMessage) {
return customMessage[language];
}
// Fallback to blockchain documentation
if (errorInfo.docs && errorInfo.docs.length > 0) {
return errorInfo.docs;
}
// Final fallback
return `Transaction failed: ${errorInfo.section}.${errorInfo.name}`;
}
// ========================================
// TOAST HELPER
// ========================================
export interface ToastFunction {
(options: {
title: string;
description: string;
variant?: 'default' | 'destructive';
}): void;
}
/**
* Handle blockchain error with toast notification
* Automatically extracts user-friendly message
*/
export function handleBlockchainError(
error: any,
api: ApiPromise | null,
toast: ToastFunction,
language: 'en' | 'kmr' = 'en'
): void {
console.error('Blockchain error:', error);
// If it's a dispatch error from transaction callback
if (error?.isModule !== undefined && api) {
const userMessage = getUserFriendlyError(api, error, language);
toast({
title: language === 'en' ? 'Transaction Failed' : 'Transaction Têk Çû',
description: userMessage,
variant: 'destructive',
});
return;
}
// If it's a standard error object
if (error?.message) {
toast({
title: language === 'en' ? 'Error' : 'Xeletî',
description: error.message,
variant: 'destructive',
});
return;
}
// If it's a string
if (typeof error === 'string') {
toast({
title: language === 'en' ? 'Error' : 'Xeletî',
description: error,
variant: 'destructive',
});
return;
}
// Generic fallback
toast({
title: language === 'en' ? 'Error' : 'Xeletî',
description:
language === 'en'
? 'An unexpected error occurred. Please try again.'
: 'Xeletîyek nediyar qewimî. Ji kerema xwe dîsa biceribîne.',
variant: 'destructive',
});
}
// ========================================
// SUCCESS MESSAGES
// ========================================
export interface SuccessMessage {
en: string;
kmr: string;
}
export const SUCCESS_MESSAGES: Record<string, SuccessMessage> = {
// Staking
'staking.bonded': {
en: 'Successfully staked {{amount}} HEZ. Rewards will start in the next era.',
kmr: '{{amount}} HEZ bi serkeftî stake kirin. Xelat di era pêşîn de dest pê dike.',
},
'staking.unbonded': {
en: 'Unbonded {{amount}} HEZ. Withdrawal available in {{days}} days.',
kmr: '{{amount}} HEZ unbond kirin. Di {{days}} rojan de derbasdarî dibe.',
},
'staking.nominated': {
en: 'Successfully nominated {{count}} validators.',
kmr: 'Bi serkeftî {{count}} validator nomînekirin.',
},
'staking.scoreStarted': {
en: 'Staking score tracking started! Your score will accumulate over time.',
kmr: 'Şopa staking dest pê kir! Xala we dê bi demê re kom bibe.',
},
// Citizenship
'citizenship.applied': {
en: 'Citizenship application submitted successfully! We will review your application.',
kmr: 'Serlêdana welatîtiyê bi serkeftî hate şandin! Em ê serlêdana we binirxînin.',
},
// Governance
'governance.voted': {
en: 'Your vote has been recorded successfully!',
kmr: 'Deng-a we bi serkeftî hate tomarkirin!',
},
'governance.proposed': {
en: 'Proposal submitted successfully! Voting will begin soon.',
kmr: 'Pêşniyar bi serkeftî hate şandin! Dengdan hêdî dest pê dike.',
},
// DEX
'dex.swapped': {
en: 'Successfully swapped {{from}} {{fromToken}} for {{to}} {{toToken}}',
kmr: 'Bi serkeftî {{from}} {{fromToken}} bo {{to}} {{toToken}} guhertin',
},
'dex.liquidityAdded': {
en: 'Successfully added liquidity to the pool!',
kmr: 'Bi serkeftî liquidity li pool-ê zêde kir!',
},
'dex.liquidityRemoved': {
en: 'Successfully removed liquidity from the pool!',
kmr: 'Bi serkeftî liquidity ji pool-ê derxist!',
},
// Welati (Elections & Governance)
'welati.candidateRegistered': {
en: 'Successfully registered as candidate! Deposit: {{deposit}} HEZ. Good luck!',
kmr: 'Bi serkeftî wekî berendam tomar bûn! Depozît: {{deposit}} HEZ. Serkeftinê!',
},
'welati.voteCast': {
en: 'Your vote has been cast successfully! Thank you for participating.',
kmr: 'Deng-a we bi serkeftî hate dayîn! Spas ji bo beşdarî bûnê.',
},
'welati.proposalSubmitted': {
en: 'Proposal submitted successfully! Voting period: {{days}} days.',
kmr: 'Pêşniyar bi serkeftî hate şandin! Dema dengdanê: {{days}} roj.',
},
'welati.proposalVoted': {
en: 'Vote recorded on proposal #{{id}}. Your voice matters!',
kmr: 'Deng li ser pêşniyara #{{id}} tomar bû. Deng-a we girîng e!',
},
'welati.officialNominated': {
en: 'Official nominated successfully! Awaiting presidential approval.',
kmr: 'Karbides bi serkeftî hate hilbijartin! Li pejirandina serokê bisekine.',
},
'welati.appointmentApproved': {
en: 'Appointment approved! {{nominee}} is now {{role}}.',
kmr: 'Bicîhbûn pejirandî! {{nominee}} niha {{role}} ye.',
},
'welati.electionFinalized': {
en: 'Election finalized! {{winners}} elected. Turnout: {{turnout}}%',
kmr: 'Hilbijartin temam bû! {{winners}} hate hilbijartin. Beşdarî: {{turnout}}%',
},
// Perwerde (Education)
'perwerde.courseCreated': {
en: 'Course "{{name}}" created successfully! Course ID: #{{id}}',
kmr: 'Dersa "{{name}}" bi serkeftî hate afirandin! ID-ya Dersê: #{{id}}',
},
'perwerde.enrolled': {
en: 'Successfully enrolled in course! Start learning now.',
kmr: 'Bi serkeftî di dersê de tomar bûn! Niha dest bi hînbûnê bike.',
},
'perwerde.completed': {
en: 'Congratulations! Course completed. Points earned: {{points}}',
kmr: 'Pîroz be! Ders temam bû. Xalên bidestxistî: {{points}}',
},
'perwerde.archived': {
en: 'Course archived successfully. No new enrollments will be accepted.',
kmr: 'Ders bi serkeftî hate arşîvkirin. Tomarên nû nayên qebûlkirin.',
},
};
/**
* Handle successful blockchain transaction
*/
export function handleBlockchainSuccess(
messageKey: string,
toast: ToastFunction,
params: Record<string, string | number> = {},
language: 'en' | 'kmr' = 'en'
): void {
const template = SUCCESS_MESSAGES[messageKey];
if (!template) {
toast({
title: language === 'en' ? 'Success' : 'Serkeft',
description: language === 'en' ? 'Transaction successful!' : 'Transaction serkeftî!',
});
return;
}
// Replace template variables like {{amount}}
let message = template[language];
Object.entries(params).forEach(([key, value]) => {
message = message.replace(new RegExp(`{{${key}}}`, 'g'), String(value));
});
toast({
title: language === 'en' ? 'Success' : 'Serkeft',
description: message,
});
}
+382
View File
@@ -0,0 +1,382 @@
// ========================================
// Route Guards & Permission Checking
// ========================================
// Functions to check user permissions for protected routes
import type { ApiPromise } from '@polkadot/api';
// ========================================
// CITIZENSHIP STATUS CHECK
// ========================================
/**
* Check if user has approved citizenship (KYC approved)
* Required for: Voting, Education, Validator Pool, etc.
*/
export async function checkCitizenStatus(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
if (!api || !address) {
return false;
}
try {
// Check if Identity KYC pallet exists
if (!api.query?.identityKyc?.kycStatuses) {
console.warn('Identity KYC pallet not available');
return false;
}
const kycStatus = await api.query.identityKyc.kycStatuses(address);
if (kycStatus.isEmpty) {
return false;
}
const statusStr = kycStatus.toString();
return statusStr === 'Approved';
} catch (error) {
console.error('Error checking citizen status:', error);
return false;
}
}
// ========================================
// VALIDATOR POOL STATUS CHECK
// ========================================
/**
* Check if user is registered in validator pool
* Required for: Validator Pool dashboard, validator settings
*/
export async function checkValidatorStatus(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
if (!api || !address) {
return false;
}
try {
// Check if ValidatorPool pallet exists
if (!api.query?.validatorPool?.poolMembers) {
console.warn('ValidatorPool pallet not available');
return false;
}
const poolMember = await api.query.validatorPool.poolMembers(address);
return !poolMember.isEmpty;
} catch (error) {
console.error('Error checking validator status:', error);
return false;
}
}
// ========================================
// TIKI ROLE CHECK
// ========================================
// Tiki role enum mapping (from pallet-tiki)
const TIKI_ROLES = [
'Hemwelatî', // 0 - Citizen
'Parlementer', // 1 - Parliament Member
'SerokiMeclise', // 2 - Speaker of Parliament
'Serok', // 3 - President
'Wezir', // 4 - Minister
'EndameDiwane', // 5 - Dîwan Member (Constitutional Court)
'Dadger', // 6 - Judge
'Dozger', // 7 - Prosecutor
'Hiquqnas', // 8 - Lawyer
'Noter', // 9 - Notary
'Xezinedar', // 10 - Treasurer
'Bacgir', // 11 - Tax Collector
'GerinendeyeCavkaniye',// 12 - Resource Manager
'OperatorêTorê', // 13 - Network Operator
'PisporêEwlehiyaSîber',// 14 - Cyber Security Expert
'GerinendeyeDaneye', // 15 - Data Manager
'Berdevk', // 16 - Spokesperson
'Qeydkar', // 17 - Registrar
'Balyoz', // 18 - Ambassador
'Navbeynkar', // 19 - Mediator
'ParêzvaneÇandî', // 20 - Cultural Protector
'Mufetîs', // 21 - Inspector
'KalîteKontrolker', // 22 - Quality Controller
'Mela', // 23 - Mullah
'Feqî', // 24 - Religious Scholar
'Perwerdekar', // 25 - Educator
'Rewsenbîr', // 26 - Intellectual
'RêveberêProjeyê', // 27 - Project Manager
'SerokêKomele', // 28 - Community Leader
'ModeratorêCivakê', // 29 - Society Moderator
'Axa', // 30 - Lord/Landowner
'Pêseng', // 31 - Pioneer
'Sêwirmend', // 32 - Counselor
'Hekem', // 33 - Wise Person
'Mamoste', // 34 - Teacher
'Bazargan', // 35 - Merchant
'SerokWeziran', // 36 - Prime Minister
'WezireDarayiye', // 37 - Finance Minister
'WezireParez', // 38 - Defense Minister
'WezireDad', // 39 - Justice Minister
'WezireBelaw', // 40 - Publication Minister
'WezireTend', // 41 - Health Minister
'WezireAva', // 42 - Infrastructure Minister
'WezireCand', // 43 - Education Minister
];
/**
* Check if user has specific Tiki role
* @param role - Kurdish name of role (e.g., 'Hemwelatî', 'Perwerdekar')
*/
export async function checkTikiRole(
api: ApiPromise | null,
address: string | null | undefined,
role: string
): Promise<boolean> {
if (!api || !address) {
return false;
}
try {
// Check if Tiki pallet exists
if (!api.query?.tiki?.userTikis) {
console.warn('Tiki pallet not available');
return false;
}
const tikis = await api.query.tiki.userTikis(address);
if (tikis.isEmpty) {
return false;
}
// userTikis returns BoundedVec of Tiki enum indices
const tikiIndices = tikis.toJSON() as number[];
// Find role index
const roleIndex = TIKI_ROLES.indexOf(role);
if (roleIndex === -1) {
console.warn(`Unknown Tiki role: ${role}`);
return false;
}
// Check if user has this role
return tikiIndices.includes(roleIndex);
} catch (error) {
console.error('Error checking Tiki role:', error);
return false;
}
}
/**
* Check if user has ANY Tiki role from a list
* Useful for checking multiple acceptable roles
*/
export async function checkAnyTikiRole(
api: ApiPromise | null,
address: string | null | undefined,
roles: string[]
): Promise<boolean> {
if (!api || !address) {
return false;
}
try {
for (const role of roles) {
const hasRole = await checkTikiRole(api, address, role);
if (hasRole) {
return true;
}
}
return false;
} catch (error) {
console.error('Error checking any Tiki role:', error);
return false;
}
}
/**
* Check if user is an educator (Perwerdekar)
* Required for: Creating courses in Perwerde
*/
export async function checkEducatorRole(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
return checkAnyTikiRole(api, address, [
'Perwerdekar', // Educator
'Mamoste', // Teacher
'WezireCand', // Education Minister
'Rewsenbîr', // Intellectual
]);
}
/**
* Check if user can moderate (ModeratorêCivakê or higher)
* Required for: Forum moderation, governance moderation
*/
export async function checkModeratorRole(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
return checkAnyTikiRole(api, address, [
'ModeratorêCivakê', // Society Moderator
'Berdevk', // Spokesperson
'Serok', // President
'SerokWeziran', // Prime Minister
]);
}
/**
* Check if user can participate in governance (citizen or higher)
* Required for: Voting, proposing, elections
*/
export async function checkGovernanceParticipation(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
// Any citizen with approved KYC can participate
return checkCitizenStatus(api, address);
}
/**
* Check if user can create proposals
* Required for: Creating referendum proposals
*/
export async function checkProposalCreationRights(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
// Citizen + certain roles can create proposals
const isCitizen = await checkCitizenStatus(api, address);
if (!isCitizen) {
return false;
}
// Additional check: has any leadership role
return checkAnyTikiRole(api, address, [
'Parlementer', // Parliament Member
'SerokiMeclise', // Speaker
'Serok', // President
'SerokWeziran', // Prime Minister
'Wezir', // Minister
'SerokêKomele', // Community Leader
'RêveberêProjeyê', // Project Manager
]);
}
// ========================================
// STAKING SCORE CHECK
// ========================================
/**
* Check if user has started staking score tracking
* Required for: Advanced staking features
*/
export async function checkStakingScoreTracking(
api: ApiPromise | null,
address: string | null | undefined
): Promise<boolean> {
if (!api || !address) {
return false;
}
try {
if (!api.query?.stakingScore?.stakingStartBlock) {
console.warn('Staking score pallet not available');
return false;
}
const startBlock = await api.query.stakingScore.stakingStartBlock(address);
return !startBlock.isNone;
} catch (error) {
console.error('Error checking staking score tracking:', error);
return false;
}
}
// ========================================
// COMBINED PERMISSION CHECKS
// ========================================
export interface UserPermissions {
isCitizen: boolean;
isValidator: boolean;
hasStakingScore: boolean;
canVote: boolean;
canCreateProposals: boolean;
canModerate: boolean;
canCreateCourses: boolean;
tikis: string[];
}
/**
* Get all user permissions at once
* Useful for dashboard/profile pages
*/
export async function getUserPermissions(
api: ApiPromise | null,
address: string | null | undefined
): Promise<UserPermissions> {
if (!api || !address) {
return {
isCitizen: false,
isValidator: false,
hasStakingScore: false,
canVote: false,
canCreateProposals: false,
canModerate: false,
canCreateCourses: false,
tikis: [],
};
}
try {
// Fetch all in parallel
const [
isCitizen,
isValidator,
hasStakingScore,
canCreateProposals,
canModerate,
canCreateCourses,
tikiData,
] = await Promise.all([
checkCitizenStatus(api, address),
checkValidatorStatus(api, address),
checkStakingScoreTracking(api, address),
checkProposalCreationRights(api, address),
checkModeratorRole(api, address),
checkEducatorRole(api, address),
api.query?.tiki?.userTikis?.(address),
]);
// Parse tikis
const tikiIndices = tikiData?.isEmpty ? [] : (tikiData?.toJSON() as number[]);
const tikis = tikiIndices.map((index) => TIKI_ROLES[index] || `Unknown(${index})`);
return {
isCitizen,
isValidator,
hasStakingScore,
canVote: isCitizen, // Citizens can vote
canCreateProposals,
canModerate,
canCreateCourses,
tikis,
};
} catch (error) {
console.error('Error getting user permissions:', error);
return {
isCitizen: false,
isValidator: false,
hasStakingScore: false,
canVote: false,
canCreateProposals: false,
canModerate: false,
canCreateCourses: false,
tikis: [],
};
}
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Perwerde (Education) Pallet Integration
*
* This module provides helper functions for interacting with the Perwerde pallet,
* which handles:
* - Course creation and management
* - Student enrollment
* - Course completion tracking
* - Education points/scores
*/
import type { ApiPromise } from '@polkadot/api';
import type { Option } from '@polkadot/types';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
export type CourseStatus = 'Active' | 'Archived';
export interface Course {
id: number;
owner: string;
name: string;
description: string;
contentLink: string;
status: CourseStatus;
createdAt: number;
}
export interface Enrollment {
student: string;
courseId: number;
enrolledAt: number;
completedAt?: number;
pointsEarned: number;
isCompleted: boolean;
}
export interface StudentProgress {
totalCourses: number;
completedCourses: number;
totalPoints: number;
activeCourses: number;
}
// ============================================================================
// QUERY FUNCTIONS (Read-only)
// ============================================================================
/**
* Get all courses (active and archived)
*/
export async function getAllCourses(api: ApiPromise): Promise<Course[]> {
const nextId = await api.query.perwerde.nextCourseId();
const currentId = (nextId.toJSON() as number) || 0;
const courses: Course[] = [];
for (let i = 0; i < currentId; i++) {
const courseOption = await api.query.perwerde.courses(i);
if (courseOption.isSome) {
const courseData = courseOption.unwrap().toJSON() as any;
courses.push({
id: i,
owner: courseData.owner,
name: hexToString(courseData.name),
description: hexToString(courseData.description),
contentLink: hexToString(courseData.contentLink),
status: courseData.status as CourseStatus,
createdAt: courseData.createdAt,
});
}
}
return courses;
}
/**
* Get active courses only
*/
export async function getActiveCourses(api: ApiPromise): Promise<Course[]> {
const allCourses = await getAllCourses(api);
return allCourses.filter((course) => course.status === 'Active');
}
/**
* Get course by ID
*/
export async function getCourseById(api: ApiPromise, courseId: number): Promise<Course | null> {
const courseOption = await api.query.perwerde.courses(courseId);
if (courseOption.isNone) {
return null;
}
const courseData = courseOption.unwrap().toJSON() as any;
return {
id: courseId,
owner: courseData.owner,
name: hexToString(courseData.name),
description: hexToString(courseData.description),
contentLink: hexToString(courseData.contentLink),
status: courseData.status as CourseStatus,
createdAt: courseData.createdAt,
};
}
/**
* Get student's enrolled courses
*/
export async function getStudentCourses(api: ApiPromise, studentAddress: string): Promise<number[]> {
const coursesOption = await api.query.perwerde.studentCourses(studentAddress);
if (coursesOption.isNone || coursesOption.isEmpty) {
return [];
}
return (coursesOption.toJSON() as number[]) || [];
}
/**
* Get enrollment details for a student in a specific course
*/
export async function getEnrollment(
api: ApiPromise,
studentAddress: string,
courseId: number
): Promise<Enrollment | null> {
const enrollmentOption = await api.query.perwerde.enrollments([studentAddress, courseId]);
if (enrollmentOption.isNone) {
return null;
}
const enrollmentData = enrollmentOption.unwrap().toJSON() as any;
return {
student: enrollmentData.student,
courseId: enrollmentData.courseId,
enrolledAt: enrollmentData.enrolledAt,
completedAt: enrollmentData.completedAt || undefined,
pointsEarned: enrollmentData.pointsEarned || 0,
isCompleted: !!enrollmentData.completedAt,
};
}
/**
* Get student's progress summary
*/
export async function getStudentProgress(api: ApiPromise, studentAddress: string): Promise<StudentProgress> {
const courseIds = await getStudentCourses(api, studentAddress);
let completedCourses = 0;
let totalPoints = 0;
for (const courseId of courseIds) {
const enrollment = await getEnrollment(api, studentAddress, courseId);
if (enrollment) {
if (enrollment.isCompleted) {
completedCourses++;
totalPoints += enrollment.pointsEarned;
}
}
}
return {
totalCourses: courseIds.length,
completedCourses,
totalPoints,
activeCourses: courseIds.length - completedCourses,
};
}
/**
* Get Perwerde score for a student (sum of all earned points)
*/
export async function getPerwerdeScore(api: ApiPromise, studentAddress: string): Promise<number> {
try {
// Try to call the get_perwerde_score runtime API
// This might not exist in all versions, fallback to manual calculation
const score = await api.call.perwerdeApi?.getPerwerdeScore(studentAddress);
return score ? (score.toJSON() as number) : 0;
} catch (error) {
// Fallback: manually sum all points
const progress = await getStudentProgress(api, studentAddress);
return progress.totalPoints;
}
}
/**
* Check if student is enrolled in a course
*/
export async function isEnrolled(
api: ApiPromise,
studentAddress: string,
courseId: number
): Promise<boolean> {
const enrollment = await getEnrollment(api, studentAddress, courseId);
return enrollment !== null;
}
/**
* Get course enrollment statistics
*/
export async function getCourseStats(
api: ApiPromise,
courseId: number
): Promise<{
totalEnrollments: number;
completions: number;
averagePoints: number;
}> {
// Note: This requires iterating through all enrollments, which can be expensive
// In production, consider caching or maintaining separate counters
const entries = await api.query.perwerde.enrollments.entries();
let totalEnrollments = 0;
let completions = 0;
let totalPoints = 0;
for (const [key, value] of entries) {
const enrollmentData = value.toJSON() as any;
const enrollmentCourseId = (key.args[1] as any).toNumber();
if (enrollmentCourseId === courseId) {
totalEnrollments++;
if (enrollmentData.completedAt) {
completions++;
totalPoints += enrollmentData.pointsEarned || 0;
}
}
}
return {
totalEnrollments,
completions,
averagePoints: completions > 0 ? Math.round(totalPoints / completions) : 0,
};
}
// ============================================================================
// TRANSACTION FUNCTIONS
// ============================================================================
/**
* Create a new course
* @requires AdminOrigin (only admin can create courses in current implementation)
*/
export async function createCourse(
api: ApiPromise,
signer: any,
name: string,
description: string,
contentLink: string
): Promise<void> {
const tx = api.tx.perwerde.createCourse(name, description, contentLink);
return new Promise((resolve, reject) => {
tx.signAndSend(signer, ({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
reject(dispatchError);
} else {
resolve();
}
}
});
});
}
/**
* Enroll in a course
*/
export async function enrollInCourse(
api: ApiPromise,
signerAddress: string,
courseId: number
): Promise<void> {
const tx = api.tx.perwerde.enroll(courseId);
return new Promise((resolve, reject) => {
tx.signAndSend(signerAddress, ({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
reject(dispatchError);
} else {
resolve();
}
}
});
});
}
/**
* Complete a course
* @requires Course owner to call this for student
*/
export async function completeCourse(
api: ApiPromise,
signer: any,
studentAddress: string,
courseId: number,
points: number
): Promise<void> {
const tx = api.tx.perwerde.completeCourse(courseId, points);
return new Promise((resolve, reject) => {
tx.signAndSend(signer, ({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
reject(dispatchError);
} else {
resolve();
}
}
});
});
}
/**
* Archive a course
* @requires Course owner
*/
export async function archiveCourse(
api: ApiPromise,
signer: any,
courseId: number
): Promise<void> {
const tx = api.tx.perwerde.archiveCourse(courseId);
return new Promise((resolve, reject) => {
tx.signAndSend(signer, ({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
reject(dispatchError);
} else {
resolve();
}
}
});
});
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Convert hex string to UTF-8 string
*/
function hexToString(hex: any): string {
if (!hex) return '';
// If it's already a string, return it
if (typeof hex === 'string' && !hex.startsWith('0x')) {
return hex;
}
// If it's a hex string, convert it
const hexStr = hex.toString().replace(/^0x/, '');
let str = '';
for (let i = 0; i < hexStr.length; i += 2) {
const code = parseInt(hexStr.substr(i, 2), 16);
if (code !== 0) {
// Skip null bytes
str += String.fromCharCode(code);
}
}
return str.trim();
}
/**
* Get course difficulty label (based on points threshold)
*/
export function getCourseDifficulty(averagePoints: number): {
label: string;
color: string;
} {
if (averagePoints >= 100) {
return { label: 'Advanced', color: 'red' };
} else if (averagePoints >= 50) {
return { label: 'Intermediate', color: 'yellow' };
} else {
return { label: 'Beginner', color: 'green' };
}
}
/**
* Format IPFS link to gateway URL
*/
export function formatIPFSLink(ipfsHash: string): string {
if (!ipfsHash) return '';
// If already a full URL, return it
if (ipfsHash.startsWith('http')) {
return ipfsHash;
}
// If starts with ipfs://, convert to gateway
if (ipfsHash.startsWith('ipfs://')) {
const hash = ipfsHash.replace('ipfs://', '');
return `https://ipfs.io/ipfs/${hash}`;
}
// If it's just a hash, add gateway
return `https://ipfs.io/ipfs/${ipfsHash}`;
}
+616
View File
@@ -0,0 +1,616 @@
/**
* Welati (Elections & Governance) Pallet Integration
*
* This module provides helper functions for interacting with the Welati pallet,
* which handles:
* - Presidential and Parliamentary Elections
* - Speaker and Constitutional Court Elections
* - Official Appointments (Ministers, Diwan)
* - Collective Proposals (Parliament/Diwan voting)
*/
import type { ApiPromise } from '@polkadot/api';
import type { Option, Vec } from '@polkadot/types';
import type { AccountId, BlockNumber } from '@polkadot/types/interfaces';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
export type ElectionType = 'Presidential' | 'Parliamentary' | 'SpeakerElection' | 'ConstitutionalCourt';
export type ElectionStatus = 'CandidacyPeriod' | 'CampaignPeriod' | 'VotingPeriod' | 'Completed';
export type VoteChoice = 'Aye' | 'Nay' | 'Abstain';
export type CollectiveDecisionType =
| 'ParliamentSimpleMajority'
| 'ParliamentSuperMajority'
| 'ParliamentAbsoluteMajority'
| 'ConstitutionalReview'
| 'ConstitutionalUnanimous'
| 'ExecutiveDecision';
export type ProposalPriority = 'Urgent' | 'High' | 'Normal' | 'Low';
export type ProposalStatus = 'Active' | 'Approved' | 'Rejected' | 'Expired' | 'Executed';
export type MinisterRole =
| 'WezireDarayiye' // Finance
| 'WezireParez' // Defense
| 'WezireDad' // Justice
| 'WezireBelaw' // Education
| 'WezireTend' // Health
| 'WezireAva' // Water Resources
| 'WezireCand'; // Culture
export type GovernmentPosition = 'Serok' | 'SerokWeziran' | 'MeclisBaskanı';
export interface ElectionInfo {
electionId: number;
electionType: ElectionType;
status: ElectionStatus;
startBlock: number;
candidacyEndBlock: number;
campaignEndBlock: number;
votingEndBlock: number;
totalCandidates: number;
totalVotes: number;
turnoutPercentage: number;
districtCount?: number;
}
export interface CandidateInfo {
account: string;
districtId?: number;
registeredAt: number;
endorsersCount: number;
voteCount: number;
depositPaid: string;
}
export interface ElectionResult {
electionId: number;
winners: string[];
totalVotes: number;
turnoutPercentage: number;
finalizedAt: number;
runoffRequired: boolean;
}
export interface ParliamentMember {
account: string;
electedAt: number;
termEndsAt: number;
votesParticipated: number;
totalVotesEligible: number;
participationRate: number;
committees: string[];
}
export interface CollectiveProposal {
proposalId: number;
proposer: string;
title: string;
description: string;
proposedAt: number;
votingStartsAt: number;
expiresAt: number;
decisionType: CollectiveDecisionType;
status: ProposalStatus;
ayeVotes: number;
nayVotes: number;
abstainVotes: number;
threshold: number;
votesCast: number;
priority: ProposalPriority;
}
export interface AppointmentProcess {
processId: number;
nominee: string;
role: string;
nominator: string;
justification: string;
status: 'Pending' | 'Approved' | 'Rejected';
createdAt: number;
deadline: number;
}
export interface GovernanceMetrics {
totalElectionsHeld: number;
activeElections: number;
parliamentSize: number;
diwanSize: number;
activeProposals: number;
totalProposalsSubmitted: number;
averageTurnout: number;
}
// ============================================================================
// QUERY FUNCTIONS (Read-only)
// ============================================================================
/**
* Get current government officials
*/
export async function getCurrentOfficials(api: ApiPromise): Promise<{
serok?: string;
serokWeziran?: string;
meclisBaskanı?: string;
}> {
const [serok, serokWeziran, speaker] = await Promise.all([
api.query.welati.currentOfficials('Serok'),
api.query.welati.currentOfficials('SerokWeziran'),
api.query.welati.currentOfficials('MeclisBaskanı'),
]);
return {
serok: serok.isSome ? serok.unwrap().toString() : undefined,
serokWeziran: serokWeziran.isSome ? serokWeziran.unwrap().toString() : undefined,
meclisBaskanı: speaker.isSome ? speaker.unwrap().toString() : undefined,
};
}
/**
* Get current cabinet ministers
*/
export async function getCurrentMinisters(api: ApiPromise): Promise<Record<MinisterRole, string | undefined>> {
const roles: MinisterRole[] = [
'WezireDarayiye',
'WezireParez',
'WezireDad',
'WezireBelaw',
'WezireTend',
'WezireAva',
'WezireCand',
];
const ministers = await Promise.all(
roles.map(role => api.query.welati.currentMinisters(role))
);
const result: Record<string, string | undefined> = {};
roles.forEach((role, index) => {
result[role] = ministers[index].isSome ? ministers[index].unwrap().toString() : undefined;
});
return result as Record<MinisterRole, string | undefined>;
}
/**
* Get parliament members list
*/
export async function getParliamentMembers(api: ApiPromise): Promise<ParliamentMember[]> {
const members = await api.query.welati.parliamentMembers();
if (!members || members.isEmpty) {
return [];
}
const memberList: ParliamentMember[] = [];
const accountIds = members.toJSON() as string[];
for (const accountId of accountIds) {
// In a real implementation, fetch detailed member info
// For now, return basic structure
memberList.push({
account: accountId,
electedAt: 0,
termEndsAt: 0,
votesParticipated: 0,
totalVotesEligible: 0,
participationRate: 0,
committees: [],
});
}
return memberList;
}
/**
* Get Diwan (Constitutional Court) members
*/
export async function getDiwanMembers(api: ApiPromise): Promise<string[]> {
const members = await api.query.welati.diwanMembers();
if (!members || members.isEmpty) {
return [];
}
return (members.toJSON() as string[]) || [];
}
/**
* Get active elections
*/
export async function getActiveElections(api: ApiPromise): Promise<ElectionInfo[]> {
const nextId = await api.query.welati.nextElectionId();
const currentId = (nextId.toJSON() as number) || 0;
const elections: ElectionInfo[] = [];
// Query last 10 elections
for (let i = Math.max(0, currentId - 10); i < currentId; i++) {
const election = await api.query.welati.activeElections(i);
if (election.isSome) {
const data = election.unwrap().toJSON() as any;
elections.push({
electionId: i,
electionType: data.electionType as ElectionType,
status: data.status as ElectionStatus,
startBlock: data.startBlock,
candidacyEndBlock: data.candidacyEndBlock,
campaignEndBlock: data.campaignEndBlock,
votingEndBlock: data.votingEndBlock,
totalCandidates: data.totalCandidates || 0,
totalVotes: data.totalVotes || 0,
turnoutPercentage: data.turnoutPercentage || 0,
districtCount: data.districtCount,
});
}
}
return elections.filter(e => e.status !== 'Completed');
}
/**
* Get election by ID
*/
export async function getElectionById(api: ApiPromise, electionId: number): Promise<ElectionInfo | null> {
const election = await api.query.welati.activeElections(electionId);
if (election.isNone) {
return null;
}
const data = election.unwrap().toJSON() as any;
return {
electionId,
electionType: data.electionType as ElectionType,
status: data.status as ElectionStatus,
startBlock: data.startBlock,
candidacyEndBlock: data.candidacyEndBlock,
campaignEndBlock: data.campaignEndBlock,
votingEndBlock: data.votingEndBlock,
totalCandidates: data.totalCandidates || 0,
totalVotes: data.totalVotes || 0,
turnoutPercentage: data.turnoutPercentage || 0,
districtCount: data.districtCount,
};
}
/**
* Get candidates for an election
*/
export async function getElectionCandidates(
api: ApiPromise,
electionId: number
): Promise<CandidateInfo[]> {
const entries = await api.query.welati.electionCandidates.entries(electionId);
const candidates: CandidateInfo[] = [];
for (const [key, value] of entries) {
const data = value.toJSON() as any;
const account = (key.args[1] as AccountId).toString();
candidates.push({
account,
districtId: data.districtId,
registeredAt: data.registeredAt,
endorsersCount: data.endorsers?.length || 0,
voteCount: data.voteCount || 0,
depositPaid: data.depositPaid?.toString() || '0',
});
}
return candidates.sort((a, b) => b.voteCount - a.voteCount);
}
/**
* Check if user has voted in an election
*/
export async function hasVoted(
api: ApiPromise,
electionId: number,
voterAddress: string
): Promise<boolean> {
const vote = await api.query.welati.electionVotes(electionId, voterAddress);
return vote.isSome;
}
/**
* Get election results
*/
export async function getElectionResults(
api: ApiPromise,
electionId: number
): Promise<ElectionResult | null> {
const result = await api.query.welati.electionResults(electionId);
if (result.isNone) {
return null;
}
const data = result.unwrap().toJSON() as any;
return {
electionId,
winners: data.winners || [],
totalVotes: data.totalVotes || 0,
turnoutPercentage: data.turnoutPercentage || 0,
finalizedAt: data.finalizedAt || 0,
runoffRequired: data.runoffRequired || false,
};
}
/**
* Get active proposals
*/
export async function getActiveProposals(api: ApiPromise): Promise<CollectiveProposal[]> {
const nextId = await api.query.welati.nextProposalId();
const currentId = (nextId.toJSON() as number) || 0;
const proposals: CollectiveProposal[] = [];
// Query last 50 proposals
for (let i = Math.max(0, currentId - 50); i < currentId; i++) {
const proposal = await api.query.welati.activeProposals(i);
if (proposal.isSome) {
const data = proposal.unwrap().toJSON() as any;
proposals.push({
proposalId: i,
proposer: data.proposer,
title: data.title,
description: data.description,
proposedAt: data.proposedAt,
votingStartsAt: data.votingStartsAt,
expiresAt: data.expiresAt,
decisionType: data.decisionType as CollectiveDecisionType,
status: data.status as ProposalStatus,
ayeVotes: data.ayeVotes || 0,
nayVotes: data.nayVotes || 0,
abstainVotes: data.abstainVotes || 0,
threshold: data.threshold || 0,
votesCast: data.votesCast || 0,
priority: data.priority as ProposalPriority,
});
}
}
return proposals.filter(p => p.status === 'Active').reverse();
}
/**
* Get proposal by ID
*/
export async function getProposalById(
api: ApiPromise,
proposalId: number
): Promise<CollectiveProposal | null> {
const proposal = await api.query.welati.activeProposals(proposalId);
if (proposal.isNone) {
return null;
}
const data = proposal.unwrap().toJSON() as any;
return {
proposalId,
proposer: data.proposer,
title: data.title,
description: data.description,
proposedAt: data.proposedAt,
votingStartsAt: data.votingStartsAt,
expiresAt: data.expiresAt,
decisionType: data.decisionType as CollectiveDecisionType,
status: data.status as ProposalStatus,
ayeVotes: data.ayeVotes || 0,
nayVotes: data.nayVotes || 0,
abstainVotes: data.abstainVotes || 0,
threshold: data.threshold || 0,
votesCast: data.votesCast || 0,
priority: data.priority as ProposalPriority,
};
}
/**
* Check if user has voted on a proposal
*/
export async function hasVotedOnProposal(
api: ApiPromise,
proposalId: number,
voterAddress: string
): Promise<boolean> {
const vote = await api.query.welati.collectiveVotes(proposalId, voterAddress);
return vote.isSome;
}
/**
* Get user's vote on a proposal
*/
export async function getProposalVote(
api: ApiPromise,
proposalId: number,
voterAddress: string
): Promise<VoteChoice | null> {
const vote = await api.query.welati.collectiveVotes(proposalId, voterAddress);
if (vote.isNone) {
return null;
}
const data = vote.unwrap().toJSON() as any;
return data.vote as VoteChoice;
}
/**
* Get pending appointments
*/
export async function getPendingAppointments(api: ApiPromise): Promise<AppointmentProcess[]> {
const nextId = await api.query.welati.nextAppointmentId();
const currentId = (nextId.toJSON() as number) || 0;
const appointments: AppointmentProcess[] = [];
for (let i = Math.max(0, currentId - 20); i < currentId; i++) {
const appointment = await api.query.welati.appointmentProcesses(i);
if (appointment.isSome) {
const data = appointment.unwrap().toJSON() as any;
if (data.status === 'Pending') {
appointments.push({
processId: i,
nominee: data.nominee,
role: data.role,
nominator: data.nominator,
justification: data.justification,
status: data.status,
createdAt: data.createdAt,
deadline: data.deadline,
});
}
}
}
return appointments;
}
/**
* Get governance statistics
*/
export async function getGovernanceStats(api: ApiPromise): Promise<GovernanceMetrics> {
const stats = await api.query.welati.governanceStats();
if (!stats || stats.isEmpty) {
return {
totalElectionsHeld: 0,
activeElections: 0,
parliamentSize: 0,
diwanSize: 0,
activeProposals: 0,
totalProposalsSubmitted: 0,
averageTurnout: 0,
};
}
const data = stats.toJSON() as any;
return {
totalElectionsHeld: data.totalElectionsHeld || 0,
activeElections: data.activeElections || 0,
parliamentSize: data.parliamentSize || 0,
diwanSize: data.diwanSize || 0,
activeProposals: data.activeProposals || 0,
totalProposalsSubmitted: data.totalProposalsSubmitted || 0,
averageTurnout: data.averageTurnout || 0,
};
}
/**
* Get current block number
*/
export async function getCurrentBlock(api: ApiPromise): Promise<number> {
const header = await api.rpc.chain.getHeader();
return header.number.toNumber();
}
/**
* Calculate remaining blocks until deadline
*/
export async function getRemainingBlocks(api: ApiPromise, deadlineBlock: number): Promise<number> {
const currentBlock = await getCurrentBlock(api);
return Math.max(0, deadlineBlock - currentBlock);
}
/**
* Convert blocks to approximate time (6 seconds per block average)
*/
export function blocksToTime(blocks: number): {
days: number;
hours: number;
minutes: number;
} {
const seconds = blocks * 6;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return { days, hours, minutes };
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Get election type label
*/
export function getElectionTypeLabel(type: ElectionType): { en: string; kmr: string } {
const labels = {
Presidential: { en: 'Presidential Election', kmr: 'Hilbijartina Serokî' },
Parliamentary: { en: 'Parliamentary Election', kmr: 'Hilbijartina Parlamentoyê' },
SpeakerElection: { en: 'Speaker Election', kmr: 'Hilbijartina Serokê Parlamentoyê' },
ConstitutionalCourt: { en: 'Constitutional Court Election', kmr: 'Hilbijartina Dadgeha Destûrî' },
};
return labels[type] || { en: type, kmr: type };
}
/**
* Get election status label
*/
export function getElectionStatusLabel(status: ElectionStatus): { en: string; kmr: string } {
const labels = {
CandidacyPeriod: { en: 'Candidate Registration Open', kmr: 'Qeydkirina Berendam Vekirî ye' },
CampaignPeriod: { en: 'Campaign Period', kmr: 'Dema Kampanyayê' },
VotingPeriod: { en: 'Voting Open', kmr: 'Dengdan Vekirî ye' },
Completed: { en: 'Completed', kmr: 'Temam bû' },
};
return labels[status] || { en: status, kmr: status };
}
/**
* Get minister role label
*/
export function getMinisterRoleLabel(role: MinisterRole): { en: string; kmr: string } {
const labels = {
WezireDarayiye: { en: 'Minister of Finance', kmr: 'Wezîrê Darayiyê' },
WezireParez: { en: 'Minister of Defense', kmr: 'Wezîrê Parezê' },
WezireDad: { en: 'Minister of Justice', kmr: 'Wezîrê Dadê' },
WezireBelaw: { en: 'Minister of Education', kmr: 'Wezîrê Perwerdeyê' },
WezireTend: { en: 'Minister of Health', kmr: 'Wezîrê Tendirustiyê' },
WezireAva: { en: 'Minister of Water Resources', kmr: 'Wezîrê Avê' },
WezireCand: { en: 'Minister of Culture', kmr: 'Wezîrê Çandî' },
};
return labels[role] || { en: role, kmr: role };
}
/**
* Get proposal decision type threshold
*/
export function getDecisionTypeThreshold(type: CollectiveDecisionType, totalMembers: number): number {
switch (type) {
case 'ParliamentSimpleMajority':
return Math.floor(totalMembers / 2) + 1; // > 50%
case 'ParliamentSuperMajority':
case 'ConstitutionalReview':
return Math.ceil((totalMembers * 2) / 3); // > 66.67%
case 'ParliamentAbsoluteMajority':
return Math.ceil((totalMembers * 3) / 4); // > 75%
case 'ConstitutionalUnanimous':
return totalMembers; // 100%
default:
return Math.floor(totalMembers / 2) + 1;
}
}
+64 -49
View File
@@ -10,6 +10,8 @@ import AdminPanel from '@/pages/AdminPanel';
import WalletDashboard from './pages/WalletDashboard';
import ReservesDashboardPage from './pages/ReservesDashboardPage';
import BeCitizen from './pages/BeCitizen';
import Elections from './pages/Elections';
import EducationPlatform from './pages/EducationPlatform';
import { AppProvider } from '@/contexts/AppContext';
import { PolkadotProvider } from '@/contexts/PolkadotContext';
import { WalletProvider } from '@/contexts/WalletContext';
@@ -19,63 +21,76 @@ import { AuthProvider } from '@/contexts/AuthContext';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import NotFound from '@/pages/NotFound';
import { Toaster } from '@/components/ui/toaster';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import './App.css';
import './i18n/config';
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<AuthProvider>
<AppProvider>
<PolkadotProvider>
<WalletProvider>
<WebSocketProvider>
<IdentityProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<ErrorBoundary>
<AuthProvider>
<AppProvider>
<PolkadotProvider endpoint="ws://127.0.0.1:9944">
<WalletProvider>
<WebSocketProvider>
<IdentityProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/email-verification" element={<EmailVerification />} />
<Route path="/reset-password" element={<PasswordReset />} />
<Route path="/" element={<Index />} />
<Route path="/be-citizen" element={<BeCitizen />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/profile/settings" element={
<ProtectedRoute>
<ProfileSettings />
</ProtectedRoute>
} />
<Route path="/admin" element={
<ProtectedRoute requireAdmin>
<AdminPanel />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletDashboard />
</ProtectedRoute>
} />
<Route path="/reserves" element={
<ProtectedRoute>
<ReservesDashboardPage />
</ProtectedRoute>
} />
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
</IdentityProvider>
</WebSocketProvider>
</WalletProvider>
</PolkadotProvider>
</AppProvider>
</AuthProvider>
<Toaster />
<Route path="/email-verification" element={<EmailVerification />} />
<Route path="/reset-password" element={<PasswordReset />} />
<Route path="/" element={<Index />} />
<Route path="/be-citizen" element={<BeCitizen />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/profile/settings" element={
<ProtectedRoute>
<ProfileSettings />
</ProtectedRoute>
} />
<Route path="/admin" element={
<ProtectedRoute requireAdmin>
<AdminPanel />
</ProtectedRoute>
} />
<Route path="/wallet" element={
<ProtectedRoute>
<WalletDashboard />
</ProtectedRoute>
} />
<Route path="/reserves" element={
<ProtectedRoute>
<ReservesDashboardPage />
</ProtectedRoute>
} />
<Route path="/elections" element={
<ProtectedRoute>
<Elections />
</ProtectedRoute>
} />
<Route path="/education" element={
<ProtectedRoute>
<EducationPlatform />
</ProtectedRoute>
} />
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
</IdentityProvider>
</WebSocketProvider>
</WalletProvider>
</PolkadotProvider>
</AppProvider>
</AuthProvider>
<Toaster />
</ErrorBoundary>
</ThemeProvider>
);
}
export default App;
export default App;
+1 -25
View File
@@ -27,7 +27,6 @@ import RewardDistribution from './RewardDistribution';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useWebSocket } from '@/contexts/WebSocketContext';
import { StakingDashboard } from './staking/StakingDashboard';
import { P2PMarket } from './p2p/P2PMarket';
import { MultiSigWallet } from './wallet/MultiSigWallet';
import { useWallet } from '@/contexts/WalletContext';
import { supabase } from '@/lib/supabase';
@@ -45,7 +44,6 @@ const AppLayout: React.FC = () => {
const [showTreasury, setShowTreasury] = useState(false);
const [treasuryTab, setTreasuryTab] = useState('overview');
const [showStaking, setShowStaking] = useState(false);
const [showP2P, setShowP2P] = useState(false);
const [showMultiSig, setShowMultiSig] = useState(false);
const [showDEX, setShowDEX] = useState(false);
const { t } = useTranslation();
@@ -182,13 +180,6 @@ const AppLayout: React.FC = () => {
<Droplet className="w-4 h-4" />
DEX Pools
</button>
<button
onClick={() => setShowP2P(true)}
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
>
<ArrowRightLeft className="w-4 h-4" />
P2P Market
</button>
<button
onClick={() => setShowStaking(true)}
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white flex items-center gap-2"
@@ -363,20 +354,6 @@ const AppLayout: React.FC = () => {
<StakingDashboard />
</div>
</div>
) : showP2P ? (
<div className="pt-20 min-h-screen bg-gray-950">
<div className="max-w-full mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4 bg-gradient-to-r from-green-500 via-yellow-400 to-red-500 bg-clip-text text-transparent">
P2P Trading Market
</h2>
<p className="text-gray-400 text-lg max-w-3xl mx-auto">
Trade tokens directly with other users
</p>
</div>
<P2PMarket />
</div>
</div>
) : showMultiSig ? (
<div className="pt-20 min-h-screen bg-gray-950">
<div className="max-w-full mx-auto px-4">
@@ -415,7 +392,7 @@ const AppLayout: React.FC = () => {
)}
{(showDEX || showProposalWizard || showDelegation || showForum || showModeration || showTreasury || showStaking || showP2P || showMultiSig) && (
{(showDEX || showProposalWizard || showDelegation || showForum || showModeration || showTreasury || showStaking || showMultiSig) && (
<div className="fixed bottom-8 right-8 z-50">
<button
onClick={() => {
@@ -426,7 +403,6 @@ const AppLayout: React.FC = () => {
setShowModeration(false);
setShowTreasury(false);
setShowStaking(false);
setShowP2P(false);
setShowMultiSig(false);
}}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-full shadow-lg flex items-center gap-2 transition-all"
+243
View File
@@ -0,0 +1,243 @@
// ========================================
// Error Boundary Component
// ========================================
// Catches React errors and displays fallback UI
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
/**
* Global Error Boundary
* Catches unhandled errors in React component tree
*
* @example
* <ErrorBoundary>
* <App />
* </ErrorBoundary>
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
// Update state so next render shows fallback UI
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log error to console
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Update state with error details
this.setState({
error,
errorInfo,
});
// Call custom error handler if provided
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// In production, you might want to log to an error reporting service
// Example: Sentry.captureException(error);
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
handleReload = (): void => {
window.location.reload();
};
handleGoHome = (): void => {
window.location.href = '/';
};
render(): ReactNode {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-2xl w-full">
<CardContent className="p-8">
<Alert className="bg-red-900/20 border-red-500 mb-6">
<AlertTriangle className="h-6 w-6 text-red-400" />
<AlertDescription className="text-gray-300">
<h2 className="text-xl font-bold mb-2 text-white">Something Went Wrong</h2>
<p className="mb-4">
An unexpected error occurred. We apologize for the inconvenience.
</p>
{this.state.error && (
<details className="mt-4 p-4 bg-gray-950 rounded border border-gray-700">
<summary className="cursor-pointer text-sm font-semibold text-gray-400 hover:text-gray-300">
Error Details (for developers)
</summary>
<div className="mt-3 text-xs font-mono space-y-2">
<div>
<strong className="text-red-400">Error:</strong>
<pre className="mt-1 text-gray-400 whitespace-pre-wrap">
{this.state.error.toString()}
</pre>
</div>
{this.state.errorInfo && (
<div>
<strong className="text-red-400">Component Stack:</strong>
<pre className="mt-1 text-gray-400 whitespace-pre-wrap">
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</div>
</details>
)}
</AlertDescription>
</Alert>
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={this.handleReset}
className="bg-green-600 hover:bg-green-700 flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Try Again
</Button>
<Button
onClick={this.handleReload}
variant="outline"
className="border-gray-700 hover:bg-gray-800 flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Reload Page
</Button>
<Button
onClick={this.handleGoHome}
variant="outline"
className="border-gray-700 hover:bg-gray-800 flex items-center gap-2"
>
<Home className="w-4 h-4" />
Go Home
</Button>
</div>
<p className="mt-6 text-sm text-gray-500">
If this problem persists, please contact support at{' '}
<a
href="mailto:info@pezkuwichain.io"
className="text-green-400 hover:underline"
>
info@pezkuwichain.io
</a>
</p>
</CardContent>
</Card>
</div>
);
}
// No error, render children normally
return this.props.children;
}
}
// ========================================
// ROUTE-LEVEL ERROR BOUNDARY
// ========================================
/**
* Smaller error boundary for individual routes
* Less intrusive, doesn't take over the whole screen
*/
export const RouteErrorBoundary: React.FC<{
children: ReactNode;
routeName?: string;
}> = ({ children, routeName = 'this page' }) => {
const [hasError, setHasError] = React.useState(false);
const handleReset = () => {
setHasError(false);
};
if (hasError) {
return (
<div className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<AlertTriangle className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Error loading {routeName}</strong>
An error occurred while rendering this component.
<div className="mt-4">
<Button onClick={handleReset} size="sm" className="bg-green-600 hover:bg-green-700">
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
</div>
</AlertDescription>
</Alert>
</div>
);
}
return (
<ErrorBoundary fallback={<RouteErrorFallback routeName={routeName} onReset={handleReset} />}>
{children}
</ErrorBoundary>
);
};
const RouteErrorFallback: React.FC<{ routeName: string; onReset: () => void }> = ({
routeName,
onReset,
}) => {
return (
<div className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<AlertTriangle className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Error loading {routeName}</strong>
An unexpected error occurred.
<div className="mt-4">
<Button onClick={onReset} size="sm" className="bg-green-600 hover:bg-green-700">
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
</div>
</AlertDescription>
</Alert>
</div>
);
};
+466
View File
@@ -0,0 +1,466 @@
// ========================================
// Route Guard Components
// ========================================
// Protected route wrappers that check user permissions
import React, { useEffect, useState, ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useAuth } from '@/contexts/AuthContext';
import {
checkCitizenStatus,
checkValidatorStatus,
checkEducatorRole,
checkModeratorRole,
} from '@pezkuwi/lib/guards';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, AlertCircle, Users, GraduationCap, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface RouteGuardProps {
children: ReactNode;
fallbackPath?: string;
}
// ========================================
// LOADING COMPONENT
// ========================================
const LoadingGuard: React.FC = () => {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<Card className="bg-gray-900 border-gray-800 p-8">
<CardContent className="flex flex-col items-center gap-4">
<Loader2 className="w-12 h-12 text-green-500 animate-spin" />
<p className="text-gray-400">Checking permissions...</p>
</CardContent>
</Card>
</div>
);
};
// ========================================
// CITIZEN ROUTE GUARD
// ========================================
/**
* CitizenRoute - Requires approved KYC (citizenship)
* Use for: Voting, Education, Elections, etc.
*
* @example
* <Route path="/vote" element={
* <CitizenRoute>
* <VotingPage />
* </CitizenRoute>
* } />
*/
export const CitizenRoute: React.FC<RouteGuardProps> = ({
children,
fallbackPath = '/be-citizen',
}) => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const { user } = useAuth();
const [isCitizen, setIsCitizen] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
if (!isApiReady || !api) {
setLoading(true);
return;
}
if (!selectedAccount?.address) {
setIsCitizen(false);
setLoading(false);
return;
}
try {
const citizenStatus = await checkCitizenStatus(api, selectedAccount.address);
setIsCitizen(citizenStatus);
} catch (error) {
console.error('Citizen check failed:', error);
setIsCitizen(false);
} finally {
setLoading(false);
}
};
checkPermission();
}, [api, isApiReady, selectedAccount]);
// Loading state
if (loading || !isApiReady) {
return <LoadingGuard />;
}
// Not connected to wallet
if (!selectedAccount) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<Users className="w-16 h-16 text-yellow-500" />
<h2 className="text-2xl font-bold text-white">Wallet Not Connected</h2>
<p className="text-gray-400">
Please connect your Polkadot wallet to access this feature.
</p>
<Button
onClick={() => window.location.href = '/'}
className="bg-green-600 hover:bg-green-700"
>
Go to Home
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Not a citizen
if (isCitizen === false) {
return <Navigate to={fallbackPath} replace />;
}
// Authorized
return <>{children}</>;
};
// ========================================
// VALIDATOR ROUTE GUARD
// ========================================
/**
* ValidatorRoute - Requires validator pool membership
* Use for: Validator pool dashboard, validator settings
*
* @example
* <Route path="/validator-pool" element={
* <ValidatorRoute>
* <ValidatorPoolDashboard />
* </ValidatorRoute>
* } />
*/
export const ValidatorRoute: React.FC<RouteGuardProps> = ({
children,
fallbackPath = '/staking',
}) => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const [isValidator, setIsValidator] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
if (!isApiReady || !api) {
setLoading(true);
return;
}
if (!selectedAccount?.address) {
setIsValidator(false);
setLoading(false);
return;
}
try {
const validatorStatus = await checkValidatorStatus(api, selectedAccount.address);
setIsValidator(validatorStatus);
} catch (error) {
console.error('Validator check failed:', error);
setIsValidator(false);
} finally {
setLoading(false);
}
};
checkPermission();
}, [api, isApiReady, selectedAccount]);
// Loading state
if (loading || !isApiReady) {
return <LoadingGuard />;
}
// Not connected to wallet
if (!selectedAccount) {
return <Navigate to="/" replace />;
}
// Not in validator pool
if (isValidator === false) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<AlertCircle className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Validator Access Required</strong>
You must be registered in the Validator Pool to access this feature.
<div className="mt-4">
<Button
onClick={() => window.location.href = fallbackPath}
className="bg-green-600 hover:bg-green-700"
>
Go to Staking
</Button>
</div>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
);
}
// Authorized
return <>{children}</>;
};
// ========================================
// EDUCATOR ROUTE GUARD
// ========================================
/**
* EducatorRoute - Requires educator Tiki role
* Use for: Creating courses in Perwerde (Education platform)
*
* @example
* <Route path="/education/create-course" element={
* <EducatorRoute>
* <CourseCreator />
* </EducatorRoute>
* } />
*/
export const EducatorRoute: React.FC<RouteGuardProps> = ({
children,
fallbackPath = '/education',
}) => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const [isEducator, setIsEducator] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
if (!isApiReady || !api) {
setLoading(true);
return;
}
if (!selectedAccount?.address) {
setIsEducator(false);
setLoading(false);
return;
}
try {
const educatorStatus = await checkEducatorRole(api, selectedAccount.address);
setIsEducator(educatorStatus);
} catch (error) {
console.error('Educator check failed:', error);
setIsEducator(false);
} finally {
setLoading(false);
}
};
checkPermission();
}, [api, isApiReady, selectedAccount]);
// Loading state
if (loading || !isApiReady) {
return <LoadingGuard />;
}
// Not connected to wallet
if (!selectedAccount) {
return <Navigate to="/" replace />;
}
// Not an educator
if (isEducator === false) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<GraduationCap className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Educator Role Required</strong>
You need one of these Tiki roles to create courses:
<ul className="list-disc list-inside mt-2 text-sm">
<li>Perwerdekar (Educator)</li>
<li>Mamoste (Teacher)</li>
<li>WezireCand (Education Minister)</li>
<li>Rewsenbîr (Intellectual)</li>
</ul>
<div className="mt-4">
<Button
onClick={() => window.location.href = fallbackPath}
className="bg-green-600 hover:bg-green-700"
>
Browse Courses
</Button>
</div>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
);
}
// Authorized
return <>{children}</>;
};
// ========================================
// MODERATOR ROUTE GUARD
// ========================================
/**
* ModeratorRoute - Requires moderator Tiki role
* Use for: Forum moderation, governance moderation
*
* @example
* <Route path="/moderate" element={
* <ModeratorRoute>
* <ModerationPanel />
* </ModeratorRoute>
* } />
*/
export const ModeratorRoute: React.FC<RouteGuardProps> = ({
children,
fallbackPath = '/',
}) => {
const { api, isApiReady, selectedAccount } = usePolkadot();
const [isModerator, setIsModerator] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
if (!isApiReady || !api) {
setLoading(true);
return;
}
if (!selectedAccount?.address) {
setIsModerator(false);
setLoading(false);
return;
}
try {
const moderatorStatus = await checkModeratorRole(api, selectedAccount.address);
setIsModerator(moderatorStatus);
} catch (error) {
console.error('Moderator check failed:', error);
setIsModerator(false);
} finally {
setLoading(false);
}
};
checkPermission();
}, [api, isApiReady, selectedAccount]);
// Loading state
if (loading || !isApiReady) {
return <LoadingGuard />;
}
// Not connected to wallet
if (!selectedAccount) {
return <Navigate to="/" replace />;
}
// Not a moderator
if (isModerator === false) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<Shield className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Moderator Access Required</strong>
You need moderator privileges to access this feature.
<div className="mt-4">
<Button
onClick={() => window.location.href = fallbackPath}
className="bg-green-600 hover:bg-green-700"
>
Go to Home
</Button>
</div>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
);
}
// Authorized
return <>{children}</>;
};
// ========================================
// ADMIN ROUTE GUARD (Supabase-based)
// ========================================
/**
* AdminRoute - Requires Supabase admin role
* Use for: Admin panel, system settings
* Note: This is separate from blockchain permissions
*/
export const AdminRoute: React.FC<RouteGuardProps> = ({
children,
fallbackPath = '/',
}) => {
const { user, isAdmin, loading } = useAuth();
// Loading state
if (loading) {
return <LoadingGuard />;
}
// Not logged in
if (!user) {
return <Navigate to="/login" replace />;
}
// Not admin
if (!isAdmin) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<Card className="bg-gray-900 border-gray-800 max-w-md">
<CardContent className="p-8">
<Alert className="bg-red-900/20 border-red-500">
<AlertCircle className="h-5 w-5 text-red-400" />
<AlertDescription className="text-gray-300">
<strong className="block mb-2">Admin Access Required</strong>
You do not have permission to access the admin panel.
<div className="mt-4">
<Button
onClick={() => window.location.href = fallbackPath}
className="bg-green-600 hover:bg-green-700"
>
Go to Home
</Button>
</div>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
);
}
// Authorized
return <>{children}</>;
};
-8
View File
@@ -13,7 +13,6 @@ import { ASSET_IDS, formatBalance, parseAmount } from '@pezkuwi/lib/wallet';
import { useToast } from '@/hooks/use-toast';
import { KurdistanSun } from './KurdistanSun';
import { PriceChart } from './trading/PriceChart';
import { LimitOrders } from './trading/LimitOrders';
// Available tokens for swap
const AVAILABLE_TOKENS = [
@@ -1107,13 +1106,6 @@ const TokenSwap = () => {
</div>
)}
</Card>
{/* Limit Orders Section */}
<LimitOrders
fromToken={fromToken}
toToken={toToken}
currentPrice={exchangeRate}
/>
</div>
<div>
@@ -13,6 +13,7 @@ import DelegateProfile from './DelegateProfile';
import { useDelegation } from '@/hooks/useDelegation';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { formatNumber } from '@/lib/utils';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
const DelegationManager: React.FC = () => {
const { t } = useTranslation();
@@ -37,14 +38,7 @@ const DelegationManager: React.FC = () => {
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-green-600 mr-3" />
<span className="text-lg">Loading delegation data from blockchain...</span>
</div>
</div>
);
return <LoadingState message="Loading delegation data from blockchain..." />;
}
if (error) {
+2 -5
View File
@@ -7,6 +7,7 @@ import { TrendingUp, Droplet, BarChart3, Search, Plus } from 'lucide-react';
import { PoolInfo } from '@/types/dex';
import { fetchPools, formatTokenBalance } from '@pezkuwi/utils/dex';
import { isFounderWallet } from '@pezkuwi/utils/auth';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
interface PoolBrowserProps {
onAddLiquidity?: (pool: PoolInfo) => void;
@@ -63,11 +64,7 @@ export const PoolBrowser: React.FC<PoolBrowserProps> = ({
});
if (loading && pools.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-400">Loading pools...</div>
</div>
);
return <LoadingState message="Loading liquidity pools..." />;
}
return (
+2 -6
View File
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
import {
MessageSquare,
Users,
@@ -105,12 +106,7 @@ export function ForumOverview() {
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Loading forum...</span>
</div>
);
return <LoadingState message="Loading forum..." />;
}
return (
@@ -9,6 +9,7 @@ import { Badge } from '../ui/badge';
import { Progress } from '../ui/progress';
import { usePolkadot } from '../../contexts/PolkadotContext';
import { formatBalance } from '@pezkuwi/lib/wallet';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
interface GovernanceStats {
activeProposals: number;
@@ -123,6 +124,10 @@ const GovernanceOverview: React.FC = () => {
}
};
if (loading) {
return <LoadingState message="Loading governance data..." />;
}
return (
<div className="space-y-6">
{/* Stats Grid */}
@@ -7,6 +7,7 @@ import { Progress } from '../ui/progress';
import { Alert, AlertDescription } from '../ui/alert';
import { useGovernance } from '@/hooks/useGovernance';
import { formatNumber } from '@/lib/utils';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
interface Proposal {
id: number;
@@ -84,12 +85,7 @@ const ProposalsList: React.FC = () => {
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mr-3" />
<span className="text-muted-foreground">Loading proposals from blockchain...</span>
</div>
);
return <LoadingState message="Loading proposals from blockchain..." />;
}
if (error) {
-798
View File
@@ -1,798 +0,0 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowUpDown, Search, Filter, TrendingUp, TrendingDown, User, Shield, Clock, DollarSign, Plus, X, SlidersHorizontal, Lock, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface P2POffer {
id: string;
type: 'buy' | 'sell';
token: 'HEZ' | 'PEZ';
amount: number;
price: number;
paymentMethod: string;
seller: {
name: string;
rating: number;
completedTrades: number;
verified: boolean;
};
minOrder: number;
maxOrder: number;
timeLimit: number;
}
export const P2PMarket: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'buy' | 'sell'>('buy');
const [selectedToken, setSelectedToken] = useState<'HEZ' | 'PEZ'>('HEZ');
const [searchTerm, setSearchTerm] = useState('');
const [selectedOffer, setSelectedOffer] = useState<P2POffer | null>(null);
const [tradeAmount, setTradeAmount] = useState('');
// Advanced filters
const [paymentMethodFilter, setPaymentMethodFilter] = useState<string>('all');
const [minPrice, setMinPrice] = useState<string>('');
const [maxPrice, setMaxPrice] = useState<string>('');
const [sortBy, setSortBy] = useState<'price' | 'rating' | 'trades'>('price');
const [showFilters, setShowFilters] = useState(false);
// Order creation
const [showCreateOrder, setShowCreateOrder] = useState(false);
const [newOrderAmount, setNewOrderAmount] = useState('');
const [newOrderPrice, setNewOrderPrice] = useState('');
const [newOrderPaymentMethod, setNewOrderPaymentMethod] = useState('Bank Transfer');
const offers: P2POffer[] = [
{
id: '1',
type: 'sell',
token: 'HEZ',
amount: 10000,
price: 0.95,
paymentMethod: 'Bank Transfer',
seller: {
name: 'CryptoTrader',
rating: 4.8,
completedTrades: 234,
verified: true
},
minOrder: 100,
maxOrder: 5000,
timeLimit: 30
},
{
id: '2',
type: 'sell',
token: 'HEZ',
amount: 5000,
price: 0.96,
paymentMethod: 'PayPal',
seller: {
name: 'TokenMaster',
rating: 4.9,
completedTrades: 567,
verified: true
},
minOrder: 50,
maxOrder: 2000,
timeLimit: 15
},
{
id: '3',
type: 'buy',
token: 'PEZ',
amount: 15000,
price: 1.02,
paymentMethod: 'Crypto',
seller: {
name: 'PezWhale',
rating: 4.7,
completedTrades: 123,
verified: false
},
minOrder: 500,
maxOrder: 10000,
timeLimit: 60
},
{
id: '4',
type: 'sell',
token: 'PEZ',
amount: 8000,
price: 1.01,
paymentMethod: 'Wire Transfer',
seller: {
name: 'QuickTrade',
rating: 4.6,
completedTrades: 89,
verified: true
},
minOrder: 200,
maxOrder: 3000,
timeLimit: 45
}
];
// Payment methods list
const paymentMethods = ['Bank Transfer', 'PayPal', 'Crypto', 'Wire Transfer', 'Cash', 'Mobile Money'];
// Advanced filtering and sorting
const filteredOffers = offers
.filter(offer => {
// Basic filters
if (offer.type !== activeTab) return false;
if (offer.token !== selectedToken) return false;
if (searchTerm && !offer.seller.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
// Payment method filter
if (paymentMethodFilter !== 'all' && offer.paymentMethod !== paymentMethodFilter) return false;
// Price range filter
if (minPrice && offer.price < parseFloat(minPrice)) return false;
if (maxPrice && offer.price > parseFloat(maxPrice)) return false;
return true;
})
.sort((a, b) => {
// Sorting logic
if (sortBy === 'price') {
return activeTab === 'buy' ? a.price - b.price : b.price - a.price;
} else if (sortBy === 'rating') {
return b.seller.rating - a.seller.rating;
} else if (sortBy === 'trades') {
return b.seller.completedTrades - a.seller.completedTrades;
}
return 0;
});
// Escrow state
const [showEscrow, setShowEscrow] = useState(false);
const [escrowStep, setEscrowStep] = useState<'funding' | 'confirmation' | 'release'>('funding');
const [escrowOffer, setEscrowOffer] = useState<P2POffer | null>(null);
const handleTrade = (offer: P2POffer) => {
console.log('Initiating trade:', tradeAmount, offer.token, 'with', offer.seller.name);
setEscrowOffer(offer);
setShowEscrow(true);
setEscrowStep('funding');
};
const handleEscrowFund = () => {
console.log('Funding escrow with:', tradeAmount, escrowOffer?.token);
setEscrowStep('confirmation');
};
const handleEscrowConfirm = () => {
console.log('Confirming payment received');
setEscrowStep('release');
};
const handleEscrowRelease = () => {
console.log('Releasing escrow funds');
setShowEscrow(false);
setSelectedOffer(null);
setEscrowOffer(null);
setEscrowStep('funding');
};
return (
<div className="space-y-6">
{/* Market Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">HEZ Price</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$0.95</div>
<div className="flex items-center text-green-500 text-xs mt-1">
<TrendingUp className="w-3 h-3 mr-1" />
+2.3%
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">PEZ Price</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$1.02</div>
<div className="flex items-center text-red-500 text-xs mt-1">
<TrendingDown className="w-3 h-3 mr-1" />
-0.8%
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">24h Volume</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">$2.4M</div>
<p className="text-xs text-gray-500 mt-1">1,234 trades</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-400">Active Offers</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">342</div>
<p className="text-xs text-gray-500 mt-1">89 verified sellers</p>
</CardContent>
</Card>
</div>
{/* P2P Trading Interface */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-xl text-white">P2P Market</CardTitle>
<CardDescription className="text-gray-400">
Buy and sell tokens directly with other users
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Top Action Bar */}
<div className="flex justify-between items-center">
<Button
onClick={() => setShowCreateOrder(true)}
className="bg-green-600 hover:bg-green-700"
>
<Plus className="w-4 h-4 mr-2" />
Create Order
</Button>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="border-gray-700"
>
<SlidersHorizontal className="w-4 h-4 mr-2" />
{showFilters ? 'Hide Filters' : 'Show Filters'}
</Button>
</div>
{/* Basic Filters */}
<div className="flex flex-wrap gap-4">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'buy' | 'sell')} className="flex-1">
<TabsList className="grid w-full max-w-[200px] grid-cols-2">
<TabsTrigger value="buy">Buy</TabsTrigger>
<TabsTrigger value="sell">Sell</TabsTrigger>
</TabsList>
</Tabs>
<Select value={selectedToken} onValueChange={(v) => setSelectedToken(v as 'HEZ' | 'PEZ')}>
<SelectTrigger className="w-[120px] bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
<div className="flex-1 max-w-xs">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search sellers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-gray-800 border-gray-700"
/>
</div>
</div>
{/* Sort Selector */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'price' | 'rating' | 'trades')}>
<SelectTrigger className="w-[150px] bg-gray-800 border-gray-700">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="price">Price</SelectItem>
<SelectItem value="rating">Rating</SelectItem>
<SelectItem value="trades">Trades</SelectItem>
</SelectContent>
</Select>
</div>
{/* Advanced Filters Panel (Binance P2P style) */}
{showFilters && (
<Card className="bg-gray-800 border-gray-700 p-4">
<div className="space-y-4">
<h4 className="font-semibold text-white flex items-center gap-2">
<Filter className="w-4 h-4" />
Advanced Filters
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Payment Method Filter */}
<div>
<Label className="text-sm text-gray-400">Payment Method</Label>
<Select value={paymentMethodFilter} onValueChange={setPaymentMethodFilter}>
<SelectTrigger className="bg-gray-900 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
{paymentMethods.map(method => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Min Price Filter */}
<div>
<Label className="text-sm text-gray-400">Min Price ($)</Label>
<Input
type="number"
placeholder="Min"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
{/* Max Price Filter */}
<div>
<Label className="text-sm text-gray-400">Max Price ($)</Label>
<Input
type="number"
placeholder="Max"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
</div>
{/* Clear Filters Button */}
<Button
variant="outline"
size="sm"
onClick={() => {
setPaymentMethodFilter('all');
setMinPrice('');
setMaxPrice('');
setSearchTerm('');
}}
className="border-gray-700"
>
<X className="w-3 h-3 mr-1" />
Clear All Filters
</Button>
</div>
</Card>
)}
{/* Offers List */}
<div className="space-y-3">
{filteredOffers.map((offer) => (
<Card key={offer.id} className="bg-gray-800 border-gray-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-gray-700 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-gray-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-white">{offer.seller.name}</span>
{offer.seller.verified && (
<Badge variant="secondary" className="bg-blue-600/20 text-blue-400">
<Shield className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span> {offer.seller.rating}</span>
<span>{offer.seller.completedTrades} trades</span>
<span>{offer.paymentMethod}</span>
</div>
</div>
</div>
<div className="text-right space-y-1">
<div className="text-lg font-bold text-white">
${offer.price} / {offer.token}
</div>
<div className="text-sm text-gray-400">
Available: {offer.amount.toLocaleString()} {offer.token}
</div>
<div className="text-xs text-gray-500">
Limits: {offer.minOrder} - {offer.maxOrder} {offer.token}
</div>
</div>
<Button
className="ml-4 bg-green-600 hover:bg-green-700"
onClick={() => setSelectedOffer(offer)}
>
{activeTab === 'buy' ? 'Buy' : 'Sell'} {offer.token}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</CardContent>
</Card>
{/* Trade Modal */}
{selectedOffer && (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle>
{activeTab === 'buy' ? 'Buy' : 'Sell'} {selectedOffer.token} from {selectedOffer.seller.name}
</CardTitle>
<CardDescription>Complete your P2P trade</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Amount ({selectedOffer.token})</Label>
<Input
type="number"
placeholder={`Min: ${selectedOffer.minOrder}, Max: ${selectedOffer.maxOrder}`}
value={tradeAmount}
onChange={(e) => setTradeAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="bg-gray-800 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Price per {selectedOffer.token}</span>
<span className="text-white">${selectedOffer.price}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Total Amount</span>
<span className="text-white font-semibold">
${(parseFloat(tradeAmount || '0') * selectedOffer.price).toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Payment Method</span>
<span className="text-white">{selectedOffer.paymentMethod}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Time Limit</span>
<span className="text-white">{selectedOffer.timeLimit} minutes</span>
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => handleTrade(selectedOffer)}
>
Confirm {activeTab === 'buy' ? 'Purchase' : 'Sale'}
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => setSelectedOffer(null)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Create Order Modal (Binance P2P style) */}
{showCreateOrder && (
<Card className="bg-gray-900 border-gray-800 fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Create P2P Order</CardTitle>
<Button variant="ghost" size="icon" onClick={() => setShowCreateOrder(false)}>
<X className="w-4 h-4" />
</Button>
</div>
<CardDescription>
Create a {activeTab === 'buy' ? 'buy' : 'sell'} order for {selectedToken}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Order Type</Label>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'buy' | 'sell')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="buy">Buy</TabsTrigger>
<TabsTrigger value="sell">Sell</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<Label>Token</Label>
<Select value={selectedToken} onValueChange={(v) => setSelectedToken(v as 'HEZ' | 'PEZ')}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEZ">HEZ</SelectItem>
<SelectItem value="PEZ">PEZ</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Amount ({selectedToken})</Label>
<Input
type="number"
placeholder="Enter amount"
value={newOrderAmount}
onChange={(e) => setNewOrderAmount(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div>
<Label>Price per {selectedToken} ($)</Label>
<Input
type="number"
placeholder="Enter price"
value={newOrderPrice}
onChange={(e) => setNewOrderPrice(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div>
<Label>Payment Method</Label>
<Select value={newOrderPaymentMethod} onValueChange={setNewOrderPaymentMethod}>
<SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentMethods.map(method => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-gray-800 p-3 rounded-lg">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Total Value</span>
<span className="text-white font-semibold">
${(parseFloat(newOrderAmount || '0') * parseFloat(newOrderPrice || '0')).toFixed(2)}
</span>
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => {
console.log('Creating order:', {
type: activeTab,
token: selectedToken,
amount: newOrderAmount,
price: newOrderPrice,
paymentMethod: newOrderPaymentMethod
});
// TODO: Implement blockchain integration
setShowCreateOrder(false);
}}
>
Create Order
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => setShowCreateOrder(false)}
>
Cancel
</Button>
</div>
<div className="text-xs text-gray-500 text-center">
Note: Blockchain integration for P2P orders is coming soon
</div>
</CardContent>
</Card>
)}
{/* Escrow Modal (Binance P2P Escrow style) */}
{showEscrow && escrowOffer && (
<Card className="bg-gray-900 border-gray-800 fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-2xl">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-blue-400" />
Secure Escrow Trade
</CardTitle>
<Button variant="ghost" size="icon" onClick={() => setShowEscrow(false)}>
<X className="w-4 h-4" />
</Button>
</div>
<CardDescription>
Trade safely with escrow protection {activeTab === 'buy' ? 'Buying' : 'Selling'} {escrowOffer.token}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Escrow Steps Indicator */}
<div className="flex justify-between items-center">
{[
{ step: 'funding', label: 'Fund Escrow', icon: Lock },
{ step: 'confirmation', label: 'Payment', icon: Clock },
{ step: 'release', label: 'Complete', icon: CheckCircle }
].map((item, idx) => (
<div key={item.step} className="flex-1 flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
escrowStep === item.step ? 'bg-blue-600' :
['funding', 'confirmation', 'release'].indexOf(escrowStep) > idx ? 'bg-green-600' : 'bg-gray-700'
}`}>
<item.icon className="w-5 h-5 text-white" />
</div>
<span className="text-xs text-gray-400 mt-2">{item.label}</span>
{idx < 2 && (
<div className={`absolute w-32 h-0.5 mt-5 ${
['funding', 'confirmation', 'release'].indexOf(escrowStep) > idx ? 'bg-green-600' : 'bg-gray-700'
}`} style={{ left: `calc(${(idx + 1) * 33.33}% - 64px)` }}></div>
)}
</div>
))}
</div>
{/* Trade Details Card */}
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-3">Trade Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Seller</span>
<span className="text-white font-semibold">{escrowOffer.seller.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Amount</span>
<span className="text-white font-semibold">{tradeAmount} {escrowOffer.token}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Price per {escrowOffer.token}</span>
<span className="text-white font-semibold">${escrowOffer.price}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Payment Method</span>
<span className="text-white font-semibold">{escrowOffer.paymentMethod}</span>
</div>
<div className="flex justify-between pt-2 border-t border-gray-700">
<span className="text-gray-400">Total</span>
<span className="text-lg font-bold text-white">
${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}
</span>
</div>
</div>
</Card>
{/* Step Content */}
{escrowStep === 'funding' && (
<div className="space-y-4">
<div className="bg-blue-900/20 border border-blue-500/30 rounded-lg p-4">
<div className="flex gap-3">
<Shield className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-200">
<strong>Escrow Protection:</strong> Your funds will be held securely in smart contract escrow until both parties confirm the trade. This protects both buyer and seller.
</div>
</div>
</div>
<div className="text-sm text-gray-400">
1. Fund the escrow with {tradeAmount} {escrowOffer.token}<br />
2. Wait for seller to provide payment details<br />
3. Complete payment via {escrowOffer.paymentMethod}<br />
4. Confirm payment to release escrow
</div>
<Button
onClick={handleEscrowFund}
className="w-full bg-blue-600 hover:bg-blue-700"
>
Fund Escrow ({tradeAmount} {escrowOffer.token})
</Button>
</div>
)}
{escrowStep === 'confirmation' && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-500/30 rounded-lg p-4">
<div className="flex gap-3">
<Clock className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-200">
<strong>Waiting for Payment:</strong> Complete your {escrowOffer.paymentMethod} payment and click confirm when done. Do not release escrow until payment is verified!
</div>
</div>
</div>
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-2">Payment Instructions</h4>
<div className="text-sm text-gray-300 space-y-1">
<p> Payment Method: {escrowOffer.paymentMethod}</p>
<p> Amount: ${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}</p>
<p> Time Limit: {escrowOffer.timeLimit} minutes</p>
</div>
</Card>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
setShowEscrow(false);
setEscrowStep('funding');
}}
className="flex-1"
>
Cancel Trade
</Button>
<Button
onClick={handleEscrowConfirm}
className="flex-1 bg-green-600 hover:bg-green-700"
>
I've Made Payment
</Button>
</div>
</div>
)}
{escrowStep === 'release' && (
<div className="space-y-4">
<div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4">
<div className="flex gap-3">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-green-200">
<strong>Payment Confirmed:</strong> Your payment has been verified. The escrow will be released to the seller automatically.
</div>
</div>
</div>
<Card className="bg-gray-800 border-gray-700 p-4">
<h4 className="font-semibold text-white mb-2">Trade Summary</h4>
<div className="text-sm text-gray-300 space-y-1">
<p>✅ Escrow Funded: {tradeAmount} {escrowOffer.token}</p>
<p>✅ Payment Sent: ${(parseFloat(tradeAmount || '0') * escrowOffer.price).toFixed(2)}</p>
<p> Payment Verified</p>
<p className="text-green-400 font-semibold mt-2">🎉 Trade Completed Successfully!</p>
</div>
</Card>
<Button
onClick={handleEscrowRelease}
className="w-full bg-green-600 hover:bg-green-700"
>
Close & Release Escrow
</Button>
</div>
)}
<div className="text-xs text-gray-500 text-center">
Note: Smart contract escrow integration coming soon
</div>
</CardContent>
</Card>
)}
{/* Overlay */}
{(showCreateOrder || selectedOffer || showEscrow) && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40" onClick={() => {
setShowCreateOrder(false);
setSelectedOffer(null);
setShowEscrow(false);
}}></div>
)}
</div>
);
};
+11 -46
View File
@@ -21,6 +21,8 @@ import {
parseAmount,
type StakingInfo
} from '@pezkuwi/lib/staking';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
import { handleBlockchainError, handleBlockchainSuccess } from '@pezkuwi/lib/error-handler';
export const StakingDashboard: React.FC = () => {
const { t } = useTranslation();
@@ -119,22 +121,10 @@ export const StakingDashboard: React.FC = () => {
console.log('Transaction in block:', status.asInBlock.toHex());
if (dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
handleBlockchainError(dispatchError, api, toast);
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Bonded ${bondAmount} HEZ successfully`,
});
handleBlockchainSuccess('staking.bonded', toast, { amount: bondAmount });
setBondAmount('');
refreshBalances();
// Refresh staking data after a delay
@@ -183,22 +173,10 @@ export const StakingDashboard: React.FC = () => {
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Nomination failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
handleBlockchainError(dispatchError, api, toast);
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Nominated ${selectedValidators.length} validator(s)`,
});
handleBlockchainSuccess('staking.nominated', toast, { count: selectedValidators.length.toString() });
// Refresh staking data
setTimeout(() => {
if (api && selectedAccount) {
@@ -241,21 +219,12 @@ export const StakingDashboard: React.FC = () => {
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
let errorMessage = 'Unbond failed';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
}
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
handleBlockchainError(dispatchError, api, toast);
setIsLoading(false);
} else {
toast({
title: 'Success',
description: `Unbonded ${unbondAmount} HEZ. Withdrawal available in ${bondingDuration} eras`,
handleBlockchainSuccess('staking.unbonded', toast, {
amount: unbondAmount,
duration: bondingDuration.toString()
});
setUnbondAmount('');
setTimeout(() => {
@@ -421,11 +390,7 @@ export const StakingDashboard: React.FC = () => {
};
if (isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-400">Loading staking data...</div>
</div>
);
return <LoadingState message="Loading staking data..." />;
}
return (
-306
View File
@@ -1,306 +0,0 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { X, Clock, CheckCircle, AlertCircle } from 'lucide-react';
interface LimitOrder {
id: string;
type: 'buy' | 'sell';
fromToken: string;
toToken: string;
fromAmount: number;
limitPrice: number;
currentPrice: number;
status: 'pending' | 'filled' | 'cancelled' | 'expired';
createdAt: number;
expiresAt: number;
}
interface LimitOrdersProps {
fromToken: string;
toToken: string;
currentPrice: number;
onCreateOrder?: (order: Omit<LimitOrder, 'id' | 'status' | 'createdAt' | 'expiresAt'>) => void;
}
export const LimitOrders: React.FC<LimitOrdersProps> = ({
fromToken,
toToken,
currentPrice,
onCreateOrder
}) => {
const [orderType, setOrderType] = useState<'buy' | 'sell'>('buy');
const [amount, setAmount] = useState('');
const [limitPrice, setLimitPrice] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
// Mock orders (in production, fetch from blockchain)
const [orders, setOrders] = useState<LimitOrder[]>([
{
id: '1',
type: 'buy',
fromToken: 'PEZ',
toToken: 'HEZ',
fromAmount: 100,
limitPrice: 0.98,
currentPrice: 1.02,
status: 'pending',
createdAt: Date.now() - 3600000,
expiresAt: Date.now() + 82800000
},
{
id: '2',
type: 'sell',
fromToken: 'HEZ',
toToken: 'PEZ',
fromAmount: 50,
limitPrice: 1.05,
currentPrice: 1.02,
status: 'pending',
createdAt: Date.now() - 7200000,
expiresAt: Date.now() + 79200000
}
]);
const handleCreateOrder = () => {
const newOrder: Omit<LimitOrder, 'id' | 'status' | 'createdAt' | 'expiresAt'> = {
type: orderType,
fromToken: orderType === 'buy' ? toToken : fromToken,
toToken: orderType === 'buy' ? fromToken : toToken,
fromAmount: parseFloat(amount),
limitPrice: parseFloat(limitPrice),
currentPrice
};
console.log('Creating limit order:', newOrder);
// Add to orders list (mock)
const order: LimitOrder = {
...newOrder,
id: Date.now().toString(),
status: 'pending',
createdAt: Date.now(),
expiresAt: Date.now() + 86400000 // 24 hours
};
setOrders([order, ...orders]);
setShowCreateForm(false);
setAmount('');
setLimitPrice('');
if (onCreateOrder) {
onCreateOrder(newOrder);
}
};
const handleCancelOrder = (orderId: string) => {
setOrders(orders.map(order =>
order.id === orderId ? { ...order, status: 'cancelled' as const } : order
));
};
const getStatusBadge = (status: LimitOrder['status']) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending
</Badge>;
case 'filled':
return <Badge variant="outline" className="bg-green-500/10 text-green-400 border-green-500/30">
<CheckCircle className="w-3 h-3 mr-1" />
Filled
</Badge>;
case 'cancelled':
return <Badge variant="outline" className="bg-gray-500/10 text-gray-400 border-gray-500/30">
<X className="w-3 h-3 mr-1" />
Cancelled
</Badge>;
case 'expired':
return <Badge variant="outline" className="bg-red-500/10 text-red-400 border-red-500/30">
<AlertCircle className="w-3 h-3 mr-1" />
Expired
</Badge>;
}
};
const getPriceDistance = (order: LimitOrder) => {
const distance = ((order.limitPrice - order.currentPrice) / order.currentPrice) * 100;
return distance;
};
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>Limit Orders</CardTitle>
<CardDescription>
Set orders to execute at your target price
</CardDescription>
</div>
<Button
onClick={() => setShowCreateForm(!showCreateForm)}
className="bg-blue-600 hover:bg-blue-700"
>
{showCreateForm ? 'Cancel' : '+ New Order'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{showCreateForm && (
<Card className="bg-gray-800 border-gray-700 p-4">
<div className="space-y-4">
<div>
<Label>Order Type</Label>
<Tabs value={orderType} onValueChange={(v) => setOrderType(v as 'buy' | 'sell')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="buy">Buy {fromToken}</TabsTrigger>
<TabsTrigger value="sell">Sell {fromToken}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<Label>Amount ({orderType === 'buy' ? toToken : fromToken})</Label>
<Input
type="number"
placeholder="0.0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
</div>
<div>
<Label>Limit Price (1 {fromToken} = ? {toToken})</Label>
<Input
type="number"
placeholder="0.0"
value={limitPrice}
onChange={(e) => setLimitPrice(e.target.value)}
className="bg-gray-900 border-gray-700"
/>
<div className="text-xs text-gray-500 mt-1">
Current market price: ${currentPrice.toFixed(4)}
</div>
</div>
<div className="bg-gray-900 p-3 rounded-lg space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-400">You will {orderType}</span>
<span className="text-white font-semibold">
{amount || '0'} {orderType === 'buy' ? fromToken : toToken}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">When price reaches</span>
<span className="text-white font-semibold">
${limitPrice || '0'} per {fromToken}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Estimated total</span>
<span className="text-white font-semibold">
{((parseFloat(amount || '0') * parseFloat(limitPrice || '0'))).toFixed(2)} {orderType === 'buy' ? toToken : fromToken}
</span>
</div>
</div>
<Button
onClick={handleCreateOrder}
disabled={!amount || !limitPrice}
className="w-full bg-green-600 hover:bg-green-700"
>
Create Limit Order
</Button>
<div className="text-xs text-gray-500 text-center">
Order will expire in 24 hours if not filled
</div>
</div>
</Card>
)}
{/* Orders List */}
<div className="space-y-3">
{orders.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No limit orders yet. Create one to get started!
</div>
) : (
orders.map(order => {
const priceDistance = getPriceDistance(order);
return (
<Card key={order.id} className="bg-gray-800 border-gray-700 p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Badge variant={order.type === 'buy' ? 'default' : 'secondary'}>
{order.type.toUpperCase()}
</Badge>
<span className="font-semibold text-white">
{order.fromToken} {order.toToken}
</span>
</div>
{getStatusBadge(order.status)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mb-3">
<div>
<div className="text-gray-400">Amount</div>
<div className="text-white font-semibold">
{order.fromAmount} {order.fromToken}
</div>
</div>
<div>
<div className="text-gray-400">Limit Price</div>
<div className="text-white font-semibold">
${order.limitPrice.toFixed(4)}
</div>
</div>
<div>
<div className="text-gray-400">Current Price</div>
<div className="text-white">
${order.currentPrice.toFixed(4)}
</div>
</div>
<div>
<div className="text-gray-400">Distance</div>
<div className={priceDistance > 0 ? 'text-green-400' : 'text-red-400'}>
{priceDistance > 0 ? '+' : ''}{priceDistance.toFixed(2)}%
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
Created {new Date(order.createdAt).toLocaleString()}
</span>
{order.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleCancelOrder(order.id)}
className="h-7 text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
Cancel
</Button>
)}
</div>
</Card>
);
})
)}
</div>
<div className="text-xs text-gray-500 text-center pt-2">
Note: Limit orders require blockchain integration to execute automatically
</div>
</CardContent>
</Card>
);
};
@@ -20,6 +20,7 @@ import {
ArrowDownRight,
Loader2
} from 'lucide-react';
import { LoadingState } from '@pezkuwi/components/AsyncComponent';
interface TreasuryMetrics {
totalBalance: number;
@@ -63,12 +64,7 @@ export const TreasuryOverview: React.FC = () => {
const HealthIcon = healthStatus.icon;
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">Loading treasury data from blockchain...</span>
</div>
);
return <LoadingState message="Loading treasury data from blockchain..." />;
}
if (error) {
+68 -65
View File
@@ -1,7 +1,12 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { supabase } from '@/lib/supabase';
import { User } from '@supabase/supabase-js';
// Session timeout configuration
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
const LAST_ACTIVITY_KEY = 'last_activity_timestamp';
interface AuthContextType {
user: User | null;
loading: boolean;
@@ -12,23 +17,6 @@ interface AuthContextType {
checkAdminStatus: () => Promise<boolean>;
}
// Demo/Founder account credentials from environment variables
// ⚠️ SECURITY: Never hardcode credentials in source code!
const FOUNDER_ACCOUNT = {
email: import.meta.env.VITE_DEMO_FOUNDER_EMAIL || '',
password: import.meta.env.VITE_DEMO_FOUNDER_PASSWORD || '',
id: import.meta.env.VITE_DEMO_FOUNDER_ID || 'founder-001',
user_metadata: {
full_name: 'Satoshi Qazi Muhammed',
phone: '+9647700557978',
recovery_email: 'satoshi@pezkuwichain.io',
founder: true
}
};
// Check if demo mode is enabled
const DEMO_MODE_ENABLED = import.meta.env.VITE_ENABLE_DEMO_MODE === 'true';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
@@ -44,6 +32,66 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
// ========================================
// SESSION TIMEOUT MANAGEMENT
// ========================================
// Update last activity timestamp
const updateLastActivity = useCallback(() => {
localStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
}, []);
// Check if session has timed out
const checkSessionTimeout = useCallback(async () => {
if (!user) return;
const lastActivity = localStorage.getItem(LAST_ACTIVITY_KEY);
if (!lastActivity) {
updateLastActivity();
return;
}
const lastActivityTime = parseInt(lastActivity, 10);
const now = Date.now();
const inactiveTime = now - lastActivityTime;
if (inactiveTime >= SESSION_TIMEOUT_MS) {
console.log('⏱️ Session timeout - logging out due to inactivity');
await signOut();
}
}, [user]);
// Setup activity listeners
useEffect(() => {
if (!user) return;
// Update activity on user interactions
const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart'];
const handleActivity = () => {
updateLastActivity();
};
// Register event listeners
activityEvents.forEach((event) => {
window.addEventListener(event, handleActivity);
});
// Initial activity timestamp
updateLastActivity();
// Check for timeout periodically
const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS);
// Cleanup
return () => {
activityEvents.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
clearInterval(timeoutChecker);
};
}, [user, updateLastActivity, checkSessionTimeout]);
useEffect(() => {
// Check active sessions and sets the user
supabase.auth.getSession().then(({ data: { session } }) => {
@@ -89,42 +137,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const signIn = async (email: string, password: string) => {
// Check if demo mode is enabled and this is the founder account
if (DEMO_MODE_ENABLED && email === FOUNDER_ACCOUNT.email && password === FOUNDER_ACCOUNT.password) {
// Try Supabase first
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (!error && data.user) {
await checkAdminStatus();
return { error: null };
}
} catch {
// Supabase not available
}
// Fallback to demo mode for founder account
const demoUser = {
id: FOUNDER_ACCOUNT.id,
email: FOUNDER_ACCOUNT.email,
user_metadata: FOUNDER_ACCOUNT.user_metadata,
email_confirmed_at: new Date().toISOString(),
created_at: new Date().toISOString(),
} as User;
setUser(demoUser);
setIsAdmin(true);
// Store in localStorage for persistence
localStorage.setItem('demo_user', JSON.stringify(demoUser));
return { error: null };
}
// For other accounts, use Supabase
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
@@ -186,21 +198,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const signOut = async () => {
localStorage.removeItem('demo_user');
setIsAdmin(false);
setUser(null);
localStorage.removeItem(LAST_ACTIVITY_KEY);
await supabase.auth.signOut();
};
// Check for demo user on mount
useEffect(() => {
const demoUser = localStorage.getItem('demo_user');
if (demoUser && !user) {
const parsedUser = JSON.parse(demoUser);
setUser(parsedUser);
setIsAdmin(true);
}
}, []);
return (
<AuthContext.Provider value={{
user,
+355
View File
@@ -0,0 +1,355 @@
/**
* Perwerde Education Platform
*
* Decentralized education system for Digital Kurdistan
* - Browse courses from blockchain
* - Enroll in courses
* - Track learning progress
* - Earn educational credentials
*/
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
GraduationCap,
BookOpen,
Award,
Users,
Clock,
Star,
TrendingUp,
CheckCircle,
Play,
ExternalLink,
} from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from '@/components/ui/use-toast';
import { AsyncComponent, LoadingState } from '@pezkuwi/components/AsyncComponent';
import {
getActiveCourses,
getStudentProgress,
getStudentCourses,
getCourseById,
isEnrolled,
type Course,
type StudentProgress,
formatIPFSLink,
getCourseDifficulty,
} from '@pezkuwi/lib/perwerde';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { handleBlockchainError, handleBlockchainSuccess } from '@pezkuwi/lib/error-handler';
export default function EducationPlatform() {
const { api, selectedAccount, isApiReady } = usePolkadot();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [courses, setCourses] = useState<Course[]>([]);
const [studentProgress, setStudentProgress] = useState<StudentProgress | null>(null);
const [enrolledCourseIds, setEnrolledCourseIds] = useState<number[]>([]);
// Fetch data
useEffect(() => {
const fetchData = async () => {
if (!api || !isApiReady) return;
try {
setLoading(true);
const coursesData = await getActiveCourses(api);
setCourses(coursesData);
// If user is logged in, fetch their progress
if (selectedAccount) {
const [progress, enrolledIds] = await Promise.all([
getStudentProgress(api, selectedAccount.address),
getStudentCourses(api, selectedAccount.address),
]);
setStudentProgress(progress);
setEnrolledCourseIds(enrolledIds);
}
} catch (error) {
console.error('Failed to load education data:', error);
toast({
title: 'Error',
description: 'Failed to load courses data',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
fetchData();
// Refresh every 30 seconds
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [api, isApiReady, selectedAccount]);
const handleEnroll = async (courseId: number) => {
if (!api || !selectedAccount) {
toast({
title: 'Error',
description: 'Please connect your wallet first',
variant: 'destructive',
});
return;
}
try {
const injector = await web3FromAddress(selectedAccount.address);
const tx = api.tx.perwerde.enroll(courseId);
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, dispatchError }) => {
if (status.isInBlock) {
if (dispatchError) {
handleBlockchainError(dispatchError, api, toast);
} else {
handleBlockchainSuccess('perwerde.enrolled', toast);
// Refresh data
setTimeout(async () => {
if (api && selectedAccount) {
const [progress, enrolledIds] = await Promise.all([
getStudentProgress(api, selectedAccount.address),
getStudentCourses(api, selectedAccount.address),
]);
setStudentProgress(progress);
setEnrolledCourseIds(enrolledIds);
}
}, 2000);
}
}
}
);
} catch (error: any) {
console.error('Enroll failed:', error);
toast({
title: 'Error',
description: error.message || 'Failed to enroll in course',
variant: 'destructive',
});
}
};
if (loading) {
return <LoadingState message="Loading education platform..." />;
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2 flex items-center gap-3">
<GraduationCap className="w-10 h-10 text-green-500" />
Perwerde - Education Platform
</h1>
<p className="text-gray-400">
Decentralized learning for Digital Kurdistan. Build skills, earn credentials, empower our nation.
</p>
</div>
{/* Stats Cards */}
{studentProgress && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-blue-500/10 flex items-center justify-center">
<BookOpen className="w-6 h-6 text-blue-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{studentProgress.totalCourses}</div>
<div className="text-sm text-gray-400">Enrolled Courses</div>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{studentProgress.completedCourses}</div>
<div className="text-sm text-gray-400">Completed</div>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-purple-500/10 flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{studentProgress.activeCourses}</div>
<div className="text-sm text-gray-400">In Progress</div>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Award className="w-6 h-6 text-yellow-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{studentProgress.totalPoints}</div>
<div className="text-sm text-gray-400">Total Points</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Courses List */}
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">
{courses.length > 0 ? `Available Courses (${courses.length})` : 'No Courses Available'}
</h2>
</div>
{courses.length === 0 ? (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="p-12 text-center">
<BookOpen className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-400 mb-2">No Active Courses</h3>
<p className="text-gray-500 mb-6">
Check back later for new educational content. Courses will be added by educators.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-6">
{courses.map((course) => {
const isUserEnrolled = enrolledCourseIds.includes(course.id);
return (
<Card
key={course.id}
className="bg-gray-900 border-gray-800 hover:border-green-500/50 transition-colors"
>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-6">
{/* Course Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-bold text-white">{course.name}</h3>
<Badge className="bg-green-500/10 text-green-400 border-green-500/30">
#{course.id}
</Badge>
{isUserEnrolled && (
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/30">
Enrolled
</Badge>
)}
</div>
<p className="text-gray-400 mb-4">{course.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<div className="flex items-center gap-1">
<GraduationCap className="w-4 h-4" />
{course.owner.slice(0, 8)}...{course.owner.slice(-6)}
</div>
{course.contentLink && (
<a
href={formatIPFSLink(course.contentLink)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-green-400 hover:text-green-300"
>
<ExternalLink className="w-4 h-4" />
Course Materials
</a>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
{isUserEnrolled ? (
<>
<Button className="bg-blue-600 hover:bg-blue-700">
<Play className="w-4 h-4 mr-2" />
Continue Learning
</Button>
<Button variant="outline">View Progress</Button>
</>
) : (
<>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => handleEnroll(course.id)}
disabled={!selectedAccount}
>
Enroll Now
</Button>
<Button variant="outline">View Details</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
{/* Blockchain Features */}
<Card className="mt-8 bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<CheckCircle className="w-5 h-5 text-green-500" />
Blockchain-Powered Education
</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-4 text-sm">
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Decentralized course hosting (IPFS)
</li>
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
On-chain enrollment & completion tracking
</li>
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Points-based achievement system
</li>
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Trust score integration
</li>
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Transparent educator verification
</li>
<li className="flex items-center gap-2 text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Immutable learning records
</li>
</ul>
</CardContent>
</Card>
</div>
);
}
+461
View File
@@ -0,0 +1,461 @@
/**
* Welati Elections & Governance Page
*
* Features:
* - View active elections (Presidential, Parliamentary, Speaker, Constitutional Court)
* - Register as candidate
* - Cast votes
* - View proposals & vote on them
* - See government officials
*/
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Vote,
Users,
Trophy,
Clock,
FileText,
CheckCircle2,
XCircle,
AlertCircle,
Crown,
Scale,
Building,
} from 'lucide-react';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from '@/components/ui/use-toast';
import { AsyncComponent, LoadingState } from '@pezkuwi/components/AsyncComponent';
import {
getActiveElections,
getElectionCandidates,
getActiveProposals,
getCurrentOfficials,
getCurrentMinisters,
getElectionTypeLabel,
getElectionStatusLabel,
getMinisterRoleLabel,
blocksToTime,
getRemainingBlocks,
type ElectionInfo,
type CollectiveProposal,
type CandidateInfo,
} from '@pezkuwi/lib/welati';
import { handleBlockchainError, handleBlockchainSuccess } from '@pezkuwi/lib/error-handler';
import { web3FromAddress } from '@polkadot/extension-dapp';
export default function Elections() {
const { api, selectedAccount, isApiReady } = usePolkadot();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [elections, setElections] = useState<ElectionInfo[]>([]);
const [proposals, setProposals] = useState<CollectiveProposal[]>([]);
const [officials, setOfficials] = useState<any>({});
const [ministers, setMinisters] = useState<any>({});
// Fetch data
useEffect(() => {
const fetchData = async () => {
if (!api || !isApiReady) return;
try {
setLoading(true);
const [electionsData, proposalsData, officialsData, ministersData] = await Promise.all([
getActiveElections(api),
getActiveProposals(api),
getCurrentOfficials(api),
getCurrentMinisters(api),
]);
setElections(electionsData);
setProposals(proposalsData);
setOfficials(officialsData);
setMinisters(ministersData);
} catch (error) {
console.error('Failed to load elections data:', error);
toast({
title: 'Error',
description: 'Failed to load elections data',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
fetchData();
// Refresh every 30 seconds
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [api, isApiReady]);
if (loading) {
return <LoadingState message="Loading elections and governance data..." />;
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Welati - Elections & Governance</h1>
<p className="text-gray-400">
Democratic governance for Digital Kurdistan. Vote, propose, and participate in building our nation.
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="elections" className="space-y-6">
<TabsList className="grid w-full grid-cols-3 lg:w-auto bg-gray-900">
<TabsTrigger value="elections">
<Vote className="w-4 h-4 mr-2" />
Elections
</TabsTrigger>
<TabsTrigger value="proposals">
<FileText className="w-4 h-4 mr-2" />
Proposals
</TabsTrigger>
<TabsTrigger value="government">
<Crown className="w-4 h-4 mr-2" />
Government
</TabsTrigger>
</TabsList>
{/* Elections Tab */}
<TabsContent value="elections" className="space-y-6">
{elections.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No active elections at this time. Check back later for upcoming elections.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-6">
{elections.map((election) => (
<ElectionCard key={election.electionId} election={election} api={api} />
))}
</div>
)}
</TabsContent>
{/* Proposals Tab */}
<TabsContent value="proposals" className="space-y-6">
{proposals.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No active proposals at this time. Parliament members can submit new proposals.
</AlertDescription>
</Alert>
) : (
<div className="grid gap-6">
{proposals.map((proposal) => (
<ProposalCard key={proposal.proposalId} proposal={proposal} api={api} />
))}
</div>
)}
</TabsContent>
{/* Government Tab */}
<TabsContent value="government" className="space-y-6">
<GovernmentOfficials officials={officials} ministers={ministers} />
</TabsContent>
</Tabs>
</div>
);
}
// ============================================================================
// ELECTION CARD
// ============================================================================
function ElectionCard({ election, api }: { election: ElectionInfo; api: any }) {
const [candidates, setCandidates] = useState<CandidateInfo[]>([]);
const [timeLeft, setTimeLeft] = useState<any>(null);
const typeLabel = getElectionTypeLabel(election.electionType);
const statusLabel = getElectionStatusLabel(election.status);
useEffect(() => {
if (!api) return;
// Load candidates
getElectionCandidates(api, election.electionId).then(setCandidates);
// Update time left
const updateTime = async () => {
let targetBlock = election.votingEndBlock;
if (election.status === 'CandidacyPeriod') targetBlock = election.candidacyEndBlock;
else if (election.status === 'CampaignPeriod') targetBlock = election.campaignEndBlock;
const remaining = await getRemainingBlocks(api, targetBlock);
setTimeLeft(blocksToTime(remaining));
};
updateTime();
const interval = setInterval(updateTime, 10000);
return () => clearInterval(interval);
}, [api, election]);
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-2xl text-white">{typeLabel.en}</CardTitle>
<CardDescription className="text-gray-400 mt-1">{typeLabel.kmr}</CardDescription>
</div>
<Badge className="bg-green-500/10 text-green-400 border-green-500/30">
{statusLabel.en}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<Users className="w-4 h-4" />
<span className="text-sm">Candidates</span>
</div>
<div className="text-2xl font-bold text-white">{election.totalCandidates}</div>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<Vote className="w-4 h-4" />
<span className="text-sm">Votes Cast</span>
</div>
<div className="text-2xl font-bold text-white">{election.totalVotes.toLocaleString()}</div>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<Clock className="w-4 h-4" />
<span className="text-sm">Time Left</span>
</div>
<div className="text-lg font-bold text-white">
{timeLeft ? `${timeLeft.days}d ${timeLeft.hours}h` : '-'}
</div>
</div>
</div>
{/* Top Candidates */}
{candidates.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-400 mb-3">Leading Candidates</h4>
<div className="space-y-2">
{candidates.slice(0, 5).map((candidate, idx) => (
<div
key={candidate.account}
className="flex items-center justify-between p-3 bg-gray-800/30 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-green-500/10 border border-green-500/30 flex items-center justify-center">
<span className="text-green-400 font-bold text-sm">#{idx + 1}</span>
</div>
<div>
<div className="text-white text-sm font-medium">
{candidate.account.slice(0, 12)}...{candidate.account.slice(-8)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-yellow-500" />
<span className="text-white font-bold">{candidate.voteCount.toLocaleString()}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
{election.status === 'CandidacyPeriod' && (
<Button className="flex-1 bg-green-600 hover:bg-green-700">
Register as Candidate
</Button>
)}
{election.status === 'VotingPeriod' && (
<Button className="flex-1 bg-green-600 hover:bg-green-700">
<Vote className="w-4 h-4 mr-2" />
Cast Your Vote
</Button>
)}
<Button variant="outline" className="flex-1">
View Details
</Button>
</div>
</CardContent>
</Card>
);
}
// ============================================================================
// PROPOSAL CARD
// ============================================================================
function ProposalCard({ proposal, api }: { proposal: CollectiveProposal; api: any }) {
const [timeLeft, setTimeLeft] = useState<any>(null);
const totalVotes = proposal.ayeVotes + proposal.nayVotes + proposal.abstainVotes;
const ayePercent = totalVotes > 0 ? Math.round((proposal.ayeVotes / totalVotes) * 100) : 0;
const nayPercent = totalVotes > 0 ? Math.round((proposal.nayVotes / totalVotes) * 100) : 0;
useEffect(() => {
if (!api) return;
const updateTime = async () => {
const remaining = await getRemainingBlocks(api, proposal.expiresAt);
setTimeLeft(blocksToTime(remaining));
};
updateTime();
const interval = setInterval(updateTime, 10000);
return () => clearInterval(interval);
}, [api, proposal]);
return (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-xl text-white">#{proposal.proposalId} {proposal.title}</CardTitle>
<CardDescription className="text-gray-400 mt-1">{proposal.description}</CardDescription>
</div>
<Badge
className={
proposal.status === 'Active'
? 'bg-green-500/10 text-green-400 border-green-500/30'
: 'bg-gray-500/10 text-gray-400'
}
>
{proposal.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Vote Progress */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Aye ({proposal.ayeVotes})</span>
<span className="text-gray-400">Nay ({proposal.nayVotes})</span>
</div>
<div className="h-3 bg-gray-800 rounded-full overflow-hidden flex">
<div className="bg-green-500" style={{ width: `${ayePercent}%` }} />
<div className="bg-red-500" style={{ width: `${nayPercent}%` }} />
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{ayePercent}% Aye</span>
<span>
{proposal.votesCast} / {proposal.threshold} votes cast
</span>
<span>{nayPercent}% Nay</span>
</div>
</div>
{/* Metadata */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-400">
<Clock className="w-4 h-4" />
{timeLeft && `${timeLeft.days}d ${timeLeft.hours}h remaining`}
</div>
<Badge variant="outline">{proposal.decisionType}</Badge>
</div>
{/* Actions */}
{proposal.status === 'Active' && (
<div className="grid grid-cols-3 gap-2">
<Button className="bg-green-600 hover:bg-green-700">
<CheckCircle2 className="w-4 h-4 mr-1" />
Aye
</Button>
<Button className="bg-red-600 hover:bg-red-700">
<XCircle className="w-4 h-4 mr-1" />
Nay
</Button>
<Button variant="outline">Abstain</Button>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// GOVERNMENT OFFICIALS
// ============================================================================
function GovernmentOfficials({ officials, ministers }: { officials: any; ministers: any }) {
return (
<div className="space-y-6">
{/* Executive */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<Crown className="w-5 h-5 text-yellow-500" />
Executive Branch
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{officials.serok && (
<OfficeRow title="Serok (President)" address={officials.serok} icon={Crown} />
)}
{officials.serokWeziran && (
<OfficeRow title="Serok Weziran (Prime Minister)" address={officials.serokWeziran} icon={Building} />
)}
{officials.meclisBaskanı && (
<OfficeRow title="Meclis Başkanı (Speaker)" address={officials.meclisBaskanı} icon={Scale} />
)}
</CardContent>
</Card>
{/* Cabinet */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<Building className="w-5 h-5" />
Cabinet Ministers
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{Object.entries(ministers).map(
([role, address]: [string, any]) =>
address && (
<OfficeRow
key={role}
title={getMinisterRoleLabel(role as any).en}
address={address}
icon={Users}
/>
)
)}
{Object.values(ministers).every((v) => !v) && (
<div className="text-gray-400 text-sm text-center py-4">No ministers appointed yet</div>
)}
</CardContent>
</Card>
</div>
);
}
function OfficeRow({ title, address, icon: Icon }: { title: string; address: string; icon: any }) {
return (
<div className="flex items-center justify-between p-3 bg-gray-800/30 rounded-lg">
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-green-400" />
<span className="text-white font-medium">{title}</span>
</div>
<span className="text-gray-400 text-sm font-mono">
{address.slice(0, 8)}...{address.slice(-6)}
</span>
</div>
);
}
+6 -5
View File
@@ -14,11 +14,12 @@
"@pezkuwi/theme": ["../shared/theme"],
"@pezkuwi/types": ["../shared/types"]
},
"noImplicitAny": false,
"noUnusedParameters": false,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"skipLibCheck": true,
"allowJs": true,
"noUnusedLocals": false,
"strictNullChecks": false
"allowJs": false,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}