Initial commit - PezkuwiChain Telegram MiniApp

This commit is contained in:
2026-02-05 10:48:14 +03:00
commit ddd28705c1
105 changed files with 29195 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { useState, lazy, Suspense, useCallback } from 'react';
import { Megaphone, MessageCircle, Gift, Wallet, Loader2, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { UpdateNotification } from '@/components/UpdateNotification';
import { P2PModal } from '@/components/P2PModal';
import { useAuth } from '@/contexts/AuthContext';
import { useWallet } from '@/contexts/WalletContext';
// Lazy load sections for code splitting
const AnnouncementsSection = lazy(() =>
import('@/sections/Announcements').then((m) => ({ default: m.AnnouncementsSection }))
);
const ForumSection = lazy(() =>
import('@/sections/Forum').then((m) => ({ default: m.ForumSection }))
);
const RewardsSection = lazy(() =>
import('@/sections/Rewards').then((m) => ({ default: m.RewardsSection }))
);
const WalletSection = lazy(() =>
import('@/sections/Wallet').then((m) => ({ default: m.WalletSection }))
);
// Loading fallback component
function SectionLoader() {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
);
}
type Section = 'announcements' | 'forum' | 'rewards' | 'wallet';
type NavId = Section | 'p2p';
interface NavItem {
id: NavId;
icon: typeof Megaphone;
label: string;
isExternal?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'announcements', icon: Megaphone, label: 'Ragihandin' },
{ id: 'forum', icon: MessageCircle, label: 'Forum' },
{ id: 'rewards', icon: Gift, label: 'Xelat' },
{ id: 'p2p', icon: ArrowLeftRight, label: 'P2P', isExternal: true },
{ id: 'wallet', icon: Wallet, label: 'Berîk' },
];
// P2P Web App URL - Mobile-optimized P2P
const P2P_WEB_URL = 'https://telegram.pezkuwichain.io/p2p';
export default function App() {
const [activeSection, setActiveSection] = useState<Section>('announcements');
const [showP2PModal, setShowP2PModal] = useState(false);
const { user } = useAuth();
const { address } = useWallet();
// Open P2P in popup with auth params
const openP2P = useCallback(() => {
window.Telegram?.WebApp.HapticFeedback.impactOccurred('medium');
// Build auth URL with params
const params = new URLSearchParams();
if (user?.telegram_id) {
params.set('tg_id', user.telegram_id.toString());
}
if (address) {
params.set('wallet', address);
}
params.set('ts', Date.now().toString());
params.set('from', 'miniapp');
const url = `${P2P_WEB_URL}?${params.toString()}`;
// Open in new window/tab
window.open(url, '_blank');
}, [user, address]);
const handleNavClick = (item: NavItem) => {
window.Telegram?.WebApp.HapticFeedback.selectionChanged();
if (item.isExternal) {
// P2P opens modal first
if (item.id === 'p2p') {
setShowP2PModal(true);
}
} else {
setActiveSection(item.id as Section);
}
};
return (
<div className="flex flex-col h-screen bg-background">
{/* Content Area */}
<main className="flex-1 overflow-hidden">
<div className="h-full animate-in">
<Suspense fallback={<SectionLoader />}>
{activeSection === 'announcements' && <AnnouncementsSection />}
{activeSection === 'forum' && <ForumSection />}
{activeSection === 'rewards' && <RewardsSection />}
{activeSection === 'wallet' && <WalletSection />}
</Suspense>
</div>
</main>
{/* Update Notification */}
<UpdateNotification />
{/* P2P Modal */}
<P2PModal isOpen={showP2PModal} onClose={() => setShowP2PModal(false)} onOpenP2P={openP2P} />
{/* Bottom Navigation */}
<nav className="flex-shrink-0 bg-secondary/50 backdrop-blur-lg border-t border-border safe-area-bottom">
<div className="flex justify-around items-center h-16">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = !item.isExternal && activeSection === item.id;
return (
<button
key={item.id}
onClick={() => handleNavClick(item)}
className={cn(
'flex flex-col items-center justify-center w-full h-full gap-1 transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
item.isExternal && 'text-cyan-400 hover:text-cyan-300'
)}
>
<Icon className={cn('w-5 h-5', isActive && 'scale-110')} />
<span className="text-[10px] font-medium">{item.label}</span>
</button>
);
})}
</div>
</nav>
</div>
);
}
+89
View File
@@ -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 çê</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;
}
}
+179
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+171
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+68
View File
@@ -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 heye!</h4>
<p className="text-xs opacity-90 mt-0.5">
Ji bo taybetmendiyê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;
+39
View File
@@ -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;
+470
View File
@@ -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 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 çê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>
);
}
+823
View File
@@ -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"> 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"> 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"> 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>
);
}
+453
View File
@@ -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"> 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>
);
}
+941
View File
@@ -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"> 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>
);
}
+151
View File
@@ -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 Bibe?</h2>
<p className="text-muted-foreground text-sm">
Ev çalakî nayê paşvekişandin. Eger seed phrase&apos;ê te tune be, tu nikarî gihîştina
wallet&apos;ê 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"
>
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 bibe
</button>
</div>
</div>
);
}
+562
View File
@@ -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) ji bo vekirina wallet&apos;ê 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&apos;ê 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&apos;ê 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&apos;ê 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&apos;ê 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 Bike
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+233
View File
@@ -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&apos;ê wallet&apos;ê xwe 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 (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>
);
}
+59
View File
@@ -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 Çêbike</p>
<p className="text-sm opacity-80">Wallet&apos;ekî 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&apos;ê xwe heyî bi kar bîne
</p>
</div>
</button>
</div>
<p className="text-center text-xs text-muted-foreground px-4">
Wallet&apos;ê te bi ewlehî li cîhaza te hilanîn. Em tu carî gihîştina mifteyên te tune.
</p>
</div>
);
}
+6
View File
@@ -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';
+66
View File
@@ -0,0 +1,66 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { signInWithTelegram } from '@/lib/supabase';
import type { User } from '@/hooks/useSupabase';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
signIn: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const signIn = async () => {
const tg = window.Telegram?.WebApp;
if (!tg?.initData) {
setIsLoading(false);
return;
}
try {
const result = await signInWithTelegram(tg.initData);
if (result?.user) {
setUser(result.user);
}
} catch (error) {
// Auth failed silently - user will see unauthenticated state
if (import.meta.env.DEV) {
console.error('[Auth] Error:', error);
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Auto sign-in when in Telegram
signIn();
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
signIn,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Referral Context for Telegram Mini App
* Provides referral stats using blockchain data from People Chain
* (pallet_referral is on People Chain, connected to KYC via OnKycApproved hook)
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import {
getReferralStats,
getMyReferrals,
subscribeToReferralEvents,
type ReferralStats,
} from '@/lib/referral';
interface ReferralContextValue {
stats: ReferralStats | null;
myReferrals: string[];
loading: boolean;
refreshStats: () => Promise<void>;
}
const ReferralContext = createContext<ReferralContextValue | undefined>(undefined);
export function ReferralProvider({ children }: { children: ReactNode }) {
// Use peopleApi for referral queries - pallet_referral is on People Chain
const { peopleApi, address } = useWallet();
const { hapticNotification, showAlert } = useTelegram();
const [stats, setStats] = useState<ReferralStats | null>(null);
const [myReferrals, setMyReferrals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
// Fetch referral statistics from People Chain
const fetchStats = useCallback(async () => {
if (!peopleApi || !address) {
setStats(null);
setMyReferrals([]);
setLoading(false);
return;
}
try {
setLoading(true);
const [fetchedStats, fetchedReferrals] = await Promise.all([
getReferralStats(peopleApi, address),
getMyReferrals(peopleApi, address),
]);
setStats(fetchedStats);
setMyReferrals(fetchedReferrals);
} catch (error) {
console.error('Error fetching referral stats:', error);
showAlert('Referral stats bar nekirin');
} finally {
setLoading(false);
}
}, [peopleApi, address, showAlert]);
// Initial fetch
useEffect(() => {
fetchStats();
}, [fetchStats]);
// Subscribe to referral events for real-time updates on People Chain
useEffect(() => {
if (!peopleApi || !address) return;
let unsub: (() => void) | undefined;
subscribeToReferralEvents(peopleApi, (event) => {
// If this user is involved in the event, refresh stats
if (event.referrer === address || event.referred === address) {
if (event.type === 'confirmed') {
hapticNotification('success');
showAlert(`Referral hat pejirandin! Hejmara te: ${event.count}`);
}
fetchStats();
}
}).then((unsubFn) => {
unsub = unsubFn;
});
return () => {
if (unsub) unsub();
};
}, [peopleApi, address, hapticNotification, showAlert, fetchStats]);
const value: ReferralContextValue = {
stats,
myReferrals,
loading,
refreshStats: fetchStats,
};
return <ReferralContext.Provider value={value}>{children}</ReferralContext.Provider>;
}
export function useReferral() {
const context = useContext(ReferralContext);
if (context === undefined) {
throw new Error('useReferral must be used within a ReferralProvider');
}
return context;
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Wallet Context
* Manages wallet state and operations
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
import {
initWalletService,
generateMnemonic,
getAddressFromMnemonic,
createKeypair,
validateMnemonic,
} from '@/lib/wallet-service';
import {
hasStoredWallet,
getStoredAddress,
saveWallet,
unlockWallet,
deleteWallet,
syncWalletToSupabase,
} from '@/lib/wallet-storage';
import { validatePassword } from '@/lib/crypto';
import { supabase } from '@/lib/supabase';
import { useAuth } from './AuthContext';
import {
initRPCConnection,
subscribeToConnection,
initAssetHubConnection,
initPeopleConnection,
} from '@/lib/rpc-manager';
interface WalletContextType {
// State
isInitialized: boolean;
isConnected: boolean;
isLoading: boolean;
address: string | null;
balance: string | null;
error: string | null;
// Wallet management
hasWallet: boolean;
generateNewWallet: () => { mnemonic: string; address: string };
confirmWallet: (mnemonic: string, password: string) => Promise<void>;
importWallet: (mnemonic: string, password: string) => Promise<string>;
connect: (password: string) => Promise<void>;
disconnect: () => void;
deleteWalletData: () => void;
// API
api: ApiPromise | null;
assetHubApi: ApiPromise | null;
peopleApi: ApiPromise | null;
keypair: KeyringPair | null;
}
const WalletContext = createContext<WalletContextType | undefined>(undefined);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useAuth();
// State
const [isInitialized, setIsInitialized] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [address, setAddress] = useState<string | null>(null);
const [balance, setBalance] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [api, setApi] = useState<ApiPromise | null>(null);
const [assetHubApi, setAssetHubApi] = useState<ApiPromise | null>(null);
const [peopleApi, setPeopleApi] = useState<ApiPromise | null>(null);
const [keypair, setKeypair] = useState<KeyringPair | null>(null);
const hasWallet = hasStoredWallet();
// Initialize wallet service and API
useEffect(() => {
const init = async () => {
try {
// Init crypto
await initWalletService();
// Load stored address
const storedAddress = getStoredAddress();
if (storedAddress) {
setAddress(storedAddress);
}
// Mark as initialized immediately - wallet can work without RPC
setIsInitialized(true);
setIsLoading(false);
// Connect to RPC in background (non-blocking)
initRPCConnection()
.then((apiInstance) => {
setApi(apiInstance);
})
.catch((err) => {
console.error('RPC connection error:', err);
// Don't set error - wallet still works offline for create/import
});
// Connect to Asset Hub for PEZ token (non-blocking)
initAssetHubConnection()
.then((assetHubInstance) => {
setAssetHubApi(assetHubInstance);
})
.catch((err) => {
console.error('Asset Hub connection error:', err);
// Don't set error - PEZ features just won't work
});
// Connect to People Chain for identity (non-blocking)
initPeopleConnection()
.then((peopleInstance) => {
setPeopleApi(peopleInstance);
})
.catch((err) => {
console.error('People Chain connection error:', err);
// Don't set error - Identity features just won't work
});
} catch (err) {
console.error('Wallet init error:', err);
setError('Wallet dest pê nekir');
setIsLoading(false);
}
};
init();
// Subscribe to connection state changes (only show error if already was connected)
let wasConnected = false;
const unsubscribe = subscribeToConnection((connected, endpoint) => {
if (connected && endpoint) {
wasConnected = true;
setError(null);
} else if (wasConnected && !connected) {
// Only show disconnect error if we were previously connected
setError('Têkiliya RPC qut bû. Dîsa girêdan tê kirin...');
}
});
return () => {
unsubscribe();
};
}, []);
// Subscribe to balance changes when connected
useEffect(() => {
let unsubscribe: (() => void) | undefined;
if (!api || !address || !isConnected) {
setBalance(null);
return;
}
const subscribeToBalance = async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsub = await (api.query.system.account as any)(
address,
(accountInfo: { data: { free: { toString: () => string } } }) => {
const free = accountInfo.data.free.toString();
// Convert from smallest unit (12 decimals)
const balanceNum = Number(free) / 1e12;
setBalance(balanceNum.toFixed(4));
}
);
unsubscribe = unsub;
} catch (err) {
console.error('Balance subscription error:', err);
}
};
subscribeToBalance();
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, [api, address, isConnected]);
// Generate new wallet (does NOT save - just creates mnemonic)
const generateNewWallet = useCallback((): { mnemonic: string; address: string } => {
const mnemonic = generateMnemonic();
const walletAddress = getAddressFromMnemonic(mnemonic);
return { mnemonic, address: walletAddress };
}, []);
// Confirm wallet after user has backed up seed phrase
const confirmWallet = useCallback(
async (mnemonic: string, password: string): Promise<void> => {
// User must be authenticated first
if (!isAuthenticated || !user?.telegram_id) {
throw new Error('Ji kerema xwe pêşî têkeve');
}
const validation = validatePassword(password);
if (!validation.valid) {
throw new Error(validation.message);
}
const walletAddress = getAddressFromMnemonic(mnemonic);
// Save encrypted locally - ONLY after user confirmed backup
await saveWallet(mnemonic, walletAddress, password);
// Sync wallet address to Supabase
await syncWalletToSupabase(supabase, user.telegram_id, walletAddress);
setAddress(walletAddress);
},
[user, isAuthenticated]
);
// Import existing wallet
const importWallet = useCallback(
async (mnemonic: string, password: string): Promise<string> => {
// User must be authenticated first
if (!isAuthenticated || !user?.telegram_id) {
throw new Error('Ji kerema xwe pêşî têkeve');
}
if (!validateMnemonic(mnemonic)) {
throw new Error('Seed phrase ne derbasdar e');
}
const validation = validatePassword(password);
if (!validation.valid) {
throw new Error(validation.message);
}
const walletAddress = getAddressFromMnemonic(mnemonic);
// Save encrypted locally
await saveWallet(mnemonic, walletAddress, password);
// Sync wallet address to Supabase
await syncWalletToSupabase(supabase, user.telegram_id, walletAddress);
setAddress(walletAddress);
return walletAddress;
},
[user, isAuthenticated]
);
// Connect (unlock) wallet
const connect = useCallback(async (password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const mnemonic = await unlockWallet(password);
const pair = createKeypair(mnemonic);
setKeypair(pair);
setIsConnected(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Girêdan neserketî';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
}, []);
// Disconnect
const disconnect = useCallback(() => {
setKeypair(null);
setIsConnected(false);
setBalance(null);
}, []);
// Delete wallet
const deleteWalletData = useCallback(() => {
deleteWallet();
setAddress(null);
setKeypair(null);
setIsConnected(false);
setBalance(null);
}, []);
return (
<WalletContext.Provider
value={{
isInitialized,
isConnected,
isLoading,
address,
balance,
error,
hasWallet,
generateNewWallet,
confirmWallet,
importWallet,
connect,
disconnect,
deleteWalletData,
api,
assetHubApi,
peopleApi,
keypair,
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
if (!context) {
throw new Error('useWallet must be used within WalletProvider');
}
return context;
}
+481
View File
@@ -0,0 +1,481 @@
/**
* Forum Hook - Fetches forum data from Supabase
* Copied from pwap/web and adapted for Telegram Mini App
*/
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/lib/supabase';
export interface AdminAnnouncement {
id: string;
title: string;
content: string;
type: 'info' | 'warning' | 'success' | 'critical';
priority: number;
created_at: string;
expires_at?: string;
}
export interface ForumCategory {
id: string;
name: string;
description: string;
icon: string;
color: string;
discussion_count?: number;
}
export interface ForumDiscussion {
id: string;
category_id: string;
category?: ForumCategory;
proposal_id?: string;
title: string;
content: string;
image_url?: string;
author_id: string;
author_name: string;
author_address?: string;
is_pinned: boolean;
is_locked: boolean;
views_count: number;
replies_count: number;
tags: string[];
created_at: string;
updated_at: string;
last_activity_at: string;
upvotes: number;
downvotes: number;
userVote?: 'upvote' | 'downvote' | null;
}
export interface ForumReply {
id: string;
discussion_id: string;
parent_reply_id?: string;
content: string;
author_id: string;
author_name: string;
author_address?: string;
is_edited: boolean;
edited_at?: string;
created_at: string;
upvotes: number;
downvotes: number;
userVote?: 'upvote' | 'downvote' | null;
}
interface CreateDiscussionParams {
title: string;
content: string;
category_id: string;
author_id: string;
author_name: string;
author_address?: string;
tags?: string[];
image_url?: string;
}
interface CreateReplyParams {
discussion_id: string;
content: string;
author_id: string;
author_name: string;
author_address?: string;
parent_reply_id?: string;
}
export function useForum(userId?: string) {
const [announcements, setAnnouncements] = useState<AdminAnnouncement[]>([]);
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [discussions, setDiscussions] = useState<ForumDiscussion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAnnouncements = useCallback(async () => {
try {
const { data, error } = await supabase
.from('admin_announcements')
.select('*')
.eq('is_active', true)
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
.order('priority', { ascending: false })
.order('created_at', { ascending: false })
.limit(3);
if (error) throw error;
setAnnouncements(data || []);
} catch (err) {
console.error('Error fetching announcements:', err);
}
}, []);
const fetchCategories = useCallback(async () => {
try {
const { data, error } = await supabase
.from('forum_categories')
.select('*')
.eq('is_active', true)
.order('display_order');
if (error) throw error;
setCategories(data || []);
} catch (err) {
console.error('Error fetching categories:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch categories');
}
}, []);
const fetchDiscussions = useCallback(async () => {
try {
const { data, error } = await supabase
.from('forum_discussions')
.select(
`
*,
category:forum_categories(*)
`
)
.order('is_pinned', { ascending: false })
.order('last_activity_at', { ascending: false })
.limit(50);
if (error) throw error;
// Fetch reaction counts and user votes for each discussion
const discussionsWithReactions = await Promise.all(
(data || []).map(async (discussion) => {
const { data: reactions } = await supabase
.from('forum_reactions')
.select('reaction_type, user_id')
.eq('discussion_id', discussion.id);
const upvotes = reactions?.filter((r) => r.reaction_type === 'upvote').length || 0;
const downvotes = reactions?.filter((r) => r.reaction_type === 'downvote').length || 0;
const userVote = userId
? reactions?.find((r) => r.user_id === userId)?.reaction_type || null
: null;
return {
...discussion,
upvotes,
downvotes,
userVote,
};
})
);
setDiscussions(discussionsWithReactions);
} catch (err) {
console.error('Error fetching discussions:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch discussions');
}
}, [userId]);
const fetchForumData = useCallback(async () => {
setLoading(true);
setError(null);
await Promise.all([fetchAnnouncements(), fetchCategories(), fetchDiscussions()]);
setLoading(false);
}, [fetchAnnouncements, fetchCategories, fetchDiscussions]);
// Fetch replies for a specific discussion
const fetchReplies = useCallback(
async (discussionId: string): Promise<ForumReply[]> => {
try {
const { data, error } = await supabase
.from('forum_replies')
.select('*')
.eq('discussion_id', discussionId)
.order('created_at', { ascending: true });
if (error) throw error;
// Fetch reaction counts for replies
const repliesWithReactions = await Promise.all(
(data || []).map(async (reply) => {
const { data: reactions } = await supabase
.from('forum_reactions')
.select('reaction_type, user_id')
.eq('reply_id', reply.id);
const upvotes = reactions?.filter((r) => r.reaction_type === 'upvote').length || 0;
const downvotes = reactions?.filter((r) => r.reaction_type === 'downvote').length || 0;
const userVote = userId
? reactions?.find((r) => r.user_id === userId)?.reaction_type || null
: null;
return {
...reply,
upvotes,
downvotes,
userVote,
};
})
);
return repliesWithReactions;
} catch (err) {
console.error('Error fetching replies:', err);
return [];
}
},
[userId]
);
// Create a new discussion
const createDiscussion = useCallback(
async (params: CreateDiscussionParams): Promise<ForumDiscussion | null> => {
try {
const { data, error } = await supabase
.from('forum_discussions')
.insert({
title: params.title,
content: params.content,
category_id: params.category_id,
author_id: params.author_id,
author_name: params.author_name,
author_address: params.author_address,
tags: params.tags || [],
image_url: params.image_url,
is_pinned: false,
is_locked: false,
views_count: 0,
replies_count: 0,
last_activity_at: new Date().toISOString(),
})
.select()
.single();
if (error) throw error;
// Refresh discussions
await fetchDiscussions();
return data;
} catch (err) {
console.error('Error creating discussion:', err);
throw err;
}
},
[fetchDiscussions]
);
// Create a new reply
const createReply = useCallback(async (params: CreateReplyParams): Promise<ForumReply | null> => {
try {
const { data, error } = await supabase
.from('forum_replies')
.insert({
discussion_id: params.discussion_id,
content: params.content,
author_id: params.author_id,
author_name: params.author_name,
author_address: params.author_address,
parent_reply_id: params.parent_reply_id,
is_edited: false,
})
.select()
.single();
if (error) throw error;
// Update discussion's reply count and last_activity_at
await supabase
.from('forum_discussions')
.update({
replies_count: supabase.rpc('increment_replies', { discussion_id: params.discussion_id }),
last_activity_at: new Date().toISOString(),
})
.eq('id', params.discussion_id);
return data;
} catch (err) {
console.error('Error creating reply:', err);
throw err;
}
}, []);
// Vote on a discussion
const voteOnDiscussion = useCallback(
async (
discussionId: string,
visitorUserId: string,
voteType: 'upvote' | 'downvote'
): Promise<void> => {
try {
// Check if user already voted
const { data: existingVote } = await supabase
.from('forum_reactions')
.select('*')
.eq('discussion_id', discussionId)
.eq('user_id', visitorUserId)
.single();
if (existingVote) {
if (existingVote.reaction_type === voteType) {
// Remove vote if same type
await supabase.from('forum_reactions').delete().eq('id', existingVote.id);
} else {
// Update vote if different type
await supabase
.from('forum_reactions')
.update({ reaction_type: voteType })
.eq('id', existingVote.id);
}
} else {
// Create new vote
await supabase.from('forum_reactions').insert({
discussion_id: discussionId,
user_id: visitorUserId,
reaction_type: voteType,
});
}
// Refresh discussions to update counts
await fetchDiscussions();
} catch (err) {
console.error('Error voting on discussion:', err);
throw err;
}
},
[fetchDiscussions]
);
// Vote on a reply
const voteOnReply = useCallback(
async (
replyId: string,
visitorUserId: string,
voteType: 'upvote' | 'downvote'
): Promise<void> => {
try {
// Check if user already voted
const { data: existingVote } = await supabase
.from('forum_reactions')
.select('*')
.eq('reply_id', replyId)
.eq('user_id', visitorUserId)
.single();
if (existingVote) {
if (existingVote.reaction_type === voteType) {
// Remove vote if same type
await supabase.from('forum_reactions').delete().eq('id', existingVote.id);
} else {
// Update vote if different type
await supabase
.from('forum_reactions')
.update({ reaction_type: voteType })
.eq('id', existingVote.id);
}
} else {
// Create new vote
await supabase.from('forum_reactions').insert({
reply_id: replyId,
user_id: visitorUserId,
reaction_type: voteType,
});
}
} catch (err) {
console.error('Error voting on reply:', err);
throw err;
}
},
[]
);
// Increment view count
const incrementViewCount = useCallback(async (discussionId: string): Promise<void> => {
try {
const { data } = await supabase
.from('forum_discussions')
.select('views_count')
.eq('id', discussionId)
.single();
if (data) {
await supabase
.from('forum_discussions')
.update({ views_count: (data.views_count || 0) + 1 })
.eq('id', discussionId);
}
} catch (err) {
console.error('Error incrementing view count:', err);
}
}, []);
// Initial fetch
useEffect(() => {
fetchForumData();
}, [fetchForumData]);
// Subscribe to real-time updates
useEffect(() => {
const discussionsSubscription = supabase
.channel('forum_discussions')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'forum_discussions',
},
() => {
fetchDiscussions();
}
)
.subscribe();
const announcementsSubscription = supabase
.channel('admin_announcements')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'admin_announcements',
},
() => {
fetchAnnouncements();
}
)
.subscribe();
const repliesSubscription = supabase
.channel('forum_replies')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'forum_replies',
},
() => {
fetchDiscussions();
}
)
.subscribe();
return () => {
discussionsSubscription.unsubscribe();
announcementsSubscription.unsubscribe();
repliesSubscription.unsubscribe();
};
}, [fetchDiscussions, fetchAnnouncements]);
return {
announcements,
categories,
discussions,
loading,
error,
refreshData: fetchForumData,
fetchReplies,
createDiscussion,
createReply,
voteOnDiscussion,
voteOnReply,
incrementViewCount,
};
}
+629
View File
@@ -0,0 +1,629 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import type {
DbUser,
DbAnnouncementWithAuthor,
DbAnnouncementReaction,
DbThreadWithAuthor,
DbReplyWithAuthor,
AnnouncementCounters,
ThreadCounters,
ReplyCounters,
} from '@/types/database';
// ==================== PUBLIC TYPES ====================
export interface User {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
is_admin: boolean;
}
export interface Announcement {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
likes: number;
dislikes: number;
views: number;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_reaction: 'like' | 'dislike' | null;
}
export interface Thread {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_liked: boolean;
}
export interface Reply {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
author: { username: string | null; first_name: string; photo_url: string | null } | null;
user_liked: boolean;
}
// ==================== HELPER TYPES ====================
interface ReactionRecord {
announcement_id: string;
reaction: 'like' | 'dislike';
}
interface ThreadLikeRecord {
thread_id: string;
}
interface ReplyLikeRecord {
reply_id: string;
}
interface IdRecord {
id: string;
}
// ==================== USER ====================
export function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: async (): Promise<User | null> => {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const { data } = await supabase.from('tg_users').select('*').eq('id', user.id).single();
return data as DbUser | null;
},
});
}
// ==================== ANNOUNCEMENTS ====================
export function useAnnouncements() {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['announcements'],
queryFn: async (): Promise<Announcement[]> => {
const { data, error } = await supabase
.from('tg_announcements')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('is_published', true)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
const announcements = (data || []) as DbAnnouncementWithAuthor[];
// Get user's reactions if logged in
if (currentUser && announcements.length > 0) {
const { data: reactions } = await supabase
.from('tg_announcement_reactions')
.select('announcement_id, reaction')
.eq('user_id', currentUser.id);
const reactionMap = new Map<string, 'like' | 'dislike'>(
((reactions || []) as ReactionRecord[]).map((r) => [r.announcement_id, r.reaction])
);
return announcements.map((a) => ({
...a,
user_reaction: reactionMap.get(a.id) || null,
}));
}
return announcements.map((a) => ({ ...a, user_reaction: null }));
},
});
}
export function useAnnouncementReaction() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({
announcementId,
reaction,
}: {
announcementId: string;
reaction: 'like' | 'dislike';
}) => {
if (!currentUser) throw new Error('Not authenticated');
// Check existing reaction
const { data: existing } = await supabase
.from('tg_announcement_reactions')
.select('*')
.eq('announcement_id', announcementId)
.eq('user_id', currentUser.id)
.single();
const existingReaction = existing as DbAnnouncementReaction | null;
if (existingReaction) {
if (existingReaction.reaction === reaction) {
// Remove reaction
await supabase.from('tg_announcement_reactions').delete().eq('id', existingReaction.id);
// Decrement counter
const { data: ann } = await supabase
.from('tg_announcements')
.select(reaction === 'like' ? 'likes' : 'dislikes')
.eq('id', announcementId)
.single();
const counters = ann as Partial<AnnouncementCounters> | null;
const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0;
await supabase
.from('tg_announcements')
.update({ [reaction === 'like' ? 'likes' : 'dislikes']: Math.max(0, currentCount - 1) })
.eq('id', announcementId);
} else {
// Change reaction
const oldReaction = existingReaction.reaction;
await supabase
.from('tg_announcement_reactions')
.update({ reaction })
.eq('id', existingReaction.id);
// Update counters
const { data: ann } = await supabase
.from('tg_announcements')
.select('likes, dislikes')
.eq('id', announcementId)
.single();
const counters = ann as AnnouncementCounters | null;
const updates: Partial<AnnouncementCounters> = {};
if (oldReaction === 'like') {
updates.likes = Math.max(0, (counters?.likes ?? 0) - 1);
} else {
updates.dislikes = Math.max(0, (counters?.dislikes ?? 0) - 1);
}
if (reaction === 'like') {
updates.likes = (counters?.likes ?? 0) + (oldReaction === 'like' ? 0 : 1);
} else {
updates.dislikes = (counters?.dislikes ?? 0) + (oldReaction === 'dislike' ? 0 : 1);
}
await supabase.from('tg_announcements').update(updates).eq('id', announcementId);
}
} else {
// Add new reaction
await supabase.from('tg_announcement_reactions').insert({
announcement_id: announcementId,
user_id: currentUser.id,
reaction,
});
// Increment counter
const { data: ann } = await supabase
.from('tg_announcements')
.select(reaction === 'like' ? 'likes' : 'dislikes')
.eq('id', announcementId)
.single();
const counters = ann as Partial<AnnouncementCounters> | null;
const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0;
await supabase
.from('tg_announcements')
.update({ [reaction === 'like' ? 'likes' : 'dislikes']: currentCount + 1 })
.eq('id', announcementId);
}
},
onMutate: async ({ announcementId, reaction }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['announcements'] });
// Snapshot the previous value
const previousAnnouncements = queryClient.getQueryData<Announcement[]>(['announcements']);
// Optimistically update to the new value
queryClient.setQueryData<Announcement[]>(['announcements'], (old) => {
if (!old) return [];
return old.map((ann) => {
if (ann.id === announcementId) {
const currentReaction = ann.user_reaction;
let newLikes = ann.likes;
let newDislikes = ann.dislikes;
let newReaction: 'like' | 'dislike' | null = reaction;
if (currentReaction === reaction) {
// Toggling off
newReaction = null;
if (reaction === 'like') newLikes--;
else newDislikes--;
} else {
// Changing reaction or adding new
if (currentReaction === 'like') newLikes--;
if (currentReaction === 'dislike') newDislikes--;
if (reaction === 'like') newLikes++;
else newDislikes++;
}
return {
...ann,
user_reaction: newReaction,
likes: Math.max(0, newLikes),
dislikes: Math.max(0, newDislikes),
};
}
return ann;
});
});
// Return a context object with the snapshotted value
return { previousAnnouncements };
},
onError: (_err, _newTodo, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousAnnouncements) {
queryClient.setQueryData(['announcements'], context.previousAnnouncements);
}
},
onSettled: () => {
// Always refetch after error or success:
queryClient.invalidateQueries({ queryKey: ['announcements'] });
},
});
}
// ==================== FORUM ====================
export function useThreads(sort: 'latest' | 'popular' | 'hot' = 'latest') {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['threads', sort],
queryFn: async (): Promise<Thread[]> => {
let query = supabase
.from('tg_threads')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.limit(50);
if (sort === 'latest') {
query = query.order('created_at', { ascending: false });
} else if (sort === 'popular') {
query = query.order('reply_count', { ascending: false });
} else if (sort === 'hot') {
query = query.order('last_activity', { ascending: false });
}
const { data, error } = await query;
if (error) throw error;
const threads = (data || []) as DbThreadWithAuthor[];
// Get user's likes if logged in
if (currentUser && threads.length > 0) {
const { data: likes } = await supabase
.from('tg_thread_likes')
.select('thread_id')
.eq('user_id', currentUser.id);
const likedIds = new Set(((likes || []) as ThreadLikeRecord[]).map((l) => l.thread_id));
return threads.map((t) => ({
...t,
user_liked: likedIds.has(t.id),
}));
}
return threads.map((t) => ({ ...t, user_liked: false }));
},
});
}
export function useThread(threadId: string | null) {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: ['thread', threadId],
queryFn: async (): Promise<{ thread: Thread; replies: Reply[] } | null> => {
if (!threadId) return null;
const { data: thread, error } = await supabase
.from('tg_threads')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('id', threadId)
.single();
if (error) throw error;
const threadData = thread as DbThreadWithAuthor;
const { data: replies } = await supabase
.from('tg_replies')
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.eq('thread_id', threadId)
.order('created_at', { ascending: true });
const repliesData = (replies || []) as DbReplyWithAuthor[];
// Increment view count
await supabase
.from('tg_threads')
.update({ views: (threadData.views ?? 0) + 1 })
.eq('id', threadId);
// Get user's likes
let userLikedThread = false;
let likedReplyIds = new Set<string>();
if (currentUser) {
const { data: threadLike } = await supabase
.from('tg_thread_likes')
.select('id')
.eq('thread_id', threadId)
.eq('user_id', currentUser.id)
.single();
userLikedThread = !!threadLike;
if (repliesData.length > 0) {
const { data: replyLikes } = await supabase
.from('tg_reply_likes')
.select('reply_id')
.eq('user_id', currentUser.id)
.in(
'reply_id',
repliesData.map((r) => r.id)
);
likedReplyIds = new Set(((replyLikes || []) as ReplyLikeRecord[]).map((l) => l.reply_id));
}
}
return {
thread: { ...threadData, user_liked: userLikedThread },
replies: repliesData.map((r) => ({
...r,
user_liked: likedReplyIds.has(r.id),
})),
};
},
enabled: !!threadId,
});
}
export function useCreateThread() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({ title, content }: { title: string; content: string }): Promise<Thread> => {
if (!currentUser) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('tg_threads')
.insert({
title,
content,
author_id: currentUser.id,
last_activity: new Date().toISOString(),
})
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.single();
if (error) throw error;
const threadData = data as DbThreadWithAuthor;
return { ...threadData, user_liked: false };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useCreateReply() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({
threadId,
content,
}: {
threadId: string;
content: string;
}): Promise<Reply> => {
if (!currentUser) throw new Error('Not authenticated');
const { data, error } = await supabase
.from('tg_replies')
.insert({
thread_id: threadId,
content,
author_id: currentUser.id,
})
.select(
`
*,
author:tg_users!author_id(username, first_name, photo_url)
`
)
.single();
if (error) throw error;
// Update thread reply count and last activity
const { data: thread } = await supabase
.from('tg_threads')
.select('reply_count')
.eq('id', threadId)
.single();
const threadCounters = thread as Partial<ThreadCounters> | null;
await supabase
.from('tg_threads')
.update({
reply_count: (threadCounters?.reply_count ?? 0) + 1,
last_activity: new Date().toISOString(),
})
.eq('id', threadId);
const replyData = data as DbReplyWithAuthor;
return { ...replyData, user_liked: false };
},
onSuccess: (_, { threadId }) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useToggleThreadLike() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async (threadId: string) => {
if (!currentUser) throw new Error('Not authenticated');
const { data: existing } = await supabase
.from('tg_thread_likes')
.select('id')
.eq('thread_id', threadId)
.eq('user_id', currentUser.id)
.single();
const existingLike = existing as IdRecord | null;
const { data: thread } = await supabase
.from('tg_threads')
.select('likes')
.eq('id', threadId)
.single();
const threadCounters = thread as Partial<ThreadCounters> | null;
const currentLikes = threadCounters?.likes ?? 0;
if (existingLike) {
await supabase.from('tg_thread_likes').delete().eq('id', existingLike.id);
await supabase
.from('tg_threads')
.update({ likes: Math.max(0, currentLikes - 1) })
.eq('id', threadId);
} else {
await supabase.from('tg_thread_likes').insert({
thread_id: threadId,
user_id: currentUser.id,
});
await supabase
.from('tg_threads')
.update({ likes: currentLikes + 1 })
.eq('id', threadId);
}
return threadId;
},
onSuccess: (threadId) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
queryClient.invalidateQueries({ queryKey: ['threads'] });
},
});
}
export function useToggleReplyLike() {
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
return useMutation({
mutationFn: async ({ replyId, threadId }: { replyId: string; threadId: string }) => {
if (!currentUser) throw new Error('Not authenticated');
const { data: existing } = await supabase
.from('tg_reply_likes')
.select('id')
.eq('reply_id', replyId)
.eq('user_id', currentUser.id)
.single();
const existingLike = existing as IdRecord | null;
const { data: reply } = await supabase
.from('tg_replies')
.select('likes')
.eq('id', replyId)
.single();
const replyCounters = reply as Partial<ReplyCounters> | null;
const currentLikes = replyCounters?.likes ?? 0;
if (existingLike) {
await supabase.from('tg_reply_likes').delete().eq('id', existingLike.id);
await supabase
.from('tg_replies')
.update({ likes: Math.max(0, currentLikes - 1) })
.eq('id', replyId);
} else {
await supabase.from('tg_reply_likes').insert({
reply_id: replyId,
user_id: currentUser.id,
});
await supabase
.from('tg_replies')
.update({ likes: currentLikes + 1 })
.eq('id', replyId);
}
return threadId;
},
onSuccess: (threadId) => {
queryClient.invalidateQueries({ queryKey: ['thread', threadId] });
},
});
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useMemo } from 'react';
export function useTelegram() {
const tg = useMemo(() => window.Telegram?.WebApp, []);
const user = useMemo(() => tg?.initDataUnsafe?.user, [tg]);
const startParam = useMemo(() => tg?.initDataUnsafe?.start_param, [tg]);
const hapticImpact = useCallback(
(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' = 'medium') => {
tg?.HapticFeedback.impactOccurred(style);
},
[tg]
);
const hapticNotification = useCallback(
(type: 'success' | 'warning' | 'error' = 'success') => {
tg?.HapticFeedback.notificationOccurred(type);
},
[tg]
);
const hapticSelection = useCallback(() => {
tg?.HapticFeedback.selectionChanged();
}, [tg]);
const showAlert = useCallback(
(message: string) => {
if (tg) {
tg.showAlert(message);
} else {
window.alert(message);
}
},
[tg]
);
const showConfirm = useCallback(
(message: string): Promise<boolean> => {
return new Promise((resolve) => {
if (tg) {
tg.showConfirm(message, resolve);
} else {
resolve(window.confirm(message));
}
});
},
[tg]
);
const openLink = useCallback(
(url: string) => {
// Validate URL to prevent javascript: and data: protocol attacks
try {
const parsed = new globalThis.URL(url);
const allowedProtocols = ['http:', 'https:', 'tg:', 'mailto:'];
if (!allowedProtocols.includes(parsed.protocol)) {
return;
}
} catch {
return;
}
if (tg) {
tg.openLink(url);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
},
[tg]
);
const openTelegramLink = useCallback(
(url: string) => {
if (tg) {
tg.openTelegramLink(url);
} else {
window.open(url, '_blank');
}
},
[tg]
);
const shareUrl = useCallback(
(url: string, text?: string) => {
const shareText = text ? encodeURIComponent(text) : '';
const shareUrl = encodeURIComponent(url);
openTelegramLink(`https://t.me/share/url?url=${shareUrl}&text=${shareText}`);
},
[openTelegramLink]
);
const close = useCallback(() => {
tg?.close();
}, [tg]);
return {
tg,
user,
startParam,
isAvailable: !!tg,
platform: tg?.platform || 'unknown',
version: tg?.version || '0.0',
hapticImpact,
hapticNotification,
hapticSelection,
showAlert,
showConfirm,
openLink,
openTelegramLink,
shareUrl,
close,
};
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Version management hook
* Handles version checking and auto-update notifications
*/
import { useState, useEffect, useCallback } from 'react';
import versionInfo from '@/version.json';
const VERSION_STORAGE_KEY = 'pezkuwi_app_version';
const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // Check every 5 minutes
interface VersionState {
currentVersion: string;
buildTime: string;
buildNumber: number;
hasUpdate: boolean;
isChecking: boolean;
}
export function useVersion() {
const [state, setState] = useState<VersionState>({
currentVersion: versionInfo.version,
buildTime: versionInfo.buildTime,
buildNumber: versionInfo.buildNumber,
hasUpdate: false,
isChecking: false,
});
// Check if this is a new version (first load after update)
useEffect(() => {
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion !== versionInfo.version) {
// New version detected - clear old cache
if (storedVersion) {
// Clear any cached data that might be stale
clearStaleCache();
}
// Store new version
localStorage.setItem(VERSION_STORAGE_KEY, versionInfo.version);
}
}, []);
// Check for updates by fetching version.json from server
const checkForUpdate = useCallback(async () => {
setState((prev) => ({ ...prev, isChecking: true }));
try {
// Add cache-busting timestamp
const response = await fetch(`/src/version.json?t=${Date.now()}`, {
cache: 'no-store',
});
if (!response.ok) {
// Try alternative path (for production build)
const altResponse = await fetch(`/version.json?t=${Date.now()}`, {
cache: 'no-store',
});
if (!altResponse.ok) throw new Error('Version check failed');
const serverVersion = await altResponse.json();
handleVersionCheck(serverVersion);
return;
}
const serverVersion = await response.json();
handleVersionCheck(serverVersion);
} catch (error) {
// Silently fail - not critical
if (import.meta.env.DEV) {
console.warn('[Version] Check failed:', error);
}
} finally {
setState((prev) => ({ ...prev, isChecking: false }));
}
}, []);
const handleVersionCheck = (serverVersion: typeof versionInfo) => {
if (serverVersion.buildNumber > versionInfo.buildNumber) {
setState((prev) => ({ ...prev, hasUpdate: true }));
}
};
// Periodic update check
useEffect(() => {
// Initial check after 30 seconds
const initialTimeout = setTimeout(checkForUpdate, 30000);
// Then check periodically
const interval = setInterval(checkForUpdate, VERSION_CHECK_INTERVAL);
return () => {
clearTimeout(initialTimeout);
clearInterval(interval);
};
}, [checkForUpdate]);
// Force refresh to get new version
const forceUpdate = useCallback(() => {
// Clear all caches
clearStaleCache();
// Force reload without cache
window.location.reload();
}, []);
// Dismiss update notification
const dismissUpdate = useCallback(() => {
setState((prev) => ({ ...prev, hasUpdate: false }));
}, []);
return {
...state,
checkForUpdate,
forceUpdate,
dismissUpdate,
};
}
// Clear stale cached data
function clearStaleCache() {
try {
// Clear React Query cache if available
if ('caches' in window && window.caches) {
window.caches.keys().then((names) => {
names.forEach((name) => window.caches?.delete(name));
});
}
// Clear specific app cache keys (not wallet data)
const keysToPreserve = ['pezkuwi_wallet', 'pezkuwi_app_version'];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key) && key.startsWith('pezkuwi_')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Clear sessionStorage
window.sessionStorage.clear();
} catch {
// Silently fail - cache clear is not critical
}
}
export default useVersion;
+61
View File
@@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--radius: 0.75rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Telegram safe area */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Hide scrollbar but allow scrolling */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Smooth animations */
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Crypto Utility Tests
*/
import { describe, it, expect } from 'vitest';
import {
encrypt,
decrypt,
validatePassword,
calculateEntropy,
getPasswordStrength,
hasWeakPatterns,
} from './crypto';
describe('crypto utilities', () => {
describe('encrypt/decrypt', () => {
it('should encrypt and decrypt data correctly', async () => {
const password = 'TestP@ssword123!';
const data = 'sensitive data to encrypt';
const encrypted = await encrypt(data, password);
expect(encrypted).not.toBe(data);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should produce different ciphertext for same input (due to random salt/iv)', async () => {
const password = 'TestP@ssword123!';
const data = 'same data';
const encrypted1 = await encrypt(data, password);
const encrypted2 = await encrypt(data, password);
expect(encrypted1).not.toBe(encrypted2);
});
it('should fail to decrypt with wrong password', async () => {
const data = 'test data';
const encrypted = await encrypt(data, 'CorrectP@ss123!');
await expect(decrypt(encrypted, 'WrongP@ssword1!')).rejects.toThrow();
});
it('should handle unicode data', async () => {
const password = 'TestP@ssword123!';
const data = 'سڵاو کوردستان 🇰🇷';
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should handle empty string', async () => {
const password = 'TestP@ssword123!';
const data = '';
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
it('should handle long data', async () => {
const password = 'TestP@ssword123!';
const data = 'a'.repeat(10000);
const encrypted = await encrypt(data, password);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(data);
});
});
describe('validatePassword', () => {
it('should reject passwords shorter than 12 characters', () => {
const result = validatePassword('Short1!a');
expect(result.valid).toBe(false);
expect(result.message).toContain('12');
});
it('should reject passwords without lowercase', () => {
const result = validatePassword('ALLUPPERCASE123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('biçûk');
});
it('should reject passwords without uppercase', () => {
const result = validatePassword('alllowercase123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('mezin');
});
it('should reject passwords without numbers', () => {
const result = validatePassword('NoNumbersHere!@');
expect(result.valid).toBe(false);
expect(result.message).toContain('hejmar');
});
it('should reject passwords without special characters', () => {
const result = validatePassword('NoSpecial12345a');
expect(result.valid).toBe(false);
expect(result.message).toContain('taybet');
});
it('should accept strong passwords', () => {
const result = validatePassword('StrongP@ssword123!');
expect(result.valid).toBe(true);
expect(result.entropy).toBeGreaterThan(60);
});
it('should return entropy and strength for all passwords', () => {
const result = validatePassword('Test1!');
expect(result.entropy).toBeGreaterThan(0);
expect(result.strength).toBeDefined();
});
});
describe('calculateEntropy', () => {
it('should calculate higher entropy for longer passwords', () => {
const short = calculateEntropy('abc');
const long = calculateEntropy('abcdefghijk');
expect(long).toBeGreaterThan(short);
});
it('should calculate higher entropy for more diverse character sets', () => {
const onlyLower = calculateEntropy('abcdefghij');
const mixed = calculateEntropy('AbC123!@#');
expect(mixed).toBeGreaterThan(onlyLower);
});
it('should return 0 for empty string', () => {
expect(calculateEntropy('')).toBe(0);
});
});
describe('getPasswordStrength', () => {
it('should categorize weak passwords correctly', () => {
expect(getPasswordStrength('abc')).toBe('weak');
expect(getPasswordStrength('abcdef')).toBe('weak');
});
it('should categorize medium passwords correctly', () => {
expect(getPasswordStrength('Abcdef123!')).toBe('medium');
});
it('should categorize strong passwords correctly', () => {
// VeryStr0ngP@ssword! is actually very strong (high entropy)
expect(getPasswordStrength('Str0ngP@ss!')).toBe('strong');
});
it('should categorize very strong passwords correctly', () => {
expect(getPasswordStrength('Super$ecure#P@ssw0rd!2024')).toBe('very-strong');
});
});
describe('hasWeakPatterns', () => {
it('should detect repeated characters', () => {
expect(hasWeakPatterns('aaaaaaaaaaaa')).toBe(true);
});
it('should detect sequential numbers', () => {
expect(hasWeakPatterns('123456789012')).toBe(true);
});
it('should detect common passwords', () => {
expect(hasWeakPatterns('password')).toBe(true);
expect(hasWeakPatterns('qwerty')).toBe(true);
});
it('should not flag strong passwords', () => {
expect(hasWeakPatterns('Xk9#mP2@nQ4!')).toBe(false);
});
});
});
+224
View File
@@ -0,0 +1,224 @@
/**
* Crypto utilities for wallet encryption
* Uses Web Crypto API (AES-GCM)
*
* Security features:
* - AES-256-GCM encryption
* - PBKDF2 key derivation (600K iterations, OWASP 2023 recommendation)
* - 16-byte random salt per encryption
* - 12-byte random IV per encryption
* - Version header for future algorithm updates
*/
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const VERSION_LENGTH = 1;
const CURRENT_VERSION = 2; // v1: 100K iterations, v2: 600K iterations
// OWASP 2023 recommendation for PBKDF2-SHA256
const KEY_ITERATIONS_V2 = 600000;
const KEY_ITERATIONS_V1 = 100000; // Legacy for backward compatibility
/**
* Derive encryption key from password using PBKDF2
* @param password - User password
* @param salt - Random salt
* @param version - Encryption version (determines iteration count)
*/
async function deriveKey(
password: string,
salt: Uint8Array,
version: number = CURRENT_VERSION
): Promise<CryptoKey> {
const encoder = new TextEncoder();
const passwordKey = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// Use appropriate iteration count based on version
const iterations = version >= 2 ? KEY_ITERATIONS_V2 : KEY_ITERATIONS_V1;
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new Uint8Array(salt), // Create new Uint8Array for compatibility
iterations,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt data with password (AES-256-GCM)
* Format: version (1 byte) + salt (16 bytes) + iv (12 bytes) + ciphertext
*/
export async function encrypt(data: string, password: string): Promise<string> {
const encoder = new TextEncoder();
const version = new Uint8Array([CURRENT_VERSION]);
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt, CURRENT_VERSION);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(data));
// Combine version + salt + iv + encrypted data
const combined = new Uint8Array(VERSION_LENGTH + salt.length + iv.length + encrypted.byteLength);
combined.set(version, 0);
combined.set(salt, VERSION_LENGTH);
combined.set(iv, VERSION_LENGTH + salt.length);
combined.set(new Uint8Array(encrypted), VERSION_LENGTH + salt.length + iv.length);
// Return as base64
return btoa(String.fromCharCode(...combined));
}
/**
* Decrypt data with password
* Supports both v1 (legacy, no version byte) and v2 (with version byte) formats
*/
export async function decrypt(encryptedData: string, password: string): Promise<string> {
const decoder = new TextDecoder();
const combined = new Uint8Array(
atob(encryptedData)
.split('')
.map((c) => c.charCodeAt(0))
);
// Detect version: if first byte is 1 or 2, it's a version header
// Legacy v1 data starts with salt which would be random (unlikely to be 1 or 2)
let version: number;
let offset: number;
if (combined[0] === 1 || combined[0] === 2) {
// New format with version header
version = combined[0];
offset = VERSION_LENGTH;
} else {
// Legacy format (v1) without version header
version = 1;
offset = 0;
}
const salt = combined.slice(offset, offset + SALT_LENGTH);
const iv = combined.slice(offset + SALT_LENGTH, offset + SALT_LENGTH + IV_LENGTH);
const data = combined.slice(offset + SALT_LENGTH + IV_LENGTH);
const key = await deriveKey(password, salt, version);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
return decoder.decode(decrypted);
}
/**
* Calculate password entropy (bits)
* Higher entropy = stronger password
*/
export function calculateEntropy(password: string): number {
let charsetSize = 0;
if (/[a-z]/.test(password)) charsetSize += 26;
if (/[A-Z]/.test(password)) charsetSize += 26;
if (/[0-9]/.test(password)) charsetSize += 10;
if (/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) charsetSize += 32;
if (/\s/.test(password)) charsetSize += 1;
if (charsetSize === 0) return 0;
return Math.floor(password.length * Math.log2(charsetSize));
}
export type PasswordStrength = 'weak' | 'medium' | 'strong' | 'very-strong';
/**
* Get password strength category based on entropy
*/
export function getPasswordStrength(password: string): PasswordStrength {
const entropy = calculateEntropy(password);
if (entropy < 50) return 'weak';
if (entropy < 70) return 'medium';
if (entropy < 90) return 'strong';
return 'very-strong';
}
/**
* Validate password strength
* Requires: 12+ chars, lowercase, uppercase, number, special char
* Minimum entropy: 60 bits
*/
export function validatePassword(password: string): {
valid: boolean;
message?: string;
entropy?: number;
strength?: PasswordStrength;
} {
const entropy = calculateEntropy(password);
const strength = getPasswordStrength(password);
if (password.length < 12) {
return { valid: false, message: 'Şîfre (password) herî kêm 12 tîp be', entropy, strength };
}
if (!/[a-z]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 tîpa biçûk hebe (a-z)',
entropy,
strength,
};
}
if (!/[A-Z]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 tîpa mezin hebe (A-Z)',
entropy,
strength,
};
}
if (!/[0-9]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 hejmar hebe (0-9)',
entropy,
strength,
};
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
return {
valid: false,
message: 'Şîfre (password) herî kêm 1 nîşana taybet hebe (!@#$%...)',
entropy,
strength,
};
}
if (entropy < 60) {
return {
valid: false,
message:
'Şîfre (password) ne têra qewî ye. Şîfreyek (password) dirêjtir bi tîpên cûrbecûr biceribîne.',
entropy,
strength,
};
}
return { valid: true, entropy, strength };
}
/**
* Detect common weak patterns in passwords
*/
export function hasWeakPatterns(password: string): boolean {
const weakPatterns = [
/^(.)\1+$/, // All same character
/^(012|123|234|345|456|567|678|789)+$/, // Sequential numbers
/^(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz)+$/i, // Sequential letters
/^(qwerty|asdfgh|zxcvbn)/i, // Keyboard patterns
/^(password|şîfre|parola|123456|qwerty)/i, // Common passwords
];
return weakPatterns.some((pattern) => pattern.test(password.toLowerCase()));
}
+35
View File
@@ -0,0 +1,35 @@
// Environment validation - fail fast if misconfigured
interface EnvConfig {
SUPABASE_URL: string;
SUPABASE_ANON_KEY: string;
IS_DEVELOPMENT: boolean;
IS_PRODUCTION: boolean;
}
function validateEnv(): EnvConfig {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const missing: string[] = [];
if (!supabaseUrl) missing.push('VITE_SUPABASE_URL');
if (!supabaseAnonKey) missing.push('VITE_SUPABASE_ANON_KEY');
if (missing.length > 0 && import.meta.env.PROD) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
if (missing.length > 0) {
console.warn(`[ENV] Missing variables (using fallbacks): ${missing.join(', ')}`);
}
return {
SUPABASE_URL: supabaseUrl || 'https://placeholder.supabase.co',
SUPABASE_ANON_KEY: supabaseAnonKey || 'placeholder-key',
IS_DEVELOPMENT: import.meta.env.DEV,
IS_PRODUCTION: import.meta.env.PROD,
};
}
export const env = validateEnv();
+161
View File
@@ -0,0 +1,161 @@
/**
* Error Tracking Utility Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
trackError,
trackWarning,
getRecentErrors,
clearErrorBuffer,
createError,
extractError,
formatUserError,
} from './error-tracking';
describe('error-tracking utilities', () => {
beforeEach(() => {
clearErrorBuffer();
});
describe('trackError', () => {
it('should add error to buffer', () => {
const error = new Error('Test error');
trackError(error);
const errors = getRecentErrors();
expect(errors).toHaveLength(1);
expect(errors[0].message).toBe('Test error');
});
it('should include context in tracked error', () => {
const error = new Error('Test error');
trackError(error, { component: 'TestComponent', action: 'test_action' });
const errors = getRecentErrors();
expect(errors[0].context?.component).toBe('TestComponent');
expect(errors[0].context?.action).toBe('test_action');
});
it('should include timestamp', () => {
const before = Date.now();
trackError(new Error('Test'));
const after = Date.now();
const errors = getRecentErrors();
expect(errors[0].timestamp).toBeGreaterThanOrEqual(before);
expect(errors[0].timestamp).toBeLessThanOrEqual(after);
});
it('should limit buffer to 50 errors', () => {
for (let i = 0; i < 60; i++) {
trackError(new Error(`Error ${i}`));
}
const errors = getRecentErrors();
expect(errors).toHaveLength(50);
expect(errors[0].message).toBe('Error 10'); // First 10 should be removed
});
});
describe('trackWarning', () => {
it('should not throw', () => {
expect(() => trackWarning('Test warning')).not.toThrow();
});
it('should accept context', () => {
expect(() => trackWarning('Test warning', { action: 'test' })).not.toThrow();
});
});
describe('clearErrorBuffer', () => {
it('should clear all errors', () => {
trackError(new Error('Error 1'));
trackError(new Error('Error 2'));
expect(getRecentErrors()).toHaveLength(2);
clearErrorBuffer();
expect(getRecentErrors()).toHaveLength(0);
});
});
describe('createError', () => {
it('should create and track error', () => {
const error = createError('Created error', { component: 'Test' });
expect(error.message).toBe('Created error');
expect(getRecentErrors()).toHaveLength(1);
});
});
describe('extractError', () => {
it('should return Error instance unchanged', () => {
const error = new Error('Test');
expect(extractError(error)).toBe(error);
});
it('should convert string to Error', () => {
const error = extractError('String error');
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('String error');
});
it('should handle unknown types', () => {
const error = extractError({ weird: 'object' });
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('An unknown error occurred');
});
it('should handle null', () => {
const error = extractError(null);
expect(error).toBeInstanceOf(Error);
});
it('should handle undefined', () => {
const error = extractError(undefined);
expect(error).toBeInstanceOf(Error);
});
});
describe('formatUserError', () => {
it('should format network errors', () => {
const error = new Error('Network Error');
const message = formatUserError(error);
expect(message).toContain('înternetê');
});
it('should format fetch errors', () => {
const error = new Error('Failed to fetch');
const message = formatUserError(error);
expect(message).toContain('înternetê');
});
it('should format timeout errors', () => {
const error = new Error('TIMEOUT');
const message = formatUserError(error);
expect(message).toContain('dirêj');
});
it('should format wallet not found errors', () => {
const error = new Error('Wallet not found');
const message = formatUserError(error);
expect(message).toContain('Wallet');
});
it('should format password errors', () => {
const error = new Error('Şîfre (password) çewt e');
const message = formatUserError(error);
expect(message).toContain('Şîfre');
});
it('should return generic message for unknown errors', () => {
const error = new Error('Some random error');
const message = formatUserError(error);
expect(message).toContain('çewt');
});
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Error Tracking Utility
* Centralized error logging and tracking infrastructure
*
* In production, this can be connected to:
* - Sentry (recommended)
* - LogRocket
* - Custom analytics endpoint
*/
export interface ErrorContext {
component?: string;
action?: string;
userId?: string;
extra?: Record<string, unknown>;
}
export interface TrackedError {
message: string;
stack?: string;
timestamp: number;
context?: ErrorContext;
fingerprint?: string;
}
// In-memory error buffer (last 50 errors for debugging)
const errorBuffer: TrackedError[] = [];
const MAX_BUFFER_SIZE = 50;
/**
* Generate a fingerprint for deduplication
*/
function generateFingerprint(error: Error, context?: ErrorContext): string {
const parts = [error.name, error.message, context?.component, context?.action].filter(Boolean);
return parts.join('::');
}
/**
* Track an error
*/
export function trackError(error: Error, context?: ErrorContext): void {
const trackedError: TrackedError = {
message: error.message,
stack: error.stack,
timestamp: Date.now(),
context,
fingerprint: generateFingerprint(error, context),
};
// Add to buffer (FIFO)
errorBuffer.push(trackedError);
if (errorBuffer.length > MAX_BUFFER_SIZE) {
errorBuffer.shift();
}
// Log in development
if (import.meta.env.DEV) {
console.error('[ErrorTracking]', {
error: error.message,
stack: error.stack,
context,
});
}
// TODO: In production, send to error tracking service
// sendToSentry(trackedError);
// sendToAnalytics(trackedError);
}
/**
* Track a warning (non-critical issue)
*/
export function trackWarning(message: string, context?: ErrorContext): void {
if (import.meta.env.DEV) {
console.warn('[Warning]', message, context);
}
// TODO: In production, send to analytics
}
/**
* Get recent errors (for debugging)
*/
export function getRecentErrors(): TrackedError[] {
return [...errorBuffer];
}
/**
* Clear error buffer
*/
export function clearErrorBuffer(): void {
errorBuffer.length = 0;
}
/**
* Create an error with context
*/
export function createError(message: string, context?: ErrorContext): Error {
const error = new Error(message);
trackError(error, context);
return error;
}
/**
* Safe error extraction from unknown catch value
*/
export function extractError(caught: unknown): Error {
if (caught instanceof Error) {
return caught;
}
if (typeof caught === 'string') {
return new Error(caught);
}
return new Error('An unknown error occurred');
}
/**
* Format error for user display
*/
export function formatUserError(error: Error): string {
// Map technical errors to user-friendly messages
const errorMap: Record<string, string> = {
'Network Error': 'Têkiliya înternetê tune ye. Ji kerema xwe têkiliya xwe kontrol bike.',
'Failed to fetch': 'Têkiliya înternetê tune ye. Ji kerema xwe têkiliya xwe kontrol bike.',
TIMEOUT: 'Operasyon zêde dirêj kişand. Ji kerema xwe dîsa biceribîne.',
'Wallet not found': 'Wallet nehate dîtin. Ji kerema xwe wallet çêke an jî restore bike.',
'Şîfre (password) çewt e': 'Şîfre (password) çewt e. Ji kerema xwe dîsa biceribîne.',
};
for (const [key, message] of Object.entries(errorMap)) {
if (error.message.includes(key)) {
return message;
}
}
return 'Tiştek çewt çêbû. Ji kerema xwe dîsa biceribîne.';
}
+158
View File
@@ -0,0 +1,158 @@
/**
* P2P Fiat Crypto Tests
* Tests for AES-256-GCM encryption of payment details
*/
import { describe, it, expect } from 'vitest';
import { encryptPaymentDetails, decryptPaymentDetails, verifyEncryption } from './p2p-fiat-crypto';
describe('P2P Payment Details Encryption', () => {
describe('encryptPaymentDetails', () => {
it('should encrypt payment details to base64 string', async () => {
const details = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
};
const encrypted = await encryptPaymentDetails(details);
expect(encrypted).toBeTruthy();
expect(typeof encrypted).toBe('string');
// Should be base64 encoded
expect(() => atob(encrypted)).not.toThrow();
});
it('should not contain plaintext in encrypted output', async () => {
const details = {
iban: 'TR123456789012345678901234',
secret_code: 'SUPERSECRET',
};
const encrypted = await encryptPaymentDetails(details);
expect(encrypted).not.toContain('TR123456789012345678901234');
expect(encrypted).not.toContain('SUPERSECRET');
});
it('should produce different ciphertext for same input (random IV)', async () => {
const details = {
iban: 'TR000000000000000000000001',
};
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
expect(encrypted1).not.toBe(encrypted2);
});
it('should handle empty object', async () => {
const details = {};
const encrypted = await encryptPaymentDetails(details);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual({});
});
it('should handle special characters', async () => {
const details = {
name: 'Hêlîn Qadîr',
bank: 'Türkiye İş Bankası',
note: 'سڵاو 👋',
};
const encrypted = await encryptPaymentDetails(details);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(details);
});
});
describe('decryptPaymentDetails', () => {
it('should decrypt to original payment details', async () => {
const original = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
bank_name: 'Test Bank',
};
const encrypted = await encryptPaymentDetails(original);
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(original);
});
it('should handle legacy base64 encoded data', async () => {
// Legacy format: just base64 encoded JSON (no encryption)
const legacy = { iban: 'TR123' };
const legacyEncoded = btoa(JSON.stringify(legacy));
const decrypted = await decryptPaymentDetails(legacyEncoded);
expect(decrypted).toEqual(legacy);
});
it('should return empty object for invalid data', async () => {
const invalid = 'not-valid-base64!!!';
const decrypted = await decryptPaymentDetails(invalid);
expect(decrypted).toEqual({});
});
});
describe('verifyEncryption', () => {
it('should return true when encryption is working', async () => {
const result = await verifyEncryption();
expect(result).toBe(true);
});
});
describe('Security Properties', () => {
it('should use random IV (first 12 bytes should differ)', async () => {
const details = { test: 'data' };
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
// Decode and compare first 12 bytes (IV)
const decoded1 = Uint8Array.from(atob(encrypted1), (c) => c.charCodeAt(0));
const decoded2 = Uint8Array.from(atob(encrypted2), (c) => c.charCodeAt(0));
const iv1 = decoded1.slice(0, 12);
const iv2 = decoded2.slice(0, 12);
// IVs should be different
expect(iv1.toString()).not.toBe(iv2.toString());
});
it('should produce authenticated ciphertext (GCM tag)', async () => {
const details = { test: 'data' };
const encrypted = await encryptPaymentDetails(details);
const decoded = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
// IV (12 bytes) + ciphertext + GCM tag (16 bytes)
// For small data, minimum should be at least 12 + 16 = 28 bytes
expect(decoded.length).toBeGreaterThan(28);
});
it('should fail decryption with tampered ciphertext', async () => {
const details = { sensitive: 'data' };
const encrypted = await encryptPaymentDetails(details);
// Tamper with the ciphertext (flip a bit in the middle)
const decoded = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
decoded[decoded.length - 10] ^= 0xff; // Flip bits
const tampered = btoa(String.fromCharCode(...decoded));
// Should fail to decrypt (GCM authentication)
const result = await decryptPaymentDetails(tampered);
// Will fall back to empty object or legacy decode failure
expect(result).toEqual({});
});
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* P2P Fiat Trading - Encryption Utilities
*
* AES-256-GCM encryption for payment details
* Extracted for testing purposes
*
* @module p2p-fiat-crypto
*/
const IV_LENGTH = 12; // 96 bits for GCM
/**
* Derive encryption key from a password/secret
*/
async function getEncryptionKey(): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode('p2p-payment-encryption-v1-pezkuwi'),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode('pezkuwi-p2p-salt'),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt payment details using AES-256-GCM
* @param details Payment details object to encrypt
* @returns Base64-encoded encrypted string
*/
export async function encryptPaymentDetails(details: Record<string, string>): Promise<string> {
try {
const key = await getEncryptionKey();
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(details));
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encrypt
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
// Combine IV + ciphertext and encode as base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
} catch (error) {
console.error('Encryption failed:', error);
// Fallback to base64 for backwards compatibility (temporary)
return btoa(JSON.stringify(details));
}
}
/**
* Decrypt payment details using AES-256-GCM
* @param encrypted Base64-encoded encrypted string
* @returns Decrypted payment details object
*/
export async function decryptPaymentDetails(encrypted: string): Promise<Record<string, string>> {
try {
const key = await getEncryptionKey();
// Decode base64
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
// Extract IV and ciphertext
const iv = combined.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
// Decrypt
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(decrypted));
} catch {
// Fallback: try to decode as plain base64 (for old data)
try {
return JSON.parse(atob(encrypted));
} catch {
return {};
}
}
}
/**
* Verify encryption is working correctly
* Used for health checks
*/
export async function verifyEncryption(): Promise<boolean> {
try {
const testData = { test: 'verification' };
const encrypted = await encryptPaymentDetails(testData);
const decrypted = await decryptPaymentDetails(encrypted);
return decrypted.test === 'verification';
} catch {
return false;
}
}
+825
View File
@@ -0,0 +1,825 @@
/**
* P2P Fiat Trading E2E Tests
*
* Tests various trading scenarios:
* 1. Happy path - Two honest users complete a 5 HEZ trade
* 2. Buyer scam - Buyer marks payment sent but didn't pay
* 3. Seller scam - Seller doesn't release even though payment arrived
* 4. Timeout scenarios
* 5. Trade cancellation
*
* @module p2p-fiat.e2e.test
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
// Test configuration - using real Supabase project
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SUPABASE_URL = (import.meta as any).env?.VITE_SUPABASE_URL || '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SUPABASE_SERVICE_KEY = (import.meta as any).env?.SUPABASE_SERVICE_ROLE_KEY || '';
// Test wallet from CRITICAL_STATE.md
// Mnemonic: crucial surge north silly divert throw habit fury zebra fabric tank output
const TEST_WALLET_ADDRESS = '5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w';
// Test user IDs (Telegram style)
const TEST_USERS = {
ALICE: {
telegram_id: 111111111,
display_name: 'Alice Test',
telegram_username: 'alice_test',
},
BOB: {
telegram_id: 222222222,
display_name: 'Bob Test',
telegram_username: 'bob_test',
},
SCAMMER: {
telegram_id: 333333333,
display_name: 'Scammer Test',
telegram_username: 'scammer_test',
},
};
// Test amounts
const TRADE_AMOUNT_HEZ = 5;
const TRADE_AMOUNT_TRY = 100;
// Supabase client for tests (with service role for admin operations)
let supabase: SupabaseClient;
// Test user Supabase IDs (will be populated after user creation)
let aliceId: string;
let bobId: string;
let scammerId: string;
// Payment method for tests
let testPaymentMethodId: string;
/**
* Skip condition for E2E tests
* These tests require:
* 1. SUPABASE_SERVICE_ROLE_KEY environment variable
* 2. Network access to Supabase
*/
const shouldSkipE2E = !SUPABASE_SERVICE_KEY;
describe.skipIf(shouldSkipE2E)('P2P Fiat Trading E2E Tests', () => {
beforeAll(async () => {
// Initialize Supabase client with service role key
supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
// Create test users
await setupTestUsers();
// Get or create test payment method
await setupTestPaymentMethod();
// Seed initial balances for test users
await seedTestBalances();
});
afterAll(async () => {
// Cleanup test data
await cleanupTestData();
});
describe('Scenario 1: Happy Path - Two Honest Users', () => {
let offerId: string;
let tradeId: string;
it('should allow Alice to create a sell offer', async () => {
// Alice creates offer to sell 5 HEZ for 100 TRY
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
expect(offer).toBeDefined();
expect(offer.status).toBe('open');
expect(offer.amount_crypto).toBe(TRADE_AMOUNT_HEZ);
offerId = offer.id;
// Lock Alice's balance
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
expect(lockError).toBeNull();
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
expect(lockResponse.success).toBe(true);
});
it('should allow Bob to accept the offer', async () => {
// Bob accepts Alice's offer
const { data: result, error } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
expect(error).toBeNull();
const response = typeof result === 'string' ? JSON.parse(result) : result;
expect(response.success).toBe(true);
expect(response.trade_id).toBeDefined();
tradeId = response.trade_id;
// Verify trade was created
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade).toBeDefined();
expect(trade.status).toBe('pending');
expect(trade.buyer_id).toBe(bobId);
expect(trade.seller_id).toBe(aliceId);
});
it('should allow Bob to mark payment as sent', async () => {
// Bob marks payment as sent
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
expect(error).toBeNull();
// Verify trade status
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.buyer_marked_paid_at).toBeDefined();
});
it('should allow Alice to confirm and release crypto', async () => {
// Release escrow to Bob
const { data: releaseData, error: releaseError } = await supabase.rpc(
'release_escrow_internal',
{
p_from_user_id: aliceId,
p_to_user_id: bobId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'trade',
p_reference_id: tradeId,
}
);
expect(releaseError).toBeNull();
const releaseResponse =
typeof releaseData === 'string' ? JSON.parse(releaseData) : releaseData;
expect(releaseResponse.success).toBe(true);
// Update trade status
const { error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
seller_confirmed_at: new Date().toISOString(),
escrow_released_at: new Date().toISOString(),
status: 'completed',
completed_at: new Date().toISOString(),
})
.eq('id', tradeId);
expect(updateError).toBeNull();
// Verify Bob received the crypto
const { data: bobBalance } = await supabase.rpc('get_user_internal_balance', {
p_user_id: bobId,
});
const balances = typeof bobBalance === 'string' ? JSON.parse(bobBalance) : bobBalance;
const hezBalance = balances?.find((b: { token: string }) => b.token === 'HEZ');
expect(hezBalance).toBeDefined();
expect(hezBalance.available_balance).toBeGreaterThanOrEqual(TRADE_AMOUNT_HEZ);
});
});
describe('Scenario 2: Buyer Scam - Marks Paid But Didnt Pay', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
// Reset balances for this scenario
await resetUserBalance(aliceId, 'HEZ', 10);
});
it('should create a trade and buyer marks payment sent', async () => {
// Alice creates offer
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
offerId = offer.id;
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Scammer accepts offer
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: scammerId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Scammer marks payment as sent (but didn't actually pay)
await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
});
it('should allow Alice to NOT release if payment not received', async () => {
// Alice checks her bank account and sees NO payment
// She should NOT confirm payment received
// Get trade status - should still be payment_sent
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.seller_confirmed_at).toBeNull();
});
it('should allow Alice to open a dispute', async () => {
// Alice opens dispute because payment not received
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'disputed',
disputed_at: new Date().toISOString(),
disputed_by: aliceId,
dispute_reason: 'Payment not received in bank account',
})
.eq('id', tradeId);
expect(error).toBeNull();
// Verify trade is disputed
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('disputed');
expect(trade.disputed_by).toBe(aliceId);
});
it('should allow admin to refund escrow to Alice', async () => {
// Admin reviews dispute and sees no payment proof
// Refund escrow to Alice
const { error: refundError } = await supabase.rpc('refund_escrow', {
p_from_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'dispute_refund',
p_reference_id: tradeId,
});
// Note: This RPC might not exist yet - thats OK, we're testing the flow
if (refundError?.code === '42883') {
// Function doesn't exist - skip
console.warn('refund_escrow function not implemented yet');
return;
}
expect(refundError).toBeNull();
// Update trade status
await supabase
.from('p2p_fiat_trades')
.update({
status: 'refunded',
dispute_resolved_at: new Date().toISOString(),
dispute_resolution: 'Refunded to seller - no payment proof provided',
})
.eq('id', tradeId);
});
});
describe('Scenario 3: Seller Scam - Doesnt Release Despite Payment', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
// Reset balances
await resetUserBalance(scammerId, 'HEZ', 10);
});
it('should create trade where scammer is seller', async () => {
// Scammer creates offer
const { data: offer, error } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: scammerId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000002' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(error).toBeNull();
offerId = offer.id;
// Lock scammer's escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: scammerId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts offer (honest buyer)
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
});
it('should allow Bob to mark payment as sent with proof', async () => {
// Bob sends real payment and marks as sent with proof
await supabase
.from('p2p_fiat_trades')
.update({
buyer_marked_paid_at: new Date().toISOString(),
buyer_payment_proof_url: 'https://example.com/bank-receipt-12345.jpg',
status: 'payment_sent',
confirmation_deadline: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
})
.eq('id', tradeId);
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('payment_sent');
expect(trade.buyer_payment_proof_url).toBeDefined();
});
it('should allow Bob to open dispute after confirmation deadline', async () => {
// Simulate time passing - scammer doesn't confirm
// In real scenario, confirmation_deadline would have passed
// Bob opens dispute
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'disputed',
disputed_at: new Date().toISOString(),
disputed_by: bobId,
dispute_reason: 'Seller not releasing crypto despite payment proof',
})
.eq('id', tradeId);
expect(error).toBeNull();
});
it('should allow admin to force release to Bob', async () => {
// Admin reviews dispute:
// - Sees Bob's payment proof (bank receipt)
// - Verifies payment in scammer's bank account
// - Forces release to Bob
// Force release escrow from scammer to Bob
const { error: releaseError } = await supabase.rpc('release_escrow_internal', {
p_from_user_id: scammerId,
p_to_user_id: bobId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
p_reference_type: 'admin_forced_release',
p_reference_id: tradeId,
});
expect(releaseError).toBeNull();
// Update trade status
await supabase
.from('p2p_fiat_trades')
.update({
status: 'completed',
completed_at: new Date().toISOString(),
dispute_resolved_at: new Date().toISOString(),
dispute_resolution: 'Admin forced release - valid payment proof verified',
})
.eq('id', tradeId);
// Penalize scammer reputation
await supabase
.from('p2p_reputation')
.update({
disputed_trades: 1,
reputation_score: 0,
trust_level: 'new',
})
.eq('user_id', scammerId);
});
});
describe('Scenario 4: Trade Cancellation', () => {
let offerId: string;
let tradeId: string;
beforeEach(async () => {
await resetUserBalance(aliceId, 'HEZ', 10);
});
it('should allow buyer to cancel before marking payment', async () => {
// Alice creates offer
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 30,
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(offer).toBeDefined();
offerId = offer?.id ?? '';
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Verify trade is pending
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
expect(trade.status).toBe('pending');
});
it('should cancel trade and restore offer availability', async () => {
// Bob cancels trade
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'cancelled',
cancelled_by: bobId,
cancel_reason: 'Changed my mind',
})
.eq('id', tradeId);
expect(error).toBeNull();
// Restore offer remaining amount
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.select('remaining_amount')
.eq('id', offerId)
.single();
await supabase
.from('p2p_fiat_offers')
.update({
remaining_amount: (offer?.remaining_amount || 0) + TRADE_AMOUNT_HEZ,
status: 'open',
})
.eq('id', offerId);
// Verify offer is open again
const { data: updatedOffer } = await supabase
.from('p2p_fiat_offers')
.select('*')
.eq('id', offerId)
.single();
expect(updatedOffer.status).toBe('open');
});
});
describe('Scenario 5: Payment Timeout', () => {
let offerId: string;
let tradeId: string;
it('should handle payment deadline timeout', async () => {
await resetUserBalance(aliceId, 'HEZ', 10);
// Alice creates offer
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.insert({
seller_id: aliceId,
seller_wallet: TEST_WALLET_ADDRESS,
token: 'HEZ',
amount_crypto: TRADE_AMOUNT_HEZ,
fiat_currency: 'TRY',
fiat_amount: TRADE_AMOUNT_TRY,
price_per_unit: TRADE_AMOUNT_TRY / TRADE_AMOUNT_HEZ,
payment_method_id: testPaymentMethodId,
payment_details_encrypted: btoa(JSON.stringify({ iban: 'TR000000000000000000000001' })),
time_limit_minutes: 1, // 1 minute for testing
status: 'open',
remaining_amount: TRADE_AMOUNT_HEZ,
})
.select()
.single();
expect(offer).toBeDefined();
offerId = offer?.id ?? '';
// Lock escrow
await supabase.rpc('lock_escrow_internal', {
p_user_id: aliceId,
p_token: 'HEZ',
p_amount: TRADE_AMOUNT_HEZ,
});
// Bob accepts
const { data: result } = await supabase.rpc('accept_p2p_offer', {
p_offer_id: offerId,
p_buyer_id: bobId,
p_buyer_wallet: TEST_WALLET_ADDRESS,
p_amount: TRADE_AMOUNT_HEZ,
});
const response = typeof result === 'string' ? JSON.parse(result) : result;
tradeId = response.trade_id;
// Simulate deadline passed - set deadline to past
await supabase
.from('p2p_fiat_trades')
.update({
payment_deadline: new Date(Date.now() - 1000).toISOString(),
})
.eq('id', tradeId);
// Check trade - in production, a cron job would auto-cancel this
const { data: trade } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
// Verify deadline has passed
expect(new Date(trade.payment_deadline).getTime()).toBeLessThan(Date.now());
});
});
});
// =====================================================
// HELPER FUNCTIONS
// =====================================================
async function setupTestUsers(): Promise<void> {
// Create or get Alice
const { data: alice } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.ALICE.telegram_id,
display_name: TEST_USERS.ALICE.display_name,
telegram_username: TEST_USERS.ALICE.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
aliceId = alice?.id;
// Create or get Bob
const { data: bob } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.BOB.telegram_id,
display_name: TEST_USERS.BOB.display_name,
telegram_username: TEST_USERS.BOB.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
bobId = bob?.id;
// Create or get Scammer
const { data: scammer } = await supabase
.from('p2p_users')
.upsert(
{
telegram_id: TEST_USERS.SCAMMER.telegram_id,
display_name: TEST_USERS.SCAMMER.display_name,
telegram_username: TEST_USERS.SCAMMER.telegram_username,
},
{ onConflict: 'telegram_id' }
)
.select()
.single();
scammerId = scammer?.id;
}
async function setupTestPaymentMethod(): Promise<void> {
// Check if test payment method exists
const { data: existing } = await supabase
.from('payment_methods')
.select('id')
.eq('method_name', 'Test Bank Transfer')
.single();
if (existing) {
testPaymentMethodId = existing.id;
return;
}
// Create test payment method
const { data: method, error } = await supabase
.from('payment_methods')
.insert({
currency: 'TRY',
country: 'TR',
method_name: 'Test Bank Transfer',
method_type: 'bank',
fields: { iban: 'IBAN Number', account_holder: 'Account Holder Name' },
validation_rules: {
iban: { required: true, pattern: '^TR[0-9]{24}$' },
account_holder: { required: true, minLength: 3 },
},
min_trade_amount: 10,
max_trade_amount: 100000,
processing_time_minutes: 30,
display_order: 1,
is_active: true,
})
.select()
.single();
if (error) {
console.error('Failed to create payment method:', error);
throw error;
}
testPaymentMethodId = method.id;
}
async function seedTestBalances(): Promise<void> {
// Seed balances for test users
const users = [
{ id: aliceId, token: 'HEZ', amount: 100 },
{ id: bobId, token: 'HEZ', amount: 50 },
{ id: scammerId, token: 'HEZ', amount: 100 },
];
for (const user of users) {
if (!user.id) continue;
await supabase.from('p2p_internal_balances').upsert(
{
user_id: user.id,
token: user.token,
available_balance: user.amount,
locked_balance: 0,
total_deposited: user.amount,
total_withdrawn: 0,
},
{ onConflict: 'user_id,token' }
);
}
}
async function resetUserBalance(userId: string, token: string, amount: number): Promise<void> {
await supabase
.from('p2p_internal_balances')
.upsert(
{
user_id: userId,
token,
available_balance: amount,
locked_balance: 0,
total_deposited: amount,
total_withdrawn: 0,
},
{ onConflict: 'user_id,token' }
)
.eq('user_id', userId)
.eq('token', token);
}
async function cleanupTestData(): Promise<void> {
// Delete test trades
await supabase
.from('p2p_fiat_trades')
.delete()
.or(`buyer_id.eq.${aliceId},buyer_id.eq.${bobId},buyer_id.eq.${scammerId}`);
// Delete test offers
await supabase
.from('p2p_fiat_offers')
.delete()
.or(`seller_id.eq.${aliceId},seller_id.eq.${bobId},seller_id.eq.${scammerId}`);
// Note: We keep user records and balances for future tests
}
+367
View File
@@ -0,0 +1,367 @@
/**
* P2P Fiat Trading Integration Tests
*
* These tests verify the business logic without requiring full E2E setup.
* Mock Supabase responses to test various scenarios.
*
* @module p2p-fiat.integration.test
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock Supabase
const mockSupabase = {
from: vi.fn(),
rpc: vi.fn(),
functions: {
invoke: vi.fn(),
},
auth: {
getUser: vi.fn(),
},
};
// Mock the supabase module
vi.mock('@/lib/supabase', () => ({
supabase: mockSupabase,
}));
// Mock toast
vi.mock('sonner', () => ({
toast: {
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
describe('P2P Fiat Trading Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Payment Details Encryption', () => {
it('should encrypt payment details with AES-256-GCM', async () => {
const { encryptPaymentDetails, decryptPaymentDetails } = await import('./p2p-fiat-crypto');
const details = {
iban: 'TR000000000000000000000001',
account_holder: 'Test User',
};
const encrypted = await encryptPaymentDetails(details);
// Encrypted should be base64 encoded
expect(encrypted).toBeTruthy();
expect(encrypted).not.toContain('TR000000');
// Should be decryptable
const decrypted = await decryptPaymentDetails(encrypted);
expect(decrypted).toEqual(details);
});
it('should produce different ciphertext for same input', async () => {
const { encryptPaymentDetails } = await import('./p2p-fiat-crypto');
const details = { iban: 'TR000000000000000000000001' };
const encrypted1 = await encryptPaymentDetails(details);
const encrypted2 = await encryptPaymentDetails(details);
// Due to random IV, outputs should differ
expect(encrypted1).not.toBe(encrypted2);
});
});
describe('Trade Status Transitions', () => {
it('should only allow valid status transitions', () => {
const validTransitions: Record<string, string[]> = {
pending: ['payment_sent', 'cancelled'],
payment_sent: ['completed', 'disputed', 'cancelled'],
disputed: ['completed', 'refunded'],
completed: [],
cancelled: [],
refunded: [],
};
// Verify transition rules
expect(validTransitions['pending']).toContain('payment_sent');
expect(validTransitions['pending']).toContain('cancelled');
expect(validTransitions['pending']).not.toContain('completed');
expect(validTransitions['payment_sent']).toContain('completed');
expect(validTransitions['payment_sent']).toContain('disputed');
expect(validTransitions['disputed']).toContain('refunded');
expect(validTransitions['disputed']).toContain('completed');
// Terminal states have no transitions
expect(validTransitions['completed']).toHaveLength(0);
expect(validTransitions['cancelled']).toHaveLength(0);
expect(validTransitions['refunded']).toHaveLength(0);
});
});
describe('Escrow Logic', () => {
it('should lock balance before creating offer', async () => {
// Simulate successful lock
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({ success: true, new_available_balance: 95, new_locked_balance: 5 }),
error: null,
});
const result = await mockSupabase.rpc('lock_escrow_internal', {
p_user_id: 'test-user-id',
p_token: 'HEZ',
p_amount: 5,
});
expect(result.error).toBeNull();
const response = JSON.parse(result.data);
expect(response.success).toBe(true);
expect(response.new_locked_balance).toBe(5);
});
it('should fail lock if insufficient balance', async () => {
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({ success: false, error: 'Insufficient balance' }),
error: null,
});
const result = await mockSupabase.rpc('lock_escrow_internal', {
p_user_id: 'test-user-id',
p_token: 'HEZ',
p_amount: 1000,
});
const response = JSON.parse(result.data);
expect(response.success).toBe(false);
expect(response.error).toBe('Insufficient balance');
});
it('should release escrow to buyer on confirmation', async () => {
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: true,
seller_new_locked: 0,
buyer_new_available: 5,
}),
error: null,
});
const result = await mockSupabase.rpc('release_escrow_internal', {
p_from_user_id: 'seller-id',
p_to_user_id: 'buyer-id',
p_token: 'HEZ',
p_amount: 5,
p_reference_type: 'trade',
p_reference_id: 'trade-123',
});
const response = JSON.parse(result.data);
expect(response.success).toBe(true);
});
});
describe('Offer Acceptance Race Condition Prevention', () => {
it('should use atomic RPC to prevent double-spending', async () => {
// First acceptance succeeds
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: true,
trade_id: 'trade-1',
crypto_amount: 5,
fiat_amount: 100,
}),
error: null,
});
const result1 = await mockSupabase.rpc('accept_p2p_offer', {
p_offer_id: 'offer-123',
p_buyer_id: 'buyer-1',
p_buyer_wallet: 'wallet-1',
p_amount: 5,
});
expect(JSON.parse(result1.data).success).toBe(true);
// Second acceptance should fail (offer already taken)
mockSupabase.rpc.mockResolvedValueOnce({
data: JSON.stringify({
success: false,
error: 'Insufficient remaining amount',
}),
error: null,
});
const result2 = await mockSupabase.rpc('accept_p2p_offer', {
p_offer_id: 'offer-123',
p_buyer_id: 'buyer-2',
p_buyer_wallet: 'wallet-2',
p_amount: 5,
});
expect(JSON.parse(result2.data).success).toBe(false);
});
});
describe('Reputation System', () => {
it('should increase reputation after successful trade', async () => {
const currentRep = {
user_id: 'user-1',
total_trades: 10,
completed_trades: 9,
cancelled_trades: 1,
disputed_trades: 0,
reputation_score: 80,
trust_level: 'intermediate',
};
// After successful trade
const newRep = {
...currentRep,
total_trades: 11,
completed_trades: 10,
reputation_score: Math.min(100, currentRep.reputation_score + 1),
};
expect(newRep.completed_trades).toBe(10);
expect(newRep.reputation_score).toBe(81);
});
it('should decrease reputation after cancelled trade', async () => {
const currentRep = {
user_id: 'user-1',
reputation_score: 50,
cancelled_trades: 0,
};
// After cancellation
const newRep = {
...currentRep,
cancelled_trades: 1,
reputation_score: Math.max(0, currentRep.reputation_score - 2),
};
expect(newRep.cancelled_trades).toBe(1);
expect(newRep.reputation_score).toBe(48);
});
it('should severely penalize disputed trades', async () => {
const currentRep = {
user_id: 'scammer-1',
reputation_score: 70,
disputed_trades: 0,
};
// After losing dispute (scammer)
const newRep = {
...currentRep,
disputed_trades: 1,
reputation_score: Math.max(0, currentRep.reputation_score - 20),
trust_level: 'new', // Demoted
};
expect(newRep.disputed_trades).toBe(1);
expect(newRep.reputation_score).toBe(50);
expect(newRep.trust_level).toBe('new');
});
});
describe('Trade Deadlines', () => {
it('should calculate payment deadline correctly', () => {
const timeLimitMinutes = 30;
const tradeCreatedAt = new Date();
const paymentDeadline = new Date(tradeCreatedAt.getTime() + timeLimitMinutes * 60 * 1000);
expect(paymentDeadline.getTime() - tradeCreatedAt.getTime()).toBe(30 * 60 * 1000);
});
it('should detect expired trades', () => {
const paymentDeadline = new Date(Date.now() - 1000); // 1 second ago
const isExpired = new Date(paymentDeadline).getTime() < Date.now();
expect(isExpired).toBe(true);
});
it('should allow action within deadline', () => {
const paymentDeadline = new Date(Date.now() + 60000); // 1 minute from now
const isExpired = new Date(paymentDeadline).getTime() < Date.now();
expect(isExpired).toBe(false);
});
});
describe('Price Calculation', () => {
it('should calculate price per unit correctly', () => {
const fiatAmount = 100;
const cryptoAmount = 5;
const pricePerUnit = fiatAmount / cryptoAmount;
expect(pricePerUnit).toBe(20);
});
it('should handle partial fills', () => {
const offerCryptoAmount = 10;
const buyAmount = 3;
const pricePerUnit = 20;
const expectedFiat = buyAmount * pricePerUnit;
const remainingCrypto = offerCryptoAmount - buyAmount;
expect(expectedFiat).toBe(60);
expect(remainingCrypto).toBe(7);
});
});
});
/**
* Test helper to simulate different dispute outcomes
*/
describe('Dispute Resolution Scenarios', () => {
it('Scenario: No payment proof, no bank verification - Refund to seller', () => {
const dispute = {
trade_id: 'trade-1',
buyer_payment_proof_url: null,
bank_verified: false,
};
// Decision: Refund to seller
const resolution =
!dispute.buyer_payment_proof_url && !dispute.bank_verified ? 'refund_to_seller' : 'unknown';
expect(resolution).toBe('refund_to_seller');
});
it('Scenario: Valid payment proof, bank verified - Release to buyer', () => {
const dispute = {
trade_id: 'trade-2',
buyer_payment_proof_url: 'https://example.com/receipt.jpg',
bank_verified: true,
};
// Decision: Release to buyer
const resolution =
dispute.buyer_payment_proof_url && dispute.bank_verified ? 'release_to_buyer' : 'unknown';
expect(resolution).toBe('release_to_buyer');
});
it('Scenario: Payment proof exists but bank not verified - Manual review', () => {
const dispute = {
trade_id: 'trade-3',
buyer_payment_proof_url: 'https://example.com/receipt.jpg',
bank_verified: false,
};
// Decision: Needs manual admin review
const resolution =
dispute.buyer_payment_proof_url && !dispute.bank_verified ? 'manual_review' : 'unknown';
expect(resolution).toBe('manual_review');
});
});
+284
View File
@@ -0,0 +1,284 @@
/**
* Referral System Integration with pallet_referral
* Based on pwap/shared/lib/referral.ts
*
* NOTE: pallet_referral is on People Chain (connected to KYC via OnKycApproved hook)
* Use peopleApi when calling these functions!
*
* Workflow:
* 1. User A calls initiateReferral(userB_address) -> creates pending referral
* 2. User B completes KYC and gets approved
* 3. Pallet automatically confirms referral via OnKycApproved hook
* 4. User A's referral count increases
*/
import type { ApiPromise } from '@pezkuwi/api';
import type { KeyringPair } from '@pezkuwi/keyring/types';
export interface ReferralInfo {
referrer: string;
createdAt: number;
}
export interface ReferralStats {
referralCount: number;
referralScore: number;
whoInvitedMe: string | null;
pendingReferral: string | null;
}
/**
* Check if the referral pallet is available on the chain
*/
function isReferralPalletAvailable(api: ApiPromise): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !!((api.query as any).referral && (api.query as any).referral.pendingReferrals);
}
/**
* Initiate a referral for a new user
*/
export function initiateReferral(
api: ApiPromise,
keypair: KeyringPair,
referredAddress: string
): Promise<string> {
return new Promise((resolve, reject) => {
const tx = api.tx.referral.initiateReferral(referredAddress);
tx.signAndSend(keypair, ({ status, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const error = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
reject(new Error(error));
} else {
reject(new Error(dispatchError.toString()));
}
return;
}
if (status.isInBlock || status.isFinalized) {
const hash = status.asInBlock?.toString() || status.asFinalized?.toString() || '';
resolve(hash);
}
}).catch(reject);
});
}
/**
* Get the pending referral for a user
*/
export async function getPendingReferral(api: ApiPromise, address: string): Promise<string | null> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
console.log('Referral pallet not available on this chain');
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query.referral as any).pendingReferrals(address);
if (result.isEmpty) {
return null;
}
return result.toString();
} catch (error) {
console.error('Error fetching pending referral:', error);
return null;
}
}
/**
* Get the number of successful referrals for a user
*/
export async function getReferralCount(api: ApiPromise, address: string): Promise<number> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const count = await (api.query.referral as any).referralCount(address);
return count.toNumber();
} catch (error) {
console.error('Error fetching referral count:', error);
return 0;
}
}
/**
* Get referral info for a user (who referred them)
*/
export async function getReferralInfo(
api: ApiPromise,
address: string
): Promise<ReferralInfo | null> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (api.query.referral as any).referrals(address);
if (result.isEmpty) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = result.toJSON() as any;
return {
referrer: data.referrer,
createdAt: parseInt(data.createdAt),
};
} catch (error) {
console.error('Error fetching referral info:', error);
return null;
}
}
/**
* Calculate referral score based on referral count
*
* Score calculation:
* - 0 referrals = 0 points
* - 1-10 referrals = count * 10 points (10, 20, 30, ..., 100)
* - 11-50 referrals = 100 + (count - 10) * 5 points
* - 51-100 referrals = 300 + (count - 50) * 4 points
* - 101+ referrals = 500 points (maximum)
*/
export function calculateReferralScore(referralCount: number): number {
if (referralCount === 0) return 0;
if (referralCount <= 10) return referralCount * 10;
if (referralCount <= 50) return 100 + (referralCount - 10) * 5;
if (referralCount <= 100) return 300 + (referralCount - 50) * 4;
return 500; // Max score
}
/**
* Get comprehensive referral statistics for a user
*/
export async function getReferralStats(api: ApiPromise, address: string): Promise<ReferralStats> {
// Check if referral pallet exists first
if (!isReferralPalletAvailable(api)) {
return {
referralCount: 0,
referralScore: 0,
whoInvitedMe: null,
pendingReferral: null,
};
}
try {
const [referralCount, referralInfo, pendingReferral] = await Promise.all([
getReferralCount(api, address),
getReferralInfo(api, address),
getPendingReferral(api, address),
]);
const referralScore = calculateReferralScore(referralCount);
return {
referralCount,
referralScore,
whoInvitedMe: referralInfo?.referrer || null,
pendingReferral,
};
} catch (error) {
console.error('Error fetching referral stats:', error);
return {
referralCount: 0,
referralScore: 0,
whoInvitedMe: null,
pendingReferral: null,
};
}
}
/**
* Get list of all users who were referred by this user
*/
export async function getMyReferrals(api: ApiPromise, referrerAddress: string): Promise<string[]> {
try {
// Check if referral pallet exists
if (!isReferralPalletAvailable(api)) {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const entries = await (api.query.referral as any).referrals.entries();
const myReferrals = entries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter(([_key, value]: [any, any]) => {
if (value.isEmpty) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = value.toJSON() as any;
return data.referrer === referrerAddress;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map(([key]: [any, any]) => {
const addressHex = key.args[0].toString();
return addressHex;
});
return myReferrals;
} catch (error) {
console.error('Error fetching my referrals:', error);
return [];
}
}
/**
* Subscribe to referral events for real-time updates
*/
export async function subscribeToReferralEvents(
api: ApiPromise,
callback: (event: {
type: 'initiated' | 'confirmed';
referrer: string;
referred: string;
count?: number;
}) => void
): Promise<() => void> {
// Check if referral pallet exists - if not, return no-op unsubscribe
if (!isReferralPalletAvailable(api)) {
return () => {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unsub = await api.query.system.events((events: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
events.forEach((record: any) => {
const { event } = record;
if (event.section === 'referral') {
if (event.method === 'ReferralInitiated') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [referrer, referred] = event.data as any;
callback({
type: 'initiated',
referrer: referrer.toString(),
referred: referred.toString(),
});
} else if (event.method === 'ReferralConfirmed') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [referrer, referred, newCount] = event.data as any;
callback({
type: 'confirmed',
referrer: referrer.toString(),
referred: referred.toString(),
count: newCount.toNumber(),
});
}
}
});
});
return unsub as unknown as () => void;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Retry Utility Tests
*/
import { describe, it, expect, vi } from 'vitest';
import { withRetry, withTimeout, createRetryWrapper } from './retry';
describe('retry utilities', () => {
describe('withRetry', () => {
it('should succeed on first try', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await withRetry(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry on failure and eventually succeed', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const result = await withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 });
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(3);
});
it('should throw after max attempts', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Network Error'));
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 })).rejects.toThrow(
'Network Error'
);
expect(fn).toHaveBeenCalledTimes(3);
});
it('should not retry on non-retryable errors', async () => {
const fn = vi.fn().mockRejectedValue(new Error('Validation failed'));
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 10 })).rejects.toThrow(
'Validation failed'
);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should call onRetry callback', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const onRetry = vi.fn();
await withRetry(fn, { maxAttempts: 3, initialDelayMs: 10, onRetry });
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error));
});
it('should use custom retry condition', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Custom retry error'))
.mockResolvedValue('success');
const result = await withRetry(fn, {
maxAttempts: 3,
initialDelayMs: 10,
retryCondition: (err) => err.message.includes('Custom'),
});
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('withTimeout', () => {
it('should resolve if function completes before timeout', async () => {
const fn = vi.fn().mockResolvedValue('success');
const result = await withTimeout(fn, 1000);
expect(result).toBe('success');
});
it('should reject if timeout is exceeded', async () => {
const fn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 500);
})
);
await expect(withTimeout(fn, 50)).rejects.toThrow('TIMEOUT');
});
it('should use custom timeout error message', async () => {
const fn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(resolve, 500);
})
);
await expect(withTimeout(fn, 50, 'Operation timed out')).rejects.toThrow(
'Operation timed out'
);
});
});
describe('createRetryWrapper', () => {
it('should create a function that retries on failure', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValue('success');
const wrappedFn = createRetryWrapper(fn as () => Promise<string>, {
maxAttempts: 3,
initialDelayMs: 10,
});
const result = await wrappedFn();
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
it('should pass all arguments through', async () => {
const fn = vi.fn().mockResolvedValue('success');
const wrappedFn = createRetryWrapper(fn as (a: string, b: number) => Promise<string>, {
maxAttempts: 2,
});
await wrappedFn('arg1', 42);
expect(fn).toHaveBeenCalledWith('arg1', 42);
});
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Retry Utility
* Implements exponential backoff with jitter for resilient operations
*/
import { trackError, trackWarning } from './error-tracking';
export interface RetryOptions {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
jitter?: boolean;
onRetry?: (attempt: number, error: Error) => void;
retryCondition?: (error: Error) => boolean;
}
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'onRetry' | 'retryCondition'>> = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
jitter: true,
};
/**
* Calculate delay with exponential backoff and optional jitter
*/
function calculateDelay(
attempt: number,
initialDelayMs: number,
maxDelayMs: number,
backoffMultiplier: number,
jitter: boolean
): number {
let delay = initialDelayMs * Math.pow(backoffMultiplier, attempt - 1);
delay = Math.min(delay, maxDelayMs);
if (jitter) {
// Add random jitter (±25%)
const jitterFactor = 0.75 + Math.random() * 0.5;
delay = Math.floor(delay * jitterFactor);
}
return delay;
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Default retry condition - retry on network and timeout errors
*/
function defaultRetryCondition(error: Error): boolean {
const retryableErrors = [
'Network Error',
'Failed to fetch',
'TIMEOUT',
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'fetch failed',
'network request failed',
];
return retryableErrors.some(
(msg) => error.message.toLowerCase().includes(msg.toLowerCase()) || error.name === msg
);
}
/**
* Execute a function with automatic retry and exponential backoff
*
* @example
* const result = await withRetry(() => fetchData(), { maxAttempts: 5 });
*/
export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
const config = { ...DEFAULT_OPTIONS, ...options };
const retryCondition = options?.retryCondition ?? defaultRetryCondition;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if we should retry
if (attempt >= config.maxAttempts || !retryCondition(lastError)) {
trackError(lastError, { action: 'retry_failed', extra: { attempt } });
throw lastError;
}
// Calculate delay and wait
const delay = calculateDelay(
attempt,
config.initialDelayMs,
config.maxDelayMs,
config.backoffMultiplier,
config.jitter
);
trackWarning(`Retry attempt ${attempt}/${config.maxAttempts}, waiting ${delay}ms`, {
action: 'retry_attempt',
extra: { error: lastError.message },
});
// Call optional retry callback
if (options?.onRetry) {
options.onRetry(attempt, lastError);
}
await sleep(delay);
}
}
// This shouldn't happen, but TypeScript needs it
throw lastError ?? new Error('Retry failed');
}
/**
* Create a retry wrapper for a specific function
*
* @example
* const fetchWithRetry = createRetryWrapper(fetchData, { maxAttempts: 5 });
* const result = await fetchWithRetry();
*/
export function createRetryWrapper<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>,
options?: RetryOptions
): (...args: TArgs) => Promise<TResult> {
return (...args: TArgs) => withRetry(() => fn(...args), options);
}
/**
* Execute with timeout
*/
export async function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
timeoutError?: string
): Promise<T> {
return Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(timeoutError ?? 'TIMEOUT')), timeoutMs)
),
]);
}
/**
* Execute with both retry and timeout
*/
export async function withRetryAndTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
retryOptions?: RetryOptions
): Promise<T> {
return withRetry(() => withTimeout(fn, timeoutMs), retryOptions);
}
+822
View File
@@ -0,0 +1,822 @@
/**
* RPC Connection Manager
* Handles multiple RPC endpoints with automatic failover
*/
import { ApiPromise, WsProvider } from '@pezkuwi/api';
import { trackError, trackWarning } from './error-tracking';
// RPC Endpoints - ordered by priority
// Note: Domain must have SSL and WebSocket proxy configured
const RPC_ENDPOINTS = [
{
url: 'wss://rpc.pezkuwichain.io',
name: 'Pezkuwichain RPC',
priority: 1,
},
];
// Asset Hub RPC for PEZ token
const ASSET_HUB_ENDPOINTS = [
{
url: 'wss://asset-hub-rpc.pezkuwichain.io',
name: 'Asset Hub RPC',
priority: 1,
},
];
// People Chain RPC for identity/citizenship
const PEOPLE_ENDPOINTS = [
{
url: 'wss://people-rpc.pezkuwichain.io',
name: 'People Chain RPC',
priority: 1,
},
];
interface RPCEndpoint {
url: string;
name: string;
priority: number;
}
interface ConnectionState {
api: ApiPromise | null;
provider: WsProvider | null;
endpoint: RPCEndpoint | null;
isConnected: boolean;
lastError: Error | null;
reconnectAttempts: number;
}
const MAX_RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_MS = 2000;
const CONNECTION_TIMEOUT_MS = 10000; // Reduced from 15s to 10s
const HEALTH_CHECK_INTERVAL_MS = 30000;
let state: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// Asset Hub connection state (for PEZ token)
let assetHubState: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// People Chain connection state (for identity/citizenship)
let peopleState: ConnectionState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
// Export last error for UI display
export function getLastError(): string | null {
return state.lastError?.message || null;
}
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
let assetHubHealthCheckInterval: ReturnType<typeof setInterval> | null = null;
let peopleHealthCheckInterval: ReturnType<typeof setInterval> | null = null;
let connectionListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> =
new Set();
let assetHubListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> =
new Set();
let peopleListeners: Set<(isConnected: boolean, endpoint: RPCEndpoint | null) => void> = new Set();
/**
* Try to connect to a specific endpoint
*/
// PezkuwiChain custom signed extensions
const PEZKUWI_SIGNED_EXTENSIONS = {
AuthorizeCall: {
extrinsic: {},
payload: {},
},
StorageWeightReclaim: {
extrinsic: {},
payload: {},
},
};
async function connectToEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
// Use simple WsProvider with auto-connect (more reliable)
const provider = new WsProvider(endpoint.url);
// Create API with timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
state.provider = provider;
state.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Try to connect to available endpoints in order of priority
*/
async function connectWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...RPC_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting connection to ${endpoint.name}`, {
action: 'rpc_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToEndpoint(endpoint);
trackWarning(`Connected to ${endpoint.name}`, {
action: 'rpc_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to ${endpoint.name}: ${err.message}`, {
action: 'rpc_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All RPC endpoints unavailable');
}
/**
* Initialize RPC connection
*/
export async function initRPCConnection(): Promise<ApiPromise> {
if (state.api && state.isConnected) {
return state.api;
}
try {
const api = await connectWithFailover();
state.api = api;
state.isConnected = true;
state.reconnectAttempts = 0;
state.lastError = null;
// Set up disconnect handler
api.on('disconnected', handleDisconnect);
api.on('error', handleError);
// Start health checks
startHealthCheck();
// Notify listeners
notifyListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
state.lastError = err;
state.isConnected = false;
trackError(err, { action: 'rpc_init_failed' });
notifyListeners();
throw err;
}
}
/**
* Handle disconnect event
*/
async function handleDisconnect(): Promise<void> {
state.isConnected = false;
notifyListeners();
trackWarning('RPC disconnected, attempting reconnect', { action: 'rpc_disconnected' });
// Attempt reconnection
await attemptReconnect();
}
/**
* Handle error event
*/
function handleError(error: Error): void {
trackError(error, { action: 'rpc_error' });
}
/**
* Attempt to reconnect with exponential backoff
*/
async function attemptReconnect(): Promise<void> {
if (state.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('Max reconnect attempts reached'), { action: 'rpc_max_reconnects' });
return;
}
state.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, state.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initRPCConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start periodic health checks
*/
function startHealthCheck(): void {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
healthCheckInterval = setInterval(async () => {
if (!state.api || !state.isConnected) return;
try {
// Simple health check - get chain name
await state.api.rpc.system.chain();
} catch {
trackWarning('Health check failed', { action: 'rpc_health_failed' });
handleDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Stop health checks
*/
function stopHealthCheck(): void {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
}
}
/**
* Get current API instance
*/
export function getAPI(): ApiPromise | null {
return state.api;
}
/**
* Get current connection state
*/
export function getConnectionState(): {
isConnected: boolean;
endpoint: RPCEndpoint | null;
lastError: Error | null;
} {
return {
isConnected: state.isConnected,
endpoint: state.endpoint,
lastError: state.lastError,
};
}
/**
* Subscribe to connection state changes
* Immediately calls callback with current state, then on every change
*/
export function subscribeToConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
connectionListeners.add(callback);
// Immediately call with current state to avoid race condition
callback(state.isConnected, state.endpoint);
return () => {
connectionListeners.delete(callback);
};
}
/**
* Notify all listeners of state change
*/
function notifyListeners(): void {
connectionListeners.forEach((callback) => {
callback(state.isConnected, state.endpoint);
});
}
/**
* Disconnect from RPC
*/
export async function disconnectRPC(): Promise<void> {
stopHealthCheck();
if (state.api) {
await state.api.disconnect();
}
if (state.provider) {
state.provider.disconnect();
}
state = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyListeners();
}
/**
* Get list of available endpoints
*/
export function getAvailableEndpoints(): RPCEndpoint[] {
return [...RPC_ENDPOINTS];
}
// ============================================
// ASSET HUB CONNECTION (for PEZ token)
// ============================================
/**
* Connect to Asset Hub endpoint
*/
async function connectToAssetHubEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
const provider = new WsProvider(endpoint.url);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
assetHubState.provider = provider;
assetHubState.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Connect to Asset Hub with failover
*/
async function connectAssetHubWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...ASSET_HUB_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting Asset Hub connection to ${endpoint.name}`, {
action: 'asset_hub_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToAssetHubEndpoint(endpoint);
trackWarning(`Connected to Asset Hub ${endpoint.name}`, {
action: 'asset_hub_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to Asset Hub ${endpoint.name}: ${err.message}`, {
action: 'asset_hub_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All Asset Hub RPC endpoints unavailable');
}
/**
* Initialize Asset Hub RPC connection
*/
export async function initAssetHubConnection(): Promise<ApiPromise> {
if (assetHubState.api && assetHubState.isConnected) {
return assetHubState.api;
}
try {
const api = await connectAssetHubWithFailover();
assetHubState.api = api;
assetHubState.isConnected = true;
assetHubState.reconnectAttempts = 0;
assetHubState.lastError = null;
// Set up disconnect handler
api.on('disconnected', handleAssetHubDisconnect);
api.on('error', handleAssetHubError);
// Start health checks
startAssetHubHealthCheck();
// Notify listeners
notifyAssetHubListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
assetHubState.lastError = err;
assetHubState.isConnected = false;
trackError(err, { action: 'asset_hub_init_failed' });
notifyAssetHubListeners();
throw err;
}
}
/**
* Handle Asset Hub disconnect
*/
async function handleAssetHubDisconnect(): Promise<void> {
assetHubState.isConnected = false;
notifyAssetHubListeners();
trackWarning('Asset Hub RPC disconnected, attempting reconnect', {
action: 'asset_hub_disconnected',
});
await attemptAssetHubReconnect();
}
/**
* Handle Asset Hub error
*/
function handleAssetHubError(error: Error): void {
trackError(error, { action: 'asset_hub_error' });
}
/**
* Attempt Asset Hub reconnection
*/
async function attemptAssetHubReconnect(): Promise<void> {
if (assetHubState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('Asset Hub max reconnect attempts reached'), {
action: 'asset_hub_max_reconnects',
});
return;
}
assetHubState.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, assetHubState.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initAssetHubConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start Asset Hub health checks
*/
function startAssetHubHealthCheck(): void {
if (assetHubHealthCheckInterval) {
clearInterval(assetHubHealthCheckInterval);
}
assetHubHealthCheckInterval = setInterval(async () => {
if (!assetHubState.api || !assetHubState.isConnected) return;
try {
await assetHubState.api.rpc.system.chain();
} catch {
trackWarning('Asset Hub health check failed', { action: 'asset_hub_health_failed' });
handleAssetHubDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Notify Asset Hub listeners
*/
function notifyAssetHubListeners(): void {
assetHubListeners.forEach((callback) => {
callback(assetHubState.isConnected, assetHubState.endpoint);
});
}
/**
* Get Asset Hub API instance
*/
export function getAssetHubAPI(): ApiPromise | null {
return assetHubState.api;
}
/**
* Subscribe to Asset Hub connection state
*/
export function subscribeToAssetHubConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
assetHubListeners.add(callback);
callback(assetHubState.isConnected, assetHubState.endpoint);
return () => {
assetHubListeners.delete(callback);
};
}
/**
* Disconnect from Asset Hub
*/
export async function disconnectAssetHub(): Promise<void> {
if (assetHubHealthCheckInterval) {
clearInterval(assetHubHealthCheckInterval);
assetHubHealthCheckInterval = null;
}
if (assetHubState.api) {
await assetHubState.api.disconnect();
}
if (assetHubState.provider) {
assetHubState.provider.disconnect();
}
assetHubState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyAssetHubListeners();
}
// ============================================
// PEOPLE CHAIN CONNECTION (for identity/citizenship)
// ============================================
/**
* Connect to People Chain endpoint
*/
async function connectToPeopleEndpoint(endpoint: RPCEndpoint): Promise<ApiPromise> {
const provider = new WsProvider(endpoint.url);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Connection to ${endpoint.name} timed out`));
}, CONNECTION_TIMEOUT_MS);
});
try {
const api = await Promise.race([
ApiPromise.create({
provider,
signedExtensions: PEZKUWI_SIGNED_EXTENSIONS,
}),
timeoutPromise,
]);
await api.isReady;
peopleState.provider = provider;
peopleState.endpoint = endpoint;
return api;
} catch (error) {
provider.disconnect();
throw error;
}
}
/**
* Connect to People Chain with failover
*/
async function connectPeopleWithFailover(): Promise<ApiPromise> {
const sortedEndpoints = [...PEOPLE_ENDPOINTS].sort((a, b) => a.priority - b.priority);
for (const endpoint of sortedEndpoints) {
try {
trackWarning(`Attempting People Chain connection to ${endpoint.name}`, {
action: 'people_connect_attempt',
extra: { url: endpoint.url },
});
const api = await connectToPeopleEndpoint(endpoint);
trackWarning(`Connected to People Chain ${endpoint.name}`, {
action: 'people_connected',
extra: { url: endpoint.url },
});
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
trackWarning(`Failed to connect to People Chain ${endpoint.name}: ${err.message}`, {
action: 'people_connect_failed',
extra: { url: endpoint.url },
});
continue;
}
}
throw new Error('All People Chain RPC endpoints unavailable');
}
/**
* Initialize People Chain RPC connection
*/
export async function initPeopleConnection(): Promise<ApiPromise> {
if (peopleState.api && peopleState.isConnected) {
return peopleState.api;
}
try {
const api = await connectPeopleWithFailover();
peopleState.api = api;
peopleState.isConnected = true;
peopleState.reconnectAttempts = 0;
peopleState.lastError = null;
// Set up disconnect handler
api.on('disconnected', handlePeopleDisconnect);
api.on('error', handlePeopleError);
// Start health checks
startPeopleHealthCheck();
// Notify listeners
notifyPeopleListeners();
return api;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
peopleState.lastError = err;
peopleState.isConnected = false;
trackError(err, { action: 'people_init_failed' });
notifyPeopleListeners();
throw err;
}
}
/**
* Handle People Chain disconnect
*/
async function handlePeopleDisconnect(): Promise<void> {
peopleState.isConnected = false;
notifyPeopleListeners();
trackWarning('People Chain RPC disconnected, attempting reconnect', {
action: 'people_disconnected',
});
await attemptPeopleReconnect();
}
/**
* Handle People Chain error
*/
function handlePeopleError(error: Error): void {
trackError(error, { action: 'people_error' });
}
/**
* Attempt People Chain reconnection
*/
async function attemptPeopleReconnect(): Promise<void> {
if (peopleState.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
trackError(new Error('People Chain max reconnect attempts reached'), {
action: 'people_max_reconnects',
});
return;
}
peopleState.reconnectAttempts++;
const delay = RECONNECT_DELAY_MS * Math.pow(2, peopleState.reconnectAttempts - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
await initPeopleConnection();
} catch {
// Will retry on next disconnect or health check
}
}
/**
* Start People Chain health checks
*/
function startPeopleHealthCheck(): void {
if (peopleHealthCheckInterval) {
clearInterval(peopleHealthCheckInterval);
}
peopleHealthCheckInterval = setInterval(async () => {
if (!peopleState.api || !peopleState.isConnected) return;
try {
await peopleState.api.rpc.system.chain();
} catch {
trackWarning('People Chain health check failed', { action: 'people_health_failed' });
handlePeopleDisconnect();
}
}, HEALTH_CHECK_INTERVAL_MS);
}
/**
* Notify People Chain listeners
*/
function notifyPeopleListeners(): void {
peopleListeners.forEach((callback) => {
callback(peopleState.isConnected, peopleState.endpoint);
});
}
/**
* Get People Chain API instance
*/
export function getPeopleAPI(): ApiPromise | null {
return peopleState.api;
}
/**
* Subscribe to People Chain connection state
*/
export function subscribeToPeopleConnection(
callback: (isConnected: boolean, endpoint: RPCEndpoint | null) => void
): () => void {
peopleListeners.add(callback);
callback(peopleState.isConnected, peopleState.endpoint);
return () => {
peopleListeners.delete(callback);
};
}
/**
* Disconnect from People Chain
*/
export async function disconnectPeople(): Promise<void> {
if (peopleHealthCheckInterval) {
clearInterval(peopleHealthCheckInterval);
peopleHealthCheckInterval = null;
}
if (peopleState.api) {
await peopleState.api.disconnect();
}
if (peopleState.provider) {
peopleState.provider.disconnect();
}
peopleState = {
api: null,
provider: null,
endpoint: null,
isConnected: false,
lastError: null,
reconnectAttempts: 0,
};
notifyPeopleListeners();
}
+42
View File
@@ -0,0 +1,42 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { env } from './env';
// Supabase client singleton
// Using 'any' for database type - run `supabase gen types typescript` for proper types
export const supabase: SupabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
// Telegram auth helper - validates initData with Edge Function
export async function signInWithTelegram(initData: string) {
if (!initData) {
throw new Error('No Telegram initData provided');
}
const { data, error } = await supabase.functions.invoke('telegram-auth', {
body: { initData },
});
if (error) {
console.error('[Auth] Telegram sign-in failed:', error);
throw error;
}
if (data?.session) {
await supabase.auth.setSession(data.session);
}
return data;
}
// Helper to get current session
export async function getCurrentSession() {
const {
data: { session },
} = await supabase.auth.getSession();
return session;
}
// Helper to sign out
export async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { cn, formatNumber, formatDate, formatAddress } from './utils';
describe('utils', () => {
describe('cn', () => {
it('merges class names correctly', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
const condition = false;
expect(cn('foo', condition && 'bar', 'baz')).toBe('foo baz');
});
it('handles tailwind conflicts', () => {
expect(cn('p-4', 'p-2')).toBe('p-2');
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});
});
describe('formatNumber', () => {
it('formats small numbers as-is', () => {
expect(formatNumber(0)).toBe('0');
expect(formatNumber(999)).toBe('999');
});
it('formats thousands with K suffix', () => {
expect(formatNumber(1000)).toBe('1.0K');
expect(formatNumber(1500)).toBe('1.5K');
expect(formatNumber(999999)).toBe('1000.0K');
});
it('formats millions with M suffix', () => {
expect(formatNumber(1000000)).toBe('1.0M');
expect(formatNumber(2500000)).toBe('2.5M');
});
});
describe('formatAddress', () => {
it('truncates long addresses', () => {
const address = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
expect(formatAddress(address)).toBe('5Grwva...GKutQY'); // default: 6 chars from each end
expect(formatAddress(address, 4)).toBe('5Grw...utQY');
});
it('returns short addresses unchanged', () => {
expect(formatAddress('abc')).toBe('abc');
expect(formatAddress('')).toBe('');
});
});
describe('formatDate', () => {
it('formats recent dates as relative time', () => {
const now = new Date();
expect(formatDate(now)).toBe('Niha');
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
expect(formatDate(fiveMinutesAgo)).toBe('5 deq berê');
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
expect(formatDate(twoHoursAgo)).toBe('2 saet berê');
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
expect(formatDate(threeDaysAgo)).toBe('3 roj berê');
});
});
});
+38
View File
@@ -0,0 +1,38 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatAddress(address: string, chars = 6): string {
if (!address || address.length < chars * 2) return address;
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
export function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
export function formatDate(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Niha';
if (minutes < 60) return `${minutes} deq berê`;
if (hours < 24) return `${hours} saet berê`;
if (days < 7) return `${days} roj berê`;
return d.toLocaleDateString('ku', { day: 'numeric', month: 'short' });
}
export function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Wallet Service
* Handles mnemonic generation, keypair creation, and signing
*/
import { Keyring } from '@pezkuwi/keyring';
import { mnemonicGenerate, mnemonicValidate, cryptoWaitReady } from '@pezkuwi/util-crypto';
// PezkuwiChain SS58 format
const SS58_FORMAT = 42;
let isReady = false;
/**
* Initialize crypto libraries (must be called before using wallet functions)
*/
export async function initWalletService(): Promise<void> {
if (isReady) return;
await cryptoWaitReady();
isReady = true;
}
/**
* Generate a new 12-word mnemonic
*/
export function generateMnemonic(): string {
if (!isReady) throw new Error('Wallet service not initialized');
// Use onlyJs=true to avoid WASM bip39Generate stub that throws error
return mnemonicGenerate(12, undefined, true);
}
/**
* Validate a mnemonic phrase
*/
export function validateMnemonic(mnemonic: string): boolean {
return mnemonicValidate(mnemonic);
}
/**
* Derive wallet address from mnemonic
*/
export function getAddressFromMnemonic(mnemonic: string): string {
if (!isReady) throw new Error('Wallet service not initialized');
const keyring = new Keyring({ type: 'sr25519', ss58Format: SS58_FORMAT });
const pair = keyring.addFromMnemonic(mnemonic);
return pair.address;
}
/**
* Create keypair from mnemonic (for signing)
*/
export function createKeypair(mnemonic: string) {
if (!isReady) throw new Error('Wallet service not initialized');
const keyring = new Keyring({ type: 'sr25519', ss58Format: SS58_FORMAT });
return keyring.addFromMnemonic(mnemonic);
}
/**
* Format address for display (truncate middle)
*/
export function formatAddress(address: string, chars = 6): string {
if (address.length <= chars * 2 + 3) return address;
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
/**
* Validate address format
*/
export function isValidAddress(address: string): boolean {
// Basic SS58 validation - starts with expected prefix and has correct length
return /^[1-9A-HJ-NP-Za-km-z]{47,48}$/.test(address);
}
/**
* Generate cryptographically secure random integer in range [0, max)
*/
function secureRandomInt(max: number): number {
const randomBuffer = new Uint32Array(1);
crypto.getRandomValues(randomBuffer);
return randomBuffer[0] % max;
}
/**
* Get random words from mnemonic for verification
* Uses crypto.getRandomValues() for security
*/
export function getVerificationWords(
mnemonic: string,
count = 3
): { index: number; word: string }[] {
const words = mnemonic.split(' ');
const indices: number[] = [];
while (indices.length < count) {
const randomIndex = secureRandomInt(words.length);
if (!indices.includes(randomIndex)) {
indices.push(randomIndex);
}
}
return indices
.sort((a, b) => a - b)
.map((index) => ({
index: index + 1, // 1-based for display
word: words[index],
}));
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Wallet Storage Service
* Handles encrypted wallet storage in localStorage
*/
import { encrypt, decrypt } from './crypto';
const STORAGE_KEY = 'pezkuwi_wallet';
export interface StoredWallet {
address: string;
encryptedMnemonic: string;
createdAt: number;
}
/**
* Check if wallet exists in storage
*/
export function hasStoredWallet(): boolean {
return localStorage.getItem(STORAGE_KEY) !== null;
}
/**
* Save wallet to storage (encrypted)
*/
export async function saveWallet(
mnemonic: string,
address: string,
password: string
): Promise<void> {
const encryptedMnemonic = await encrypt(mnemonic, password);
const wallet: StoredWallet = {
address,
encryptedMnemonic,
createdAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(wallet));
}
/**
* Load wallet from storage
*/
export function getStoredWallet(): StoredWallet | null {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
try {
return JSON.parse(data) as StoredWallet;
} catch {
return null;
}
}
/**
* Get stored wallet address (without decryption)
*/
export function getStoredAddress(): string | null {
const wallet = getStoredWallet();
return wallet?.address ?? null;
}
/**
* Unlock wallet (decrypt mnemonic)
*/
export async function unlockWallet(password: string): Promise<string> {
const wallet = getStoredWallet();
if (!wallet) throw new Error('Wallet not found');
try {
const mnemonic = await decrypt(wallet.encryptedMnemonic, password);
return mnemonic;
} catch {
throw new Error('Şîfre (password) çewt e');
}
}
/**
* Delete wallet from storage
*/
export function deleteWallet(): void {
localStorage.removeItem(STORAGE_KEY);
}
/**
* Update wallet address in Supabase for existing user
* User must already exist (created by telegram-auth Edge Function)
*/
export async function syncWalletToSupabase(
supabase: { from: (table: string) => unknown },
telegramId: number,
address: string
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = supabase as any;
// UPDATE existing user's wallet_address (don't create new user)
const { error } = await client
.from('users')
.update({
wallet_address: address,
updated_at: new Date().toISOString(),
})
.eq('telegram_id', telegramId);
if (error) {
console.error('Wallet sync error:', error);
throw new Error('Wallet adresa DB-ê re senkronîze nebû');
}
}
+106
View File
@@ -0,0 +1,106 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
import { WalletProvider } from './contexts/WalletContext';
import { ReferralProvider } from './contexts/ReferralContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import App from './App';
import './index.css';
// Suppress console logs in production
if (import.meta.env.PROD) {
const noop = () => {};
console.log = noop;
console.debug = noop;
console.info = noop;
// Keep console.warn and console.error for critical issues
}
// Initialize Telegram WebApp
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
tg.setHeaderColor('#030712');
tg.setBackgroundColor('#030712');
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
retry: 2,
},
},
});
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<WalletProvider>
<ReferralProvider>
<App />
</ReferralProvider>
</WalletProvider>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);
// Type declarations for Telegram WebApp
declare global {
interface Window {
Telegram?: {
WebApp: {
ready: () => void;
expand: () => void;
close: () => void;
setHeaderColor: (color: string) => void;
setBackgroundColor: (color: string) => void;
showAlert: (message: string) => void;
showConfirm: (message: string, callback: (confirmed: boolean) => void) => void;
HapticFeedback: {
impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
notificationOccurred: (type: 'error' | 'success' | 'warning') => void;
selectionChanged: () => void;
};
initDataUnsafe: {
user?: {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
};
start_param?: string;
};
openLink: (url: string) => void;
openTelegramLink: (url: string) => void;
showScanQrPopup: (
params: { text?: string },
callback: (text: string) => boolean | void
) => void;
closeScanQrPopup: () => void;
initData: string;
version: string;
platform: string;
themeParams: {
bg_color?: string;
text_color?: string;
hint_color?: string;
link_color?: string;
button_color?: string;
button_text_color?: string;
};
};
};
}
}
+168
View File
@@ -0,0 +1,168 @@
import {
Megaphone,
ThumbsUp,
ThumbsDown,
RefreshCw,
ExternalLink,
Calendar,
Eye,
} from 'lucide-react';
import { cn, formatDate, formatNumber } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAnnouncements, useAnnouncementReaction } from '@/hooks/useSupabase';
import { useAuth } from '@/contexts/AuthContext';
export function AnnouncementsSection() {
const { hapticImpact, hapticNotification, openLink } = useTelegram();
const { isAuthenticated } = useAuth();
const { data: announcements, isLoading, refetch, isRefetching } = useAnnouncements();
const reactionMutation = useAnnouncementReaction();
const handleReaction = (id: string, reaction: 'like' | 'dislike') => {
if (!isAuthenticated) {
hapticNotification('error');
// Show alert or toast here if UI library allows, for now using browser alert for clarity in dev
// In production better to use a Toast component
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.showAlert('Ji bo dengdanê divê tu têketî bî');
} else {
window.alert('Ji bo dengdanê divê tu têketî bî');
}
return;
}
hapticImpact('light');
reactionMutation.mutate(
{ announcementId: id, reaction },
{ onSuccess: () => hapticNotification('success') }
);
};
const handleRefresh = () => {
hapticImpact('medium');
refetch();
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<Megaphone className="w-4 h-4 text-primary" />
</div>
<h1 className="text-lg font-semibold">Ragihandin</h1>
</div>
<button
onClick={handleRefresh}
disabled={isRefetching}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<RefreshCw
className={cn('w-5 h-5 text-muted-foreground', isRefetching && 'animate-spin')}
/>
</button>
</div>
</header>
{/* Content */}
<div className="flex-1 overflow-y-auto hide-scrollbar">
{isLoading ? (
<div className="p-4 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-3/4 mb-3" />
<div className="h-3 bg-secondary rounded w-full mb-2" />
<div className="h-3 bg-secondary rounded w-2/3" />
</div>
))}
</div>
) : (
<div className="p-4 space-y-4">
{announcements?.map((announcement) => (
<article
key={announcement.id}
className="bg-secondary/30 rounded-xl overflow-hidden border border-border/50"
>
{/* Image */}
{announcement.image_url && (
<div className="overflow-hidden bg-secondary/50">
<img
src={announcement.image_url}
alt=""
className="w-full h-auto object-contain"
/>
</div>
)}
<div className="p-4">
{/* Title */}
<h2 className="font-semibold text-foreground mb-2 leading-tight">
{announcement.title}
</h2>
{/* Content */}
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">
{announcement.content}
</p>
{/* Link */}
{announcement.link_url && (
<button
onClick={() => openLink(announcement.link_url as string)}
className="flex items-center gap-1.5 text-sm text-primary mb-3 hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
Zêdetir bixwîne
</button>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{formatDate(announcement.created_at)}
</span>
<span className="flex items-center gap-1">
<Eye className="w-3.5 h-3.5" />
{formatNumber(announcement.views)}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
<button
onClick={() => handleReaction(announcement.id, 'like')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
announcement.user_reaction === 'like'
? 'bg-green-500/20 text-green-400'
: 'bg-secondary hover:bg-secondary/80 text-muted-foreground'
)}
>
<ThumbsUp className="w-4 h-4" />
{formatNumber(announcement.likes)}
</button>
<button
onClick={() => handleReaction(announcement.id, 'dislike')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
announcement.user_reaction === 'dislike'
? 'bg-red-500/20 text-red-400'
: 'bg-secondary hover:bg-secondary/80 text-muted-foreground'
)}
>
<ThumbsDown className="w-4 h-4" />
{formatNumber(announcement.dislikes)}
</button>
</div>
</div>
</article>
))}
</div>
)}
</div>
</div>
);
}
+862
View File
@@ -0,0 +1,862 @@
/**
* Forum Section - Community Discussions
* Uses shared Supabase tables from pwap/web (forum_discussions, forum_categories)
*/
import { useState, useEffect } from 'react';
import {
MessageCircle,
ArrowLeft,
ThumbsUp,
ThumbsDown,
MessageSquare,
Clock,
TrendingUp,
Flame,
RefreshCw,
Search,
Pin,
Lock,
Eye,
AlertTriangle,
Info,
CheckCircle,
Megaphone,
Plus,
Send,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAuth } from '@/contexts/AuthContext';
import { useForum, type ForumDiscussion, type ForumReply } from '@/hooks/useForum';
import { formatDistanceToNow } from 'date-fns';
type SortBy = 'recent' | 'popular' | 'replies' | 'views';
export function ForumSection() {
const { hapticImpact, hapticNotification, showAlert } = useTelegram();
const { user: authUser } = useAuth();
// Use authenticated user ID from backend, not initDataUnsafe
const userId = authUser?.telegram_id?.toString() || '';
const userName = authUser?.first_name || 'Telegram User';
const {
announcements,
categories,
discussions,
loading,
refreshData,
fetchReplies,
createDiscussion,
createReply,
voteOnDiscussion,
voteOnReply,
incrementViewCount,
} = useForum(userId);
const [view, setView] = useState<'list' | 'thread' | 'create'>('list');
const [selectedDiscussion, setSelectedDiscussion] = useState<ForumDiscussion | null>(null);
const [sortBy, setSortBy] = useState<SortBy>('recent');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
// Thread view state
const [replies, setReplies] = useState<ForumReply[]>([]);
const [loadingReplies, setLoadingReplies] = useState(false);
const [replyText, setReplyText] = useState('');
const [submittingReply, setSubmittingReply] = useState(false);
// Create discussion state
const [newTitle, setNewTitle] = useState('');
const [newContent, setNewContent] = useState('');
const [newCategory, setNewCategory] = useState<string>('');
const [newTags, setNewTags] = useState('');
const [submittingDiscussion, setSubmittingDiscussion] = useState(false);
const handleOpenThread = async (discussion: ForumDiscussion) => {
hapticImpact('light');
setSelectedDiscussion(discussion);
setView('thread');
// Increment view count
await incrementViewCount(discussion.id);
// Load replies
setLoadingReplies(true);
const loadedReplies = await fetchReplies(discussion.id);
setReplies(loadedReplies);
setLoadingReplies(false);
};
const handleBack = () => {
hapticImpact('light');
setView('list');
setSelectedDiscussion(null);
setReplies([]);
setReplyText('');
};
const handleOpenCreate = () => {
hapticImpact('medium');
setView('create');
// Set default category if available
if (categories.length > 0 && !newCategory) {
setNewCategory(categories[0].id);
}
};
const handleCloseCreate = () => {
hapticImpact('light');
setView('list');
setNewTitle('');
setNewContent('');
setNewTags('');
};
const handleVoteDiscussion = async (voteType: 'upvote' | 'downvote') => {
if (!selectedDiscussion || !userId) {
showAlert('Ji bo dengdanê têkeve');
return;
}
hapticImpact('light');
try {
await voteOnDiscussion(selectedDiscussion.id, userId, voteType);
hapticNotification('success');
// Update local state
const updatedDiscussion = discussions.find((d) => d.id === selectedDiscussion.id);
if (updatedDiscussion) {
setSelectedDiscussion(updatedDiscussion);
}
} catch {
hapticNotification('error');
showAlert('Çewtî di dengdanê de');
}
};
const handleVoteReply = async (replyId: string, voteType: 'upvote' | 'downvote') => {
if (!userId) {
showAlert('Ji bo dengdanê têkeve');
return;
}
hapticImpact('light');
try {
await voteOnReply(replyId, userId, voteType);
hapticNotification('success');
// Refresh replies
if (selectedDiscussion) {
const loadedReplies = await fetchReplies(selectedDiscussion.id);
setReplies(loadedReplies);
}
} catch {
hapticNotification('error');
}
};
const handleSubmitReply = async () => {
if (!selectedDiscussion || !replyText.trim() || !userId) {
showAlert('Ji kerema xwe bersiva xwe binivîse');
return;
}
if (selectedDiscussion.is_locked) {
showAlert('Ev mijar kilîtkirî ye');
return;
}
setSubmittingReply(true);
hapticImpact('medium');
try {
await createReply({
discussion_id: selectedDiscussion.id,
content: replyText.trim(),
author_id: userId,
author_name: userName,
});
setReplyText('');
hapticNotification('success');
// Refresh replies
const loadedReplies = await fetchReplies(selectedDiscussion.id);
setReplies(loadedReplies);
} catch {
hapticNotification('error');
showAlert('Çewtî di şandina bersivê de');
} finally {
setSubmittingReply(false);
}
};
const handleSubmitDiscussion = async () => {
if (!newTitle.trim() || !newContent.trim() || !newCategory || !userId) {
showAlert('Ji kerema xwe hemû qadan tije bike');
return;
}
setSubmittingDiscussion(true);
hapticImpact('medium');
try {
const tags = newTags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
await createDiscussion({
title: newTitle.trim(),
content: newContent.trim(),
category_id: newCategory,
author_id: userId,
author_name: userName,
tags,
});
hapticNotification('success');
showAlert('Mijar hat afirandin!');
handleCloseCreate();
} catch {
hapticNotification('error');
showAlert('Çewtî di afirandina mijarê de');
} finally {
setSubmittingDiscussion(false);
}
};
const getAnnouncementStyle = (type: string) => {
switch (type) {
case 'critical':
return { icon: AlertTriangle, bgClass: 'bg-red-500/20 border-red-500/40 text-red-300' };
case 'warning':
return {
icon: AlertTriangle,
bgClass: 'bg-yellow-500/20 border-yellow-500/40 text-yellow-300',
};
case 'success':
return { icon: CheckCircle, bgClass: 'bg-green-500/20 border-green-500/40 text-green-300' };
default:
return { icon: Info, bgClass: 'bg-blue-500/20 border-blue-500/40 text-blue-300' };
}
};
// Filter and sort discussions
const filteredDiscussions = discussions
.filter((d) => {
const matchesSearch =
d.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.content.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory =
filterCategory === 'all' || d.category?.name.toLowerCase() === filterCategory.toLowerCase();
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
switch (sortBy) {
case 'popular':
return (b.upvotes || 0) - (a.upvotes || 0);
case 'replies':
return b.replies_count - a.replies_count;
case 'views':
return b.views_count - a.views_count;
default:
return new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime();
}
});
// Refresh selected discussion when discussions update
const selectedDiscussionId = selectedDiscussion?.id;
useEffect(() => {
if (selectedDiscussionId) {
const updated = discussions.find((d) => d.id === selectedDiscussionId);
if (updated) {
setSelectedDiscussion(updated);
}
}
}, [discussions, selectedDiscussionId]);
// Create Discussion View
if (view === 'create') {
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleCloseCreate}
className="p-2 -ml-2 rounded-lg hover:bg-secondary"
>
<X className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold">Mijara </h1>
</div>
<button
onClick={handleSubmitDiscussion}
disabled={submittingDiscussion || !newTitle.trim() || !newContent.trim()}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
submittingDiscussion || !newTitle.trim() || !newContent.trim()
? 'bg-secondary text-muted-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{submittingDiscussion ? 'Tê şandin...' : 'Biweşîne'}
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar p-4 space-y-4">
{/* Category */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Kategorî</label>
<div className="flex gap-2 flex-wrap">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
hapticImpact('light');
setNewCategory(cat.id);
}}
className={cn(
'px-3 py-2 rounded-lg text-sm transition-colors flex items-center gap-2',
newCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
<span>{cat.icon}</span>
{cat.name}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Sernav</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Navê mijarê..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground"
maxLength={200}
/>
</div>
{/* Content */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">Naverok</label>
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="Naveroka mijarê binivîse..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground min-h-[200px] resize-none"
/>
</div>
{/* Tags */}
<div>
<label className="text-sm text-muted-foreground mb-2 block">
Etîket (bi virgulê cuda bike)
</label>
<input
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="blockchain, kurd, pez..."
className="w-full px-4 py-3 bg-secondary rounded-lg text-foreground"
/>
</div>
</div>
</div>
);
}
// Thread Detail View
if (view === 'thread' && selectedDiscussion) {
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center gap-3">
<button onClick={handleBack} className="p-2 -ml-2 rounded-lg hover:bg-secondary">
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold truncate flex-1">{selectedDiscussion.title}</h1>
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar p-4">
{/* Badges */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selectedDiscussion.is_pinned && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded-full">
<Pin className="w-3 h-3" />
Pinned
</span>
)}
{selectedDiscussion.is_locked && (
<span className="inline-flex items-center gap-1 text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">
<Lock className="w-3 h-3" />
Kilîtkirî
</span>
)}
{selectedDiscussion.category && (
<span className="inline-flex items-center gap-1 text-xs bg-secondary text-muted-foreground px-2 py-1 rounded-full">
{selectedDiscussion.category.icon} {selectedDiscussion.category.name}
</span>
)}
</div>
{/* Content */}
<div className="bg-secondary/30 rounded-xl p-4 mb-4 border border-border/50">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold">
{selectedDiscussion.author_name?.charAt(0) || 'A'}
</div>
<div>
<p className="text-sm font-medium">
{selectedDiscussion.author_name || 'Anonymous'}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(selectedDiscussion.created_at), {
addSuffix: true,
})}
</p>
</div>
</div>
{/* Image if exists */}
{selectedDiscussion.image_url && (
<div className="mb-4 rounded-lg overflow-hidden bg-secondary/50">
<img
src={selectedDiscussion.image_url}
alt=""
className="w-full h-auto object-contain"
/>
</div>
)}
<p className="text-foreground leading-relaxed whitespace-pre-wrap">
{selectedDiscussion.content}
</p>
{/* Tags */}
{selectedDiscussion.tags && selectedDiscussion.tags.length > 0 && (
<div className="flex gap-2 mt-4 flex-wrap">
{selectedDiscussion.tags.map((tag) => (
<span
key={tag}
className="text-xs bg-cyan-500/20 text-cyan-400 px-2 py-1 rounded-full"
>
#{tag}
</span>
))}
</div>
)}
{/* Stats & Vote Buttons */}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border/50">
<button
onClick={() => handleVoteDiscussion('upvote')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors',
selectedDiscussion.userVote === 'upvote'
? 'bg-green-500/20 text-green-400'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
)}
>
<ThumbsUp className="w-4 h-4" />
{selectedDiscussion.upvotes || 0}
</button>
<button
onClick={() => handleVoteDiscussion('downvote')}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors',
selectedDiscussion.userVote === 'downvote'
? 'bg-red-500/20 text-red-400'
: 'bg-secondary text-muted-foreground hover:bg-secondary/80'
)}
>
<ThumbsDown className="w-4 h-4" />
{selectedDiscussion.downvotes || 0}
</button>
<span className="flex items-center gap-1 text-sm text-muted-foreground ml-auto">
<MessageSquare className="w-4 h-4" />
{selectedDiscussion.replies_count}
</span>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Eye className="w-4 h-4" />
{selectedDiscussion.views_count}
</span>
</div>
</div>
{/* Replies Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Bersiv ({replies.length})
</h3>
{loadingReplies ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-secondary/30 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-1/4 mb-2" />
<div className="h-3 bg-secondary rounded w-3/4" />
</div>
))}
</div>
) : replies.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Hêj bersiv tune ye</p>
<p className="text-xs">Yekemîn bersivê tu bide!</p>
</div>
) : (
<div className="space-y-3">
{replies.map((reply) => (
<div
key={reply.id}
className="bg-secondary/20 rounded-xl p-3 border border-border/30"
>
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold">
{reply.author_name?.charAt(0) || 'A'}
</div>
<span className="text-sm font-medium">
{reply.author_name || 'Anonymous'}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(reply.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap mb-2">
{reply.content}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handleVoteReply(reply.id, 'upvote')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
reply.userVote === 'upvote'
? 'bg-green-500/20 text-green-400'
: 'text-muted-foreground hover:bg-secondary'
)}
>
<ThumbsUp className="w-3 h-3" />
{reply.upvotes || 0}
</button>
<button
onClick={() => handleVoteReply(reply.id, 'downvote')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
reply.userVote === 'downvote'
? 'bg-red-500/20 text-red-400'
: 'text-muted-foreground hover:bg-secondary'
)}
>
<ThumbsDown className="w-3 h-3" />
{reply.downvotes || 0}
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Reply Input */}
{!selectedDiscussion.is_locked && (
<div className="flex-shrink-0 p-4 border-t border-border bg-background safe-area-bottom">
<div className="flex gap-2">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Bersiva xwe binivîse..."
className="flex-1 px-4 py-2.5 bg-secondary rounded-lg text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitReply();
}
}}
/>
<button
onClick={handleSubmitReply}
disabled={submittingReply || !replyText.trim()}
className={cn(
'p-2.5 rounded-lg transition-colors',
submittingReply || !replyText.trim()
? 'bg-secondary text-muted-foreground'
: 'bg-primary text-primary-foreground'
)}
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
);
}
// Thread List View
return (
<div className="flex flex-col h-full">
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-blue-400" />
</div>
<h1 className="text-lg font-semibold">Forum</h1>
<span className="text-xs text-muted-foreground">({discussions.length})</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleOpenCreate}
className="p-2 rounded-lg bg-primary text-primary-foreground"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => {
hapticImpact('light');
refreshData();
}}
disabled={loading}
className="p-2 rounded-lg hover:bg-secondary"
>
<RefreshCw
className={`w-5 h-5 text-muted-foreground ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
</div>
{/* Search */}
<div className="relative mb-3">
<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="Mijar bigere..."
className="w-full pl-9 pr-4 py-2 bg-secondary rounded-lg text-sm"
/>
</div>
{/* Sort Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1">
{[
{ id: 'recent' as SortBy, icon: Clock, label: 'Nû' },
{ id: 'popular' as SortBy, icon: TrendingUp, label: 'Populer' },
{ id: 'replies' as SortBy, icon: MessageSquare, label: 'Bersiv' },
{ id: 'views' as SortBy, icon: Eye, label: 'Dîtin' },
].map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setSortBy(id);
}}
className={cn(
'flex-1 flex items-center justify-center gap-1 py-2 rounded-md text-xs transition-colors',
sortBy === id ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground'
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar">
{/* Announcements */}
{announcements.length > 0 && (
<div className="p-4 space-y-2">
{announcements.map((announcement) => {
const style = getAnnouncementStyle(announcement.type);
const Icon = style.icon;
return (
<div key={announcement.id} className={`rounded-xl p-3 border ${style.bgClass}`}>
<div className="flex items-start gap-3">
<Megaphone className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm">{announcement.title}</h4>
<p className="text-xs mt-1 opacity-90 line-clamp-2">{announcement.content}</p>
</div>
<Icon className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
})}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="px-4 pb-3">
<div className="flex gap-2 overflow-x-auto hide-scrollbar py-1">
<button
onClick={() => {
hapticImpact('light');
setFilterCategory('all');
}}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs transition-colors',
filterCategory === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
Hemû
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => {
hapticImpact('light');
setFilterCategory(category.name.toLowerCase());
}}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs transition-colors flex items-center gap-1',
filterCategory === category.name.toLowerCase()
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
)}
>
<span>{category.icon}</span>
{category.name}
</button>
))}
</div>
</div>
)}
{/* Discussions List */}
{loading ? (
<div className="p-4 space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-3/4 mb-2" />
<div className="h-3 bg-secondary rounded w-1/2" />
</div>
))}
</div>
) : filteredDiscussions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 p-8 text-center">
<MessageCircle className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Mijar nehat dîtin</p>
<p className="text-sm text-muted-foreground/70 mb-4">Filterên xwe biguhêre</p>
<button
onClick={handleOpenCreate}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm"
>
<Plus className="w-4 h-4" />
Mijara Biafirîne
</button>
</div>
) : (
<div className="p-4 space-y-3">
{filteredDiscussions.map((discussion) => (
<button
key={discussion.id}
onClick={() => handleOpenThread(discussion)}
className="w-full text-left bg-secondary/30 rounded-xl p-4 border border-border/50 hover:bg-secondary/50 transition-colors"
>
{/* Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
{discussion.is_pinned && (
<span className="inline-flex items-center gap-1 text-[10px] bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
<Pin className="w-2.5 h-2.5" />
Pinned
</span>
)}
{discussion.is_locked && (
<span className="inline-flex items-center gap-1 text-[10px] bg-red-500/20 text-red-400 px-1.5 py-0.5 rounded">
<Lock className="w-2.5 h-2.5" />
</span>
)}
{discussion.category && (
<span className="text-[10px] bg-secondary text-muted-foreground px-1.5 py-0.5 rounded">
{discussion.category.icon} {discussion.category.name}
</span>
)}
{(discussion.upvotes || 0) > 10 && (
<span className="inline-flex items-center gap-1 text-[10px] bg-orange-500/20 text-orange-400 px-1.5 py-0.5 rounded">
<Flame className="w-2.5 h-2.5" />
Trending
</span>
)}
</div>
{/* Image thumbnail if exists */}
{discussion.image_url && (
<div className="mb-2 rounded-lg overflow-hidden bg-secondary/50">
<img
src={discussion.image_url}
alt=""
className="w-full h-auto object-contain max-h-40"
/>
</div>
)}
{/* Title */}
<h3 className="font-medium text-foreground mb-1 line-clamp-2">
{discussion.title}
</h3>
{/* Meta */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span>{discussion.author_name || 'Anonymous'}</span>
<span>
{formatDistanceToNow(new Date(discussion.last_activity_at), {
addSuffix: true,
})}
</span>
<span className="flex items-center gap-1">
<ThumbsUp className="w-3 h-3" />
{(discussion.upvotes || 0) - (discussion.downvotes || 0)}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{discussion.replies_count}
</span>
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{discussion.views_count}
</span>
</div>
{/* Tags */}
{discussion.tags && discussion.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{discussion.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-[10px] text-cyan-400">
#{tag}
</span>
))}
{discussion.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{discussion.tags.length - 3}
</span>
)}
</div>
)}
</button>
))}
</div>
)}
</div>
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
/**
* Rewards Section - Referral System
* Uses real blockchain data from ReferralContext
*/
import { useState, useEffect, useCallback } from 'react';
import {
Gift,
Users,
Trophy,
Copy,
Check,
Share2,
RefreshCw,
Star,
TrendingUp,
Award,
Zap,
Coins,
} from 'lucide-react';
import { cn, formatAddress } from '@/lib/utils';
import { useTelegram } from '@/hooks/useTelegram';
import { useAuth } from '@/contexts/AuthContext';
import { useReferral } from '@/contexts/ReferralContext';
import { useWallet } from '@/contexts/WalletContext';
import { SocialLinks } from '@/components/SocialLinks';
// Activity tracking constants
const ACTIVITY_STORAGE_KEY = 'pezkuwi_last_active';
const ACTIVITY_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
export function RewardsSection() {
const { hapticImpact, hapticNotification, shareUrl, showAlert } = useTelegram();
const { user: authUser } = useAuth();
const { stats, myReferrals, loading, refreshStats } = useReferral();
const { isConnected } = useWallet();
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'referrals'>('overview');
const [isActive, setIsActive] = useState(false);
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
// Check activity status
const checkActivityStatus = useCallback(() => {
const lastActive = localStorage.getItem(ACTIVITY_STORAGE_KEY);
if (lastActive) {
const lastActiveTime = parseInt(lastActive, 10);
const now = Date.now();
const elapsed = now - lastActiveTime;
if (elapsed < ACTIVITY_DURATION_MS) {
setIsActive(true);
const remaining = ACTIVITY_DURATION_MS - elapsed;
const hours = Math.floor(remaining / (60 * 60 * 1000));
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
setTimeRemaining(`${hours}s ${minutes}d`);
} else {
setIsActive(false);
setTimeRemaining(null);
}
} else {
setIsActive(false);
setTimeRemaining(null);
}
}, []);
// Check activity status on mount and every minute
useEffect(() => {
// Run check after a microtask to avoid synchronous setState in effect
const timeoutId = setTimeout(checkActivityStatus, 0);
const interval = setInterval(checkActivityStatus, 60000);
return () => {
clearTimeout(timeoutId);
clearInterval(interval);
};
}, [checkActivityStatus]);
const handleActivate = () => {
hapticNotification('success');
localStorage.setItem(ACTIVITY_STORAGE_KEY, Date.now().toString());
setIsActive(true);
setTimeRemaining('24s 0d');
showAlert('Tu niha aktîv î! 24 saet paşê dîsa bikirtîne.');
};
// Telegram referral link (for sharing) - use authenticated user ID
const referralLink = authUser?.telegram_id
? `https://t.me/pezkuwichain_bot?start=ref_${authUser.telegram_id}`
: 'https://t.me/pezkuwichain_bot';
const handleCopy = async () => {
try {
await window.navigator.clipboard.writeText(referralLink);
setCopied(true);
hapticNotification('success');
setTimeout(() => setCopied(false), 2000);
} catch {
showAlert('Kopî bû');
}
};
const handleShare = () => {
hapticImpact('medium');
shareUrl(
referralLink,
'Pezkuwichain - Dewleta Dîjîtal a Kurd! Bi lînka min ve tev li me bibe:'
);
};
const handleRefresh = () => {
hapticImpact('medium');
refreshStats();
};
// Calculate points per referral based on position
const getPointsForPosition = (position: number): number => {
if (position <= 10) return 10;
if (position <= 50) return 5;
if (position <= 100) return 4;
return 0;
};
if (!isConnected) {
return (
<div className="flex flex-col h-full items-center justify-center p-8 text-center">
<Gift className="w-16 h-16 text-purple-400 mb-4" />
<h2 className="text-xl font-semibold mb-2">Xelat - Referral System</h2>
<p className="text-muted-foreground mb-4">
Ji bo dîtina referral û xelatên xwe, berî her tiştî cîzdanê xwe girêde.
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Gift className="w-4 h-4 text-purple-400" />
</div>
<h1 className="text-lg font-semibold">Xelat</h1>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="p-2 rounded-lg hover:bg-secondary"
>
<RefreshCw
className={`w-5 h-5 text-muted-foreground ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1">
{[
{ id: 'overview' as const, label: 'Geşbîn' },
{ id: 'referrals' as const, label: 'Referral' },
].map(({ id, label }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setActiveTab(id);
}}
className={cn(
'flex-1 py-2 rounded-md text-sm transition-colors',
activeTab === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-y-auto hide-scrollbar">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-4 space-y-4">
{loading ? (
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-1/2 mb-2" />
<div className="h-6 bg-secondary rounded w-3/4" />
</div>
))}
</div>
) : (
<>
{/* Score Card */}
<div className="bg-gradient-to-br from-purple-600 to-pink-600 rounded-2xl p-4 text-white">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-purple-100 text-sm">Pûana Referral</p>
<p className="text-4xl font-bold">{stats?.referralScore ?? 0}</p>
</div>
<div className="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
<Trophy className="w-8 h-8" />
</div>
</div>
<div className="flex items-center gap-2 text-sm text-purple-100">
<TrendingUp className="w-4 h-4" />
<span>Max pûan: 500</span>
</div>
</div>
{/* Refer More Card */}
<div className="bg-gradient-to-r from-amber-500/20 to-orange-500/20 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/30 flex items-center justify-center flex-shrink-0">
<Coins className="w-5 h-5 text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-amber-100 mb-1">
زیاتر ڕیفەر بکە، زیاتر قازانج بکە!
</h4>
<p className="text-sm text-amber-200/80">
هەر کەسێک بهێنیت، HEZ و PEZ وەک خەڵات وەردەگریت. زیاتر ڕیفەر = زیاتر خەڵات!
</p>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Users className="w-4 h-4 text-green-400" />
<span className="text-xs text-muted-foreground">Referral</span>
</div>
<p className="text-2xl font-bold text-foreground">
{stats?.referralCount ?? 0}
</p>
<p className="text-xs text-muted-foreground">KYC pejirandî</p>
</div>
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center gap-2 mb-2">
<Award className="w-4 h-4 text-blue-400" />
<span className="text-xs text-muted-foreground">Referrer</span>
</div>
{stats?.whoInvitedMe ? (
<p className="text-sm font-mono text-foreground truncate">
{formatAddress(stats.whoInvitedMe, 6)}
</p>
) : (
<p className="text-sm text-muted-foreground">Tune</p>
)}
<p className="text-xs text-muted-foreground">Min vexwand</p>
</div>
</div>
{/* Pending Referral Notification */}
{stats?.pendingReferral && (
<div className="bg-blue-900/20 border border-blue-600/30 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-600/30 flex items-center justify-center">
<Award className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1">
<div className="text-white font-semibold">Referral li bendê</div>
<div className="text-sm text-blue-300">
KYC temam bike ji bo pejirandina referral ji{' '}
<span className="font-mono">
{formatAddress(stats.pendingReferral, 6)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Invite Card */}
<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">
<Share2 className="w-4 h-4 text-primary" />
Hevalên xwe vexwîne
</h3>
<div className="bg-background rounded-lg p-3 mb-3">
<p className="text-xs text-muted-foreground mb-1">Lînka te</p>
<code className="text-sm text-foreground break-all">{referralLink}</code>
</div>
<div className="flex gap-2">
<button
onClick={handleCopy}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors',
copied
? 'bg-green-500/20 text-green-400'
: 'bg-secondary hover:bg-secondary/80'
)}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopî bû!' : 'Kopî bike'}
</button>
<button
onClick={handleShare}
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-primary rounded-lg text-primary-foreground text-sm font-medium"
>
<Share2 className="w-4 h-4" />
Parve bike
</button>
</div>
</div>
{/* Score System */}
<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">
<Star className="w-4 h-4 text-yellow-400" />
Sîstema pûanan
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">1-10 referral</span>
<span className="text-green-400 font-medium">×10 pûan</span>
</div>
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">11-50 referral</span>
<span className="text-green-400 font-medium">100 + ×5</span>
</div>
<div className="flex justify-between py-2 border-b border-border/30">
<span className="text-muted-foreground">51-100 referral</span>
<span className="text-green-400 font-medium">300 + ×4</span>
</div>
<div className="flex justify-between py-2">
<span className="text-muted-foreground">101+ referral</span>
<span className="text-yellow-400 font-medium">500 (Max)</span>
</div>
</div>
</div>
{/* I am Active Button */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap
className={cn('w-5 h-5', isActive ? 'text-green-400' : 'text-gray-400')}
/>
<div>
<h3 className="font-medium text-foreground">Rewşa Aktîvbûnê</h3>
{isActive && timeRemaining && (
<p className="text-xs text-green-400">Dem: {timeRemaining} maye</p>
)}
</div>
</div>
<div
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
isActive ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
)}
>
{isActive ? 'Aktîv' : 'Ne Aktîv'}
</div>
</div>
<p className="text-sm text-muted-foreground mb-3">
Her 24 saet carekê bikirtîne da ku aktîv bimînî û xelatên zêdetir qezenc bikî!
</p>
<button
onClick={handleActivate}
disabled={isActive}
className={cn(
'w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-all',
isActive
? 'bg-green-500/20 text-green-400 cursor-not-allowed'
: 'bg-gradient-to-r from-green-500 to-emerald-600 text-white hover:opacity-90'
)}
>
<Zap className="w-5 h-5" />
{isActive ? 'Tu Aktîv î!' : 'Ez Aktîv im!'}
</button>
</div>
{/* Social Links */}
<SocialLinks />
</>
)}
</div>
)}
{/* Referrals Tab */}
{activeTab === 'referrals' && (
<div className="p-4 space-y-4">
{/* Refer More Card */}
<div className="bg-gradient-to-r from-amber-500/20 to-orange-500/20 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/30 flex items-center justify-center flex-shrink-0">
<Coins className="w-5 h-5 text-amber-400" />
</div>
<div>
<h4 className="font-semibold text-amber-100 mb-1">
زیاتر ڕیفەر بکە، زیاتر قازانج بکە!
</h4>
<p className="text-sm text-amber-200/80">
هەر کەسێک بهێنیت، HEZ و PEZ وەک خەڵات وەردەگریت. زیاتر ڕیفەر = زیاتر خەڵات!
</p>
</div>
</div>
</div>
{/* I am Active Button */}
<div className="bg-secondary/30 rounded-xl p-4 border border-border/50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className={cn('w-5 h-5', isActive ? 'text-green-400' : 'text-gray-400')} />
<div>
<h3 className="font-medium text-foreground">Rewşa Aktîvbûnê</h3>
{isActive && timeRemaining && (
<p className="text-xs text-green-400">Dem: {timeRemaining} maye</p>
)}
</div>
</div>
<div
className={cn(
'px-2 py-1 rounded-full text-xs font-medium',
isActive ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
)}
>
{isActive ? 'Aktîv' : 'Ne Aktîv'}
</div>
</div>
<button
onClick={handleActivate}
disabled={isActive}
className={cn(
'w-full py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-all',
isActive
? 'bg-green-500/20 text-green-400 cursor-not-allowed'
: 'bg-gradient-to-r from-green-500 to-emerald-600 text-white hover:opacity-90'
)}
>
<Zap className="w-5 h-5" />
{isActive ? 'Tu Aktîv î!' : 'Ez Aktîv im!'}
</button>
</div>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="bg-secondary/50 rounded-xl p-4 animate-pulse">
<div className="h-4 bg-secondary rounded w-2/3" />
</div>
))}
</div>
) : myReferrals.length > 0 ? (
<div className="space-y-3">
<div className="text-sm text-muted-foreground mb-2">
{myReferrals.length} referral (KYC pejirandî)
</div>
{myReferrals.map((referralAddress, index) => (
<div
key={referralAddress}
className="bg-secondary/30 rounded-xl p-4 border border-border/50 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center text-sm font-bold text-green-400">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<code className="text-sm text-foreground">
{formatAddress(referralAddress, 8)}
</code>
<p className="text-xs text-green-400">KYC Pejirandî</p>
</div>
<div className="text-right">
<span className="text-green-400 text-sm font-medium">
+{getPointsForPosition(index + 1)} pûan
</span>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Users className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">Hêj referralên te tune ne</p>
<p className="text-sm text-muted-foreground mt-1">Lînka xwe parve bike!</p>
<button
onClick={handleShare}
className="mt-4 flex items-center gap-2 px-4 py-2 bg-primary rounded-lg text-primary-foreground text-sm font-medium"
>
<Share2 className="w-4 h-4" />
Parve bike
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Wallet Section
* Main wallet interface with create, import, connect, and dashboard flows
*/
import { useState, useMemo } from 'react';
import { Wallet, AlertTriangle, RefreshCw } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useAuth } from '@/contexts/AuthContext';
import {
WalletSetup,
WalletCreate,
WalletImport,
WalletConnect,
WalletDashboard,
} from '@/components/wallet';
import { LoadingScreen } from '@/components/LoadingScreen';
import { VersionInfo } from '@/components/VersionInfo';
type Screen = 'loading' | 'auth-error' | 'setup' | 'create' | 'import' | 'connect' | 'dashboard';
type UserScreen = 'create' | 'import' | null;
export function WalletSection() {
const { isInitialized, isConnected, hasWallet, deleteWalletData } = useWallet();
const { isAuthenticated, isLoading: authLoading, signIn } = useAuth();
const [userScreen, setUserScreen] = useState<UserScreen>(null);
// Derive screen from wallet state and user navigation
const screen = useMemo<Screen>(() => {
// Auth loading - wait
if (authLoading) return 'loading';
// Wallet not initialized yet - wait
if (!isInitialized) return 'loading';
// Auth failed - show error
if (!isAuthenticated) return 'auth-error';
// Connected - show dashboard
if (isConnected) return 'dashboard';
// User navigating to create/import
if (userScreen) return userScreen;
// Has wallet but not connected
if (hasWallet) return 'connect';
// No wallet - show setup
return 'setup';
}, [authLoading, isInitialized, isAuthenticated, isConnected, hasWallet, userScreen]);
// Handle wallet deletion
const handleDeleteWallet = () => {
deleteWalletData();
setUserScreen(null);
};
// Reset user screen when wallet state changes
const handleComplete = () => setUserScreen(null);
// Handle retry auth
const handleRetryAuth = () => {
signIn();
};
// Loading state
if (screen === 'loading') {
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 flex items-center justify-center">
<LoadingScreen />
</div>
</div>
);
}
// Auth error state
if (screen === 'auth-error') {
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center space-y-6">
<div className="w-16 h-16 mx-auto bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Têketin Têk Çû</h2>
<p className="text-muted-foreground text-sm">
Ji kerema xwe piştrast bikin ku hûn app-ê di nav Telegram de vedikin
</p>
</div>
<button
onClick={handleRetryAuth}
className="px-6 py-3 bg-primary text-primary-foreground rounded-xl font-semibold flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-4 h-4" />
Dîsa Biceribîne
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<Header />
<div className="flex-1 overflow-y-auto">
{screen === 'setup' && (
<WalletSetup
onCreate={() => setUserScreen('create')}
onImport={() => setUserScreen('import')}
/>
)}
{screen === 'create' && (
<WalletCreate onComplete={handleComplete} onBack={() => setUserScreen(null)} />
)}
{screen === 'import' && (
<WalletImport onComplete={handleComplete} onBack={() => setUserScreen(null)} />
)}
{screen === 'connect' && (
<WalletConnect onConnected={handleComplete} onDelete={handleDeleteWallet} />
)}
{screen === 'dashboard' && <WalletDashboard onDisconnect={handleComplete} />}
</div>
</div>
);
}
// Header component
function Header() {
return (
<header className="flex-shrink-0 px-4 py-3 border-b border-border bg-background/80 backdrop-blur-sm safe-area-top">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<Wallet className="w-4 h-4 text-cyan-400" />
</div>
<h1 className="text-lg font-semibold">Berîk</h1>
</div>
<VersionInfo />
</div>
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock Telegram WebApp
Object.defineProperty(window, 'Telegram', {
value: {
WebApp: {
ready: vi.fn(),
expand: vi.fn(),
close: vi.fn(),
setHeaderColor: vi.fn(),
setBackgroundColor: vi.fn(),
showAlert: vi.fn(),
showConfirm: vi.fn(),
initData: '',
initDataUnsafe: {
user: {
id: 123456789,
first_name: 'Test',
last_name: 'User',
username: 'testuser',
language_code: 'ku',
},
},
HapticFeedback: {
impactOccurred: vi.fn(),
notificationOccurred: vi.fn(),
selectionChanged: vi.fn(),
},
openLink: vi.fn(),
openTelegramLink: vi.fn(),
version: '7.0',
platform: 'web',
themeParams: {},
},
},
writable: true,
});
// Mock environment variables
vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+205
View File
@@ -0,0 +1,205 @@
/**
* Database types for Supabase tables
* These types match the actual database schema
*/
// ==================== USERS ====================
export interface DbUser {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
is_admin: boolean;
created_at: string;
updated_at: string;
}
// ==================== ANNOUNCEMENTS ====================
export interface DbAnnouncement {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
is_published: boolean;
likes: number;
dislikes: number;
views: number;
created_at: string;
updated_at: string;
}
export interface DbAnnouncementWithAuthor extends DbAnnouncement {
author: DbAuthorInfo | null;
}
export interface DbAnnouncementReaction {
id: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at: string;
}
// ==================== FORUM ====================
export interface DbThread {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
updated_at: string;
}
export interface DbThreadWithAuthor extends DbThread {
author: DbAuthorInfo | null;
}
export interface DbReply {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
updated_at: string;
}
export interface DbReplyWithAuthor extends DbReply {
author: DbAuthorInfo | null;
}
export interface DbThreadLike {
id: string;
thread_id: string;
user_id: string;
created_at: string;
}
export interface DbReplyLike {
id: string;
reply_id: string;
user_id: string;
created_at: string;
}
// ==================== COMMON ====================
export interface DbAuthorInfo {
username: string | null;
first_name: string;
photo_url: string | null;
}
// ==================== QUERY RESULT TYPES ====================
export interface AnnouncementCounters {
likes: number;
dislikes: number;
}
export interface ThreadCounters {
likes: number;
reply_count: number;
views: number;
}
export interface ReplyCounters {
likes: number;
}
// ==================== TYPE GUARDS ====================
export function isDbAnnouncementWithAuthor(data: unknown): data is DbAnnouncementWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'title' in data &&
'content' in data &&
'author_id' in data
);
}
export function isDbThreadWithAuthor(data: unknown): data is DbThreadWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'title' in data &&
'content' in data &&
'author_id' in data &&
'reply_count' in data
);
}
export function isDbReplyWithAuthor(data: unknown): data is DbReplyWithAuthor {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'thread_id' in data &&
'content' in data &&
'author_id' in data
);
}
// ==================== SUPABASE DATABASE SCHEMA ====================
export type Database = {
public: {
Tables: {
tg_users: {
Row: DbUser;
Insert: Omit<DbUser, 'created_at' | 'updated_at'>;
Update: Partial<Omit<DbUser, 'id' | 'created_at'>>;
};
tg_announcements: {
Row: DbAnnouncement;
Insert: Omit<
DbAnnouncement,
'id' | 'likes' | 'dislikes' | 'views' | 'created_at' | 'updated_at'
>;
Update: Partial<Omit<DbAnnouncement, 'id' | 'created_at'>>;
};
tg_announcement_reactions: {
Row: DbAnnouncementReaction;
Insert: Omit<DbAnnouncementReaction, 'id' | 'created_at'>;
Update: Partial<Omit<DbAnnouncementReaction, 'id' | 'created_at'>>;
};
tg_threads: {
Row: DbThread;
Insert: Omit<
DbThread,
'id' | 'reply_count' | 'likes' | 'views' | 'created_at' | 'updated_at'
>;
Update: Partial<Omit<DbThread, 'id' | 'created_at'>>;
};
tg_replies: {
Row: DbReply;
Insert: Omit<DbReply, 'id' | 'likes' | 'created_at' | 'updated_at'>;
Update: Partial<Omit<DbReply, 'id' | 'created_at'>>;
};
tg_thread_likes: {
Row: DbThreadLike;
Insert: Omit<DbThreadLike, 'id' | 'created_at'>;
Update: never;
};
tg_reply_likes: {
Row: DbReplyLike;
Insert: Omit<DbReplyLike, 'id' | 'created_at'>;
Update: never;
};
};
};
};
+254
View File
@@ -0,0 +1,254 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export interface Database {
public: {
Tables: {
users: {
Row: {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
photo_url: string | null;
language_code: string | null;
is_admin: boolean;
wallet_address: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
telegram_id: number;
username?: string | null;
first_name: string;
last_name?: string | null;
photo_url?: string | null;
language_code?: string | null;
is_admin?: boolean;
wallet_address?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
telegram_id?: number;
username?: string | null;
first_name?: string;
last_name?: string | null;
photo_url?: string | null;
language_code?: string | null;
is_admin?: boolean;
wallet_address?: string | null;
created_at?: string;
updated_at?: string;
};
};
announcements: {
Row: {
id: string;
title: string;
content: string;
image_url: string | null;
link_url: string | null;
author_id: string;
likes: number;
dislikes: number;
views: number;
is_published: boolean;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
title: string;
content: string;
image_url?: string | null;
link_url?: string | null;
author_id: string;
likes?: number;
dislikes?: number;
views?: number;
is_published?: boolean;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
title?: string;
content?: string;
image_url?: string | null;
link_url?: string | null;
author_id?: string;
likes?: number;
dislikes?: number;
views?: number;
is_published?: boolean;
created_at?: string;
updated_at?: string;
};
};
announcement_reactions: {
Row: {
id: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at: string;
};
Insert: {
id?: string;
announcement_id: string;
user_id: string;
reaction: 'like' | 'dislike';
created_at?: string;
};
Update: {
id?: string;
announcement_id?: string;
user_id?: string;
reaction?: 'like' | 'dislike';
created_at?: string;
};
};
threads: {
Row: {
id: string;
title: string;
content: string;
author_id: string;
reply_count: number;
likes: number;
views: number;
last_activity: string;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
title: string;
content: string;
author_id: string;
reply_count?: number;
likes?: number;
views?: number;
last_activity?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
title?: string;
content?: string;
author_id?: string;
reply_count?: number;
likes?: number;
views?: number;
last_activity?: string;
created_at?: string;
updated_at?: string;
};
};
thread_likes: {
Row: {
id: string;
thread_id: string;
user_id: string;
created_at: string;
};
Insert: {
id?: string;
thread_id: string;
user_id: string;
created_at?: string;
};
Update: {
id?: string;
thread_id?: string;
user_id?: string;
created_at?: string;
};
};
replies: {
Row: {
id: string;
thread_id: string;
content: string;
author_id: string;
likes: number;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
thread_id: string;
content: string;
author_id: string;
likes?: number;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
thread_id?: string;
content?: string;
author_id?: string;
likes?: number;
created_at?: string;
updated_at?: string;
};
};
reply_likes: {
Row: {
id: string;
reply_id: string;
user_id: string;
created_at: string;
};
Insert: {
id?: string;
reply_id: string;
user_id: string;
created_at?: string;
};
Update: {
id?: string;
reply_id?: string;
user_id?: string;
created_at?: string;
};
};
};
Views: {
[_ in never]: never;
};
Functions: {
[_ in never]: never;
};
Enums: {
[_ in never]: never;
};
};
}
// Helper types
export type User = Database['public']['Tables']['users']['Row'];
export type Announcement = Database['public']['Tables']['announcements']['Row'];
export type Thread = Database['public']['Tables']['threads']['Row'];
export type Reply = Database['public']['Tables']['replies']['Row'];
// Extended types with author info
export type AnnouncementWithAuthor = Announcement & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_reaction?: 'like' | 'dislike' | null;
};
export type ThreadWithAuthor = Thread & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_liked?: boolean;
};
export type ReplyWithAuthor = Reply & {
author: Pick<User, 'username' | 'first_name' | 'photo_url'>;
user_liked?: boolean;
};
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.0.112",
"buildTime": "2026-02-05T07:53:10.125Z",
"buildNumber": 1770277990126
}
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Global version constants defined in vite.config.ts
declare const __APP_VERSION__: string;
declare const __BUILD_TIME__: string;