diff --git a/mobile/docs/FAZ_1_SUMMARY.md b/mobile/docs/FAZ_1_SUMMARY.md new file mode 100644 index 00000000..c6339f2d --- /dev/null +++ b/mobile/docs/FAZ_1_SUMMARY.md @@ -0,0 +1,343 @@ +# FAZ 1: Mobile App Temel Yapı - Özet Rapor + +## Genel Bakış +FAZ 1, mobil uygulama için temel kullanıcı akışının ve blockchain bağlantısının kurulmasını kapsar. Bu faz tamamlandığında kullanıcı, dil seçimi yapabilecek, insan doğrulamasından geçecek ve gerçek blockchain verilerini görebilecek. + +## Tamamlanan Görevler ✅ + +### 1. WelcomeScreen - Dil Seçimi ✅ +**Dosya:** `/home/mamostehp/pwap/mobile/src/screens/WelcomeScreen.tsx` + +**Durum:** Tamamen hazır, değişiklik gerekmez + +**Özellikler:** +- 6 dil desteği (EN, TR, KMR, CKB, AR, FA) +- RTL (Sağdan-sola) dil desteği badge'i +- Kurdistan renk paleti ile gradient tasarım +- i18next entegrasyonu aktif +- LanguageContext ile dil state yönetimi + +**Kod İncelemesi:** +- Lines 22-42: 6 dil tanımı (name, nativeName, code, rtl) +- Lines 44-58: handleLanguageSelect() - Dil değişim fonksiyonu +- Lines 59-88: Dil kartları UI (TouchableOpacity ile seçilebilir) +- Lines 104-107: Devam butonu (dil seçildikten sonra aktif olur) + +### 2. VerificationScreen - İnsan Doğrulama ✅ +**Dosya:** `/home/mamostehp/pwap/mobile/src/screens/VerificationScreen.tsx` + +**Durum:** Syntax hatası düzeltildi (line 50: KurdistanColors) + +**Özellikler:** +- Mock doğrulama (FAZ 1.2 için yeterli) +- Dev modunda "Skip" butonu (__DEV__ flag) +- 1.5 saniye simüle doğrulama delay'i +- Linear gradient tasarım (Kesk → Zer) +- i18n çeviri desteği +- Loading state (ActivityIndicator) + +**Kod İncelemesi:** +- Lines 30-38: handleVerify() - 1.5s simüle doğrulama +- Lines 40-45: handleSkip() - Sadece dev modda aktif +- Lines 50: **FIX APPLIED** - `KurdistanColors.kesk` (was: `Kurdistan Colors.kesk`) +- Lines 75-81: Dev mode badge gösterimi +- Lines 100-110: Skip butonu (sadece __DEV__) + +**Düzeltilen Hata:** +```diff +- colors={[Kurdistan Colors.kesk, KurdistanColors.zer]} ++ colors={[KurdistanColors.kesk, KurdistanColors.zer]} +``` + +## Devam Eden Görevler 🚧 + +### 3. DashboardScreen - Blockchain Bağlantısı 🚧 +**Dosya:** `/home/mamostehp/pwap/mobile/src/screens/DashboardScreen.tsx` + +**Durum:** UI hazır, blockchain entegrasyonu gerekli + +**Hardcoded Değerler (Değiştirilmesi Gereken):** + +#### Balance Card (Lines 94-108) +```typescript +// ❌ ŞU AN HARDCODED: +0.00 HEZ + +// Satır 98-101: Total Staked +0.00 + +// Satır 103-106: Rewards +0.00 +``` + +**Gerekli Değişiklik:** +```typescript +// ✅ OLMASI GEREKEN: +import { useBalance } from '@pezkuwi/shared/hooks/blockchain/useBalance'; + +const { balance, isLoading, error } = useBalance(api, userAddress); + + + {isLoading ? 'Loading...' : formatBalance(balance.free)} HEZ + +``` + +#### Active Proposals Card (Lines 133-142) +```typescript +// ❌ ŞU AN HARDCODED: +0 +``` + +**Gerekli Değişiklik:** +```typescript +// ✅ OLMASI GEREKEN: +import { useProposals } from '@pezkuwi/shared/hooks/blockchain/useProposals'; + +const { proposals, isLoading } = useProposals(api); + + + {isLoading ? '...' : proposals.length} + +``` + +### 4. Quick Actions - Gerçek Veri Bağlantısı 🚧 +**Durum:** UI hazır, blockchain queries gerekli + +**Mevcut Quick Actions:** +1. 💼 **Wallet** - `onNavigateToWallet()` ✅ (navigation var) +2. 🔒 **Staking** - `console.log()` ❌ (stub) +3. 🗳️ **Governance** - `console.log()` ❌ (stub) +4. 💱 **DEX** - `console.log()` ❌ (stub) +5. 📜 **History** - `console.log()` ❌ (stub) +6. ⚙️ **Settings** - `onNavigateToSettings()` ✅ (navigation var) + +**FAZ 1 İçin Gerekli:** +- Quick Actions'lar gerçek blockchain data ile çalışacak şekilde güncellenecek +- Her action için ilgili screen navigation'ı eklenecek +- FAZ 2'de detaylı implementasyonlar yapılacak (şimdilik sadece navigation yeterli) + +## Gerekli Shared Hooks (Oluşturulmalı) + +### 1. useBalance Hook ✅ (ZATEN OLUŞTURULDU) +**Dosya:** `/home/mamostehp/pwap/shared/hooks/blockchain/usePolkadotApi.ts` + +**Durum:** Platform-agnostic API connection hook hazır + +**Kod:** +```typescript +export function usePolkadotApi(endpoint?: string): UsePolkadotApiReturn { + const [api, setApi] = useState(null); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + + // Auto-connect on mount, disconnect on unmount + // Returns: { api, isReady, error, connect, disconnect } +} +``` + +**Kullanım:** +```typescript +import { usePolkadotApi } from '@pezkuwi/shared/hooks/blockchain/usePolkadotApi'; + +const { api, isReady, error } = usePolkadotApi('ws://localhost:9944'); +``` + +### 2. useBalance Hook (Oluşturulacak) +**Dosya:** `/home/mamostehp/pwap/shared/hooks/blockchain/useBalance.ts` (YOK) + +**Gerekli Kod:** +```typescript +import { useState, useEffect } from 'react'; +import { ApiPromise } from '@polkadot/api'; + +interface Balance { + free: string; + reserved: string; + frozen: string; +} + +export function useBalance(api: ApiPromise | null, address: string) { + const [balance, setBalance] = useState({ + free: '0', + reserved: '0', + frozen: '0' + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!api || !address) return; + + setIsLoading(true); + + api.query.system.account(address) + .then((account: any) => { + setBalance({ + free: account.data.free.toString(), + reserved: account.data.reserved.toString(), + frozen: account.data.frozen.toString(), + }); + setIsLoading(false); + }) + .catch((err) => { + setError(err); + setIsLoading(false); + }); + }, [api, address]); + + return { balance, isLoading, error }; +} +``` + +### 3. useStaking Hook (Oluşturulacak) +**Dosya:** `/home/mamostehp/pwap/shared/hooks/blockchain/useStaking.ts` (YOK) + +**Gerekli Queries:** +- `api.query.staking.bonded(address)` - Bonded amount +- `api.query.staking.ledger(address)` - Staking ledger +- `api.query.staking.payee(address)` - Reward destination + +### 4. useProposals Hook (Oluşturulacak) +**Dosya:** `/home/mamostehp/pwap/shared/hooks/blockchain/useProposals.ts` (YOK) + +**Gerekli Queries:** +- `api.query.welati.proposals()` - All active proposals +- `api.query.welati.proposalCount()` - Total proposal count + +## FAZ 1 Tamamlama Planı + +### Adım 1: Shared Hooks Oluşturma ⏳ +1. `useBalance.ts` - Balance fetching +2. `useStaking.ts` - Staking info +3. `useProposals.ts` - Governance proposals +4. `formatBalance.ts` utility - Token formatting + +### Adım 2: DashboardScreen Entegrasyonu ⏳ +1. Import shared hooks +2. Replace hardcoded `0.00 HEZ` with real balance +3. Replace hardcoded `0.00` staked amount +4. Replace hardcoded `0.00` rewards +5. Replace hardcoded `0` proposals count + +### Adım 3: Error Handling & Loading States ⏳ +1. Add loading spinners for blockchain queries +2. Add error messages for failed queries +3. Add retry mechanism +4. Add offline state detection + +### Adım 4: Testing ⏳ +1. Test with local dev node (ws://localhost:9944) +2. Test with beta testnet +3. Test offline behavior +4. Test error scenarios + +## Blockchain Endpoints + +### Development +```typescript +const DEV_ENDPOINT = 'ws://localhost:9944'; +``` + +### Beta Testnet +```typescript +const BETA_ENDPOINT = 'ws://beta.pezkuwichain.io:9944'; +``` + +### Mainnet (Future) +```typescript +const MAINNET_ENDPOINT = 'wss://mainnet.pezkuwichain.io'; +``` + +## Dosya Yapısı + +``` +pwap/ +├── mobile/ +│ ├── src/ +│ │ ├── screens/ +│ │ │ ├── WelcomeScreen.tsx ✅ (Tamamlandı) +│ │ │ ├── VerificationScreen.tsx ✅ (Tamamlandı, syntax fix) +│ │ │ ├── DashboardScreen.tsx 🚧 (Blockchain entegrasyonu gerekli) +│ │ │ ├── WalletScreen.tsx ❌ (FAZ 2) +│ │ │ └── SettingsScreen.tsx ❌ (Var, ama update gerekli) +│ │ └── theme/ +│ │ └── colors.ts ✅ +│ └── docs/ +│ ├── QUICK_ACTIONS_IMPLEMENTATION.md ✅ (400+ satır) +│ └── FAZ_1_SUMMARY.md ✅ (Bu dosya) +└── shared/ + └── hooks/ + └── blockchain/ + ├── usePolkadotApi.ts ✅ (Tamamlandı) + ├── useBalance.ts ❌ (Oluşturulacak) + ├── useStaking.ts ❌ (Oluşturulacak) + └── useProposals.ts ❌ (Oluşturulacak) +``` + +## Sonraki Adımlar (Öncelik Sırasına Göre) + +### FAZ 1.3 (Şu An) 🚧 +1. `useBalance.ts` hook'unu oluştur +2. `useStaking.ts` hook'unu oluştur +3. `useProposals.ts` hook'unu oluştur +4. `formatBalance.ts` utility'sini oluştur +5. DashboardScreen'e entegre et +6. Test et + +### FAZ 1.4 (Sonraki) ⏳ +1. Quick Actions navigation'larını ekle +2. Her action için loading state ekle +3. Error handling ekle +4. Offline state detection ekle + +### FAZ 2 (Gelecek) 📅 +1. WalletScreen - Transfer, Receive, History +2. StakingScreen - Bond, Unbond, Nominate +3. GovernanceScreen - Proposals, Voting +4. DEXScreen - Swap, Liquidity +5. HistoryScreen - Transaction list +6. Detailed documentation (QUICK_ACTIONS_IMPLEMENTATION.md zaten var) + +## Beklenen Timeline + +- **FAZ 1.3 (Blockchain Bağlantısı):** 2-3 gün +- **FAZ 1.4 (Quick Actions Navigation):** 1 gün +- **FAZ 1 Toplam:** ~1 hafta +- **FAZ 2 (Detaylı Features):** 3-4 hafta (daha önce planlandı) + +## Bağımlılıklar + +### NPM Paketleri (Zaten Kurulu) +- `@polkadot/api` v16.5.2 ✅ +- `@polkadot/util` v13.5.7 ✅ +- `@polkadot/util-crypto` v13.5.7 ✅ +- `react-i18next` ✅ +- `expo-linear-gradient` ✅ + +### Platform Desteği +- ✅ React Native (mobile) +- ✅ Web (shared hooks platform-agnostic) + +## Notlar + +### Güvenlik +- Mnemonic/private key'ler SecureStore'da saklanacak +- Biometric authentication FAZ 2'de eklenecek +- Demo mode sadece `__DEV__` flag'inde aktif + +### i18n +- 6 dil desteği aktif (EN, TR, KMR, CKB, AR, FA) +- RTL diller için özel layout (AR, FA) +- Çeviriler `/home/mamostehp/pwap/mobile/src/locales/` klasöründe + +### Tasarım +- Kurdistan renk paleti: Kesk (green), Zer (yellow), Sor (red), Spi (white), Reş (black) +- Linear gradient backgrounds +- Shadow/elevation effects +- Responsive grid layout + +--- + +**Durum:** FAZ 1.2 tamamlandı, FAZ 1.3 devam ediyor +**Güncelleme:** 2025-11-17 +**Yazar:** Claude Code diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 1bda6527..61746f2b 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -405,7 +405,7 @@ export const translations = { statusRejected: 'Your citizenship application has been rejected. Please check for notifications or contact support for more information.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'Referral Program', subtitle: 'Invite friends and earn rewards', code: 'Your Referral Code', @@ -420,7 +420,7 @@ export const translations = { copiedLinkMessage: 'Your referral link is copied to the clipboard.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'Please log in to view your profile.', editProfile: 'Edit Profile', walletAddress: 'Wallet Address', @@ -860,7 +860,7 @@ export const translations = { statusRejected: 'داواکاری هاوڵاتیبوونت ڕەتکرایەوە. تکایە سەیری ئاگادارکردنەوەکان بکە یان بۆ زانیاری زیاتر پەیوەندی بە پشتگیرییەوە بکە.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'بەرنامەی ئاماژەدان', subtitle: 'هاوڕێکانت بانگهێشت بکە و خەڵات بەدەست بهێنە', code: 'کۆدی ئاماژەدانەکەت', @@ -875,7 +875,7 @@ export const translations = { copiedLinkMessage: 'لینکی ئamaژەدانەکەت بۆ کلیپبۆرد کۆپی کرا.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'تکایە بۆ بینینی پڕۆفایلەکەت بچۆ ژوورەوە.', editProfile: 'دەستکاری پڕۆفایل', walletAddress: 'ناونیشانی جزدان', @@ -1311,7 +1311,7 @@ export const translations = { statusRejected: 'Serlêdana weya hemwelatiyê hate red kirin. Ji kerema xwe ji bo bêtir agahdarî agahdariyan kontrol bikin an bi piştgiriyê re têkilî daynin.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'Programa Referansê', subtitle: 'Hevalên xwe vexwîne û xelatan qezenc bike', code: 'Koda We ya Referansê', @@ -1326,7 +1326,7 @@ export const translations = { copiedLinkMessage: 'Lînka weya referansê li clipboardê hat kopî kirin.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'Ji kerema xwe ji bo dîtina profîla xwe têkevin.', editProfile: 'Profîlê Biguherîne', walletAddress: 'Navnîşana Berîkê', @@ -1762,7 +1762,7 @@ export const translations = { statusRejected: 'تم رفض طلب المواطنة الخاص بك. يرجى التحقق من الإشعارات أو الاتصال بالدعم لمزيد من المعلومات.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'برنامج الإحالة', subtitle: 'ادعُ الأصدقاء واكسب المكافآت', code: 'رمز الإحالة الخاص بك', @@ -1777,7 +1777,7 @@ export const translations = { copiedLinkMessage: 'تم نسخ رابط الإحالة الخاص بك إلى الحافظة.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'يرجى تسجيل الدخول لعرض ملفك الشخصي.', editProfile: 'تعديل الملف الشخصي', walletAddress: 'عنوان المحفظة', @@ -2136,22 +2136,22 @@ export const translations = { statusRejected: 'Vatandaşlık başvurunuz reddedildi. Lütfen bildirimleri kontrol edin veya daha fazla bilgi için destek ile iletişime geçin.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'Yönlendirme Programı', subtitle: 'Arkadaşlarını davet et ve ödüller kazan', - code: 'Yönlendirme Kodunuz', - link: 'Yönlendirme Linkiniz', + code: 'Yönlendirme Kodun', + link: 'Yönlendirme Bağlantın', count: 'Toplam Davet Edilen', people: 'Kişi', errorNoUser: 'Yönlendirme bilgilerini görmek için giriş yapmalısınız.', - errorFetch: 'Yönlendirme bilgileri alınamadı. Lütfen yenilemek için aşağı çekin.', + errorFetch: 'Yönlendirme bilgileri alınamadı. Lütfen yenilemek için çekin.', copiedCodeTitle: 'Kod Kopyalandı', - copiedCodeMessage: 'Yönlendirme kodunuz panoya kopyalandı.', - copiedLinkTitle: 'Link Kopyalandı', - copiedLinkMessage: 'Yönlendirme linkiniz panoya kopyalandı.' + copiedCodeMessage: 'Yönlendirme kodun panoya kopyalandı.', + copiedLinkTitle: 'Bağlantı Kopyalandı', + copiedLinkMessage: 'Yönlendirme bağlantın panoya kopyalandı.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'Profilinizi görüntülemek için lütfen giriş yapın.', editProfile: 'Profili Düzenle', walletAddress: 'Cüzdan Adresi', @@ -2510,29 +2510,29 @@ export const translations = { statusRejected: 'درخواست شهروندی شما رد شده است. لطفاً اعلان‌ها را بررسی کنید یا برای اطلاعات بیشتر با پشتیبانی تماس بگیرید.', }, // Referral Tab - YENİ EKLENDİ - referral: { + referralTab: { title: 'برنامه ارجاع', subtitle: 'دوستان خود را دعوت کنید و پاداش بگیرید', code: 'کد ارجاع شما', link: 'لینک ارجاع شما', - count: 'مجموع دعوت‌شدگان', + count: 'مجموع دعوت شدگان', people: 'نفر', errorNoUser: 'برای مشاهده اطلاعات ارجاع باید وارد شوید.', - errorFetch: 'اطلاعات ارجاع بازیابی نشد. لطفاً برای تازه‌سازی صفحه را به پایین بکشید.', + errorFetch: 'اطلاعات ارجاع دریافت نشد. لطفاً برای تازه کردن بکشید.', copiedCodeTitle: 'کد کپی شد', - copiedCodeMessage: 'کد ارجاع شما در کلیپ‌بورد کپی شد.', + copiedCodeMessage: 'کد ارجاع شما در کلیپ بورد کپی شد.', copiedLinkTitle: 'لینک کپی شد', - copiedLinkMessage: 'لینک ارجاع شما در کلیپ‌Bورد کپی شد.' + copiedLinkMessage: 'لینک ارجاع شما در کلیپ بورد کپی شد.' }, // Profile Tab - YENİ EKLENDİ - profile: { + profileTab: { notLoggedIn: 'لطفاً برای مشاهده پروفایل خود وارد شوید.', editProfile: 'ویرایش پروفایل', walletAddress: 'آدرس کیف پول', changePassword: 'تغییر رمز عبور', security: 'امنیت و 2FA', - signOutAlertTitle: 'خروج از حساب', - signOutAlertMessage: 'آیا برای خروج از حساب مطمئن هستید؟', + signOutAlertTitle: 'خروج از سیستم', + signOutAlertMessage: 'آیا مطمئن هستید که می خواهید از سیستم خارج شوید؟', }, // Send Modal - YENİ EKLENDİ sendModal: { diff --git a/shared/lib/ipfs.ts b/shared/lib/ipfs.ts new file mode 100644 index 00000000..9c1f1460 --- /dev/null +++ b/shared/lib/ipfs.ts @@ -0,0 +1,39 @@ +import { toast } from 'sonner'; + +const PINATA_JWT = import.meta.env.VITE_PINATA_JWT; +const PINATA_API = 'https://api.pinata.cloud/pinning/pinFileToIPFS'; + +export async function uploadToIPFS(file: File): Promise { + if (!PINATA_JWT || PINATA_JWT === 'your_pinata_jwt_here') { + throw new Error('Pinata JWT not configured. Set VITE_PINATA_JWT in .env'); + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch(PINATA_API, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${PINATA_JWT}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Upload failed: ${response.statusText}`); + } + + const data = await response.json(); + return data.IpfsHash; // Returns: Qm... + } catch (error) { + console.error('IPFS upload error:', error); + toast.error('Failed to upload to IPFS'); + throw error; + } +} + +export function getIPFSUrl(hash: string): string { + return `https://gateway.pinata.cloud/ipfs/${hash}`; +} diff --git a/shared/lib/perwerde.ts b/shared/lib/perwerde.ts index b937e502..be4d7f06 100644 --- a/shared/lib/perwerde.ts +++ b/shared/lib/perwerde.ts @@ -1,416 +1,372 @@ +import { ApiPromise } from '@polkadot/api'; +import { SubmittableExtrinsic } from '@polkadot/api/types'; +import { InjectedAccountWithMeta } from '@polkadot/extension-inject/types'; +import { toast } from 'sonner'; +import { supabase } from '@/lib/supabase'; + /** - * Perwerde (Education) Pallet Integration - * - * This module provides helper functions for interacting with the Perwerde pallet, - * which handles: - * - Course creation and management - * - Student enrollment - * - Course completion tracking - * - Education points/scores + * Course data structure matching blockchain pallet */ - -import type { ApiPromise } from '@polkadot/api'; -import type { Option } from '@polkadot/types'; - -// ============================================================================ -// TYPE DEFINITIONS -// ============================================================================ - -export type CourseStatus = 'Active' | 'Archived'; - export interface Course { id: number; owner: string; name: string; description: string; - contentLink: string; - status: CourseStatus; - createdAt: number; + content_link: string; // IPFS hash + status: 'Active' | 'Archived'; + created_at: string; } +/** + * Enrollment data structure + */ export interface Enrollment { - student: string; - courseId: number; - enrolledAt: number; - completedAt?: number; - pointsEarned: number; - isCompleted: boolean; -} - -export interface StudentProgress { - totalCourses: number; - completedCourses: number; - totalPoints: number; - activeCourses: number; -} - -// ============================================================================ -// QUERY FUNCTIONS (Read-only) -// ============================================================================ - -/** - * Get all courses (active and archived) - */ -export async function getAllCourses(api: ApiPromise): Promise { - const nextId = await api.query.perwerde.nextCourseId(); - const currentId = (nextId.toJSON() as number) || 0; - - const courses: Course[] = []; - - for (let i = 0; i < currentId; i++) { - const courseOption = await api.query.perwerde.courses(i); - - if (courseOption.isSome) { - const courseData = courseOption.unwrap().toJSON() as any; - - courses.push({ - id: i, - owner: courseData.owner, - name: hexToString(courseData.name), - description: hexToString(courseData.description), - contentLink: hexToString(courseData.contentLink), - status: courseData.status as CourseStatus, - createdAt: courseData.createdAt, - }); - } - } - - return courses; + id: string; + student_address: string; + course_id: number; + enrolled_at: string; + completed_at?: string; + points_earned: number; + is_completed: boolean; } /** - * Get active courses only - */ -export async function getActiveCourses(api: ApiPromise): Promise { - const allCourses = await getAllCourses(api); - return allCourses.filter((course) => course.status === 'Active'); -} - -/** - * Get course by ID - */ -export async function getCourseById(api: ApiPromise, courseId: number): Promise { - const courseOption = await api.query.perwerde.courses(courseId); - - if (courseOption.isNone) { - return null; - } - - const courseData = courseOption.unwrap().toJSON() as any; - - return { - id: courseId, - owner: courseData.owner, - name: hexToString(courseData.name), - description: hexToString(courseData.description), - contentLink: hexToString(courseData.contentLink), - status: courseData.status as CourseStatus, - createdAt: courseData.createdAt, - }; -} - -/** - * Get student's enrolled courses - */ -export async function getStudentCourses(api: ApiPromise, studentAddress: string): Promise { - const coursesOption = await api.query.perwerde.studentCourses(studentAddress); - - if (coursesOption.isNone || coursesOption.isEmpty) { - return []; - } - - return (coursesOption.toJSON() as number[]) || []; -} - -/** - * Get enrollment details for a student in a specific course - */ -export async function getEnrollment( - api: ApiPromise, - studentAddress: string, - courseId: number -): Promise { - const enrollmentOption = await api.query.perwerde.enrollments([studentAddress, courseId]); - - if (enrollmentOption.isNone) { - return null; - } - - const enrollmentData = enrollmentOption.unwrap().toJSON() as any; - - return { - student: enrollmentData.student, - courseId: enrollmentData.courseId, - enrolledAt: enrollmentData.enrolledAt, - completedAt: enrollmentData.completedAt || undefined, - pointsEarned: enrollmentData.pointsEarned || 0, - isCompleted: !!enrollmentData.completedAt, - }; -} - -/** - * Get student's progress summary - */ -export async function getStudentProgress(api: ApiPromise, studentAddress: string): Promise { - const courseIds = await getStudentCourses(api, studentAddress); - - let completedCourses = 0; - let totalPoints = 0; - - for (const courseId of courseIds) { - const enrollment = await getEnrollment(api, studentAddress, courseId); - - if (enrollment) { - if (enrollment.isCompleted) { - completedCourses++; - totalPoints += enrollment.pointsEarned; - } - } - } - - return { - totalCourses: courseIds.length, - completedCourses, - totalPoints, - activeCourses: courseIds.length - completedCourses, - }; -} - -/** - * Get Perwerde score for a student (sum of all earned points) - */ -export async function getPerwerdeScore(api: ApiPromise, studentAddress: string): Promise { - try { - // Try to call the get_perwerde_score runtime API - // This might not exist in all versions, fallback to manual calculation - const score = await api.call.perwerdeApi?.getPerwerdeScore(studentAddress); - return score ? (score.toJSON() as number) : 0; - } catch (error) { - // Fallback: manually sum all points - const progress = await getStudentProgress(api, studentAddress); - return progress.totalPoints; - } -} - -/** - * Check if student is enrolled in a course - */ -export async function isEnrolled( - api: ApiPromise, - studentAddress: string, - courseId: number -): Promise { - const enrollment = await getEnrollment(api, studentAddress, courseId); - return enrollment !== null; -} - -/** - * Get course enrollment statistics - */ -export async function getCourseStats( - api: ApiPromise, - courseId: number -): Promise<{ - totalEnrollments: number; - completions: number; - averagePoints: number; -}> { - // Note: This requires iterating through all enrollments, which can be expensive - // In production, consider caching or maintaining separate counters - - const entries = await api.query.perwerde.enrollments.entries(); - - let totalEnrollments = 0; - let completions = 0; - let totalPoints = 0; - - for (const [key, value] of entries) { - const enrollmentData = value.toJSON() as any; - const enrollmentCourseId = (key.args[1] as any).toNumber(); - - if (enrollmentCourseId === courseId) { - totalEnrollments++; - - if (enrollmentData.completedAt) { - completions++; - totalPoints += enrollmentData.pointsEarned || 0; - } - } - } - - return { - totalEnrollments, - completions, - averagePoints: completions > 0 ? Math.round(totalPoints / completions) : 0, - }; -} - -// ============================================================================ -// TRANSACTION FUNCTIONS -// ============================================================================ - -/** - * Create a new course - * @requires AdminOrigin (only admin can create courses in current implementation) + * Create a new course on blockchain and sync to Supabase + * + * Flow: + * 1. Call blockchain create_course extrinsic + * 2. Wait for block inclusion + * 3. Extract course_id from event + * 4. Insert to Supabase with blockchain course_id */ export async function createCourse( api: ApiPromise, - signer: any, + account: InjectedAccountWithMeta, name: string, description: string, - contentLink: string -): Promise { - const tx = api.tx.perwerde.createCourse(name, description, contentLink); + ipfsHash: string +): Promise { + try { + // Convert strings to bounded vecs (Vec) + const nameVec = Array.from(new TextEncoder().encode(name)); + const descVec = Array.from(new TextEncoder().encode(description)); + const linkVec = Array.from(new TextEncoder().encode(ipfsHash)); - return new Promise((resolve, reject) => { - tx.signAndSend(signer, ({ status, dispatchError }) => { - if (status.isInBlock) { - if (dispatchError) { - reject(dispatchError); - } else { - resolve(); + // Create extrinsic + const extrinsic = api.tx.perwerde.createCourse(nameVec, descVec, linkVec); + + // Sign and send + const courseId = await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, events, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + // Find CourseCreated event + const courseCreatedEvent = events.find( + ({ event }) => + event.section === 'perwerde' && event.method === 'CourseCreated' + ); + + if (courseCreatedEvent) { + const courseId = courseCreatedEvent.event.data[0].toString(); + resolve(parseInt(courseId)); + } else { + reject(new Error('CourseCreated event not found')); + } + + if (unsub) unsub(); + } } - } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); }); - }); + + // Insert to Supabase + const { error: supabaseError } = await supabase.from('courses').insert({ + id: courseId, + owner: account.address, + name, + description, + content_link: ipfsHash, + status: 'Active', + created_at: new Date().toISOString(), + }); + + if (supabaseError) { + console.error('Supabase insert failed:', supabaseError); + toast.error('Course created on blockchain but failed to sync to database'); + } else { + toast.success(`Course created with ID: ${courseId}`); + } + + return courseId; + } catch (error) { + console.error('Create course error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to create course'); + throw error; + } } /** - * Enroll in a course + * Enroll student in a course */ export async function enrollInCourse( api: ApiPromise, - signerAddress: string, + account: InjectedAccountWithMeta, courseId: number ): Promise { - const tx = api.tx.perwerde.enroll(courseId); + try { + const extrinsic = api.tx.perwerde.enroll(courseId); - return new Promise((resolve, reject) => { - tx.signAndSend(signerAddress, ({ status, dispatchError }) => { - if (status.isInBlock) { - if (dispatchError) { - reject(dispatchError); - } else { - resolve(); + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, events, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + resolve(); + if (unsub) unsub(); + } } - } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); }); - }); + + // Insert enrollment to Supabase + const { error: supabaseError } = await supabase.from('enrollments').insert({ + student_address: account.address, + course_id: courseId, + enrolled_at: new Date().toISOString(), + is_completed: false, + points_earned: 0, + }); + + if (supabaseError) { + console.error('Supabase enrollment insert failed:', supabaseError); + toast.error('Enrolled on blockchain but failed to sync to database'); + } else { + toast.success('Successfully enrolled in course'); + } + } catch (error) { + console.error('Enroll error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to enroll in course'); + throw error; + } } /** - * Complete a course - * @requires Course owner to call this for student + * Mark course as completed (student self-completes) */ export async function completeCourse( api: ApiPromise, - signer: any, - studentAddress: string, + account: InjectedAccountWithMeta, courseId: number, points: number ): Promise { - const tx = api.tx.perwerde.completeCourse(courseId, points); + try { + const extrinsic = api.tx.perwerde.completeCourse(courseId, points); - return new Promise((resolve, reject) => { - tx.signAndSend(signer, ({ status, dispatchError }) => { - if (status.isInBlock) { - if (dispatchError) { - reject(dispatchError); - } else { - resolve(); + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + resolve(); + if (unsub) unsub(); + } } - } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); }); - }); + + // Update enrollment in Supabase + const { error: supabaseError } = await supabase + .from('enrollments') + .update({ + is_completed: true, + completed_at: new Date().toISOString(), + points_earned: points, + }) + .eq('student_address', account.address) + .eq('course_id', courseId); + + if (supabaseError) { + console.error('Supabase completion update failed:', supabaseError); + toast.error('Completed on blockchain but failed to sync to database'); + } else { + toast.success(`Course completed! Earned ${points} points`); + } + } catch (error) { + console.error('Complete course error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to complete course'); + throw error; + } } /** - * Archive a course - * @requires Course owner + * Archive a course (admin/owner only) */ export async function archiveCourse( api: ApiPromise, - signer: any, + account: InjectedAccountWithMeta, courseId: number ): Promise { - const tx = api.tx.perwerde.archiveCourse(courseId); + try { + const extrinsic = api.tx.perwerde.archiveCourse(courseId); - return new Promise((resolve, reject) => { - tx.signAndSend(signer, ({ status, dispatchError }) => { - if (status.isInBlock) { - if (dispatchError) { - reject(dispatchError); - } else { - resolve(); + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + resolve(); + if (unsub) unsub(); + } } - } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); }); - }); -} -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ + // Update course status in Supabase + const { error: supabaseError } = await supabase + .from('courses') + .update({ status: 'Archived' }) + .eq('id', courseId); -/** - * Convert hex string to UTF-8 string - */ -function hexToString(hex: any): string { - if (!hex) return ''; - - // If it's already a string, return it - if (typeof hex === 'string' && !hex.startsWith('0x')) { - return hex; - } - - // If it's a hex string, convert it - const hexStr = hex.toString().replace(/^0x/, ''); - let str = ''; - - for (let i = 0; i < hexStr.length; i += 2) { - const code = parseInt(hexStr.substr(i, 2), 16); - if (code !== 0) { - // Skip null bytes - str += String.fromCharCode(code); + if (supabaseError) { + console.error('Supabase archive update failed:', supabaseError); + toast.error('Archived on blockchain but failed to sync to database'); + } else { + toast.success('Course archived'); } - } - - return str.trim(); -} - -/** - * Get course difficulty label (based on points threshold) - */ -export function getCourseDifficulty(averagePoints: number): { - label: string; - color: string; -} { - if (averagePoints >= 100) { - return { label: 'Advanced', color: 'red' }; - } else if (averagePoints >= 50) { - return { label: 'Intermediate', color: 'yellow' }; - } else { - return { label: 'Beginner', color: 'green' }; + } catch (error) { + console.error('Archive course error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to archive course'); + throw error; } } /** - * Format IPFS link to gateway URL + * Get Perwerde score for a student (from blockchain) */ -export function formatIPFSLink(ipfsHash: string): string { - if (!ipfsHash) return ''; +export async function getPerwerdeScore( + api: ApiPromise, + studentAddress: string +): Promise { + try { + // This would require a custom RPC or query if exposed + // For now, calculate from Supabase + const { data, error } = await supabase + .from('enrollments') + .select('points_earned') + .eq('student_address', studentAddress) + .eq('is_completed', true); - // If already a full URL, return it - if (ipfsHash.startsWith('http')) { - return ipfsHash; + if (error) throw error; + + const totalPoints = data?.reduce((sum, e) => sum + e.points_earned, 0) || 0; + return totalPoints; + } catch (error) { + console.error('Get Perwerde score error:', error); + return 0; + } +} + +/** + * Fetch all courses from Supabase + */ +export async function getCourses(status?: 'Active' | 'Archived'): Promise { + try { + let query = supabase.from('courses').select('*').order('created_at', { ascending: false }); + + if (status) { + query = query.eq('status', status); + } + + const { data, error } = await query; + + if (error) throw error; + + return data || []; + } catch (error) { + console.error('Get courses error:', error); + toast.error('Failed to fetch courses'); + return []; + } +} + +/** + * Fetch student enrollments + */ +export async function getStudentEnrollments(studentAddress: string): Promise { + try { + const { data, error } = await supabase + .from('enrollments') + .select('*') + .eq('student_address', studentAddress) + .order('enrolled_at', { ascending: false }); + + if (error) throw error; + + return data || []; + } catch (error) { + console.error('Get enrollments error:', error); + toast.error('Failed to fetch enrollments'); + return []; } - - // If starts with ipfs://, convert to gateway - if (ipfsHash.startsWith('ipfs://')) { - const hash = ipfsHash.replace('ipfs://', ''); - return `https://ipfs.io/ipfs/${hash}`; - } - - // If it's just a hash, add gateway - return `https://ipfs.io/ipfs/${ipfsHash}`; } diff --git a/shared/lib/validator-pool.ts b/shared/lib/validator-pool.ts new file mode 100644 index 00000000..8f9c794d --- /dev/null +++ b/shared/lib/validator-pool.ts @@ -0,0 +1,375 @@ +import { ApiPromise } from '@polkadot/api'; +import { InjectedAccountWithMeta } from '@polkadot/extension-inject/types'; +import { toast } from 'sonner'; + +/** + * Validator pool categories from runtime + */ +export enum ValidatorPoolCategory { + StakeValidator = 'StakeValidator', + ParliamentaryValidator = 'ParliamentaryValidator', + MeritValidator = 'MeritValidator', +} + +/** + * Pool member information + */ +export interface PoolMember { + address: string; + category: ValidatorPoolCategory; + joinedAt: number; +} + +/** + * Validator set structure + */ +export interface ValidatorSet { + stake_validators: string[]; + parliamentary_validators: string[]; + merit_validators: string[]; + total_count: number; +} + +/** + * Performance metrics + */ +export interface PerformanceMetrics { + blocks_produced: number; + blocks_missed: number; + era_points: number; + reputation_score: number; +} + +/** + * Join validator pool with specified category + */ +export async function joinValidatorPool( + api: ApiPromise, + account: InjectedAccountWithMeta, + category: ValidatorPoolCategory +): Promise { + try { + // Convert category to runtime enum + const categoryEnum = { [category]: null }; + + const extrinsic = api.tx.validatorPool.joinValidatorPool(categoryEnum); + + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + const errorMsg = `${decoded.section}.${decoded.name}`; + + // User-friendly error messages + if (errorMsg === 'validatorPool.InsufficientTrustScore') { + reject(new Error('Insufficient trust score. Minimum 500 required.')); + } else if (errorMsg === 'validatorPool.AlreadyInPool') { + reject(new Error('Already in validator pool')); + } else if (errorMsg === 'validatorPool.MissingRequiredTiki') { + reject(new Error('Missing required Tiki citizenship for this category')); + } else { + reject(new Error(errorMsg)); + } + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + toast.success(`Joined ${category} pool successfully`); + resolve(); + if (unsub) unsub(); + } + } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); + }); + } catch (error) { + console.error('Join validator pool error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to join validator pool'); + throw error; + } +} + +/** + * Leave validator pool + */ +export async function leaveValidatorPool( + api: ApiPromise, + account: InjectedAccountWithMeta +): Promise { + try { + const extrinsic = api.tx.validatorPool.leaveValidatorPool(); + + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + const errorMsg = `${decoded.section}.${decoded.name}`; + + if (errorMsg === 'validatorPool.NotInPool') { + reject(new Error('Not currently in validator pool')); + } else { + reject(new Error(errorMsg)); + } + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + toast.success('Left validator pool successfully'); + resolve(); + if (unsub) unsub(); + } + } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); + }); + } catch (error) { + console.error('Leave validator pool error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to leave validator pool'); + throw error; + } +} + +/** + * Update validator category + */ +export async function updateValidatorCategory( + api: ApiPromise, + account: InjectedAccountWithMeta, + newCategory: ValidatorPoolCategory +): Promise { + try { + const categoryEnum = { [newCategory]: null }; + const extrinsic = api.tx.validatorPool.updateCategory(categoryEnum); + + await new Promise((resolve, reject) => { + let unsub: () => void; + + extrinsic.signAndSend( + account.address, + ({ status, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock || status.isFinalized) { + toast.success(`Category updated to ${newCategory}`); + resolve(); + if (unsub) unsub(); + } + } + ).then((unsubscribe) => { + unsub = unsubscribe; + }); + }); + } catch (error) { + console.error('Update category error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to update category'); + throw error; + } +} + +/** + * Get validator pool member info + */ +export async function getPoolMember( + api: ApiPromise, + address: string +): Promise { + try { + const member = await api.query.validatorPool.poolMembers(address); + + if (member.isNone) { + return null; + } + + const category = member.unwrap(); + + // Parse category enum + if (category.isStakeValidator) { + return ValidatorPoolCategory.StakeValidator; + } else if (category.isParliamentaryValidator) { + return ValidatorPoolCategory.ParliamentaryValidator; + } else if (category.isMeritValidator) { + return ValidatorPoolCategory.MeritValidator; + } + + return null; + } catch (error) { + console.error('Get pool member error:', error); + return null; + } +} + +/** + * Get total pool size + */ +export async function getPoolSize(api: ApiPromise): Promise { + try { + const size = await api.query.validatorPool.poolSize(); + return size.toNumber(); + } catch (error) { + console.error('Get pool size error:', error); + return 0; + } +} + +/** + * Get current validator set + */ +export async function getCurrentValidatorSet(api: ApiPromise): Promise { + try { + const validatorSet = await api.query.validatorPool.currentValidatorSet(); + + if (validatorSet.isNone) { + return null; + } + + const set = validatorSet.unwrap(); + + return { + stake_validators: set.stakeValidators.map((v: any) => v.toString()), + parliamentary_validators: set.parliamentaryValidators.map((v: any) => v.toString()), + merit_validators: set.meritValidators.map((v: any) => v.toString()), + total_count: set.totalCount.toNumber(), + }; + } catch (error) { + console.error('Get validator set error:', error); + return null; + } +} + +/** + * Get current era + */ +export async function getCurrentEra(api: ApiPromise): Promise { + try { + const era = await api.query.validatorPool.currentEra(); + return era.toNumber(); + } catch (error) { + console.error('Get current era error:', error); + return 0; + } +} + +/** + * Get performance metrics for a validator + */ +export async function getPerformanceMetrics( + api: ApiPromise, + address: string +): Promise { + try { + const metrics = await api.query.validatorPool.performanceMetrics(address); + + return { + blocks_produced: metrics.blocksProduced.toNumber(), + blocks_missed: metrics.blocksMissed.toNumber(), + era_points: metrics.eraPoints.toNumber(), + reputation_score: metrics.reputationScore.toNumber(), + }; + } catch (error) { + console.error('Get performance metrics error:', error); + return { + blocks_produced: 0, + blocks_missed: 0, + era_points: 0, + reputation_score: 0, + }; + } +} + +/** + * Get all pool members (requires iterating storage) + */ +export async function getAllPoolMembers(api: ApiPromise): Promise { + try { + const entries = await api.query.validatorPool.poolMembers.entries(); + + const members: PoolMember[] = entries.map(([key, value]) => { + const address = key.args[0].toString(); + const category = value.unwrap(); + + let categoryType: ValidatorPoolCategory; + if (category.isStakeValidator) { + categoryType = ValidatorPoolCategory.StakeValidator; + } else if (category.isParliamentaryValidator) { + categoryType = ValidatorPoolCategory.ParliamentaryValidator; + } else { + categoryType = ValidatorPoolCategory.MeritValidator; + } + + return { + address, + category: categoryType, + joinedAt: 0, // Block number not stored in this version + }; + }); + + return members; + } catch (error) { + console.error('Get all pool members error:', error); + return []; + } +} + +/** + * Check if address meets requirements for category + */ +export async function checkCategoryRequirements( + api: ApiPromise, + address: string, + category: ValidatorPoolCategory +): Promise<{ eligible: boolean; reason?: string }> { + try { + // Get trust score + const trustScore = await api.query.trust.trustScores(address); + const score = trustScore.toNumber(); + + if (score < 500) { + return { eligible: false, reason: 'Trust score below 500' }; + } + + // Check Tiki for Parliamentary/Merit validators + if ( + category === ValidatorPoolCategory.ParliamentaryValidator || + category === ValidatorPoolCategory.MeritValidator + ) { + const tikiScore = await api.query.tiki.tikiScores(address); + if (tikiScore.isNone || tikiScore.unwrap().toNumber() === 0) { + return { eligible: false, reason: 'Tiki citizenship required' }; + } + } + + return { eligible: true }; + } catch (error) { + console.error('Check category requirements error:', error); + return { eligible: false, reason: 'Failed to check requirements' }; + } +} diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 45bd5737..58776d6b 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -32,6 +32,8 @@ import { useWallet } from '@/contexts/WalletContext'; import { supabase } from '@/lib/supabase'; import { PolkadotWalletButton } from './PolkadotWalletButton'; import { DEXDashboard } from './dex/DEXDashboard'; +import EducationPlatform from '../pages/EducationPlatform'; + const AppLayout: React.FC = () => { const navigate = useNavigate(); const [walletModalOpen, setWalletModalOpen] = useState(false); @@ -46,6 +48,7 @@ const AppLayout: React.FC = () => { const [showStaking, setShowStaking] = useState(false); const [showMultiSig, setShowMultiSig] = useState(false); const [showDEX, setShowDEX] = useState(false); + const [showEducation, setShowEducation] = useState(false); const { t } = useTranslation(); const { isConnected } = useWebSocket(); const { account } = useWallet(); @@ -197,6 +200,17 @@ const AppLayout: React.FC = () => { + +