feat(telegram): add Telegram Mini App for Pezkuwichain

- Add @twa-dev/sdk dependency for Telegram WebApp integration
- Create useTelegram hook for Telegram SDK integration (haptics, popups, etc.)
- Create usePezkuwiApi hook for blockchain API connection
- Add Discord-like Sidebar with 5 sections navigation
- Add Announcements section with like/dislike reactions
- Add Forum section with thread creation and replies
- Add Rewards section with referral program and epoch claims
- Add APK section for Pezwallet download with changelog
- Add Wallet section with balance, staking info, and transactions
- Create main TelegramApp component with routing
- Add /telegram route to App.tsx

UI Structure:
- Left: Discord-style icon sidebar (Announcements, Forum, Rewards, APK, Wallet)
- Right: Active section content area
- Mobile-first responsive design with Telegram theme integration

Integrations:
- Uses existing shared/lib functions for referral, staking, scores
- Supports Telegram startParam for referral codes
- Haptic feedback for native Telegram experience
- Telegram Main/Back button integration
This commit is contained in:
2026-01-26 17:42:35 +03:00
parent 48b51828fa
commit bf85df1651
18 changed files with 15579 additions and 0 deletions
+12930
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -55,6 +55,7 @@
"@sentry/react": "^10.26.0",
"@supabase/supabase-js": "^2.49.4",
"@tanstack/react-query": "^5.56.2",
"@twa-dev/sdk": "^8.0.2",
"@types/dompurify": "^3.0.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
+5
View File
@@ -58,6 +58,9 @@ const ForumTopic = lazy(() => import('@/pages/ForumTopic'));
const Telemetry = lazy(() => import('@/pages/Telemetry'));
const Subdomains = lazy(() => import('@/pages/Subdomains'));
// Telegram Mini App
const TelegramApp = lazy(() => import('./telegram/TelegramApp'));
// Network pages
const Mainnet = lazy(() => import('@/pages/networks/Mainnet'));
const Staging = lazy(() => import('@/pages/networks/Staging'));
@@ -112,6 +115,8 @@ function App() {
<ReferralHandler />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Telegram Mini App - standalone route */}
<Route path="/telegram" element={<TelegramApp />} />
<Route path="/login" element={<Login />} />
<Route path="/email-verification" element={<EmailVerification />} />
<Route path="/reset-password" element={<PasswordReset />} />
+132
View File
@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { useTelegram } from './hooks/useTelegram';
import { usePezkuwiApi } from './hooks/usePezkuwiApi';
import { Sidebar, Section, Announcements, Forum, Rewards, APK, Wallet } from './components';
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react';
export function TelegramApp() {
const {
isReady: isTelegramReady,
isTelegram,
user,
startParam,
colorScheme,
setHeaderColor,
setBackgroundColor,
enableClosingConfirmation,
} = useTelegram();
const { isReady: isApiReady, error: apiError, reconnect, isConnecting } = usePezkuwiApi();
const [activeSection, setActiveSection] = useState<Section>('announcements');
// Handle referral from startParam
useEffect(() => {
if (startParam) {
// Store referral address in localStorage for later use
localStorage.setItem('referrerAddress', startParam);
if (import.meta.env.DEV) {
console.log('[TelegramApp] Referral address from startParam:', startParam);
}
}
}, [startParam]);
// Setup Telegram theme colors
useEffect(() => {
if (isTelegram) {
// Set header and background colors to match our dark theme
setHeaderColor('#111827'); // gray-900
setBackgroundColor('#111827');
// Enable closing confirmation when user has unsaved changes
// (disabled for now, can be enabled per-section)
// enableClosingConfirmation();
}
}, [isTelegram, setHeaderColor, setBackgroundColor]);
// Render the active section content
const renderContent = () => {
switch (activeSection) {
case 'announcements':
return <Announcements />;
case 'forum':
return <Forum />;
case 'rewards':
return <Rewards />;
case 'apk':
return <APK />;
case 'wallet':
return <Wallet />;
default:
return <Announcements />;
}
};
// Loading state
if (!isTelegramReady) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-950">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-green-500 animate-spin" />
<span className="text-gray-400">Loading...</span>
</div>
</div>
);
}
// API connection error
if (apiError && !isConnecting) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-950 p-6">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-red-600/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-white font-semibold">Connection Error</h2>
<p className="text-gray-400 text-sm max-w-xs">
Unable to connect to Pezkuwichain network. Please check your connection and try again.
</p>
<button
onClick={reconnect}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
</div>
</div>
);
}
return (
<div
className={`flex h-screen bg-gray-950 text-white overflow-hidden ${
colorScheme === 'light' ? 'theme-light' : 'theme-dark'
}`}
>
{/* Sidebar */}
<Sidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
/>
{/* Main content */}
<main className="flex-1 flex flex-col overflow-hidden bg-gray-900">
{/* API connecting indicator */}
{!isApiReady && isConnecting && (
<div className="bg-yellow-900/30 border-b border-yellow-700/50 px-4 py-2 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 text-yellow-500 animate-spin" />
<span className="text-yellow-500 text-sm">Connecting to network...</span>
</div>
)}
{/* Content area */}
<div className="flex-1 overflow-hidden">
{renderContent()}
</div>
</main>
</div>
);
}
export default TelegramApp;
+311
View File
@@ -0,0 +1,311 @@
import { useState } from 'react';
import { Smartphone, Download, Clock, CheckCircle2, AlertCircle, ExternalLink, FileText, Shield } from 'lucide-react';
import { useTelegram } from '../../hooks/useTelegram';
import { cn } from '@/lib/utils';
interface AppVersion {
version: string;
releaseDate: Date;
downloadUrl: string;
size: string;
changelog: string[];
isLatest?: boolean;
minAndroidVersion?: string;
}
const appVersions: AppVersion[] = [
{
version: '1.2.0',
releaseDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2), // 2 days ago
downloadUrl: 'https://github.com/pezkuwichain/pezwallet/releases/download/v1.2.0/pezwallet-v1.2.0.apk',
size: '45.2 MB',
isLatest: true,
minAndroidVersion: '7.0',
changelog: [
'New: Telegram Mini App integration',
'New: Improved staking interface',
'Fix: Balance refresh issues',
'Fix: Transaction history loading',
'Improved: Overall performance',
],
},
{
version: '1.1.2',
releaseDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14), // 14 days ago
downloadUrl: 'https://github.com/pezkuwichain/pezwallet/releases/download/v1.1.2/pezwallet-v1.1.2.apk',
size: '44.8 MB',
minAndroidVersion: '7.0',
changelog: [
'Fix: Critical security update',
'Fix: Wallet connection stability',
'Improved: Transaction signing',
],
},
{
version: '1.1.0',
releaseDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 30 days ago
downloadUrl: 'https://github.com/pezkuwichain/pezwallet/releases/download/v1.1.0/pezwallet-v1.1.0.apk',
size: '44.5 MB',
minAndroidVersion: '7.0',
changelog: [
'New: Multi-language support',
'New: Dark theme improvements',
'New: QR code scanning',
'Fix: Various bug fixes',
],
},
];
const features = [
{
icon: <Shield className="w-5 h-5" />,
title: 'Secure Wallet',
description: 'Your keys, your crypto. Full self-custody.',
},
{
icon: <FileText className="w-5 h-5" />,
title: 'Citizenship Management',
description: 'Apply for citizenship and manage your Tiki.',
},
{
icon: <Download className="w-5 h-5" />,
title: 'Offline Support',
description: 'View balances and history offline.',
},
];
export function APK() {
const { hapticImpact, openLink, showConfirm } = useTelegram();
const [expandedVersion, setExpandedVersion] = useState<string | null>(appVersions[0]?.version || null);
const [downloading, setDownloading] = useState<string | null>(null);
const formatDate = (date: Date) => {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const handleDownload = async (version: AppVersion) => {
hapticImpact('medium');
const confirmed = await showConfirm(
`Download Pezwallet v${version.version} (${version.size})?`
);
if (confirmed) {
setDownloading(version.version);
// Open download link
openLink(version.downloadUrl);
// Reset downloading state after a delay
setTimeout(() => {
setDownloading(null);
}, 3000);
}
};
const handleOpenGitHub = () => {
hapticImpact('light');
openLink('https://github.com/pezkuwichain/pezwallet/releases');
};
const latestVersion = appVersions.find(v => v.isLatest);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<Smartphone className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Pezwallet APK</h2>
</div>
<button
onClick={handleOpenGitHub}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<ExternalLink className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* App Banner */}
<div className="bg-gradient-to-br from-green-600 to-emerald-700 rounded-lg p-4">
<div className="flex items-start gap-4">
{/* App Icon */}
<div className="w-16 h-16 rounded-2xl bg-white/10 flex items-center justify-center flex-shrink-0">
<span className="text-3xl font-bold text-white">P</span>
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-white mb-1">Pezwallet</h3>
<p className="text-green-100 text-sm mb-3">
Official wallet app for Pezkuwichain
</p>
{latestVersion && (
<button
onClick={() => handleDownload(latestVersion)}
disabled={downloading === latestVersion.version}
className="flex items-center gap-2 bg-white text-green-700 px-4 py-2 rounded-lg font-medium hover:bg-green-50 transition-colors disabled:opacity-70"
>
{downloading === latestVersion.version ? (
<>
<span className="animate-pulse">Downloading...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
Download v{latestVersion.version}
</>
)}
</button>
)}
</div>
</div>
</div>
{/* Features */}
<div className="grid grid-cols-1 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="bg-gray-800 rounded-lg p-3 flex items-start gap-3 border border-gray-700"
>
<div className="w-10 h-10 rounded-lg bg-green-600/20 flex items-center justify-center text-green-500 flex-shrink-0">
{feature.icon}
</div>
<div>
<h4 className="text-white font-medium text-sm">{feature.title}</h4>
<p className="text-gray-400 text-xs">{feature.description}</p>
</div>
</div>
))}
</div>
{/* Installation Guide */}
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-yellow-500 font-medium text-sm mb-1">Installation Guide</h4>
<ol className="text-yellow-200/80 text-xs space-y-1 list-decimal list-inside">
<li>Download the APK file</li>
<li>Open your device's Settings</li>
<li>Enable "Install from unknown sources"</li>
<li>Open the downloaded APK file</li>
<li>Follow the installation prompts</li>
</ol>
</div>
</div>
</div>
{/* Version History */}
<div className="bg-gray-800 rounded-lg border border-gray-700">
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-medium">Version History</h3>
</div>
<div className="divide-y divide-gray-700">
{appVersions.map((version) => (
<div key={version.version}>
<button
onClick={() => setExpandedVersion(
expandedVersion === version.version ? null : version.version
)}
className="w-full p-4 flex items-center justify-between hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center',
version.isLatest ? 'bg-green-600' : 'bg-gray-700'
)}>
{version.isLatest ? (
<CheckCircle2 className="w-5 h-5 text-white" />
) : (
<Clock className="w-5 h-5 text-gray-400" />
)}
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="text-white font-medium">v{version.version}</span>
{version.isLatest && (
<span className="text-xs bg-green-600 text-white px-1.5 py-0.5 rounded">
Latest
</span>
)}
</div>
<div className="text-xs text-gray-400">
{formatDate(version.releaseDate)} • {version.size}
</div>
</div>
</div>
<Download
className={cn(
'w-5 h-5 transition-transform',
expandedVersion === version.version ? 'rotate-180' : ''
)}
/>
</button>
{/* Expanded content */}
{expandedVersion === version.version && (
<div className="px-4 pb-4">
<div className="bg-gray-900 rounded-lg p-3">
<h4 className="text-sm font-medium text-white mb-2">Changelog</h4>
<ul className="space-y-1">
{version.changelog.map((item, idx) => (
<li key={idx} className="text-xs text-gray-400 flex items-start gap-2">
<span className="text-green-500 mt-1">•</span>
{item}
</li>
))}
</ul>
{version.minAndroidVersion && (
<div className="mt-3 pt-3 border-t border-gray-800 text-xs text-gray-500">
Requires Android {version.minAndroidVersion} or higher
</div>
)}
<button
onClick={() => handleDownload(version)}
disabled={downloading === version.version}
className="mt-3 w-full flex items-center justify-center gap-2 bg-gray-700 hover:bg-gray-600 text-white py-2 rounded-lg text-sm transition-colors disabled:opacity-70"
>
{downloading === version.version ? (
'Downloading...'
) : (
<>
<Download className="w-4 h-4" />
Download v{version.version}
</>
)}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* Footer note */}
<div className="text-center text-xs text-gray-500 pb-4">
<p>Always verify downloads from official sources</p>
<p className="mt-1">
<button
onClick={handleOpenGitHub}
className="text-green-500 hover:text-green-400"
>
View all releases on GitHub
</button>
</p>
</div>
</div>
</div>
);
}
export default APK;
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { ThumbsUp, ThumbsDown, Clock, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTelegram } from '../../hooks/useTelegram';
export interface Announcement {
id: string;
title: string;
content: string;
author: string;
authorAvatar?: string;
createdAt: Date;
likes: number;
dislikes: number;
userReaction?: 'like' | 'dislike' | null;
isPinned?: boolean;
imageUrl?: string;
}
interface AnnouncementCardProps {
announcement: Announcement;
onReact: (id: string, reaction: 'like' | 'dislike') => void;
}
export function AnnouncementCard({ announcement, onReact }: AnnouncementCardProps) {
const { hapticImpact, isTelegram } = useTelegram();
const [isExpanded, setIsExpanded] = useState(false);
const handleReact = (reaction: 'like' | 'dislike') => {
if (isTelegram) {
hapticImpact('light');
}
onReact(announcement.id, reaction);
};
const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(hours / 24);
if (hours < 1) return 'Just now';
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('tr-TR', { day: 'numeric', month: 'short' });
};
const truncatedContent = announcement.content.length > 200 && !isExpanded
? announcement.content.slice(0, 200) + '...'
: announcement.content;
return (
<div className={cn(
'bg-gray-800 rounded-lg p-4 border border-gray-700',
announcement.isPinned && 'border-green-600 border-l-4'
)}>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{/* Author avatar */}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center overflow-hidden">
{announcement.authorAvatar ? (
<img
src={announcement.authorAvatar}
alt={announcement.author}
className="w-full h-full object-cover"
/>
) : (
<User className="w-5 h-5 text-white" />
)}
</div>
<div>
<h3 className="font-semibold text-white text-sm">{announcement.author}</h3>
<div className="flex items-center gap-1 text-xs text-gray-400">
<Clock className="w-3 h-3" />
<span>{formatDate(announcement.createdAt)}</span>
</div>
</div>
</div>
{announcement.isPinned && (
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
Pinned
</span>
)}
</div>
{/* Title */}
<h4 className="text-white font-medium mb-2">{announcement.title}</h4>
{/* Content */}
<p className="text-gray-300 text-sm leading-relaxed whitespace-pre-wrap">
{truncatedContent}
</p>
{/* Show more/less button */}
{announcement.content.length > 200 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-green-500 text-sm mt-2 hover:text-green-400"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
{/* Image */}
{announcement.imageUrl && (
<div className="mt-3 rounded-lg overflow-hidden">
<img
src={announcement.imageUrl}
alt=""
className="w-full h-auto max-h-64 object-cover"
/>
</div>
)}
{/* Reactions */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-gray-700">
<button
onClick={() => handleReact('like')}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors',
announcement.userReaction === 'like'
? 'text-green-500'
: 'text-gray-400 hover:text-green-500'
)}
>
<ThumbsUp className={cn(
'w-4 h-4',
announcement.userReaction === 'like' && 'fill-current'
)} />
<span>{announcement.likes}</span>
</button>
<button
onClick={() => handleReact('dislike')}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors',
announcement.userReaction === 'dislike'
? 'text-red-500'
: 'text-gray-400 hover:text-red-500'
)}
>
<ThumbsDown className={cn(
'w-4 h-4',
announcement.userReaction === 'dislike' && 'fill-current'
)} />
<span>{announcement.dislikes}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { Megaphone, RefreshCw } from 'lucide-react';
import { AnnouncementCard, Announcement } from './AnnouncementCard';
import { useTelegram } from '../../hooks/useTelegram';
// Mock data - will be replaced with API calls
const mockAnnouncements: Announcement[] = [
{
id: '1',
title: 'Pezkuwichain Mainnet Launch!',
content: 'We are excited to announce that Pezkuwichain mainnet is now live! This marks a significant milestone in our journey towards building a decentralized digital state for the Kurdish people.\n\nKey features:\n- Fast transaction finality (6 seconds)\n- Low gas fees\n- Native staking support\n- Democratic governance\n\nStart exploring at app.pezkuwichain.io',
author: 'Pezkuwi Team',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
likes: 142,
dislikes: 3,
isPinned: true,
},
{
id: '2',
title: 'New Referral Program',
content: 'Invite friends and earn rewards! For every friend who completes KYC, you will receive bonus points that contribute to your trust score.\n\nShare your referral link from the Rewards section.',
author: 'Pezkuwi Team',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
likes: 89,
dislikes: 5,
},
{
id: '3',
title: 'Staking Rewards Update',
content: 'We have updated the staking rewards mechanism. Citizens who stake HEZ will now earn PEZ tokens as rewards each epoch.\n\nMinimum stake: 10 HEZ\nEpoch duration: 7 days',
author: 'Pezkuwi Team',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), // 3 days ago
likes: 67,
dislikes: 2,
},
];
export function Announcements() {
const { hapticNotification } = useTelegram();
const [announcements, setAnnouncements] = useState<Announcement[]>(mockAnnouncements);
const [isLoading, setIsLoading] = useState(false);
const handleReact = (id: string, reaction: 'like' | 'dislike') => {
setAnnouncements(prev => prev.map(ann => {
if (ann.id !== id) return ann;
const wasLiked = ann.userReaction === 'like';
const wasDisliked = ann.userReaction === 'dislike';
const isSameReaction = ann.userReaction === reaction;
return {
...ann,
userReaction: isSameReaction ? null : reaction,
likes: reaction === 'like'
? ann.likes + (isSameReaction ? -1 : 1)
: ann.likes - (wasLiked ? 1 : 0),
dislikes: reaction === 'dislike'
? ann.dislikes + (isSameReaction ? -1 : 1)
: ann.dislikes - (wasDisliked ? 1 : 0),
};
}));
};
const handleRefresh = async () => {
setIsLoading(true);
hapticNotification('success');
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// In production, this would fetch from API
setAnnouncements(mockAnnouncements);
setIsLoading(false);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<Megaphone className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Duyurular</h2>
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 text-gray-400 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{announcements.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Megaphone className="w-12 h-12 mb-3 opacity-50" />
<p>No announcements yet</p>
</div>
) : (
announcements.map(announcement => (
<AnnouncementCard
key={announcement.id}
announcement={announcement}
onReact={handleReact}
/>
))
)}
</div>
</div>
);
}
export default Announcements;
@@ -0,0 +1,121 @@
import { MessageCircle, Eye, Clock, User, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTelegram } from '../../hooks/useTelegram';
export interface ForumThread {
id: string;
title: string;
content: string;
author: string;
authorAddress?: string;
createdAt: Date;
replyCount: number;
viewCount: number;
lastReplyAt?: Date;
lastReplyAuthor?: string;
isPinned?: boolean;
tags?: string[];
}
interface ThreadCardProps {
thread: ForumThread;
onClick: () => void;
}
export function ThreadCard({ thread, onClick }: ThreadCardProps) {
const { hapticSelection, isTelegram } = useTelegram();
const handleClick = () => {
if (isTelegram) {
hapticSelection();
}
onClick();
};
const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(hours / 24);
if (hours < 1) return 'Just now';
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('tr-TR', { day: 'numeric', month: 'short' });
};
const truncatedContent = thread.content.length > 100
? thread.content.slice(0, 100) + '...'
: thread.content;
return (
<button
onClick={handleClick}
className={cn(
'w-full text-left bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-all',
thread.isPinned && 'border-l-4 border-l-green-600'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Title */}
<div className="flex items-center gap-2 mb-1">
{thread.isPinned && (
<span className="text-xs bg-green-600 text-white px-1.5 py-0.5 rounded">
Pinned
</span>
)}
<h3 className="font-medium text-white truncate">{thread.title}</h3>
</div>
{/* Preview */}
<p className="text-gray-400 text-sm mb-2 line-clamp-2">{truncatedContent}</p>
{/* Tags */}
{thread.tags && thread.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{thread.tags.slice(0, 3).map(tag => (
<span
key={tag}
className="text-xs bg-gray-700 text-gray-300 px-2 py-0.5 rounded-full"
>
{tag}
</span>
))}
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-xs text-gray-500">
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
<span>{thread.author}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{formatDate(thread.createdAt)}</span>
</div>
<div className="flex items-center gap-1">
<MessageCircle className="w-3 h-3" />
<span>{thread.replyCount}</span>
</div>
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<span>{thread.viewCount}</span>
</div>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-500 flex-shrink-0" />
</div>
{/* Last reply info */}
{thread.lastReplyAt && thread.lastReplyAuthor && (
<div className="mt-3 pt-2 border-t border-gray-700 text-xs text-gray-500">
Last reply by <span className="text-gray-400">{thread.lastReplyAuthor}</span>{' '}
{formatDate(thread.lastReplyAt)}
</div>
)}
</button>
);
}
@@ -0,0 +1,197 @@
import { useState } from 'react';
import { ArrowLeft, Send, User, Clock, ThumbsUp } from 'lucide-react';
import { ForumThread } from './ThreadCard';
import { useTelegram } from '../../hooks/useTelegram';
export interface ForumReply {
id: string;
content: string;
author: string;
authorAddress?: string;
createdAt: Date;
likes: number;
userLiked?: boolean;
}
interface ThreadViewProps {
thread: ForumThread;
replies: ForumReply[];
onBack: () => void;
onReply: (content: string) => void;
onLikeReply: (replyId: string) => void;
isConnected: boolean;
}
export function ThreadView({
thread,
replies,
onBack,
onReply,
onLikeReply,
isConnected
}: ThreadViewProps) {
const { hapticImpact, showBackButton, hideBackButton, isTelegram } = useTelegram();
const [replyContent, setReplyContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Setup Telegram back button
useState(() => {
if (isTelegram) {
showBackButton(onBack);
return () => hideBackButton();
}
});
const formatDate = (date: Date) => {
return date.toLocaleDateString('tr-TR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const handleSubmitReply = async () => {
if (!replyContent.trim() || isSubmitting) return;
setIsSubmitting(true);
if (isTelegram) {
hapticImpact('medium');
}
try {
await onReply(replyContent);
setReplyContent('');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-gray-800">
<button
onClick={onBack}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
<ArrowLeft className="w-4 h-4 text-gray-400" />
</button>
<h2 className="text-lg font-semibold text-white truncate flex-1">{thread.title}</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Original post */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-semibold text-white text-sm">{thread.author}</h3>
<div className="flex items-center gap-1 text-xs text-gray-400">
<Clock className="w-3 h-3" />
<span>{formatDate(thread.createdAt)}</span>
</div>
</div>
</div>
{/* Tags */}
{thread.tags && thread.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{thread.tags.map(tag => (
<span
key={tag}
className="text-xs bg-gray-700 text-gray-300 px-2 py-0.5 rounded-full"
>
{tag}
</span>
))}
</div>
)}
<p className="text-gray-300 text-sm leading-relaxed whitespace-pre-wrap">
{thread.content}
</p>
</div>
{/* Replies */}
<div className="p-4">
<h4 className="text-sm font-medium text-gray-400 mb-4">
{replies.length} {replies.length === 1 ? 'Reply' : 'Replies'}
</h4>
<div className="space-y-4">
{replies.map(reply => (
<div key={reply.id} className="bg-gray-800 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<User className="w-4 h-4 text-gray-400" />
</div>
<div>
<span className="text-sm font-medium text-white">{reply.author}</span>
<span className="text-xs text-gray-500 ml-2">
{formatDate(reply.createdAt)}
</span>
</div>
</div>
<button
onClick={() => onLikeReply(reply.id)}
className={`flex items-center gap-1 text-xs transition-colors ${
reply.userLiked ? 'text-green-500' : 'text-gray-500 hover:text-green-500'
}`}
>
<ThumbsUp className={`w-3 h-3 ${reply.userLiked ? 'fill-current' : ''}`} />
<span>{reply.likes}</span>
</button>
</div>
<p className="text-gray-300 text-sm whitespace-pre-wrap">{reply.content}</p>
</div>
))}
{replies.length === 0 && (
<div className="text-center text-gray-500 py-8">
No replies yet. Be the first to reply!
</div>
)}
</div>
</div>
</div>
{/* Reply input */}
{isConnected ? (
<div className="p-4 border-t border-gray-800 bg-gray-900">
<div className="flex gap-2">
<input
type="text"
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Write a reply..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-green-500"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitReply();
}
}}
/>
<button
onClick={handleSubmitReply}
disabled={!replyContent.trim() || isSubmitting}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:opacity-50 rounded-lg transition-colors"
>
<Send className="w-4 h-4 text-white" />
</button>
</div>
</div>
) : (
<div className="p-4 border-t border-gray-800 bg-gray-900 text-center text-gray-500 text-sm">
Connect wallet to reply
</div>
)}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from 'react';
import { MessageCircle, Plus, RefreshCw, Search } from 'lucide-react';
import { ThreadCard, ForumThread } from './ThreadCard';
import { ThreadView, ForumReply } from './ThreadView';
import { useTelegram } from '../../hooks/useTelegram';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
// Mock data - will be replaced with API calls
const mockThreads: ForumThread[] = [
{
id: '1',
title: 'Welcome to Pezkuwi Forum!',
content: 'This is the official community forum for Pezkuwi citizens. Feel free to discuss anything related to our digital state, governance, development, and more.\n\nPlease be respectful and follow our community guidelines.',
author: 'Admin',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
replyCount: 45,
viewCount: 1234,
isPinned: true,
tags: ['announcement', 'rules'],
lastReplyAt: new Date(Date.now() - 1000 * 60 * 30),
lastReplyAuthor: 'NewCitizen',
},
{
id: '2',
title: 'How to stake HEZ and earn rewards?',
content: 'Hi everyone! I just got my first HEZ tokens and want to start staking. Can someone explain the process step by step? What is the minimum amount required?',
author: 'CryptoNewbie',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5),
replyCount: 12,
viewCount: 256,
tags: ['staking', 'help'],
lastReplyAt: new Date(Date.now() - 1000 * 60 * 60 * 2),
lastReplyAuthor: 'StakingPro',
},
{
id: '3',
title: 'Proposal: Add support for Kurdish language in the app',
content: 'As a Kurdish digital state, I think it would be great to have full Kurdish language support (Kurmanci and Sorani) in all our applications.\n\nWhat do you think?',
author: 'KurdishDev',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2),
replyCount: 28,
viewCount: 567,
tags: ['proposal', 'localization'],
lastReplyAt: new Date(Date.now() - 1000 * 60 * 60 * 4),
lastReplyAuthor: 'LanguageExpert',
},
{
id: '4',
title: 'Bug report: Wallet balance not updating',
content: 'After making a transfer, my wallet balance doesn\'t update immediately. I have to refresh the page multiple times. Is anyone else experiencing this issue?',
author: 'TechUser',
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 12),
replyCount: 8,
viewCount: 89,
tags: ['bug', 'wallet'],
lastReplyAt: new Date(Date.now() - 1000 * 60 * 60 * 6),
lastReplyAuthor: 'DevTeam',
},
];
const mockReplies: ForumReply[] = [
{
id: '1',
content: 'Great to be here! Looking forward to participating in the community.',
author: 'NewCitizen',
createdAt: new Date(Date.now() - 1000 * 60 * 30),
likes: 5,
},
{
id: '2',
content: 'Welcome! Make sure to check out the staking guide in the docs section.',
author: 'Helper',
createdAt: new Date(Date.now() - 1000 * 60 * 60),
likes: 12,
},
];
export function Forum() {
const { hapticNotification, showAlert } = useTelegram();
const { selectedAccount } = usePezkuwi();
const [threads, setThreads] = useState<ForumThread[]>(mockThreads);
const [selectedThread, setSelectedThread] = useState<ForumThread | null>(null);
const [replies, setReplies] = useState<ForumReply[]>(mockReplies);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isConnected = !!selectedAccount;
const handleRefresh = async () => {
setIsLoading(true);
hapticNotification('success');
await new Promise(resolve => setTimeout(resolve, 1000));
setThreads(mockThreads);
setIsLoading(false);
};
const handleCreateThread = () => {
if (!isConnected) {
showAlert('Please connect your wallet to create a thread');
return;
}
// TODO: Implement thread creation modal
showAlert('Thread creation coming soon!');
};
const handleReply = async (content: string) => {
if (!isConnected || !selectedThread) return;
const newReply: ForumReply = {
id: String(Date.now()),
content,
author: selectedAccount?.meta?.name || 'Anonymous',
createdAt: new Date(),
likes: 0,
};
setReplies(prev => [...prev, newReply]);
hapticNotification('success');
};
const handleLikeReply = (replyId: string) => {
setReplies(prev => prev.map(reply => {
if (reply.id !== replyId) return reply;
return {
...reply,
likes: reply.userLiked ? reply.likes - 1 : reply.likes + 1,
userLiked: !reply.userLiked,
};
}));
};
const filteredThreads = threads.filter(thread =>
thread.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
thread.content.toLowerCase().includes(searchQuery.toLowerCase())
);
// Sort: pinned first, then by date
const sortedThreads = [...filteredThreads].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return b.createdAt.getTime() - a.createdAt.getTime();
});
if (selectedThread) {
return (
<ThreadView
thread={selectedThread}
replies={replies}
onBack={() => setSelectedThread(null)}
onReply={handleReply}
onLikeReply={handleLikeReply}
isConnected={isConnected}
/>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Forum</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 text-gray-400 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleCreateThread}
className="p-2 rounded-lg bg-green-600 hover:bg-green-700 transition-colors"
>
<Plus className="w-4 h-4 text-white" />
</button>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-gray-800">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search threads..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-green-500"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{sortedThreads.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<MessageCircle className="w-12 h-12 mb-3 opacity-50" />
<p>{searchQuery ? 'No threads found' : 'No threads yet'}</p>
{!searchQuery && (
<button
onClick={handleCreateThread}
className="mt-4 text-green-500 hover:text-green-400"
>
Create the first thread
</button>
)}
</div>
) : (
sortedThreads.map(thread => (
<ThreadCard
key={thread.id}
thread={thread}
onClick={() => setSelectedThread(thread)}
/>
))
)}
</div>
</div>
);
}
export default Forum;
@@ -0,0 +1,356 @@
import { useState, useEffect } from 'react';
import { Gift, Users, Trophy, Calendar, Copy, Check, Share2, Loader2, Star } from 'lucide-react';
import { useTelegram } from '../../hooks/useTelegram';
import { usePezkuwiApi } from '../../hooks/usePezkuwiApi';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { getReferralStats, calculateReferralScore, ReferralStats } from '@shared/lib/referral';
import { getAllScores, UserScores, getScoreRating, getScoreColor } from '@shared/lib/scores';
import { getStakingInfo, StakingInfo } from '@shared/lib/staking';
import { cn } from '@/lib/utils';
interface DailyTask {
id: string;
title: string;
description: string;
reward: number;
completed: boolean;
progress?: number;
maxProgress?: number;
}
const dailyTasks: DailyTask[] = [
{
id: 'login',
title: 'Daily Login',
description: 'Open the app daily',
reward: 5,
completed: true,
},
{
id: 'forum',
title: 'Forum Activity',
description: 'Post or reply in forum',
reward: 10,
completed: false,
},
{
id: 'referral',
title: 'Invite a Friend',
description: 'Invite a new user to join',
reward: 50,
completed: false,
},
];
export function Rewards() {
const { hapticNotification, hapticImpact, showAlert, user, openTelegramLink } = useTelegram();
const { api, isReady: isApiReady } = usePezkuwiApi();
const { selectedAccount } = usePezkuwi();
const [referralStats, setReferralStats] = useState<ReferralStats | null>(null);
const [userScores, setUserScores] = useState<UserScores | null>(null);
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [claimingEpoch, setClaimingEpoch] = useState<number | null>(null);
const isConnected = !!selectedAccount;
const address = selectedAccount?.address;
// Generate referral link
const referralLink = address
? `https://t.me/pezkuwichain?start=${address}`
: '';
// Fetch data when connected
useEffect(() => {
if (!api || !isApiReady || !address) return;
const fetchData = async () => {
setIsLoading(true);
try {
const [stats, scores, staking] = await Promise.all([
getReferralStats(api, address),
getAllScores(api, address),
getStakingInfo(api, address),
]);
setReferralStats(stats);
setUserScores(scores);
setStakingInfo(staking);
} catch (err) {
console.error('Failed to fetch rewards data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [api, isApiReady, address]);
const handleCopyLink = async () => {
if (!referralLink) return;
try {
await navigator.clipboard.writeText(referralLink);
setCopied(true);
hapticNotification('success');
setTimeout(() => setCopied(false), 2000);
} catch {
showAlert('Failed to copy link');
}
};
const handleShare = () => {
if (!referralLink) return;
hapticImpact('medium');
const text = `Join Pezkuwichain - The Digital Kurdish State! Use my referral link:`;
const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(text)}`;
openTelegramLink(shareUrl);
};
const handleClaimEpoch = async (epoch: number) => {
if (!api || !selectedAccount) return;
setClaimingEpoch(epoch);
hapticImpact('medium');
try {
// TODO: Implement actual claim transaction
await new Promise(resolve => setTimeout(resolve, 2000));
hapticNotification('success');
showAlert(`Successfully claimed rewards for epoch ${epoch}!`);
} catch (err) {
hapticNotification('error');
showAlert('Failed to claim rewards');
} finally {
setClaimingEpoch(null);
}
};
if (!isConnected) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 p-4 border-b border-gray-800">
<Gift className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Rewards</h2>
</div>
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 p-6">
<Gift className="w-16 h-16 mb-4 opacity-50" />
<p className="text-center mb-4">Connect your wallet to view rewards and referrals</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-2 p-4 border-b border-gray-800">
<Gift className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Rewards</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
</div>
) : (
<>
{/* Score Overview */}
{userScores && (
<div className="bg-gradient-to-br from-green-600 to-emerald-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-400" />
<span className="text-white font-medium">Trust Score</span>
</div>
<span className={cn('text-sm font-medium', getScoreColor(userScores.totalScore))}>
{getScoreRating(userScores.totalScore)}
</span>
</div>
<div className="text-4xl font-bold text-white mb-2">
{userScores.totalScore}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="bg-black/20 rounded px-2 py-1">
<span className="text-green-200">Staking:</span>{' '}
<span className="text-white">{userScores.stakingScore}</span>
</div>
<div className="bg-black/20 rounded px-2 py-1">
<span className="text-green-200">Referral:</span>{' '}
<span className="text-white">{userScores.referralScore}</span>
</div>
<div className="bg-black/20 rounded px-2 py-1">
<span className="text-green-200">Tiki:</span>{' '}
<span className="text-white">{userScores.tikiScore}</span>
</div>
<div className="bg-black/20 rounded px-2 py-1">
<span className="text-green-200">Trust:</span>{' '}
<span className="text-white">{userScores.trustScore}</span>
</div>
</div>
</div>
)}
{/* Referral Section */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 mb-3">
<Users className="w-5 h-5 text-green-500" />
<span className="text-white font-medium">Referral Program</span>
</div>
{referralStats && (
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-gray-900 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-white">
{referralStats.referralCount}
</div>
<div className="text-xs text-gray-400">Referrals</div>
</div>
<div className="bg-gray-900 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-500">
{referralStats.referralScore}
</div>
<div className="text-xs text-gray-400">Score</div>
</div>
</div>
)}
{/* Referral link */}
<div className="bg-gray-900 rounded-lg p-3 mb-3">
<div className="text-xs text-gray-400 mb-1">Your referral link</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm text-gray-300 truncate">
{referralLink}
</code>
<button
onClick={handleCopyLink}
className="p-2 rounded bg-gray-700 hover:bg-gray-600 transition-colors"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-gray-400" />
)}
</button>
</div>
</div>
<button
onClick={handleShare}
className="w-full flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 text-white py-2.5 rounded-lg transition-colors"
>
<Share2 className="w-4 h-4" />
Share via Telegram
</button>
{/* Who invited me */}
{referralStats?.whoInvitedMe && (
<div className="mt-3 text-sm text-gray-400">
Invited by:{' '}
<span className="text-gray-300">
{referralStats.whoInvitedMe.slice(0, 8)}...{referralStats.whoInvitedMe.slice(-6)}
</span>
</div>
)}
</div>
{/* Epoch Rewards */}
{stakingInfo?.pezRewards && stakingInfo.pezRewards.hasPendingClaim && (
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 mb-3">
<Calendar className="w-5 h-5 text-green-500" />
<span className="text-white font-medium">Epoch Rewards</span>
</div>
<div className="bg-gray-900 rounded-lg p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-gray-400 text-sm">Current Epoch</span>
<span className="text-white font-medium">
#{stakingInfo.pezRewards.currentEpoch}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">Claimable</span>
<span className="text-green-500 font-bold">
{stakingInfo.pezRewards.totalClaimable} PEZ
</span>
</div>
</div>
<div className="space-y-2">
{stakingInfo.pezRewards.claimableRewards.map(reward => (
<div
key={reward.epoch}
className="flex items-center justify-between bg-gray-900 rounded-lg p-3"
>
<div>
<div className="text-sm text-white">Epoch #{reward.epoch}</div>
<div className="text-xs text-green-500">{reward.amount} PEZ</div>
</div>
<button
onClick={() => handleClaimEpoch(reward.epoch)}
disabled={claimingEpoch === reward.epoch}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 text-white text-sm rounded-lg transition-colors"
>
{claimingEpoch === reward.epoch ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Claim'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Daily Tasks */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 mb-3">
<Star className="w-5 h-5 text-yellow-500" />
<span className="text-white font-medium">Daily Tasks</span>
</div>
<div className="space-y-3">
{dailyTasks.map(task => (
<div
key={task.id}
className={cn(
'bg-gray-900 rounded-lg p-3 flex items-center justify-between',
task.completed && 'opacity-60'
)}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={cn(
'text-sm font-medium',
task.completed ? 'text-gray-400 line-through' : 'text-white'
)}>
{task.title}
</span>
{task.completed && (
<Check className="w-4 h-4 text-green-500" />
)}
</div>
<div className="text-xs text-gray-500">{task.description}</div>
</div>
<div className="text-sm font-medium text-yellow-500">
+{task.reward} pts
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
export default Rewards;
+124
View File
@@ -0,0 +1,124 @@
import { cn } from '@/lib/utils';
import { useTelegram } from '../hooks/useTelegram';
import {
Megaphone,
MessageCircle,
Gift,
Smartphone,
Wallet
} from 'lucide-react';
export type Section = 'announcements' | 'forum' | 'rewards' | 'apk' | 'wallet';
interface SidebarItem {
id: Section;
icon: React.ReactNode;
label: string;
emoji: string;
}
const sidebarItems: SidebarItem[] = [
{
id: 'announcements',
icon: <Megaphone className="w-5 h-5" />,
label: 'Duyurular',
emoji: '📢'
},
{
id: 'forum',
icon: <MessageCircle className="w-5 h-5" />,
label: 'Forum',
emoji: '💬'
},
{
id: 'rewards',
icon: <Gift className="w-5 h-5" />,
label: 'Rewards',
emoji: '🎁'
},
{
id: 'apk',
icon: <Smartphone className="w-5 h-5" />,
label: 'APK',
emoji: '📱'
},
{
id: 'wallet',
icon: <Wallet className="w-5 h-5" />,
label: 'Wallet',
emoji: '💳'
},
];
interface SidebarProps {
activeSection: Section;
onSectionChange: (section: Section) => void;
}
export function Sidebar({ activeSection, onSectionChange }: SidebarProps) {
const { hapticSelection, isTelegram } = useTelegram();
const handleClick = (section: Section) => {
if (isTelegram) {
hapticSelection();
}
onSectionChange(section);
};
return (
<div className="w-16 bg-gray-900 border-r border-gray-800 flex flex-col items-center py-3 gap-1">
{/* Logo at top */}
<div className="mb-4 p-2">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center">
<span className="text-white font-bold text-lg">P</span>
</div>
</div>
{/* Divider */}
<div className="w-8 h-0.5 bg-gray-700 rounded-full mb-3" />
{/* Navigation items */}
<nav className="flex flex-col items-center gap-2 flex-1">
{sidebarItems.map((item) => (
<button
key={item.id}
onClick={() => handleClick(item.id)}
className={cn(
'relative w-12 h-12 rounded-2xl flex items-center justify-center transition-all duration-200',
'hover:rounded-xl hover:bg-green-600',
activeSection === item.id
? 'bg-green-600 rounded-xl'
: 'bg-gray-800 hover:bg-gray-700'
)}
title={item.label}
>
{/* Active indicator */}
<div
className={cn(
'absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-1 rounded-r-full bg-white transition-all duration-200',
activeSection === item.id ? 'h-10' : 'h-0 group-hover:h-5'
)}
/>
{/* Icon */}
<span className={cn(
'text-gray-400 transition-colors duration-200',
activeSection === item.id ? 'text-white' : 'hover:text-white'
)}>
{item.icon}
</span>
</button>
))}
</nav>
{/* Bottom section - could add settings or user avatar here */}
<div className="mt-auto pt-3 border-t border-gray-800">
<div className="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center">
<span className="text-xs text-gray-500">v1</span>
</div>
</div>
</div>
);
}
export default Sidebar;
@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react';
import { Wallet as WalletIcon, Send, ArrowDownToLine, RefreshCw, Copy, Check, Loader2, TrendingUp, Clock, ExternalLink } from 'lucide-react';
import { useTelegram } from '../../hooks/useTelegram';
import { usePezkuwiApi } from '../../hooks/usePezkuwiApi';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { formatBalance, CHAIN_CONFIG, formatAddress } from '@shared/lib/wallet';
import { getStakingInfo, StakingInfo } from '@shared/lib/staking';
import { cn } from '@/lib/utils';
interface TokenBalance {
symbol: string;
name: string;
balance: string;
balanceUsd?: string;
icon?: string;
isNative?: boolean;
}
interface Transaction {
id: string;
type: 'send' | 'receive' | 'stake' | 'unstake' | 'claim';
amount: string;
symbol: string;
address?: string;
timestamp: Date;
status: 'pending' | 'confirmed' | 'failed';
}
// Mock recent transactions - will be replaced with actual data
const mockTransactions: Transaction[] = [
{
id: '1',
type: 'receive',
amount: '100.00',
symbol: 'HEZ',
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
status: 'confirmed',
},
{
id: '2',
type: 'stake',
amount: '50.00',
symbol: 'HEZ',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24),
status: 'confirmed',
},
{
id: '3',
type: 'claim',
amount: '5.25',
symbol: 'PEZ',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
status: 'confirmed',
},
];
export function Wallet() {
const { hapticNotification, hapticImpact, showAlert, openLink } = useTelegram();
const { api, isReady: isApiReady } = usePezkuwiApi();
const { selectedAccount, connectWallet, disconnectWallet } = usePezkuwi();
const [balances, setBalances] = useState<TokenBalance[]>([]);
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>(mockTransactions);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [copied, setCopied] = useState(false);
const isConnected = !!selectedAccount;
const address = selectedAccount?.address;
// Fetch balances when connected
useEffect(() => {
if (!api || !isApiReady || !address) return;
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch native balance
const accountInfo = await api.query.system.account(address);
const { data } = accountInfo.toJSON() as { data: { free: string; reserved: string } };
const freeBalance = BigInt(data.free || 0);
const nativeBalance: TokenBalance = {
symbol: CHAIN_CONFIG.symbol,
name: 'Hezar Token',
balance: formatBalance(freeBalance.toString()),
isNative: true,
};
setBalances([nativeBalance]);
// Fetch staking info
const staking = await getStakingInfo(api, address);
setStakingInfo(staking);
} catch (err) {
console.error('Failed to fetch wallet data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [api, isApiReady, address]);
const handleRefresh = async () => {
if (!api || !address || isRefreshing) return;
setIsRefreshing(true);
hapticNotification('success');
try {
const accountInfo = await api.query.system.account(address);
const { data } = accountInfo.toJSON() as { data: { free: string } };
const freeBalance = BigInt(data.free || 0);
setBalances([{
symbol: CHAIN_CONFIG.symbol,
name: 'Hezar Token',
balance: formatBalance(freeBalance.toString()),
isNative: true,
}]);
const staking = await getStakingInfo(api, address);
setStakingInfo(staking);
} catch (err) {
showAlert('Failed to refresh');
} finally {
setIsRefreshing(false);
}
};
const handleCopyAddress = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
hapticNotification('success');
setTimeout(() => setCopied(false), 2000);
} catch {
showAlert('Failed to copy address');
}
};
const handleConnect = () => {
hapticImpact('medium');
connectWallet();
};
const handleDisconnect = () => {
hapticImpact('medium');
disconnectWallet();
};
const handleOpenExplorer = () => {
if (!address) return;
hapticImpact('light');
openLink(`https://explorer.pezkuwichain.io/account/${address}`);
};
const getTransactionIcon = (type: Transaction['type']) => {
switch (type) {
case 'send':
return <Send className="w-4 h-4 text-red-400" />;
case 'receive':
return <ArrowDownToLine className="w-4 h-4 text-green-400" />;
case 'stake':
return <TrendingUp className="w-4 h-4 text-blue-400" />;
case 'unstake':
return <Clock className="w-4 h-4 text-orange-400" />;
case 'claim':
return <ArrowDownToLine className="w-4 h-4 text-green-400" />;
default:
return null;
}
};
const formatTimestamp = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(hours / 24);
if (hours < 1) return 'Just now';
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('tr-TR', { day: 'numeric', month: 'short' });
};
if (!isConnected) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 p-4 border-b border-gray-800">
<WalletIcon className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Wallet</h2>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-6">
<div className="w-20 h-20 rounded-full bg-gray-800 flex items-center justify-center mb-4">
<WalletIcon className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-white font-medium mb-2">Connect Your Wallet</h3>
<p className="text-gray-400 text-sm text-center mb-6">
Connect your Pezkuwi wallet to view balances, stake tokens, and manage your assets.
</p>
<button
onClick={handleConnect}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
<WalletIcon className="w-5 h-5" />
Connect Wallet
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<WalletIcon className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-white">Wallet</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('w-4 h-4 text-gray-400', isRefreshing && 'animate-spin')} />
</button>
<button
onClick={handleDisconnect}
className="text-xs text-gray-500 hover:text-gray-400"
>
Disconnect
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-8 h-8 text-green-500 animate-spin" />
</div>
) : (
<>
{/* Address Card */}
<div className="p-4">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg p-4 border border-gray-700">
<div className="flex items-center justify-between mb-3">
<span className="text-gray-400 text-sm">
{selectedAccount?.meta?.name || 'Account'}
</span>
<button
onClick={handleOpenExplorer}
className="text-gray-500 hover:text-gray-400"
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<code className="text-white text-sm flex-1 truncate">
{formatAddress(address || '')}
</code>
<button
onClick={handleCopyAddress}
className="p-2 rounded bg-gray-700 hover:bg-gray-600 transition-colors"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-gray-400" />
)}
</button>
</div>
</div>
</div>
{/* Balance Card */}
<div className="px-4 pb-4">
<div className="bg-gradient-to-br from-green-600 to-emerald-700 rounded-lg p-4">
<div className="text-green-100 text-sm mb-1">Total Balance</div>
<div className="text-3xl font-bold text-white mb-1">
{balances[0]?.balance || '0.00'} {CHAIN_CONFIG.symbol}
</div>
{stakingInfo && parseFloat(stakingInfo.bonded) > 0 && (
<div className="text-green-200 text-sm">
Staked: {stakingInfo.bonded} {CHAIN_CONFIG.symbol}
</div>
)}
</div>
</div>
{/* Quick Actions */}
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => showAlert('Send feature coming soon!')}
className="flex flex-col items-center gap-2 bg-gray-800 hover:bg-gray-700 rounded-lg p-3 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-blue-600/20 flex items-center justify-center">
<Send className="w-5 h-5 text-blue-400" />
</div>
<span className="text-xs text-gray-400">Send</span>
</button>
<button
onClick={() => showAlert('Receive feature coming soon!')}
className="flex flex-col items-center gap-2 bg-gray-800 hover:bg-gray-700 rounded-lg p-3 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-green-600/20 flex items-center justify-center">
<ArrowDownToLine className="w-5 h-5 text-green-400" />
</div>
<span className="text-xs text-gray-400">Receive</span>
</button>
<button
onClick={() => showAlert('Stake feature coming soon!')}
className="flex flex-col items-center gap-2 bg-gray-800 hover:bg-gray-700 rounded-lg p-3 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-purple-600/20 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-purple-400" />
</div>
<span className="text-xs text-gray-400">Stake</span>
</button>
</div>
</div>
{/* Staking Info */}
{stakingInfo && parseFloat(stakingInfo.bonded) > 0 && (
<div className="px-4 pb-4">
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-5 h-5 text-purple-500" />
<span className="text-white font-medium">Staking Overview</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-900 rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Bonded</div>
<div className="text-white font-medium">{stakingInfo.bonded}</div>
</div>
<div className="bg-gray-900 rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Active</div>
<div className="text-white font-medium">{stakingInfo.active}</div>
</div>
{stakingInfo.stakingScore !== null && (
<div className="bg-gray-900 rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Staking Score</div>
<div className="text-green-500 font-medium">{stakingInfo.stakingScore}</div>
</div>
)}
<div className="bg-gray-900 rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Nominations</div>
<div className="text-white font-medium">{stakingInfo.nominations.length}</div>
</div>
</div>
</div>
</div>
)}
{/* Recent Transactions */}
<div className="px-4 pb-4">
<div className="bg-gray-800 rounded-lg border border-gray-700">
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-medium">Recent Activity</h3>
</div>
{transactions.length === 0 ? (
<div className="p-6 text-center text-gray-500 text-sm">
No recent transactions
</div>
) : (
<div className="divide-y divide-gray-700">
{transactions.map(tx => (
<div key={tx.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center">
{getTransactionIcon(tx.type)}
</div>
<div>
<div className="text-white text-sm font-medium capitalize">
{tx.type}
</div>
<div className="text-gray-500 text-xs">
{formatTimestamp(tx.timestamp)}
</div>
</div>
</div>
<div className="text-right">
<div className={cn(
'font-medium text-sm',
tx.type === 'send' || tx.type === 'stake' ? 'text-red-400' : 'text-green-400'
)}>
{tx.type === 'send' || tx.type === 'stake' ? '-' : '+'}
{tx.amount} {tx.symbol}
</div>
<div className={cn(
'text-xs',
tx.status === 'confirmed' ? 'text-gray-500' :
tx.status === 'pending' ? 'text-yellow-500' : 'text-red-500'
)}>
{tx.status}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
);
}
export default Wallet;
+8
View File
@@ -0,0 +1,8 @@
export { Sidebar } from './Sidebar';
export type { Section } from './Sidebar';
export { Announcements } from './Announcements';
export { Forum } from './Forum';
export { Rewards } from './Rewards';
export { APK } from './APK';
export { Wallet } from './Wallet';
+4
View File
@@ -0,0 +1,4 @@
export { useTelegram } from './useTelegram';
export type { TelegramUser, TelegramTheme } from './useTelegram';
export { usePezkuwiApi, getApiInstance } from './usePezkuwiApi';
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import { ApiPromise, WsProvider } from '@pezkuwi/api';
// RPC endpoint - uses environment variable or falls back to mainnet
const RPC_ENDPOINT = import.meta.env.VITE_WS_ENDPOINT || 'wss://rpc.pezkuwichain.io:9944';
const FALLBACK_ENDPOINTS = [
RPC_ENDPOINT,
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_1,
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_2,
].filter(Boolean) as string[];
interface UsePezkuwiApiReturn {
api: ApiPromise | null;
isReady: boolean;
isConnecting: boolean;
error: string | null;
reconnect: () => Promise<void>;
}
// Singleton API instance to avoid multiple connections
let globalApi: ApiPromise | null = null;
let connectionPromise: Promise<ApiPromise> | null = null;
async function createApiConnection(): Promise<ApiPromise> {
// Return existing connection promise if one is in progress
if (connectionPromise) {
return connectionPromise;
}
// Return existing API if already connected
if (globalApi && globalApi.isConnected) {
return globalApi;
}
// Create new connection
connectionPromise = (async () => {
for (const endpoint of FALLBACK_ENDPOINTS) {
try {
if (import.meta.env.DEV) {
console.log('[PezkuwiApi] Connecting to:', endpoint);
}
const provider = new WsProvider(endpoint);
const api = await ApiPromise.create({ provider });
await api.isReady;
globalApi = api;
if (import.meta.env.DEV) {
const [chain, nodeName, nodeVersion] = await Promise.all([
api.rpc.system.chain(),
api.rpc.system.name(),
api.rpc.system.version(),
]);
console.log(`[PezkuwiApi] Connected to ${chain} (${nodeName} v${nodeVersion})`);
}
return api;
} catch (err) {
if (import.meta.env.DEV) {
console.warn(`[PezkuwiApi] Failed to connect to ${endpoint}:`, err);
}
continue;
}
}
throw new Error('Failed to connect to any endpoint');
})();
try {
return await connectionPromise;
} finally {
connectionPromise = null;
}
}
export function usePezkuwiApi(): UsePezkuwiApiReturn {
const [api, setApi] = useState<ApiPromise | null>(globalApi);
const [isReady, setIsReady] = useState(globalApi?.isConnected || false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
if (isConnecting) return;
setIsConnecting(true);
setError(null);
try {
const apiInstance = await createApiConnection();
setApi(apiInstance);
setIsReady(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Connection failed';
setError(errorMessage);
setIsReady(false);
} finally {
setIsConnecting(false);
}
};
const reconnect = async () => {
// Disconnect existing connection
if (globalApi) {
await globalApi.disconnect();
globalApi = null;
}
await connect();
};
useEffect(() => {
// If we already have a global API, use it
if (globalApi && globalApi.isConnected) {
setApi(globalApi);
setIsReady(true);
return;
}
// Otherwise, establish connection
connect();
// Cleanup on unmount - don't disconnect global API, just clean up local state
return () => {
// Note: We don't disconnect globalApi here to maintain connection across components
};
}, []);
// Handle disconnection events
useEffect(() => {
if (!api) return;
const handleDisconnected = () => {
if (import.meta.env.DEV) {
console.log('[PezkuwiApi] Disconnected, attempting to reconnect...');
}
setIsReady(false);
reconnect();
};
api.on('disconnected', handleDisconnected);
return () => {
api.off('disconnected', handleDisconnected);
};
}, [api]);
return {
api,
isReady,
isConnecting,
error,
reconnect,
};
}
// Export helper to get the global API instance (for non-hook usage)
export function getApiInstance(): ApiPromise | null {
return globalApi;
}
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useState, useCallback } from 'react';
import WebApp from '@twa-dev/sdk';
export interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
is_premium?: boolean;
photo_url?: string;
}
export interface TelegramTheme {
bg_color: string;
text_color: string;
hint_color: string;
link_color: string;
button_color: string;
button_text_color: string;
secondary_bg_color: string;
}
interface UseTelegramReturn {
// State
isReady: boolean;
isTelegram: boolean;
user: TelegramUser | null;
startParam: string | null;
theme: TelegramTheme | null;
colorScheme: 'light' | 'dark';
viewportHeight: number;
viewportStableHeight: number;
isExpanded: boolean;
// Actions
ready: () => void;
expand: () => void;
close: () => void;
showAlert: (message: string) => void;
showConfirm: (message: string) => Promise<boolean>;
showPopup: (params: { title?: string; message: string; buttons?: Array<{ id: string; type?: string; text: string }> }) => Promise<string>;
openLink: (url: string, options?: { try_instant_view?: boolean }) => void;
openTelegramLink: (url: string) => void;
sendData: (data: string) => void;
enableClosingConfirmation: () => void;
disableClosingConfirmation: () => void;
setHeaderColor: (color: string) => void;
setBackgroundColor: (color: string) => void;
// Main Button
showMainButton: (text: string, onClick: () => void) => void;
hideMainButton: () => void;
setMainButtonLoading: (loading: boolean) => void;
// Back Button
showBackButton: (onClick: () => void) => void;
hideBackButton: () => void;
// Haptic Feedback
hapticImpact: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
hapticNotification: (type: 'error' | 'success' | 'warning') => void;
hapticSelection: () => void;
}
export function useTelegram(): UseTelegramReturn {
const [isReady, setIsReady] = useState(false);
const [isTelegram, setIsTelegram] = useState(false);
const [user, setUser] = useState<TelegramUser | null>(null);
const [startParam, setStartParam] = useState<string | null>(null);
const [theme, setTheme] = useState<TelegramTheme | null>(null);
const [colorScheme, setColorScheme] = useState<'light' | 'dark'>('dark');
const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
const [viewportStableHeight, setViewportStableHeight] = useState(window.innerHeight);
const [isExpanded, setIsExpanded] = useState(false);
// Initialize Telegram WebApp
useEffect(() => {
try {
// Check if running in Telegram WebApp environment
const tg = WebApp;
if (tg && tg.initData) {
setIsTelegram(true);
// Get user info
if (tg.initDataUnsafe?.user) {
setUser(tg.initDataUnsafe.user as TelegramUser);
}
// Get start parameter (referral code, etc.)
if (tg.initDataUnsafe?.start_param) {
setStartParam(tg.initDataUnsafe.start_param);
}
// Get theme
if (tg.themeParams) {
setTheme(tg.themeParams as TelegramTheme);
}
// Get color scheme
setColorScheme(tg.colorScheme as 'light' | 'dark' || 'dark');
// Get viewport
setViewportHeight(tg.viewportHeight || window.innerHeight);
setViewportStableHeight(tg.viewportStableHeight || window.innerHeight);
setIsExpanded(tg.isExpanded || false);
// Listen for viewport changes
tg.onEvent('viewportChanged', (event: { isStateStable: boolean }) => {
setViewportHeight(tg.viewportHeight);
if (event.isStateStable) {
setViewportStableHeight(tg.viewportStableHeight);
}
});
// Listen for theme changes
tg.onEvent('themeChanged', () => {
setTheme(tg.themeParams as TelegramTheme);
setColorScheme(tg.colorScheme as 'light' | 'dark' || 'dark');
});
// Signal that app is ready
tg.ready();
setIsReady(true);
// Expand by default for better UX
tg.expand();
if (import.meta.env.DEV) {
console.log('[Telegram] Mini App initialized');
console.log('[Telegram] User:', tg.initDataUnsafe?.user);
console.log('[Telegram] Start param:', tg.initDataUnsafe?.start_param);
}
} else {
// Not running in Telegram, but still mark as ready
setIsReady(true);
if (import.meta.env.DEV) {
console.log('[Telegram] Not running in Telegram WebApp environment');
}
}
} catch (err) {
console.error('[Telegram] Initialization error:', err);
setIsReady(true); // Mark as ready even on error for graceful fallback
}
}, []);
// Actions
const ready = useCallback(() => {
if (isTelegram) WebApp.ready();
}, [isTelegram]);
const expand = useCallback(() => {
if (isTelegram) {
WebApp.expand();
setIsExpanded(true);
}
}, [isTelegram]);
const close = useCallback(() => {
if (isTelegram) WebApp.close();
}, [isTelegram]);
const showAlert = useCallback((message: string) => {
if (isTelegram) {
WebApp.showAlert(message);
} else {
alert(message);
}
}, [isTelegram]);
const showConfirm = useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
if (isTelegram) {
WebApp.showConfirm(message, (confirmed) => {
resolve(confirmed);
});
} else {
resolve(confirm(message));
}
});
}, [isTelegram]);
const showPopup = useCallback((params: { title?: string; message: string; buttons?: Array<{ id: string; type?: string; text: string }> }): Promise<string> => {
return new Promise((resolve) => {
if (isTelegram) {
WebApp.showPopup(params, (buttonId) => {
resolve(buttonId || '');
});
} else {
// Fallback for non-Telegram environment
const result = confirm(params.message);
resolve(result ? 'ok' : 'cancel');
}
});
}, [isTelegram]);
const openLink = useCallback((url: string, options?: { try_instant_view?: boolean }) => {
if (isTelegram) {
WebApp.openLink(url, options);
} else {
window.open(url, '_blank');
}
}, [isTelegram]);
const openTelegramLink = useCallback((url: string) => {
if (isTelegram) {
WebApp.openTelegramLink(url);
} else {
window.open(url, '_blank');
}
}, [isTelegram]);
const sendData = useCallback((data: string) => {
if (isTelegram) WebApp.sendData(data);
}, [isTelegram]);
const enableClosingConfirmation = useCallback(() => {
if (isTelegram) WebApp.enableClosingConfirmation();
}, [isTelegram]);
const disableClosingConfirmation = useCallback(() => {
if (isTelegram) WebApp.disableClosingConfirmation();
}, [isTelegram]);
const setHeaderColor = useCallback((color: string) => {
if (isTelegram) WebApp.setHeaderColor(color as `#${string}`);
}, [isTelegram]);
const setBackgroundColor = useCallback((color: string) => {
if (isTelegram) WebApp.setBackgroundColor(color as `#${string}`);
}, [isTelegram]);
// Main Button
const showMainButton = useCallback((text: string, onClick: () => void) => {
if (isTelegram) {
WebApp.MainButton.setText(text);
WebApp.MainButton.onClick(onClick);
WebApp.MainButton.show();
}
}, [isTelegram]);
const hideMainButton = useCallback(() => {
if (isTelegram) WebApp.MainButton.hide();
}, [isTelegram]);
const setMainButtonLoading = useCallback((loading: boolean) => {
if (isTelegram) {
if (loading) {
WebApp.MainButton.showProgress();
} else {
WebApp.MainButton.hideProgress();
}
}
}, [isTelegram]);
// Back Button
const showBackButton = useCallback((onClick: () => void) => {
if (isTelegram) {
WebApp.BackButton.onClick(onClick);
WebApp.BackButton.show();
}
}, [isTelegram]);
const hideBackButton = useCallback(() => {
if (isTelegram) WebApp.BackButton.hide();
}, [isTelegram]);
// Haptic Feedback
const hapticImpact = useCallback((style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => {
if (isTelegram) WebApp.HapticFeedback.impactOccurred(style);
}, [isTelegram]);
const hapticNotification = useCallback((type: 'error' | 'success' | 'warning') => {
if (isTelegram) WebApp.HapticFeedback.notificationOccurred(type);
}, [isTelegram]);
const hapticSelection = useCallback(() => {
if (isTelegram) WebApp.HapticFeedback.selectionChanged();
}, [isTelegram]);
return {
isReady,
isTelegram,
user,
startParam,
theme,
colorScheme,
viewportHeight,
viewportStableHeight,
isExpanded,
ready,
expand,
close,
showAlert,
showConfirm,
showPopup,
openLink,
openTelegramLink,
sendData,
enableClosingConfirmation,
disableClosingConfirmation,
setHeaderColor,
setBackgroundColor,
showMainButton,
hideMainButton,
setMainButtonLoading,
showBackButton,
hideBackButton,
hapticImpact,
hapticNotification,
hapticSelection,
};
}
+3
View File
@@ -0,0 +1,3 @@
export { TelegramApp } from './TelegramApp';
export { useTelegram, usePezkuwiApi } from './hooks';
export type { TelegramUser, TelegramTheme } from './hooks';