mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-06-14 01:41:13 +00:00
Initial commit - PezkuwiChain Telegram MiniApp
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { trackError } from '@/lib/error-tracking';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
componentName?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
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> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// Track error with context
|
||||
trackError(error, {
|
||||
component: this.props.componentName ?? 'ErrorBoundary',
|
||||
action: 'component_crash',
|
||||
extra: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-background text-foreground">
|
||||
<div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mb-2">Tiştek çewt çêbû</h1>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6 max-w-xs">
|
||||
Bibore, pirsgirêkek teknîkî derket. Ji kerema xwe dîsa biceribîne.
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary rounded-lg text-primary-foreground font-medium"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Dîsa biceribîne
|
||||
</button>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<div className="mt-6 w-full max-w-lg">
|
||||
<pre className="p-4 bg-secondary rounded-lg text-xs text-red-400 overflow-auto max-h-48">
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
{'\n\n'}
|
||||
<strong>Stack:</strong>
|
||||
{'\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
{this.state.errorInfo?.componentStack && (
|
||||
<pre className="mt-2 p-4 bg-secondary rounded-lg text-xs text-yellow-400 overflow-auto max-h-32">
|
||||
<strong>Component Stack:</strong>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Kurdistan Sun Component
|
||||
* Animated sun with 21 rays and rotating kesk-sor-zer halos
|
||||
*/
|
||||
|
||||
interface KurdistanSunProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KurdistanSun({ size = 200, className = '' }: KurdistanSunProps) {
|
||||
return (
|
||||
<div className={`kurdistan-sun-container ${className}`} style={{ width: size, height: size }}>
|
||||
{/* Rotating colored halos - Kesk u Sor u Zer */}
|
||||
<div className="sun-halos">
|
||||
{/* Green halo (outermost) - Kesk */}
|
||||
<div className="halo halo-green" />
|
||||
{/* Red halo (middle) - Sor */}
|
||||
<div className="halo halo-red" />
|
||||
{/* Yellow halo (inner) - Zer */}
|
||||
<div className="halo halo-yellow" />
|
||||
</div>
|
||||
|
||||
{/* Kurdistan Sun with 21 rays */}
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className="kurdistan-sun-svg"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{/* Sun rays (21 rays for Kurdistan flag) */}
|
||||
<g className="sun-rays">
|
||||
{Array.from({ length: 21 }).map((_, i) => {
|
||||
const angle = (i * 360) / 21;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2="100"
|
||||
y2="20"
|
||||
stroke="rgba(255, 255, 255, 0.9)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(${angle} 100 100)`}
|
||||
className="ray"
|
||||
style={{
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Central white circle */}
|
||||
<circle cx="100" cy="100" r="35" fill="white" className="sun-center" />
|
||||
|
||||
{/* Inner glow */}
|
||||
<circle cx="100" cy="100" r="35" fill="url(#sunGradient)" className="sun-glow" />
|
||||
|
||||
<defs>
|
||||
<radialGradient id="sunGradient">
|
||||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.8)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<style>{`
|
||||
.kurdistan-sun-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sun-halos {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.halo {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: rotate-halo 3s linear infinite;
|
||||
}
|
||||
|
||||
.halo-green {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #00FF00;
|
||||
border-bottom-color: #00FF00;
|
||||
animation-duration: 3s;
|
||||
}
|
||||
|
||||
.halo-red {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
border: 4px solid transparent;
|
||||
border-left-color: #FF0000;
|
||||
border-right-color: #FF0000;
|
||||
animation-duration: 2.5s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.halo-yellow {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #FFD700;
|
||||
border-bottom-color: #FFD700;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
@keyframes rotate-halo {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.kurdistan-sun-svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.sun-rays {
|
||||
animation: pulse-rays 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-rays {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ray {
|
||||
animation: ray-shine 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ray-shine {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sun-center {
|
||||
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
|
||||
.sun-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingScreen({ message = 'Tê barkirin...' }: LoadingScreenProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react';
|
||||
import { X, ExternalLink, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface P2PModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenP2P: () => void;
|
||||
}
|
||||
|
||||
type Language = 'en' | 'ckb' | 'ku' | 'tr';
|
||||
|
||||
const LANGUAGES: { code: Language; label: string }[] = [
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'ckb', label: 'سۆرانی' },
|
||||
{ code: 'ku', label: 'Kurmancî' },
|
||||
{ code: 'tr', label: 'TR' },
|
||||
];
|
||||
|
||||
const CONTENT: Record<
|
||||
Language,
|
||||
{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
firstTime: string;
|
||||
steps: string[];
|
||||
note: string;
|
||||
button: string;
|
||||
}
|
||||
> = {
|
||||
en: {
|
||||
title: 'P2P Exchange',
|
||||
subtitle: 'Trade crypto peer-to-peer',
|
||||
firstTime: 'First time using P2P?',
|
||||
steps: [
|
||||
'Click the button below to open the web app',
|
||||
'Create an account or log in',
|
||||
'Complete the P2P setup process',
|
||||
'After setup, you can access P2P directly',
|
||||
],
|
||||
note: 'The web app will open in a new window. Complete the registration process there.',
|
||||
button: 'Open P2P Platform',
|
||||
},
|
||||
ckb: {
|
||||
title: 'P2P ئاڵۆگۆڕ',
|
||||
subtitle: 'ئاڵۆگۆڕی کریپتۆ لە نێوان کەسەکاندا',
|
||||
firstTime: 'یەکەم جار P2P بەکاردەهێنیت؟',
|
||||
steps: [
|
||||
'کلیک لە دوگمەی خوارەوە بکە بۆ کردنەوەی ماڵپەڕ',
|
||||
'هەژمارێک دروست بکە یان بچۆ ژوورەوە',
|
||||
'پرۆسەی دامەزراندنی P2P تەواو بکە',
|
||||
'دوای دامەزراندن، دەتوانیت ڕاستەوخۆ بچیتە P2P',
|
||||
],
|
||||
note: 'ماڵپەڕ لە پەنجەرەیەکی نوێ دەکرێتەوە. پرۆسەی تۆمارکردن لەوێ تەواو بکە.',
|
||||
button: 'کردنەوەی P2P',
|
||||
},
|
||||
ku: {
|
||||
title: 'P2P Danûstandin',
|
||||
subtitle: 'Danûstandina krîpto di navbera kesan de',
|
||||
firstTime: 'Cara yekem P2P bikar tînin?',
|
||||
steps: [
|
||||
'Li bişkoja jêrîn bikirtînin da ku malpera webê vebike',
|
||||
'Hesabek çêbikin an têkevin',
|
||||
'Pêvajoya sazkirina P2P temam bikin',
|
||||
'Piştî sazkirinê, hûn dikarin rasterast bigihîjin P2P',
|
||||
],
|
||||
note: 'Malpera webê di pencereyek nû de vedibe. Pêvajoya qeydkirinê li wir temam bikin.',
|
||||
button: 'P2P Veke',
|
||||
},
|
||||
tr: {
|
||||
title: 'P2P Borsa',
|
||||
subtitle: 'Kullanıcılar arası kripto alım satım',
|
||||
firstTime: "P2P'yi ilk kez mi kullanıyorsunuz?",
|
||||
steps: [
|
||||
'Web uygulamasını açmak için aşağıdaki butona tıklayın',
|
||||
'Hesap oluşturun veya giriş yapın',
|
||||
'P2P kurulum sürecini tamamlayın',
|
||||
"Kurulumdan sonra P2P'ye doğrudan erişebilirsiniz",
|
||||
],
|
||||
note: 'Web uygulaması yeni bir pencerede açılacak. Kayıt işlemini orada tamamlayın.',
|
||||
button: 'P2P Platformunu Aç',
|
||||
},
|
||||
};
|
||||
|
||||
export function P2PModal({ isOpen, onClose, onOpenP2P }: P2PModalProps) {
|
||||
const [lang, setLang] = useState<Language>('en');
|
||||
const content = CONTENT[lang];
|
||||
const isRTL = lang === 'ckb';
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOpenP2P = () => {
|
||||
onOpenP2P();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
|
||||
isRTL && 'direction-rtl'
|
||||
)}
|
||||
dir={isRTL ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-foreground">{content.title}</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="flex gap-2 p-4 pb-0">
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLang(l.code)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
|
||||
lang === l.code
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{content.subtitle}</p>
|
||||
|
||||
{/* First Time Info */}
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-amber-400">{content.firstTime}</p>
|
||||
<ol className="space-y-1.5">
|
||||
{content.steps.map((step, i) => (
|
||||
<li key={i} className="text-xs text-amber-200/80 flex gap-2">
|
||||
<span className="font-semibold text-amber-400">{i + 1}.</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-muted-foreground">{content.note}</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="p-4 pt-0">
|
||||
<button
|
||||
onClick={handleOpenP2P}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-cyan-500 hover:bg-cyan-600 text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
{content.button}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
|
||||
interface SocialLink {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SOCIAL_LINKS: SocialLink[] = [
|
||||
{
|
||||
name: 'Instagram',
|
||||
url: 'https://www.instagram.com/pezkuwichain',
|
||||
icon: '📸',
|
||||
color: 'from-pink-500 to-purple-600',
|
||||
description: 'Wêne û Story',
|
||||
},
|
||||
{
|
||||
name: 'TikTok',
|
||||
url: 'https://www.tiktok.com/@pezkuwi.chain',
|
||||
icon: '🎵',
|
||||
color: 'from-gray-800 to-gray-900',
|
||||
description: 'Vîdyoyên kurt',
|
||||
},
|
||||
{
|
||||
name: 'Snapchat',
|
||||
url: 'https://www.snapchat.com/add/pezkuwichain',
|
||||
icon: '👻',
|
||||
color: 'from-yellow-400 to-yellow-500',
|
||||
description: 'Snap bike!',
|
||||
},
|
||||
{
|
||||
name: 'Telegram',
|
||||
url: 'https://t.me/pezkuwichain',
|
||||
icon: '📢',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
description: 'Kanala fermî',
|
||||
},
|
||||
{
|
||||
name: 'X (Twitter)',
|
||||
url: 'https://x.com/pezkuwichain',
|
||||
icon: '𝕏',
|
||||
color: 'from-gray-700 to-gray-900',
|
||||
description: 'Nûçeyên rojane',
|
||||
},
|
||||
{
|
||||
name: 'YouTube',
|
||||
url: 'https://www.youtube.com/@SatoshiQazi',
|
||||
icon: '▶️',
|
||||
color: 'from-red-500 to-red-700',
|
||||
description: 'Vîdyoyên me',
|
||||
},
|
||||
{
|
||||
name: 'Facebook',
|
||||
url: 'https://www.facebook.com/people/Pezkuwi-Chain/61587122224932/',
|
||||
icon: '📘',
|
||||
color: 'from-blue-600 to-blue-800',
|
||||
description: 'Rûpela fermî',
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
url: 'https://discord.gg/Y3VyEC6h8W',
|
||||
icon: '💬',
|
||||
color: 'from-indigo-500 to-purple-600',
|
||||
description: 'Civaka me',
|
||||
},
|
||||
];
|
||||
|
||||
export function SocialLinks() {
|
||||
const { openLink, hapticImpact } = useTelegram();
|
||||
|
||||
const handleClick = (url: string) => {
|
||||
hapticImpact('light');
|
||||
openLink(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
|
||||
<h3 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-primary" />
|
||||
Me bişopîne
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Bi me re têkiliyê ragire û nûçeyên herî dawî bistîne!
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<button
|
||||
key={link.name}
|
||||
onClick={() => handleClick(link.url)}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-xl
|
||||
bg-gradient-to-r ${link.color} bg-opacity-10
|
||||
hover:opacity-90 transition-opacity
|
||||
border border-white/10
|
||||
`}
|
||||
>
|
||||
<span className="text-2xl">{link.icon}</span>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{link.name}</p>
|
||||
<p className="text-xs text-white/70">{link.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Update Notification Component
|
||||
* Shows when a new version is available
|
||||
*/
|
||||
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { useVersion } from '@/hooks/useVersion';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const { hasUpdate, forceUpdate, dismissUpdate, currentVersion } = useVersion();
|
||||
const { hapticImpact } = useTelegram();
|
||||
|
||||
if (!hasUpdate) return null;
|
||||
|
||||
const handleUpdate = () => {
|
||||
hapticImpact('medium');
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
hapticImpact('light');
|
||||
dismissUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 right-4 z-50 animate-in slide-in-from-bottom-4 fade-in duration-300">
|
||||
<div className="bg-primary text-primary-foreground rounded-xl p-4 shadow-lg border border-primary/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm">Guhertoya nû heye!</h4>
|
||||
<p className="text-xs opacity-90 mt-0.5">
|
||||
Ji bo taybetmendiyên nû û rastkirinên ewlehiyê nûve bike.
|
||||
</p>
|
||||
<p className="text-[10px] opacity-70 mt-1">v{currentVersion}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-1 rounded-lg hover:bg-white/20 transition-colors flex-shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 py-2 px-3 rounded-lg bg-white/10 hover:bg-white/20 text-sm font-medium transition-colors"
|
||||
>
|
||||
Paşê
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="flex-1 py-2 px-3 rounded-lg bg-white text-primary text-sm font-medium hover:bg-white/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Nûve bike
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateNotification;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Version Info Component
|
||||
* Displays current app version
|
||||
*/
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import { useVersion } from '@/hooks/useVersion';
|
||||
|
||||
interface VersionInfoProps {
|
||||
className?: string;
|
||||
showBuildTime?: boolean;
|
||||
}
|
||||
|
||||
export function VersionInfo({ className = '', showBuildTime = false }: VersionInfoProps) {
|
||||
const { currentVersion, buildTime } = useVersion();
|
||||
|
||||
const formattedBuildTime = new Date(buildTime).toLocaleDateString('ku', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs text-muted-foreground ${className}`}>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>v{currentVersion}</span>
|
||||
{showBuildTime && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
<span className="opacity-70">{formattedBuildTime}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VersionInfo;
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Fund Fees Modal - XCM Teleport HEZ to Teyerchains
|
||||
* Allows users to transfer HEZ from relay chain to Asset Hub or People chain for fees
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, ArrowDown, Loader2, CheckCircle, AlertCircle, Fuel, Info } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
|
||||
type TargetChain = 'asset-hub' | 'people';
|
||||
|
||||
interface ChainInfo {
|
||||
id: TargetChain;
|
||||
name: string;
|
||||
description: string;
|
||||
teyrchainId: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const TARGET_CHAINS: ChainInfo[] = [
|
||||
{
|
||||
id: 'asset-hub',
|
||||
name: 'Asset Hub',
|
||||
description: 'Ji bo PEZ veguheztin',
|
||||
teyrchainId: 1000,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
name: 'People Chain',
|
||||
description: 'Ji bo nasname',
|
||||
teyrchainId: 1004,
|
||||
color: 'purple',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FundFeesModal({ isOpen, onClose }: Props) {
|
||||
const { api, assetHubApi, peopleApi, address, keypair } = useWallet();
|
||||
const { hapticImpact, showAlert } = useTelegram();
|
||||
|
||||
const [targetChain, setTargetChain] = useState<TargetChain>('asset-hub');
|
||||
const [amount, setAmount] = useState('0.5');
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>(
|
||||
'idle'
|
||||
);
|
||||
const [relayBalance, setRelayBalance] = useState<string>('--');
|
||||
const [assetHubBalance, setAssetHubBalance] = useState<string>('--');
|
||||
const [peopleBalance, setPeopleBalance] = useState<string>('--');
|
||||
|
||||
const selectedChain = TARGET_CHAINS.find((c) => c.id === targetChain) || TARGET_CHAINS[0];
|
||||
|
||||
// Fetch balances
|
||||
useEffect(() => {
|
||||
const fetchBalances = async () => {
|
||||
if (!address) return;
|
||||
|
||||
// Relay chain balance
|
||||
if (api) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accountInfo = (await (api.query.system as any).account(address)) as {
|
||||
data: { free: { toString(): string } };
|
||||
};
|
||||
const free = accountInfo.data.free.toString();
|
||||
const balanceNum = Number(free) / 1e12;
|
||||
setRelayBalance(balanceNum.toFixed(4));
|
||||
} catch (err) {
|
||||
console.error('Error fetching relay balance:', err);
|
||||
setRelayBalance('0.0000');
|
||||
}
|
||||
} else {
|
||||
setRelayBalance('--');
|
||||
}
|
||||
|
||||
// Asset Hub balance
|
||||
if (assetHubApi) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accountInfo = (await (assetHubApi.query.system as any).account(address)) as {
|
||||
data: { free: { toString(): string } };
|
||||
};
|
||||
const free = accountInfo.data.free.toString();
|
||||
const balanceNum = Number(free) / 1e12;
|
||||
setAssetHubBalance(balanceNum.toFixed(4));
|
||||
} catch (err) {
|
||||
console.error('Error fetching Asset Hub balance:', err);
|
||||
setAssetHubBalance('0.0000');
|
||||
}
|
||||
} else {
|
||||
setAssetHubBalance('--');
|
||||
}
|
||||
|
||||
// People chain balance
|
||||
if (peopleApi) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accountInfo = (await (peopleApi.query.system as any).account(address)) as {
|
||||
data: { free: { toString(): string } };
|
||||
};
|
||||
const free = accountInfo.data.free.toString();
|
||||
const balanceNum = Number(free) / 1e12;
|
||||
setPeopleBalance(balanceNum.toFixed(4));
|
||||
} catch (err) {
|
||||
console.error('Error fetching People chain balance:', err);
|
||||
setPeopleBalance('0.0000');
|
||||
}
|
||||
} else {
|
||||
setPeopleBalance('--');
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
fetchBalances();
|
||||
}
|
||||
}, [api, assetHubApi, peopleApi, address, isOpen]);
|
||||
|
||||
const getTargetBalance = () => {
|
||||
return targetChain === 'asset-hub' ? assetHubBalance : peopleBalance;
|
||||
};
|
||||
|
||||
const handleTeleport = async () => {
|
||||
if (!api || !address || !keypair) {
|
||||
showAlert('Cizdan girêdayî nîne');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!amount || parseFloat(amount) <= 0) {
|
||||
showAlert('Mîqdarek rast binivîse');
|
||||
return;
|
||||
}
|
||||
|
||||
if (relayBalance === '--') {
|
||||
showAlert('Relay Chain girêdayî nîne');
|
||||
return;
|
||||
}
|
||||
|
||||
const sendAmount = parseFloat(amount);
|
||||
const currentBalance = parseFloat(relayBalance);
|
||||
|
||||
if (sendAmount > currentBalance) {
|
||||
showAlert('Bakiye têrê nake');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTransferring(true);
|
||||
setTxStatus('signing');
|
||||
hapticImpact('medium');
|
||||
|
||||
try {
|
||||
// Convert to smallest unit (12 decimals)
|
||||
const amountInSmallestUnit = BigInt(Math.floor(parseFloat(amount) * 1e12));
|
||||
|
||||
// Get target teyrchain ID
|
||||
const targetTeyrchainId = selectedChain.teyrchainId;
|
||||
|
||||
// Destination: Target teyrchain
|
||||
const dest = {
|
||||
V3: {
|
||||
parents: 0,
|
||||
interior: {
|
||||
X1: { teyrchain: targetTeyrchainId },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Beneficiary: Same account on target chain
|
||||
const beneficiary = {
|
||||
V3: {
|
||||
parents: 0,
|
||||
interior: {
|
||||
X1: {
|
||||
accountid32: {
|
||||
network: null,
|
||||
id: api.createType('AccountId32', address).toHex(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Assets: Native token (HEZ)
|
||||
const assets = {
|
||||
V3: [
|
||||
{
|
||||
id: {
|
||||
Concrete: {
|
||||
parents: 0,
|
||||
interior: 'Here',
|
||||
},
|
||||
},
|
||||
fun: {
|
||||
Fungible: amountInSmallestUnit.toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fee asset ID: Native HEZ token
|
||||
const feeAssetId = {
|
||||
V3: {
|
||||
Concrete: {
|
||||
parents: 0,
|
||||
interior: 'Here',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const weightLimit = 'Unlimited';
|
||||
|
||||
// Create teleport transaction
|
||||
const tx = api.tx.xcmPallet.limitedTeleportAssets(
|
||||
dest,
|
||||
beneficiary,
|
||||
assets,
|
||||
feeAssetId,
|
||||
weightLimit
|
||||
);
|
||||
|
||||
setTxStatus('pending');
|
||||
|
||||
const unsub = await tx.signAndSend(keypair, ({ status, dispatchError }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Teleport neserketî';
|
||||
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}`;
|
||||
}
|
||||
|
||||
setTxStatus('error');
|
||||
hapticImpact('heavy');
|
||||
showAlert(errorMessage);
|
||||
} else {
|
||||
setTxStatus('success');
|
||||
hapticImpact('medium');
|
||||
|
||||
// Reset after success
|
||||
setTimeout(() => {
|
||||
setAmount('0.5');
|
||||
setTxStatus('idle');
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
setIsTransferring(false);
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Teleport error:', error);
|
||||
setTxStatus('error');
|
||||
setIsTransferring(false);
|
||||
hapticImpact('heavy');
|
||||
showAlert(error instanceof Error ? error.message : 'Çewtiyekî çêbû');
|
||||
}
|
||||
};
|
||||
|
||||
const setQuickAmount = (percent: number) => {
|
||||
const balance = parseFloat(relayBalance);
|
||||
if (balance > 0) {
|
||||
const quickAmount = ((balance * percent) / 100).toFixed(4);
|
||||
setAmount(quickAmount);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-background rounded-t-3xl p-6 pb-8 animate-slide-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-full">
|
||||
<Fuel className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Fee Zêde Bike</h2>
|
||||
<p className="text-xs text-muted-foreground">HEZ teleport</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isTransferring}
|
||||
className="p-2 text-muted-foreground hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{txStatus === 'success' ? (
|
||||
<div className="py-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">Serketî!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{amount} HEZ bo {selectedChain.name} hate şandin
|
||||
</p>
|
||||
</div>
|
||||
) : txStatus === 'error' ? (
|
||||
<div className="py-8 text-center">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">Neserketî</h3>
|
||||
<button
|
||||
onClick={() => setTxStatus('idle')}
|
||||
className="mt-4 px-6 py-2 bg-muted rounded-lg"
|
||||
>
|
||||
Dîsa Biceribîne
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Target Chain Selection */}
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-2 block">Zincîra Armanc</label>
|
||||
<div className="flex gap-2">
|
||||
{TARGET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={chain.id}
|
||||
onClick={() => {
|
||||
setTargetChain(chain.id);
|
||||
hapticImpact('light');
|
||||
}}
|
||||
className={`flex-1 p-3 rounded-xl border transition-all ${
|
||||
targetChain === chain.id
|
||||
? chain.id === 'asset-hub'
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-border bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mb-1 mx-auto ${
|
||||
chain.id === 'asset-hub' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-sm font-medium">{chain.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{chain.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Display */}
|
||||
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-muted-foreground">Relay Chain</span>
|
||||
</div>
|
||||
<span className="font-mono">{relayBalance} HEZ</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<ArrowDown className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
targetChain === 'asset-hub' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{selectedChain.name}</span>
|
||||
</div>
|
||||
<span className="font-mono">{getTargetBalance()} HEZ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div
|
||||
className={`p-3 rounded-lg flex gap-2 ${
|
||||
targetChain === 'asset-hub'
|
||||
? 'bg-blue-500/10 border border-blue-500/30'
|
||||
: 'bg-purple-500/10 border border-purple-500/30'
|
||||
}`}
|
||||
>
|
||||
<Info
|
||||
className={`w-5 h-5 flex-shrink-0 ${
|
||||
targetChain === 'asset-hub' ? 'text-blue-400' : 'text-purple-400'
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
targetChain === 'asset-hub' ? 'text-blue-400' : 'text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{selectedChain.description} kêmî 0.1 HEZ tê pêşniyarkirin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-2 block">Mîqdar (HEZ)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.5"
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl text-lg font-mono"
|
||||
disabled={isTransferring}
|
||||
/>
|
||||
|
||||
{/* Quick Amount Buttons */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[10, 25, 50, 100].map((percent) => (
|
||||
<button
|
||||
key={percent}
|
||||
onClick={() => {
|
||||
setQuickAmount(percent);
|
||||
hapticImpact('light');
|
||||
}}
|
||||
className="flex-1 py-2 text-xs bg-muted hover:bg-muted/80 rounded-lg"
|
||||
disabled={isTransferring}
|
||||
>
|
||||
{percent}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{txStatus === 'signing' && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<p className="text-yellow-400 text-sm">Danûstandinê îmze bikin...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{txStatus === 'pending' && (
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<p className="text-blue-400 text-sm flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
XCM Teleport tê çêkirin...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleTeleport}
|
||||
disabled={isTransferring || !amount || parseFloat(amount) <= 0}
|
||||
className="w-full py-4 rounded-xl font-semibold bg-gradient-to-r from-green-600 to-yellow-500 text-white disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isTransferring ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{txStatus === 'signing' ? 'Tê îmzekirin...' : 'Tê çêkirin...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Fuel className="w-5 h-5" />
|
||||
Bo {selectedChain.name} Bişîne
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
/**
|
||||
* Pools Modal Component
|
||||
* Mobile-optimized liquidity pools interface for Telegram miniapp
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Droplets, Plus, Minus, AlertCircle, Check } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { KurdistanSun } from '@/components/KurdistanSun';
|
||||
|
||||
interface PoolsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Pool {
|
||||
id: string;
|
||||
asset0: number;
|
||||
asset1: number;
|
||||
asset0Symbol: string;
|
||||
asset1Symbol: string;
|
||||
asset0Decimals: number;
|
||||
asset1Decimals: number;
|
||||
lpTokenId: number;
|
||||
reserve0: number;
|
||||
reserve1: number;
|
||||
price: number;
|
||||
userLpBalance?: number;
|
||||
userShare?: number;
|
||||
}
|
||||
|
||||
// Native token ID
|
||||
const NATIVE_TOKEN_ID = -1;
|
||||
|
||||
// Token info mapping
|
||||
const TOKEN_INFO: Record<number, { symbol: string; decimals: number; icon: string }> = {
|
||||
[-1]: { symbol: 'HEZ', decimals: 12, icon: '/tokens/HEZ.png' },
|
||||
1: { symbol: 'PEZ', decimals: 12, icon: '/tokens/PEZ.png' },
|
||||
1000: { symbol: 'USDT', decimals: 6, icon: '/tokens/USDT.png' },
|
||||
};
|
||||
|
||||
// Helper to convert asset ID to XCM Location format
|
||||
const formatAssetLocation = (id: number) => {
|
||||
if (id === NATIVE_TOKEN_ID) {
|
||||
return { parents: 1, interior: 'Here' };
|
||||
}
|
||||
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
|
||||
};
|
||||
|
||||
export function PoolsModal({ isOpen, onClose }: PoolsModalProps) {
|
||||
const { assetHubApi, keypair } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
|
||||
const [pools, setPools] = useState<Pool[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPool, setSelectedPool] = useState<Pool | null>(null);
|
||||
const [isAddingLiquidity, setIsAddingLiquidity] = useState(false);
|
||||
const [isRemovingLiquidity, setIsRemovingLiquidity] = useState(false);
|
||||
const [amount0, setAmount0] = useState('');
|
||||
const [amount1, setAmount1] = useState('');
|
||||
const [lpAmountToRemove, setLpAmountToRemove] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
// Token balances
|
||||
const [balances, setBalances] = useState<Record<string, string>>({
|
||||
HEZ: '0',
|
||||
PEZ: '0',
|
||||
USDT: '0',
|
||||
});
|
||||
|
||||
// Fetch balances and pools
|
||||
useEffect(() => {
|
||||
if (!isOpen || !assetHubApi || !keypair) return;
|
||||
|
||||
// Reset state when modal opens
|
||||
setError('');
|
||||
setAmount0('');
|
||||
setAmount1('');
|
||||
setLpAmountToRemove('');
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Add timeout wrapper for API calls
|
||||
const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('API call timeout')), ms)
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
// Fetch HEZ balance from Asset Hub (native token)
|
||||
|
||||
const hezAccount = (await withTimeout(
|
||||
(assetHubApi.query.system as any).account(keypair.address),
|
||||
10000
|
||||
)) as any;
|
||||
if (isCancelled) return;
|
||||
const hezFree = hezAccount.data.free.toString();
|
||||
setBalances((prev) => ({ ...prev, HEZ: (parseInt(hezFree) / 1e12).toFixed(4) }));
|
||||
|
||||
const pezResult = (await withTimeout(
|
||||
(assetHubApi.query.assets as any).account(1, keypair.address),
|
||||
10000
|
||||
)) as any;
|
||||
if (isCancelled) return;
|
||||
if (pezResult.isSome) {
|
||||
setBalances((prev) => ({
|
||||
...prev,
|
||||
PEZ: (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4),
|
||||
}));
|
||||
} else {
|
||||
setBalances((prev) => ({ ...prev, PEZ: '0.0000' }));
|
||||
}
|
||||
|
||||
const usdtResult = (await withTimeout(
|
||||
(assetHubApi.query.assets as any).account(1000, keypair.address),
|
||||
10000
|
||||
)) as any;
|
||||
if (isCancelled) return;
|
||||
if (usdtResult.isSome) {
|
||||
setBalances((prev) => ({
|
||||
...prev,
|
||||
USDT: (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2),
|
||||
}));
|
||||
} else {
|
||||
setBalances((prev) => ({ ...prev, USDT: '0.00' }));
|
||||
}
|
||||
|
||||
// Fetch pools
|
||||
const poolPairs: [number, number][] = [
|
||||
[NATIVE_TOKEN_ID, 1], // HEZ-PEZ
|
||||
[NATIVE_TOKEN_ID, 1000], // HEZ-USDT
|
||||
];
|
||||
|
||||
const fetchedPools: Pool[] = [];
|
||||
|
||||
for (const [asset0, asset1] of poolPairs) {
|
||||
if (isCancelled) return;
|
||||
try {
|
||||
const poolKey = [formatAssetLocation(asset0), formatAssetLocation(asset1)];
|
||||
const poolInfo = await withTimeout(
|
||||
assetHubApi.query.assetConversion.pools(poolKey),
|
||||
10000
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (poolInfo && !(poolInfo as any).isEmpty) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poolData = (poolInfo as any).unwrap().toJSON() as { lpToken: number };
|
||||
const lpTokenId = poolData.lpToken;
|
||||
|
||||
const token0 = TOKEN_INFO[asset0];
|
||||
const token1 = TOKEN_INFO[asset1];
|
||||
|
||||
// Get price quote
|
||||
let price = 0;
|
||||
let reserve0 = 0;
|
||||
let reserve1 = 0;
|
||||
|
||||
try {
|
||||
const oneUnit = BigInt(Math.pow(10, token0.decimals));
|
||||
|
||||
const quote = await withTimeout(
|
||||
(assetHubApi.call as any).assetConversionApi.quotePriceExactTokensForTokens(
|
||||
formatAssetLocation(asset0),
|
||||
formatAssetLocation(asset1),
|
||||
oneUnit.toString(),
|
||||
true
|
||||
),
|
||||
10000
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (quote && !(quote as any).isNone) {
|
||||
price =
|
||||
Number(BigInt((quote as any).unwrap().toString())) /
|
||||
Math.pow(10, token1.decimals);
|
||||
|
||||
// Estimate reserves from LP supply
|
||||
const lpAsset = await withTimeout(
|
||||
assetHubApi.query.poolAssets.asset(lpTokenId),
|
||||
10000
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((lpAsset as any).isSome) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lpSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
|
||||
// Decimal correction factor for mixed-decimal pools
|
||||
const decimalFactor = Math.pow(
|
||||
10,
|
||||
12 - (token0.decimals + token1.decimals) / 2
|
||||
);
|
||||
const sqrtPrice = Math.sqrt(price);
|
||||
reserve0 = (lpSupply * decimalFactor) / sqrtPrice;
|
||||
reserve1 = lpSupply * decimalFactor * sqrtPrice;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch price for pool:', err);
|
||||
}
|
||||
|
||||
// Get user's LP balance
|
||||
let userLpBalance = 0;
|
||||
let userShare = 0;
|
||||
|
||||
try {
|
||||
const userLp = await withTimeout(
|
||||
assetHubApi.query.poolAssets.account(lpTokenId, keypair.address),
|
||||
10000
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((userLp as any).isSome) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
userLpBalance = Number((userLp as any).unwrap().toJSON().balance) / 1e12;
|
||||
|
||||
const lpAsset = await withTimeout(
|
||||
assetHubApi.query.poolAssets.asset(lpTokenId),
|
||||
10000
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((lpAsset as any).isSome) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const totalSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12;
|
||||
userShare = totalSupply > 0 ? (userLpBalance / totalSupply) * 100 : 0;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch user LP balance:', err);
|
||||
}
|
||||
|
||||
fetchedPools.push({
|
||||
id: `${asset0}-${asset1}`,
|
||||
asset0,
|
||||
asset1,
|
||||
asset0Symbol: token0.symbol,
|
||||
asset1Symbol: token1.symbol,
|
||||
asset0Decimals: token0.decimals,
|
||||
asset1Decimals: token1.decimals,
|
||||
lpTokenId,
|
||||
reserve0,
|
||||
reserve1,
|
||||
price,
|
||||
userLpBalance,
|
||||
userShare,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Pool not found:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setPools(fetchedPools);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pools:', err);
|
||||
if (!isCancelled) {
|
||||
setError('Bağlantı hatası - tekrar deneyin');
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [isOpen, assetHubApi, keypair]);
|
||||
|
||||
// Auto-calculate amount1 based on pool price
|
||||
useEffect(() => {
|
||||
if (selectedPool && amount0 && selectedPool.price > 0) {
|
||||
const calculated = parseFloat(amount0) * selectedPool.price;
|
||||
setAmount1(calculated.toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4));
|
||||
} else {
|
||||
setAmount1('');
|
||||
}
|
||||
}, [amount0, selectedPool]);
|
||||
|
||||
// Add liquidity
|
||||
const handleAddLiquidity = async () => {
|
||||
if (!assetHubApi || !keypair || !selectedPool || !amount0 || !amount1) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const amt0 = BigInt(
|
||||
Math.floor(parseFloat(amount0) * Math.pow(10, selectedPool.asset0Decimals))
|
||||
);
|
||||
const amt1 = BigInt(
|
||||
Math.floor(parseFloat(amount1) * Math.pow(10, selectedPool.asset1Decimals))
|
||||
);
|
||||
const minAmt0 = (amt0 * BigInt(90)) / BigInt(100); // 10% slippage
|
||||
const minAmt1 = (amt1 * BigInt(90)) / BigInt(100);
|
||||
|
||||
const asset0Location = formatAssetLocation(selectedPool.asset0);
|
||||
const asset1Location = formatAssetLocation(selectedPool.asset1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tx = (assetHubApi.tx.assetConversion as any).addLiquidity(
|
||||
asset0Location,
|
||||
asset1Location,
|
||||
amt0.toString(),
|
||||
amt1.toString(),
|
||||
minAmt0.toString(),
|
||||
minAmt1.toString(),
|
||||
keypair.address
|
||||
);
|
||||
|
||||
// Wait for transaction to be finalized
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
keypair,
|
||||
({ status, dispatchError }: { status: any; dispatchError: any }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMsg = 'Zêdekirin neserketî';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else if (dispatchError.toString) {
|
||||
errorMsg = dispatchError.toString();
|
||||
}
|
||||
console.error('Add liquidity error:', errorMsg);
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
setSuccessMessage(
|
||||
`${amount0} ${selectedPool.asset0Symbol} + ${amount1} ${selectedPool.asset1Symbol} hate zêdekirin`
|
||||
);
|
||||
setSuccess(true);
|
||||
hapticNotification('success');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
setIsAddingLiquidity(false);
|
||||
setSelectedPool(null);
|
||||
setAmount0('');
|
||||
setAmount1('');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Add liquidity failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Zêdekirin neserketî');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove liquidity
|
||||
const handleRemoveLiquidity = async () => {
|
||||
if (!assetHubApi || !keypair || !selectedPool || !lpAmountToRemove) return;
|
||||
|
||||
const lpAmount = parseFloat(lpAmountToRemove);
|
||||
if (lpAmount <= 0 || lpAmount > (selectedPool.userLpBalance || 0)) {
|
||||
setError('Mîqdara LP ne derbasdar e');
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const lpAmountRaw = BigInt(Math.floor(lpAmount * 1e12));
|
||||
|
||||
// Calculate minimum amounts to receive (with 10% slippage)
|
||||
const userShare =
|
||||
((lpAmount / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100;
|
||||
const expectedAmt0 = selectedPool.reserve0 * userShare;
|
||||
const expectedAmt1 = selectedPool.reserve1 * userShare;
|
||||
|
||||
const minAmt0 = BigInt(
|
||||
Math.floor(expectedAmt0 * 0.9 * Math.pow(10, selectedPool.asset0Decimals))
|
||||
);
|
||||
const minAmt1 = BigInt(
|
||||
Math.floor(expectedAmt1 * 0.9 * Math.pow(10, selectedPool.asset1Decimals))
|
||||
);
|
||||
|
||||
const asset0Location = formatAssetLocation(selectedPool.asset0);
|
||||
const asset1Location = formatAssetLocation(selectedPool.asset1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tx = (assetHubApi.tx.assetConversion as any).removeLiquidity(
|
||||
asset0Location,
|
||||
asset1Location,
|
||||
lpAmountRaw.toString(),
|
||||
minAmt0.toString(),
|
||||
minAmt1.toString(),
|
||||
keypair.address
|
||||
);
|
||||
|
||||
// Wait for transaction to be finalized
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
keypair,
|
||||
({ status, dispatchError }: { status: any; dispatchError: any }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMsg = 'Derxistin neserketî';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else if (dispatchError.toString) {
|
||||
errorMsg = dispatchError.toString();
|
||||
}
|
||||
console.error('Remove liquidity error:', errorMsg);
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
setSuccessMessage(`${lpAmountToRemove} LP token hate vegerandin`);
|
||||
setSuccess(true);
|
||||
hapticNotification('success');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
setIsRemovingLiquidity(false);
|
||||
setSelectedPool(null);
|
||||
setLpAmountToRemove('');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Remove liquidity failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Derxistin neserketî');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Success screen
|
||||
if (success) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<Check className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Serketî!</h2>
|
||||
<p className="text-muted-foreground">{successMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add liquidity form
|
||||
if (isAddingLiquidity && selectedPool) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAddingLiquidity(false);
|
||||
setAmount0('');
|
||||
setAmount1('');
|
||||
setError('');
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
← Paş
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">Liquidity Zêde Bike</h2>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
{/* Pool Info */}
|
||||
<div className="p-4 bg-muted/30 border-b border-border">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-1">
|
||||
1 {selectedPool.asset0Symbol} = {selectedPool.price.toFixed(4)}{' '}
|
||||
{selectedPool.asset1Symbol}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Amount 0 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{selectedPool.asset0Symbol} Mîqdar</span>
|
||||
<span className="text-muted-foreground">
|
||||
Bakiye: {balances[selectedPool.asset0Symbol]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={amount0}
|
||||
onChange={(e) => setAmount0(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setAmount0(balances[selectedPool.asset0Symbol])}
|
||||
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Plus className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Amount 1 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{selectedPool.asset1Symbol} Mîqdar (otomatîk)
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Bakiye: {balances[selectedPool.asset1Symbol]}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={amount1}
|
||||
readOnly
|
||||
placeholder="0.00"
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl text-lg font-mono text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button or Loading Animation */}
|
||||
{isSubmitting ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 space-y-3">
|
||||
<KurdistanSun size={80} />
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Tê zêdekirin...</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddLiquidity}
|
||||
disabled={!amount0 || !amount1 || parseFloat(amount0) <= 0}
|
||||
className="w-full py-4 bg-gradient-to-r from-green-600 to-blue-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Droplets className="w-5 h-5" />
|
||||
Liquidity Zêde Bike
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove liquidity form
|
||||
if (isRemovingLiquidity && selectedPool) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRemovingLiquidity(false);
|
||||
setLpAmountToRemove('');
|
||||
setError('');
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
← Paş
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">Liquidity Derxe</h2>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
{/* Pool Info */}
|
||||
<div className="p-4 bg-muted/30 border-b border-border">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-lg font-semibold">
|
||||
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground mt-1">
|
||||
LP Bakiye: {selectedPool.userLpBalance?.toFixed(4) || '0'} LP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* LP Amount */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">LP Token Mîqdar</span>
|
||||
<span className="text-muted-foreground">
|
||||
Max: {selectedPool.userLpBalance?.toFixed(4) || '0'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={lpAmountToRemove}
|
||||
onChange={(e) => setLpAmountToRemove(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setLpAmountToRemove(selectedPool.userLpBalance?.toString() || '0')}
|
||||
className="px-3 py-2 bg-muted rounded-xl text-sm text-primary"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated Returns */}
|
||||
{lpAmountToRemove && parseFloat(lpAmountToRemove) > 0 && (
|
||||
<div className="bg-muted/50 rounded-xl p-3 space-y-2 text-sm">
|
||||
<p className="text-muted-foreground">Texmînî vegerandin:</p>
|
||||
<div className="flex justify-between">
|
||||
<span>{selectedPool.asset0Symbol}</span>
|
||||
<span className="font-mono">
|
||||
~
|
||||
{(
|
||||
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
|
||||
(selectedPool.userShare || 0)) /
|
||||
100) *
|
||||
selectedPool.reserve0
|
||||
).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{selectedPool.asset1Symbol}</span>
|
||||
<span className="font-mono">
|
||||
~
|
||||
{(
|
||||
(((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) *
|
||||
(selectedPool.userShare || 0)) /
|
||||
100) *
|
||||
selectedPool.reserve1
|
||||
).toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button or Loading Animation */}
|
||||
{isSubmitting ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 space-y-3">
|
||||
<KurdistanSun size={80} />
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Tê derxistin...</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRemoveLiquidity}
|
||||
disabled={!lpAmountToRemove || parseFloat(lpAmountToRemove) <= 0}
|
||||
className="w-full py-4 bg-gradient-to-r from-red-600 to-orange-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Minus className="w-5 h-5" />
|
||||
Liquidity Derxe
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pool list
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Liquidity Pools</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<KurdistanSun size={80} />
|
||||
<p className="text-muted-foreground mt-3 animate-pulse">Tê barkirin...</p>
|
||||
</div>
|
||||
) : pools.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Droplets className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">Pool tune</p>
|
||||
</div>
|
||||
) : (
|
||||
pools.map((pool) => (
|
||||
<div key={pool.id} className="bg-muted/50 rounded-xl p-4 border border-border">
|
||||
{/* Pool Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
<img
|
||||
src={TOKEN_INFO[pool.asset0]?.icon}
|
||||
alt={pool.asset0Symbol}
|
||||
className="w-8 h-8 rounded-full border-2 border-card"
|
||||
/>
|
||||
<img
|
||||
src={TOKEN_INFO[pool.asset1]?.icon}
|
||||
alt={pool.asset1Symbol}
|
||||
className="w-8 h-8 rounded-full border-2 border-card"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
{pool.asset0Symbol}/{pool.asset1Symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pool Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Rezerv {pool.asset0Symbol}</span>
|
||||
<p className="font-mono">
|
||||
{pool.reserve0.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Rezerv {pool.asset1Symbol}</span>
|
||||
<p className="font-mono">
|
||||
{pool.reserve1.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Position */}
|
||||
{pool.userLpBalance && pool.userLpBalance > 0 && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-2 mb-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-400">Pozîsyona Te</span>
|
||||
<span className="text-green-400 font-mono">
|
||||
{pool.userShare?.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
LP Token: {pool.userLpBalance.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
setSelectedPool(pool);
|
||||
setIsAddingLiquidity(true);
|
||||
}}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-green-600/20 to-blue-600/20 border border-green-500/30 text-green-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Zêde Bike
|
||||
</button>
|
||||
{pool.userLpBalance && pool.userLpBalance > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
setSelectedPool(pool);
|
||||
setIsRemovingLiquidity(true);
|
||||
}}
|
||||
className="flex-1 py-2 bg-gradient-to-r from-red-600/20 to-orange-600/20 border border-red-500/30 text-red-400 font-medium rounded-lg flex items-center justify-center gap-1 text-sm"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
Derxe
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Swap Modal Component
|
||||
* Mobile-optimized token swap interface for Telegram miniapp
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, ArrowDownUp, RefreshCw, AlertCircle, Check } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { KurdistanSun } from '@/components/KurdistanSun';
|
||||
|
||||
interface SwapModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Token configuration
|
||||
const TOKENS = [
|
||||
{ symbol: 'HEZ', name: 'Hezkurd', assetId: -1, decimals: 12, icon: '/tokens/HEZ.png' },
|
||||
{ symbol: 'PEZ', name: 'Pezkuwi', assetId: 1, decimals: 12, icon: '/tokens/PEZ.png' },
|
||||
{ symbol: 'USDT', name: 'Tether', assetId: 1000, decimals: 6, icon: '/tokens/USDT.png' },
|
||||
];
|
||||
|
||||
// Native token ID for relay chain HEZ
|
||||
const NATIVE_TOKEN_ID = -1;
|
||||
|
||||
// Helper to convert asset ID to XCM Location format
|
||||
const formatAssetLocation = (id: number) => {
|
||||
if (id === NATIVE_TOKEN_ID) {
|
||||
return { parents: 1, interior: 'Here' };
|
||||
}
|
||||
return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } };
|
||||
};
|
||||
|
||||
export function SwapModal({ isOpen, onClose }: SwapModalProps) {
|
||||
const { assetHubApi, keypair } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
|
||||
const [fromToken, setFromToken] = useState(TOKENS[0]); // HEZ
|
||||
const [toToken, setToToken] = useState(TOKENS[1]); // PEZ
|
||||
const [fromAmount, setFromAmount] = useState('');
|
||||
const [toAmount, setToAmount] = useState('');
|
||||
const [exchangeRate, setExchangeRate] = useState<number | null>(null);
|
||||
const [isLoadingRate, setIsLoadingRate] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Token balances
|
||||
const [balances, setBalances] = useState<Record<string, string>>({
|
||||
HEZ: '0',
|
||||
PEZ: '0',
|
||||
USDT: '0',
|
||||
});
|
||||
|
||||
// Fetch balances from Asset Hub (where swaps happen)
|
||||
useEffect(() => {
|
||||
if (!isOpen || !assetHubApi || !keypair) return;
|
||||
|
||||
// Reset state when modal opens
|
||||
setError('');
|
||||
setFromAmount('');
|
||||
setToAmount('');
|
||||
|
||||
const fetchBalances = async () => {
|
||||
try {
|
||||
// HEZ balance from Asset Hub (native token for fees and swaps)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hezAccount = await (assetHubApi.query.system as any).account(keypair.address);
|
||||
const hezFree = hezAccount.data.free.toString();
|
||||
const hezBalance = (parseInt(hezFree) / 1e12).toFixed(4);
|
||||
|
||||
// PEZ balance (Asset 1)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pezResult = await (assetHubApi.query.assets as any).account(1, keypair.address);
|
||||
const pezBalance = pezResult.isSome
|
||||
? (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4)
|
||||
: '0.0000';
|
||||
|
||||
// USDT balance (Asset 1000)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const usdtResult = await (assetHubApi.query.assets as any).account(1000, keypair.address);
|
||||
const usdtBalance = usdtResult.isSome
|
||||
? (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2)
|
||||
: '0.00';
|
||||
|
||||
// Update all balances at once
|
||||
setBalances({
|
||||
HEZ: hezBalance,
|
||||
PEZ: pezBalance,
|
||||
USDT: usdtBalance,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch balances:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBalances();
|
||||
}, [assetHubApi, keypair, isOpen]);
|
||||
|
||||
// Fetch exchange rate
|
||||
const fetchExchangeRate = useCallback(async () => {
|
||||
if (!assetHubApi || fromToken.symbol === toToken.symbol) {
|
||||
setExchangeRate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingRate(true);
|
||||
try {
|
||||
// Sort assets for pool query (native token first)
|
||||
const fromId = fromToken.assetId;
|
||||
const toId = toToken.assetId;
|
||||
|
||||
const [asset1, asset2] =
|
||||
fromId === NATIVE_TOKEN_ID
|
||||
? [fromId, toId]
|
||||
: toId === NATIVE_TOKEN_ID
|
||||
? [toId, fromId]
|
||||
: fromId < toId
|
||||
? [fromId, toId]
|
||||
: [toId, fromId];
|
||||
|
||||
const poolKey = [formatAssetLocation(asset1), formatAssetLocation(asset2)];
|
||||
|
||||
// Check if pool exists
|
||||
const poolInfo = await assetHubApi.query.assetConversion.pools(poolKey);
|
||||
|
||||
if (poolInfo && !poolInfo.isEmpty) {
|
||||
// Get quote from runtime API
|
||||
const decimals1 = asset1 === 1000 ? 6 : 12;
|
||||
const decimals2 = asset2 === 1000 ? 6 : 12;
|
||||
const oneUnit = BigInt(Math.pow(10, decimals1));
|
||||
|
||||
const quote = await (
|
||||
assetHubApi.call as any
|
||||
).assetConversionApi.quotePriceExactTokensForTokens(
|
||||
formatAssetLocation(asset1),
|
||||
formatAssetLocation(asset2),
|
||||
oneUnit.toString(),
|
||||
true
|
||||
);
|
||||
|
||||
if (quote && !quote.isNone) {
|
||||
const priceRaw = quote.unwrap().toString();
|
||||
const price = Number(BigInt(priceRaw)) / Math.pow(10, decimals2);
|
||||
|
||||
// Calculate rate based on direction
|
||||
const rate = fromId === asset1 ? price : 1 / price;
|
||||
setExchangeRate(rate);
|
||||
} else {
|
||||
setExchangeRate(null);
|
||||
}
|
||||
} else {
|
||||
setExchangeRate(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch exchange rate:', err);
|
||||
setExchangeRate(null);
|
||||
} finally {
|
||||
setIsLoadingRate(false);
|
||||
}
|
||||
}, [assetHubApi, fromToken, toToken]);
|
||||
|
||||
// Fetch rate when tokens change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchExchangeRate();
|
||||
}
|
||||
}, [isOpen, fetchExchangeRate]);
|
||||
|
||||
// Calculate toAmount when fromAmount or rate changes
|
||||
useEffect(() => {
|
||||
if (fromAmount && exchangeRate) {
|
||||
const calculated = parseFloat(fromAmount) * exchangeRate;
|
||||
setToAmount(calculated.toFixed(toToken.decimals === 6 ? 2 : 4));
|
||||
} else {
|
||||
setToAmount('');
|
||||
}
|
||||
}, [fromAmount, exchangeRate, toToken.decimals]);
|
||||
|
||||
// Swap tokens
|
||||
const handleSwapTokens = () => {
|
||||
hapticImpact('light');
|
||||
const temp = fromToken;
|
||||
setFromToken(toToken);
|
||||
setToToken(temp);
|
||||
setFromAmount('');
|
||||
setToAmount('');
|
||||
};
|
||||
|
||||
// Execute swap
|
||||
const handleSwap = async () => {
|
||||
if (!assetHubApi || !keypair || !fromAmount || !exchangeRate) return;
|
||||
|
||||
const fromBalance = parseFloat(balances[fromToken.symbol] || '0');
|
||||
const swapAmount = parseFloat(fromAmount);
|
||||
|
||||
if (swapAmount > fromBalance) {
|
||||
setError('Bakiye têrê nake');
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwapping(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const amountIn = BigInt(Math.floor(swapAmount * Math.pow(10, fromToken.decimals)));
|
||||
const minAmountOut = BigInt(
|
||||
Math.floor(parseFloat(toAmount) * 0.95 * Math.pow(10, toToken.decimals))
|
||||
); // 5% slippage
|
||||
|
||||
// Build swap path using XCM Locations
|
||||
const fromLocation = formatAssetLocation(fromToken.assetId);
|
||||
const toLocation = formatAssetLocation(toToken.assetId);
|
||||
|
||||
// Check if we need multi-hop (e.g., PEZ -> USDT needs PEZ -> HEZ -> USDT)
|
||||
let path;
|
||||
if (fromToken.assetId !== NATIVE_TOKEN_ID && toToken.assetId !== NATIVE_TOKEN_ID) {
|
||||
// Multi-hop through native token
|
||||
const nativeLocation = formatAssetLocation(NATIVE_TOKEN_ID);
|
||||
path = [fromLocation, nativeLocation, toLocation];
|
||||
} else {
|
||||
path = [fromLocation, toLocation];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tx = (assetHubApi.tx.assetConversion as any).swapExactTokensForTokens(
|
||||
path,
|
||||
amountIn.toString(),
|
||||
minAmountOut.toString(),
|
||||
keypair.address,
|
||||
true
|
||||
);
|
||||
|
||||
// Wait for transaction to be finalized
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.signAndSend(
|
||||
keypair,
|
||||
({ status, dispatchError }: { status: any; dispatchError: any }) => {
|
||||
if (status.isFinalized) {
|
||||
if (dispatchError) {
|
||||
let errorMsg = 'Swap neserketî';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
||||
errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
} else if (dispatchError.toString) {
|
||||
errorMsg = dispatchError.toString();
|
||||
}
|
||||
console.error('Swap error:', errorMsg);
|
||||
reject(new Error(errorMsg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
).catch(reject);
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
hapticNotification('success');
|
||||
|
||||
// Reset after success
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
setFromAmount('');
|
||||
setToAmount('');
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Swap failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Swap neserketî');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsSwapping(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl p-6 text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<Check className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Swap Serketî!</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{fromAmount} {fromToken.symbol} → {toAmount} {toToken.symbol}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Token Swap</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* From Token */}
|
||||
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Ji (From)</span>
|
||||
<select
|
||||
value={fromToken.symbol}
|
||||
onChange={(e) => {
|
||||
const token = TOKENS.find((t) => t.symbol === e.target.value);
|
||||
if (token) {
|
||||
if (token.symbol === toToken.symbol) {
|
||||
setToToken(fromToken);
|
||||
}
|
||||
setFromToken(token);
|
||||
}
|
||||
}}
|
||||
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
{TOKENS.map((t) => (
|
||||
<option key={t.symbol} value={t.symbol}>
|
||||
{t.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={fromAmount}
|
||||
onChange={(e) => setFromAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-transparent text-2xl font-bold outline-none"
|
||||
/>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<button
|
||||
onClick={() => setFromAmount(balances[fromToken.symbol])}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
<span className="text-muted-foreground">
|
||||
Bakiye: {balances[fromToken.symbol]} {fromToken.symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Button */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleSwapTokens}
|
||||
className="p-2 bg-muted rounded-full hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<ArrowDownUp className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* To Token */}
|
||||
<div className="bg-muted/50 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Bo (To)</span>
|
||||
<select
|
||||
value={toToken.symbol}
|
||||
onChange={(e) => {
|
||||
const token = TOKENS.find((t) => t.symbol === e.target.value);
|
||||
if (token) {
|
||||
if (token.symbol === fromToken.symbol) {
|
||||
setFromToken(toToken);
|
||||
}
|
||||
setToToken(token);
|
||||
}
|
||||
}}
|
||||
className="bg-background border border-border rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
{TOKENS.map((t) => (
|
||||
<option key={t.symbol} value={t.symbol}>
|
||||
{t.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={toAmount}
|
||||
readOnly
|
||||
placeholder="0.00"
|
||||
className="w-full bg-transparent text-2xl font-bold outline-none text-muted-foreground"
|
||||
/>
|
||||
<div className="flex justify-end text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Bakiye: {balances[toToken.symbol]} {toToken.symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange Rate */}
|
||||
<div className="bg-muted/30 rounded-xl p-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Rêjeya Guherandinê</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{isLoadingRate ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : exchangeRate ? (
|
||||
`1 ${fromToken.symbol} = ${exchangeRate.toFixed(4)} ${toToken.symbol}`
|
||||
) : (
|
||||
<span className="text-yellow-500">Pool tune</span>
|
||||
)}
|
||||
<button onClick={fetchExchangeRate} className="p-1 hover:bg-muted rounded">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Slippage</span>
|
||||
<span>5%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swap Button */}
|
||||
{/* Swap Button or Loading Animation */}
|
||||
{isSwapping ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 space-y-3">
|
||||
<KurdistanSun size={80} />
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Tê guhertin...</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSwap}
|
||||
disabled={!fromAmount || !exchangeRate || parseFloat(fromAmount) <= 0}
|
||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-xl disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{!exchangeRate ? 'Pool Tune' : 'Swap Bike'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
/**
|
||||
* Tokens Card Component
|
||||
* Token management with search, add/remove, show/hide functionality
|
||||
* Fetches live data from blockchain and CoinGecko prices
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Coins,
|
||||
Search,
|
||||
Plus,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
Send,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Fuel,
|
||||
} from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import {
|
||||
subscribeToConnection,
|
||||
getLastError,
|
||||
getAssetHubAPI,
|
||||
getPeopleAPI,
|
||||
getConnectionState,
|
||||
} from '@/lib/rpc-manager';
|
||||
import { FundFeesModal } from './FundFeesModal';
|
||||
|
||||
// Asset IDs matching pwap/web configuration
|
||||
const ASSET_IDS = {
|
||||
WHEZ: 0, // Wrapped HEZ (12 decimals)
|
||||
PEZ: 1, // PEZ token (12 decimals)
|
||||
WUSDT: 1000, // Wrapped USDT (6 decimals) - displayed as USDT
|
||||
BTC: 4, // wBTC
|
||||
ETH: 5, // wETH
|
||||
DOT: 6, // wDOT
|
||||
BNB: 7, // wBNB (assumed)
|
||||
};
|
||||
|
||||
// LP Token IDs (from poolAssets pallet)
|
||||
const LP_TOKEN_IDS = {
|
||||
HEZ_PEZ: 0, // HEZ-PEZ LP (12 decimals)
|
||||
HEZ_USDT: 1, // HEZ-USDT LP (12 decimals)
|
||||
};
|
||||
|
||||
// CoinGecko ID mapping for tokens
|
||||
const COINGECKO_IDS: Record<string, string> = {
|
||||
DOT: 'polkadot',
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
BNB: 'binancecoin',
|
||||
USDT: 'tether',
|
||||
HEZ: 'hez-token', // Will fallback to DOT/3 if not found
|
||||
PEZ: 'pez-token', // Will fallback to DOT/10 if not found
|
||||
};
|
||||
|
||||
interface PriceData {
|
||||
usd: number;
|
||||
usd_24h_change: number;
|
||||
}
|
||||
|
||||
// Token configurations
|
||||
interface TokenConfig {
|
||||
assetId: number;
|
||||
symbol: string;
|
||||
displaySymbol: string;
|
||||
name: string;
|
||||
decimals: number;
|
||||
logo: string;
|
||||
isDefault: boolean;
|
||||
priority: number; // Lower = higher in list
|
||||
}
|
||||
|
||||
const DEFAULT_TOKENS: TokenConfig[] = [
|
||||
{
|
||||
assetId: -1, // Special: native token
|
||||
symbol: 'HEZ',
|
||||
displaySymbol: 'HEZ',
|
||||
name: 'HEZ Native Token',
|
||||
decimals: 12,
|
||||
logo: '/tokens/HEZ.png',
|
||||
isDefault: true,
|
||||
priority: 0,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.PEZ,
|
||||
symbol: 'PEZ',
|
||||
displaySymbol: 'PEZ',
|
||||
name: 'PEZ Governance Token',
|
||||
decimals: 12,
|
||||
logo: '/tokens/PEZ.png',
|
||||
isDefault: true,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.WUSDT,
|
||||
symbol: 'wUSDT',
|
||||
displaySymbol: 'USDT', // User sees USDT, backend uses wUSDT
|
||||
name: 'Tether USD',
|
||||
decimals: 6,
|
||||
logo: '/tokens/USDT.png',
|
||||
isDefault: true,
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.DOT,
|
||||
symbol: 'wDOT',
|
||||
displaySymbol: 'DOT',
|
||||
name: 'Polkadot',
|
||||
decimals: 12,
|
||||
logo: '/tokens/DOT.png',
|
||||
isDefault: true,
|
||||
priority: 3,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.BTC,
|
||||
symbol: 'wBTC',
|
||||
displaySymbol: 'BTC',
|
||||
name: 'Bitcoin',
|
||||
decimals: 12,
|
||||
logo: '/tokens/BTC.png',
|
||||
isDefault: true,
|
||||
priority: 4,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.ETH,
|
||||
symbol: 'wETH',
|
||||
displaySymbol: 'ETH',
|
||||
name: 'Ethereum',
|
||||
decimals: 12,
|
||||
logo: '/tokens/ETH.png',
|
||||
isDefault: true,
|
||||
priority: 5,
|
||||
},
|
||||
{
|
||||
assetId: ASSET_IDS.BNB,
|
||||
symbol: 'wBNB',
|
||||
displaySymbol: 'BNB',
|
||||
name: 'BNB',
|
||||
decimals: 12,
|
||||
logo: '/tokens/BNB.png',
|
||||
isDefault: true,
|
||||
priority: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// LP Token configurations (separate from regular tokens)
|
||||
const LP_TOKENS: TokenConfig[] = [
|
||||
{
|
||||
assetId: -100 - LP_TOKEN_IDS.HEZ_PEZ, // Use negative IDs starting from -100 to distinguish from regular tokens
|
||||
symbol: 'HEZ-PEZ-LP',
|
||||
displaySymbol: 'HEZ-PEZ LP',
|
||||
name: 'HEZ-PEZ Liquidity Pool',
|
||||
decimals: 12,
|
||||
logo: '', // Uses initials fallback
|
||||
isDefault: true,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
assetId: -100 - LP_TOKEN_IDS.HEZ_USDT, // -101
|
||||
symbol: 'HEZ-USDT-LP',
|
||||
displaySymbol: 'HEZ-USDT LP',
|
||||
name: 'HEZ-USDT Liquidity Pool',
|
||||
decimals: 12,
|
||||
logo: '', // Uses initials fallback
|
||||
isDefault: true,
|
||||
priority: 11,
|
||||
},
|
||||
];
|
||||
|
||||
interface TokenBalance extends TokenConfig {
|
||||
balance: string;
|
||||
isHidden: boolean;
|
||||
priceUsd?: number;
|
||||
priceChange24h?: number;
|
||||
valueUsd?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSendToken?: (token: TokenBalance) => void;
|
||||
}
|
||||
|
||||
export function TokensCard({ onSendToken }: Props) {
|
||||
const { address, balance: hezBalance } = useWallet();
|
||||
const { hapticImpact } = useTelegram();
|
||||
const [rpcConnected, setRpcConnected] = useState(false);
|
||||
const [endpointName, setEndpointName] = useState<string | null>(null);
|
||||
|
||||
// Track RPC connection state
|
||||
useEffect(() => {
|
||||
// Get initial state
|
||||
const initialState = getConnectionState();
|
||||
setRpcConnected(initialState.isConnected);
|
||||
setEndpointName(initialState.endpoint?.name || null);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = subscribeToConnection((connected, endpoint) => {
|
||||
setRpcConnected(connected);
|
||||
setEndpointName(endpoint?.name || null);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Fetch multi-chain HEZ balances (Asset Hub & People Chain)
|
||||
useEffect(() => {
|
||||
if (!address) return;
|
||||
|
||||
const fetchMultiChainBalances = async () => {
|
||||
// Asset Hub HEZ balance
|
||||
const assetHubApi = getAssetHubAPI();
|
||||
if (assetHubApi) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accountInfo = (await (assetHubApi.query.system as any).account(address)) as {
|
||||
data: { free: { toString(): string } };
|
||||
};
|
||||
const free = accountInfo.data.free.toString();
|
||||
const balanceNum = Number(free) / 1e12;
|
||||
setAssetHubHezBalance(balanceNum.toFixed(4));
|
||||
} catch (err) {
|
||||
console.error('Error fetching Asset Hub HEZ balance:', err);
|
||||
setAssetHubHezBalance('0.0000');
|
||||
}
|
||||
}
|
||||
|
||||
// People Chain HEZ balance
|
||||
const peopleApi = getPeopleAPI();
|
||||
if (peopleApi) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accountInfo = (await (peopleApi.query.system as any).account(address)) as {
|
||||
data: { free: { toString(): string } };
|
||||
};
|
||||
const free = accountInfo.data.free.toString();
|
||||
const balanceNum = Number(free) / 1e12;
|
||||
setPeopleHezBalance(balanceNum.toFixed(4));
|
||||
} catch (err) {
|
||||
console.error('Error fetching People Chain HEZ balance:', err);
|
||||
setPeopleHezBalance('0.0000');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchMultiChainBalances();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchMultiChainBalances, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [address, rpcConnected]);
|
||||
|
||||
// Initialize with default tokens immediately (no API required)
|
||||
const [tokens, setTokens] = useState<TokenBalance[]>(() =>
|
||||
DEFAULT_TOKENS.map((config) => ({
|
||||
...config,
|
||||
balance: '--', // Placeholder until connected
|
||||
isHidden: false,
|
||||
}))
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAddToken, setShowAddToken] = useState(false);
|
||||
const [newAssetId, setNewAssetId] = useState('');
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [hiddenTokens, setHiddenTokens] = useState<number[]>(() => {
|
||||
const stored = localStorage.getItem('hiddenTokens');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
});
|
||||
const [customTokenIds, setCustomTokenIds] = useState<number[]>(() => {
|
||||
const stored = localStorage.getItem('customTokenIds');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
});
|
||||
const [prices, setPrices] = useState<Record<string, PriceData>>({});
|
||||
const [isPriceLoading, setIsPriceLoading] = useState(false);
|
||||
// Multi-chain HEZ balances
|
||||
const [assetHubHezBalance, setAssetHubHezBalance] = useState<string>('--');
|
||||
const [peopleHezBalance, setPeopleHezBalance] = useState<string>('--');
|
||||
const [showFundFeesModal, setShowFundFeesModal] = useState(false);
|
||||
|
||||
// Fetch prices from CoinGecko
|
||||
const fetchPrices = useCallback(async () => {
|
||||
setIsPriceLoading(true);
|
||||
try {
|
||||
// Fetch prices for known tokens
|
||||
const ids = Object.values(COINGECKO_IDS).join(',');
|
||||
const response = await fetch(
|
||||
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd&include_24hr_change=true`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('CoinGecko API error');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Map CoinGecko response to our token symbols
|
||||
const priceMap: Record<string, PriceData> = {};
|
||||
|
||||
for (const [symbol, geckoId] of Object.entries(COINGECKO_IDS)) {
|
||||
if (data[geckoId]) {
|
||||
priceMap[symbol] = {
|
||||
usd: data[geckoId].usd,
|
||||
usd_24h_change: data[geckoId].usd_24h_change || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate HEZ and PEZ prices from DOT if not available on CoinGecko
|
||||
const dotPrice = priceMap['DOT'];
|
||||
if (dotPrice) {
|
||||
// HEZ = DOT / 3 (if not found on CoinGecko)
|
||||
if (!priceMap['HEZ']) {
|
||||
priceMap['HEZ'] = {
|
||||
usd: dotPrice.usd / 3,
|
||||
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
|
||||
};
|
||||
}
|
||||
// PEZ = DOT / 10 (if not found on CoinGecko)
|
||||
if (!priceMap['PEZ']) {
|
||||
priceMap['PEZ'] = {
|
||||
usd: dotPrice.usd / 10,
|
||||
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setPrices(priceMap);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch prices:', err);
|
||||
} finally {
|
||||
setIsPriceLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch prices on mount and every 60 seconds
|
||||
useEffect(() => {
|
||||
fetchPrices();
|
||||
const interval = setInterval(fetchPrices, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchPrices]);
|
||||
|
||||
// Update tokens with price data (works without API)
|
||||
const updateTokensWithPrices = useCallback(() => {
|
||||
setTokens((prev) =>
|
||||
prev.map((token) => {
|
||||
const priceData = prices[token.displaySymbol];
|
||||
const numBalance = parseFloat(token.balance) || 0;
|
||||
return {
|
||||
...token,
|
||||
isHidden: hiddenTokens.includes(token.assetId),
|
||||
priceUsd: priceData?.usd,
|
||||
priceChange24h: priceData?.usd_24h_change,
|
||||
valueUsd:
|
||||
priceData?.usd && token.balance !== '--' ? numBalance * priceData.usd : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [prices, hiddenTokens]);
|
||||
|
||||
// Fetch token balances from blockchain (only when API is available)
|
||||
const fetchTokenBalances = useCallback(async () => {
|
||||
// Update prices even without API
|
||||
updateTokensWithPrices();
|
||||
|
||||
// Get Asset Hub API from rpc-manager (PEZ, USDT etc are on Asset Hub)
|
||||
// Note: Native HEZ balance comes from WalletContext (hezBalance), not directly from API
|
||||
const assetHubApi = getAssetHubAPI();
|
||||
|
||||
// If no address, keep showing "--" for balances
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Fetch all tokens (default + custom + LP tokens)
|
||||
const allTokenConfigs = [
|
||||
...DEFAULT_TOKENS,
|
||||
...LP_TOKENS, // Include LP tokens
|
||||
...customTokenIds
|
||||
.filter((id) => !DEFAULT_TOKENS.find((t) => t.assetId === id))
|
||||
.map((id) => ({
|
||||
assetId: id,
|
||||
symbol: `Token #${id}`,
|
||||
displaySymbol: `Token #${id}`,
|
||||
name: `Custom Token`,
|
||||
decimals: 12,
|
||||
logo: '',
|
||||
isDefault: false,
|
||||
priority: 100 + id,
|
||||
})),
|
||||
];
|
||||
|
||||
const tokenBalances: TokenBalance[] = [];
|
||||
|
||||
for (const config of allTokenConfigs) {
|
||||
let balance = '0';
|
||||
|
||||
if (config.assetId === -1) {
|
||||
// Native HEZ balance (from relay chain)
|
||||
balance = hezBalance ?? '0.0000';
|
||||
} else if (config.assetId <= -100) {
|
||||
// LP Token balance (from poolAssets pallet)
|
||||
// Convert back to pool asset ID: -100 - assetId
|
||||
const poolAssetId = -100 - config.assetId;
|
||||
if (!assetHubApi) {
|
||||
balance = '--';
|
||||
} else {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lpBalance = await (assetHubApi.query.poolAssets as any).account(
|
||||
poolAssetId,
|
||||
address
|
||||
);
|
||||
|
||||
if (lpBalance && lpBalance.isSome) {
|
||||
const data = lpBalance.unwrap();
|
||||
const rawBalance = data.balance.toString();
|
||||
const numBalance = parseInt(rawBalance) / Math.pow(10, config.decimals);
|
||||
// Show more decimals for LP tokens to catch small amounts
|
||||
balance =
|
||||
numBalance < 0.0001 && numBalance > 0
|
||||
? numBalance.toExponential(2)
|
||||
: numBalance.toFixed(4);
|
||||
} else {
|
||||
balance = '0.0000';
|
||||
}
|
||||
} catch {
|
||||
balance = '0.0000';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Asset balance - PEZ, USDT etc are on Asset Hub!
|
||||
// Use Asset Hub API instead of relay chain API
|
||||
if (!assetHubApi) {
|
||||
balance = '--'; // Asset Hub not connected
|
||||
} else {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const assetBalance = await (assetHubApi.query.assets as any).account(
|
||||
config.assetId,
|
||||
address
|
||||
);
|
||||
|
||||
if (assetBalance && assetBalance.isSome) {
|
||||
const data = assetBalance.unwrap();
|
||||
const rawBalance = data.balance.toString();
|
||||
const formatted = (parseInt(rawBalance) / Math.pow(10, config.decimals)).toFixed(
|
||||
config.decimals === 6 ? 2 : 4
|
||||
);
|
||||
balance = formatted;
|
||||
} else {
|
||||
// User has no balance for this asset
|
||||
balance = config.decimals === 6 ? '0.00' : '0.0000';
|
||||
}
|
||||
} catch {
|
||||
// Token might not exist or user has no balance
|
||||
balance = config.decimals === 6 ? '0.00' : '0.0000';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add price data
|
||||
const priceData = prices[config.displaySymbol];
|
||||
const numBalance = parseFloat(balance) || 0;
|
||||
|
||||
tokenBalances.push({
|
||||
...config,
|
||||
balance,
|
||||
isHidden: hiddenTokens.includes(config.assetId),
|
||||
priceUsd: priceData?.usd,
|
||||
priceChange24h: priceData?.usd_24h_change,
|
||||
valueUsd: priceData?.usd ? numBalance * priceData.usd : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
tokenBalances.sort((a, b) => a.priority - b.priority);
|
||||
setTokens(tokenBalances);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch token balances:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [address, hezBalance, hiddenTokens, customTokenIds, prices, updateTokensWithPrices]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokenBalances();
|
||||
}, [fetchTokenBalances]);
|
||||
|
||||
// Toggle token visibility
|
||||
const toggleTokenVisibility = (assetId: number) => {
|
||||
hapticImpact('light');
|
||||
setHiddenTokens((prev) => {
|
||||
const newHidden = prev.includes(assetId)
|
||||
? prev.filter((id) => id !== assetId)
|
||||
: [...prev, assetId];
|
||||
localStorage.setItem('hiddenTokens', JSON.stringify(newHidden));
|
||||
return newHidden;
|
||||
});
|
||||
};
|
||||
|
||||
// Add custom token
|
||||
const handleAddToken = () => {
|
||||
const id = parseInt(newAssetId);
|
||||
if (isNaN(id) || id < 0) return;
|
||||
|
||||
if (customTokenIds.includes(id) || DEFAULT_TOKENS.find((t) => t.assetId === id)) {
|
||||
return; // Already exists
|
||||
}
|
||||
|
||||
hapticImpact('medium');
|
||||
const newIds = [...customTokenIds, id];
|
||||
setCustomTokenIds(newIds);
|
||||
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
|
||||
setNewAssetId('');
|
||||
setShowAddToken(false);
|
||||
fetchTokenBalances();
|
||||
};
|
||||
|
||||
// Remove custom token
|
||||
const removeCustomToken = (assetId: number) => {
|
||||
hapticImpact('medium');
|
||||
const newIds = customTokenIds.filter((id) => id !== assetId);
|
||||
setCustomTokenIds(newIds);
|
||||
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
|
||||
setTokens((prev) => prev.filter((t) => t.assetId !== assetId));
|
||||
};
|
||||
|
||||
// Filter tokens based on search
|
||||
const filteredTokens = tokens.filter((token) => {
|
||||
if (!searchQuery) return !token.isHidden || showSettings;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
token.displaySymbol.toLowerCase().includes(query) ||
|
||||
token.name.toLowerCase().includes(query) ||
|
||||
token.symbol.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Get token gradient based on symbol
|
||||
const getTokenGradient = (symbol: string) => {
|
||||
const gradients: Record<string, string> = {
|
||||
HEZ: 'from-green-500/20 to-yellow-500/20 border-green-500/30',
|
||||
PEZ: 'from-blue-500/20 to-purple-500/20 border-blue-500/30',
|
||||
USDT: 'from-emerald-500/20 to-teal-500/20 border-emerald-500/30',
|
||||
DOT: 'from-pink-500/20 to-purple-500/20 border-pink-500/30',
|
||||
BTC: 'from-orange-500/20 to-yellow-500/20 border-orange-500/30',
|
||||
ETH: 'from-blue-500/20 to-indigo-500/20 border-blue-500/30',
|
||||
BNB: 'from-yellow-500/20 to-orange-500/20 border-yellow-500/30',
|
||||
'HEZ-PEZ LP': 'from-green-500/20 to-blue-500/20 border-cyan-500/30',
|
||||
'HEZ-USDT LP': 'from-green-500/20 to-emerald-500/20 border-teal-500/30',
|
||||
};
|
||||
return gradients[symbol] || 'from-gray-500/20 to-gray-600/20 border-gray-500/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 border border-border rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="w-5 h-5 text-cyan-400" />
|
||||
<h3 className="font-semibold">Tokens</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({tokens.filter((t) => !t.isHidden).length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hapticImpact('light');
|
||||
fetchTokenBalances();
|
||||
fetchPrices();
|
||||
}}
|
||||
disabled={isLoading || isPriceLoading}
|
||||
className="p-1.5 text-muted-foreground hover:text-white rounded"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading || isPriceLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hapticImpact('light');
|
||||
setShowSettings(!showSettings);
|
||||
}}
|
||||
className={`p-1.5 rounded ${showSettings ? 'text-cyan-400 bg-cyan-400/10' : 'text-muted-foreground hover:text-white'}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Search & Add */}
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Token bigere..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-background rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
setShowAddToken(!showAddToken);
|
||||
}}
|
||||
className="w-full py-2 border border-dashed border-border rounded-lg text-sm text-muted-foreground hover:text-white hover:border-cyan-500/50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Token Zêde Bike
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAddToken && (
|
||||
<div className="p-3 bg-background rounded-lg space-y-2">
|
||||
<input
|
||||
type="number"
|
||||
value={newAssetId}
|
||||
onChange={(e) => setNewAssetId(e.target.value)}
|
||||
placeholder="Asset ID binivîse (mînak: 3)"
|
||||
className="w-full px-3 py-2 bg-muted rounded-lg text-sm"
|
||||
min="0"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAddToken(false)}
|
||||
className="flex-1 py-2 bg-muted rounded-lg text-sm"
|
||||
>
|
||||
Betal
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddToken}
|
||||
disabled={!newAssetId}
|
||||
className="flex-1 py-2 bg-cyan-600 rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
Zêde Bike
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection Status Banner */}
|
||||
{rpcConnected ? (
|
||||
<div className="mx-4 mb-2 px-3 py-2 bg-green-500/10 border border-green-500/30 rounded-lg flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<div>
|
||||
<p className="text-xs text-green-400 font-medium">Pezkuwichain Girêdayî</p>
|
||||
{endpointName && <p className="text-[10px] text-green-400/70">{endpointName}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-2 px-3 py-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 text-yellow-400 animate-spin flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-yellow-400 font-medium">Girêdana Blockchain...</p>
|
||||
<p className="text-[10px] text-yellow-400/70">
|
||||
{getLastError() || 'RPC serverê tê girêdan...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token List */}
|
||||
<div className="px-4 pb-4 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredTokens.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Coins className="w-6 h-6 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Token nehat dîtin</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTokens.map((token) =>
|
||||
// Special rendering for HEZ (multi-chain)
|
||||
token.assetId === -1 ? (
|
||||
<div
|
||||
key={token.assetId}
|
||||
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
|
||||
token.isHidden ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* HEZ Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={token.logo}
|
||||
alt={token.displaySymbol}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{token.displaySymbol}</span>
|
||||
<span className="text-[10px] bg-green-500/20 text-green-400 px-1.5 py-0.5 rounded">
|
||||
Multi-Chain
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{token.priceUsd !== undefined ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
|
||||
</span>
|
||||
{token.priceChange24h !== undefined && (
|
||||
<span
|
||||
className={`text-[10px] flex items-center gap-0.5 ${
|
||||
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{Math.abs(token.priceChange24h).toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
setShowFundFeesModal(true);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/30 rounded-lg flex items-center gap-1.5 text-yellow-400 text-xs font-medium hover:bg-yellow-500/30 transition-colors"
|
||||
>
|
||||
<Fuel className="w-3.5 h-3.5" />
|
||||
Add Fee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Multi-chain balances */}
|
||||
<div className="space-y-2 mt-2">
|
||||
{/* Relay Chain */}
|
||||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-muted-foreground">Relay Chain</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono">{token.balance} HEZ</span>
|
||||
</div>
|
||||
|
||||
{/* Asset Hub */}
|
||||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
|
||||
<span className="text-xs text-muted-foreground">Asset Hub</span>
|
||||
{parseFloat(assetHubHezBalance) < 0.1 && assetHubHezBalance !== '--' && (
|
||||
<span className="text-[10px] text-yellow-400">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-mono">{assetHubHezBalance} HEZ</span>
|
||||
</div>
|
||||
|
||||
{/* People Chain */}
|
||||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-purple-500" />
|
||||
<span className="text-xs text-muted-foreground">People Chain</span>
|
||||
{parseFloat(peopleHezBalance) < 0.1 && peopleHezBalance !== '--' && (
|
||||
<span className="text-[10px] text-yellow-400">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-mono">{peopleHezBalance} HEZ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Value */}
|
||||
{token.valueUsd !== undefined && token.balance !== '--' && (
|
||||
<div className="mt-2 pt-2 border-t border-white/10 flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Toplam</span>
|
||||
<span className="text-sm font-semibold">
|
||||
≈ $
|
||||
{(
|
||||
(parseFloat(token.balance) +
|
||||
parseFloat(assetHubHezBalance === '--' ? '0' : assetHubHezBalance) +
|
||||
parseFloat(peopleHezBalance === '--' ? '0' : peopleHezBalance)) *
|
||||
(token.priceUsd || 0)
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Regular token card
|
||||
<div
|
||||
key={token.assetId}
|
||||
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
|
||||
token.isHidden ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{token.logo ? (
|
||||
<img
|
||||
src={token.logo}
|
||||
alt={token.displaySymbol}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<span className="text-xs font-bold">
|
||||
{token.displaySymbol.slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{token.displaySymbol}</span>
|
||||
{token.assetId <= -100 && (
|
||||
<span className="text-[10px] bg-purple-500/20 text-purple-400 px-1.5 py-0.5 rounded">
|
||||
LP
|
||||
</span>
|
||||
)}
|
||||
{!token.isDefault && (
|
||||
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{token.priceUsd !== undefined ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
|
||||
</span>
|
||||
{token.priceChange24h !== undefined && (
|
||||
<span
|
||||
className={`text-[10px] flex items-center gap-0.5 ${
|
||||
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{Math.abs(token.priceChange24h).toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-semibold font-mono ${token.balance === '--' ? 'text-muted-foreground' : ''}`}
|
||||
>
|
||||
{token.balance}
|
||||
</p>
|
||||
{token.balance === '--' ? (
|
||||
<p className="text-xs text-muted-foreground">Tê barkirin...</p>
|
||||
) : token.valueUsd !== undefined ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
≈ ${token.valueUsd.toFixed(2)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{token.displaySymbol}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSettings ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggleTokenVisibility(token.assetId)}
|
||||
className="p-1.5 text-muted-foreground hover:text-white rounded"
|
||||
>
|
||||
{token.isHidden ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{!token.isDefault && (
|
||||
<button
|
||||
onClick={() => removeCustomToken(token.assetId)}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
onSendToken &&
|
||||
token.balance !== '--' &&
|
||||
parseFloat(token.balance) > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
onSendToken(token);
|
||||
}}
|
||||
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fund Fees Modal for XCM Teleport */}
|
||||
<FundFeesModal isOpen={showFundFeesModal} onClose={() => setShowFundFeesModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Wallet Connect Component
|
||||
* Unlock wallet with password
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye, EyeOff, Wallet, Unlock, Trash2 } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { formatAddress } from '@/lib/wallet-service';
|
||||
|
||||
interface Props {
|
||||
onConnected: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function WalletConnect({ onConnected, onDelete }: Props) {
|
||||
const { address, connect, error: walletError } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!password) {
|
||||
setError('Şîfre (password) binivîse');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
hapticImpact('medium');
|
||||
|
||||
try {
|
||||
await connect(password);
|
||||
hapticNotification('success');
|
||||
onConnected();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Şîfre (password) çewt e');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
hapticImpact('heavy');
|
||||
onDelete();
|
||||
};
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-red-500/20 rounded-full flex items-center justify-center mb-4">
|
||||
<Trash2 className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Wallet Jê Bibe?</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ev çalakî nayê paşvekişandin. Eger seed phrase'ê te tune be, tu nikarî gihîştina
|
||||
wallet'ê xwe bistînî.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 py-3 bg-muted rounded-xl font-semibold"
|
||||
>
|
||||
Betal
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 py-3 bg-red-500 text-white rounded-xl font-semibold"
|
||||
>
|
||||
Jê Bibe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-primary/20 rounded-full flex items-center justify-center mb-4">
|
||||
<Wallet className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Wallet Veke</h2>
|
||||
{address && (
|
||||
<p className="text-muted-foreground text-sm font-mono">{formatAddress(address)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Şîfre (Password)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
|
||||
placeholder="Şîfre (password) binivîse"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(error || walletError) && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error || walletError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isLoading || !password}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
'Tê vekirin...'
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="w-4 h-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full py-3 text-red-400 text-sm"
|
||||
>
|
||||
Wallet jê bibe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Wallet Create Component
|
||||
* Multi-step wallet creation flow following pezWallet architecture
|
||||
* Flow: Password → Backup (show mnemonic + 3 conditions) → Verify (word ordering) → Complete
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
|
||||
type Step = 'password' | 'backup' | 'verify' | 'complete';
|
||||
|
||||
interface MnemonicWord {
|
||||
id: number;
|
||||
content: string;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function WalletCreate({ onComplete, onBack }: Props) {
|
||||
const { generateNewWallet, confirmWallet, isInitialized } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
|
||||
const [step, setStep] = useState<Step>('password');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Backup conditions (3 checkboxes - all must be checked)
|
||||
const [conditions, setConditions] = useState({
|
||||
writtenDown: false,
|
||||
neverShare: false,
|
||||
lossRisk: false,
|
||||
});
|
||||
|
||||
// Verify step - word ordering
|
||||
const [sourceWords, setSourceWords] = useState<MnemonicWord[]>([]);
|
||||
const [destinationWords, setDestinationWords] = useState<MnemonicWord[]>([]);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Password strength validation rules (must match crypto.ts validatePassword)
|
||||
const passwordRules = {
|
||||
minLength: password.length >= 12,
|
||||
hasLowercase: /[a-z]/.test(password),
|
||||
hasUppercase: /[A-Z]/.test(password),
|
||||
hasNumber: /[0-9]/.test(password),
|
||||
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
|
||||
passwordsMatch: password === confirmPassword && password.length > 0,
|
||||
};
|
||||
|
||||
const allPasswordRulesPass =
|
||||
passwordRules.minLength &&
|
||||
passwordRules.hasLowercase &&
|
||||
passwordRules.hasUppercase &&
|
||||
passwordRules.hasNumber &&
|
||||
passwordRules.hasSpecialChar &&
|
||||
passwordRules.passwordsMatch;
|
||||
|
||||
// Check if all conditions are met
|
||||
const allConditionsChecked =
|
||||
conditions.writtenDown && conditions.neverShare && conditions.lossRisk;
|
||||
|
||||
// Step 1: Password - validate and generate wallet (NOT saved yet)
|
||||
const handlePasswordSubmit = () => {
|
||||
setError('');
|
||||
|
||||
if (!isInitialized) {
|
||||
setError('Wallet service amade nîne. Ji kerema xwe bisekinin.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allPasswordRulesPass) {
|
||||
setError('Ji kerema xwe hemû şertên şîfre (password) bicîh bînin');
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
hapticImpact('medium');
|
||||
|
||||
try {
|
||||
// Generate wallet but DON'T save yet - user must verify backup first
|
||||
const result = generateNewWallet();
|
||||
setMnemonic(result.mnemonic);
|
||||
setAddress(result.address);
|
||||
setStep('backup');
|
||||
} catch (err) {
|
||||
console.error('Wallet generation error:', err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Wallet çênebû. Ji kerema xwe dîsa biceribînin'
|
||||
);
|
||||
hapticNotification('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Backup - Copy mnemonic
|
||||
const handleCopyMnemonic = () => {
|
||||
navigator.clipboard.writeText(mnemonic);
|
||||
setCopied(true);
|
||||
hapticNotification('success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Step 2: Backup - Proceed to verify (only if all conditions checked)
|
||||
const handleBackupContinue = () => {
|
||||
if (!allConditionsChecked) {
|
||||
setError('Ji kerema xwe hemû şertan bipejirînin');
|
||||
return;
|
||||
}
|
||||
|
||||
hapticImpact('light');
|
||||
|
||||
// Initialize verification step with shuffled words
|
||||
const words = mnemonic.split(' ');
|
||||
const shuffled = [...words]
|
||||
.map((word, idx) => ({ id: idx, content: word, removed: false }))
|
||||
.sort(() => Math.random() - 0.5);
|
||||
|
||||
setSourceWords(shuffled);
|
||||
setDestinationWords([]);
|
||||
setError('');
|
||||
setStep('verify');
|
||||
};
|
||||
|
||||
// Step 3: Verify - Source word clicked (add to destination)
|
||||
const handleSourceWordClick = (word: MnemonicWord) => {
|
||||
if (word.removed) return;
|
||||
|
||||
hapticImpact('light');
|
||||
|
||||
// Mark as removed in source
|
||||
setSourceWords((prev) => prev.map((w) => (w.id === word.id ? { ...w, removed: true } : w)));
|
||||
|
||||
// Add to destination
|
||||
setDestinationWords((prev) => [...prev, { ...word, removed: false }]);
|
||||
};
|
||||
|
||||
// Step 3: Verify - Destination word clicked (return to source)
|
||||
const handleDestinationWordClick = (word: MnemonicWord) => {
|
||||
hapticImpact('light');
|
||||
|
||||
// Remove from destination
|
||||
setDestinationWords((prev) => prev.filter((w) => w.id !== word.id));
|
||||
|
||||
// Restore in source
|
||||
setSourceWords((prev) => prev.map((w) => (w.id === word.id ? { ...w, removed: false } : w)));
|
||||
};
|
||||
|
||||
// Step 3: Verify - Reset
|
||||
const handleReset = () => {
|
||||
hapticImpact('medium');
|
||||
setSourceWords((prev) => prev.map((w) => ({ ...w, removed: false })));
|
||||
setDestinationWords([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// Step 3: Verify - Check and create wallet
|
||||
const handleVerify = async () => {
|
||||
const originalWords = mnemonic.split(' ');
|
||||
const enteredWords = destinationWords.map((w) => w.content);
|
||||
|
||||
// Check if order matches
|
||||
const isCorrect =
|
||||
originalWords.length === enteredWords.length &&
|
||||
originalWords.every((word, idx) => word === enteredWords[idx]);
|
||||
|
||||
if (!isCorrect) {
|
||||
setError('Rêza peyvan ne rast e. Ji kerema xwe dîsa biceribînin');
|
||||
hapticNotification('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// NOW save the wallet after user has verified backup
|
||||
await confirmWallet(mnemonic, password);
|
||||
hapticNotification('success');
|
||||
setStep('complete');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Wallet çênebû');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Can continue in verify step only when all words are placed
|
||||
const canVerify = destinationWords.length === 12;
|
||||
|
||||
// Render based on step
|
||||
if (step === 'password') {
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<button onClick={onBack} className="flex items-center gap-2 text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Paş</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Şîfre (Password) Diyar Bike</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ev şîfre (password) dê ji bo vekirina wallet'ê were bikaranîn
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Şîfre (Password)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
|
||||
placeholder="Herî kêm 12 tîp (min 12 characters)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Şîfre Dubare (Confirm Password)</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl"
|
||||
placeholder="Şîfre dubare binivîse (confirm password)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Real-time password strength indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="p-3 bg-muted/50 rounded-xl space-y-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Şertên Şîfre (Password):</p>
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.minLength ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.minLength ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 12 tîp (min 12 characters)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasLowercase ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasLowercase ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 tîpa biçûk (a-z)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasUppercase ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasUppercase ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 tîpa mezin (A-Z)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasNumber ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasNumber ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 hejmar (0-9)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasSpecialChar ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasSpecialChar ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 sembola taybetî (!@#$%...)</span>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.passwordsMatch ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.passwordsMatch ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Şîfre (password) hev digirin</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={isLoading || !allPasswordRulesPass || !isInitialized}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{!isInitialized
|
||||
? 'Tê amadekirin...'
|
||||
: isLoading
|
||||
? 'Tê çêkirin...'
|
||||
: allPasswordRulesPass
|
||||
? 'Berdewam'
|
||||
: 'Şertên şîfre (password) bicîh bînin'}
|
||||
{!isLoading && allPasswordRulesPass && isInitialized && (
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'backup') {
|
||||
const words = mnemonic.split(' ');
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Seed Phrase Paşguh Bike</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ev 12 peyv wallet'ê te ne. Wan li cihekî ewle binivîse!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-200">
|
||||
<strong>Girîng:</strong> Ev peyvan tenê yek car têne xuyang kirin. Eger te ev peyv winda
|
||||
bikin, tu nikarî gihîştina wallet'ê xwe bistînî.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{words.map((word, idx) => (
|
||||
<div key={idx} className="px-3 py-2 bg-muted rounded-lg text-center text-sm font-mono">
|
||||
<span className="text-muted-foreground mr-1">{idx + 1}.</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopyMnemonic}
|
||||
className="w-full py-3 bg-muted rounded-xl flex items-center justify-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
<span className="text-green-400">Hat kopîkirin!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>Kopî Bike</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 3 Condition Checkboxes */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={conditions.writtenDown}
|
||||
onChange={(e) =>
|
||||
setConditions((prev) => ({ ...prev, writtenDown: e.target.checked }))
|
||||
}
|
||||
className="mt-1 w-5 h-5 accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Min ev 12 peyv li cihekî ewle nivîsandine</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={conditions.neverShare}
|
||||
onChange={(e) => setConditions((prev) => ({ ...prev, neverShare: e.target.checked }))}
|
||||
className="mt-1 w-5 h-5 accent-primary"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Ez fêm dikim ku ez nikarim ev peyvan bi kesî re parve bikim
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 p-3 bg-muted rounded-xl cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={conditions.lossRisk}
|
||||
onChange={(e) => setConditions((prev) => ({ ...prev, lossRisk: e.target.checked }))}
|
||||
className="mt-1 w-5 h-5 accent-primary"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Ez fêm dikim ku eger van peyvan winda bikim ez nikarim gihîştina wallet'ê xwe
|
||||
bistînim
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleBackupContinue}
|
||||
disabled={!allConditionsChecked}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{allConditionsChecked ? 'Berdewam' : 'Hemû şertan bipejirînin'}
|
||||
{allConditionsChecked && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'verify') {
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Peyvan Verast Bike</h2>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm text-center">
|
||||
Ji kerema xwe peyvan bi rêza rast bixin nav qutîkê
|
||||
</p>
|
||||
|
||||
{/* Destination area - where user builds the correct order */}
|
||||
<div className="min-h-[120px] p-4 bg-muted/50 border-2 border-dashed border-border rounded-xl">
|
||||
{destinationWords.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground text-sm">Peyvan li vir bixin...</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{destinationWords.map((word, idx) => (
|
||||
<button
|
||||
key={word.id}
|
||||
onClick={() => handleDestinationWordClick(word)}
|
||||
className="px-3 py-2 bg-primary/20 border border-primary/40 rounded-lg text-sm font-mono flex items-center gap-1 hover:bg-primary/30 transition-colors"
|
||||
>
|
||||
<span className="text-primary text-xs">{idx + 1}.</span>
|
||||
{word.content}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source area - shuffled words to pick from */}
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{sourceWords.map((word) => (
|
||||
<button
|
||||
key={word.id}
|
||||
onClick={() => handleSourceWordClick(word)}
|
||||
disabled={word.removed}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-mono transition-all ${
|
||||
word.removed
|
||||
? 'bg-muted/30 text-muted-foreground/30 cursor-not-allowed'
|
||||
: 'bg-muted hover:bg-muted/80 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{word.content}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={isLoading || !canVerify}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50"
|
||||
>
|
||||
{isLoading
|
||||
? 'Tê tomarkirin...'
|
||||
: canVerify
|
||||
? 'Verast Bike'
|
||||
: `${destinationWords.length}/12 peyv`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Complete
|
||||
return (
|
||||
<div className="p-4 space-y-6 text-center">
|
||||
<div className="w-20 h-20 mx-auto bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<Check className="w-10 h-10 text-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Wallet Hat Çêkirin!</h2>
|
||||
<p className="text-muted-foreground text-sm">Wallet'ê te amade ye</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-xl">
|
||||
<p className="text-xs text-muted-foreground mb-1">Navnîşana te</p>
|
||||
<p className="font-mono text-sm break-all">{address}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold"
|
||||
>
|
||||
Dest Pê Bike
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Wallet Import Component
|
||||
* Import existing wallet with seed phrase
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye, EyeOff, ArrowLeft, ArrowRight, Check, AlertTriangle } from 'lucide-react';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useTelegram } from '@/hooks/useTelegram';
|
||||
import { validatePassword } from '@/lib/crypto';
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function WalletImport({ onComplete, onBack }: Props) {
|
||||
const { importWallet } = useWallet();
|
||||
const { hapticImpact, hapticNotification } = useTelegram();
|
||||
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Password strength validation rules (must match crypto.ts validatePassword)
|
||||
const passwordRules = {
|
||||
minLength: password.length >= 12,
|
||||
hasLowercase: /[a-z]/.test(password),
|
||||
hasUppercase: /[A-Z]/.test(password),
|
||||
hasNumber: /[0-9]/.test(password),
|
||||
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
|
||||
passwordsMatch: password === confirmPassword && password.length > 0,
|
||||
};
|
||||
|
||||
const allPasswordRulesPass =
|
||||
passwordRules.minLength &&
|
||||
passwordRules.hasLowercase &&
|
||||
passwordRules.hasUppercase &&
|
||||
passwordRules.hasNumber &&
|
||||
passwordRules.hasSpecialChar &&
|
||||
passwordRules.passwordsMatch;
|
||||
|
||||
const handleImport = async () => {
|
||||
setError('');
|
||||
|
||||
// Validate mnemonic
|
||||
const words = mnemonic.trim().split(/\s+/);
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
setError('Seed phrase divê 12 an 24 peyv be');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password using crypto.ts rules
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
setError(passwordValidation.message || 'Şîfre (password) ne derbasdar e');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Şîfre (password) hev nagirin');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
hapticImpact('medium');
|
||||
|
||||
try {
|
||||
await importWallet(mnemonic.trim().toLowerCase(), password);
|
||||
hapticNotification('success');
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import neserketî');
|
||||
hapticNotification('error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<button onClick={onBack} className="flex items-center gap-2 text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Paş</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Wallet Import Bike</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seed phrase'ê wallet'ê xwe yê heyî binivîse
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Seed Phrase (12 an 24 peyv)</label>
|
||||
<textarea
|
||||
value={mnemonic}
|
||||
onChange={(e) => setMnemonic(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl resize-none h-28 font-mono text-sm"
|
||||
placeholder="Peyvên xwe bi valahî cuda binivîse..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mnemonic.trim().split(/\s+/).filter(Boolean).length} / 12 peyv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Şîfreya Nû (New Password)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl pr-12"
|
||||
placeholder="Herî kêm 12 tîp (min 12 characters)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-muted-foreground">Şîfre Dubare (Confirm Password)</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-muted rounded-xl"
|
||||
placeholder="Şîfre dubare binivîse (confirm password)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Real-time password strength indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="p-3 bg-muted/50 rounded-xl space-y-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Şertên Şîfre (Password):</p>
|
||||
<div className="grid grid-cols-1 gap-1.5 text-xs">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.minLength ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.minLength ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 12 tîp (min 12 characters)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasLowercase ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasLowercase ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 tîpa biçûk (a-z)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasUppercase ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasUppercase ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 tîpa mezin (A-Z)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasNumber ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasNumber ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 hejmar (0-9)</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.hasSpecialChar ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.hasSpecialChar ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Herî kêm 1 sembola taybetî (!@#$%...)</span>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<div
|
||||
className={`flex items-center gap-2 ${passwordRules.passwordsMatch ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{passwordRules.passwordsMatch ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Şîfre (password) hev digirin</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isLoading || !mnemonic || !allPasswordRulesPass}
|
||||
className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading
|
||||
? 'Tê import kirin...'
|
||||
: allPasswordRulesPass
|
||||
? 'Import Bike'
|
||||
: 'Şertên şîfre (password) bicîh bînin'}
|
||||
{!isLoading && allPasswordRulesPass && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Wallet Setup Component
|
||||
* Initial screen for wallet creation or import
|
||||
*/
|
||||
|
||||
import { Wallet, Plus, Download } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
onCreate: () => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
export function WalletSetup({ onCreate, onImport }: Props) {
|
||||
return (
|
||||
<div className="p-4 space-y-8">
|
||||
<div className="text-center pt-8">
|
||||
<div className="w-20 h-20 mx-auto bg-primary/20 rounded-full flex items-center justify-center mb-6">
|
||||
<Wallet className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">Pezkuwi Wallet</h1>
|
||||
<p className="text-muted-foreground">Berîka fermî ya Pezkuwichain</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="w-full p-4 bg-primary text-primary-foreground rounded-xl flex items-center gap-4"
|
||||
>
|
||||
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Plus className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold">Wallet Nû Çêbike</p>
|
||||
<p className="text-sm opacity-80">Wallet'ekî nû bi seed phrase çêbike</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="w-full p-4 bg-muted rounded-xl flex items-center gap-4"
|
||||
>
|
||||
<div className="w-12 h-12 bg-primary/20 rounded-full flex items-center justify-center">
|
||||
<Download className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold">Wallet Import Bike</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seed phrase'ê xwe yê heyî bi kar bîne
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground px-4">
|
||||
Wallet'ê te bi ewlehî li cîhaza te tê hilanîn. Em tu carî gihîştina mifteyên te tune.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { WalletSetup } from './WalletSetup';
|
||||
export { WalletCreate } from './WalletCreate';
|
||||
export { WalletImport } from './WalletImport';
|
||||
export { WalletConnect } from './WalletConnect';
|
||||
export { WalletDashboard } from './WalletDashboard';
|
||||
export { TokensCard } from './TokensCard';
|
||||
Reference in New Issue
Block a user